ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

教务管理系统课设:MySQL与Java实现选课并发与事务管理

教务管理系统课设:MySQL与Java实现选课并发与事务管理

简介:一套面向高校计算机专业学生的数据库课程设计资料包,围绕教务管理系统,采用 MySQL 与 Java 实现,覆盖需求分析、数据库设计、系统实现及测试等完整流程,适合正在学习数据库原理、Java 编程并需要完成课程设计的学生参考使用。压缩包共 28 个文件,大小 4.45MB,含 9 个 Java 源码文件、9 个编译后的 class 文件、1 份 SQL 建库脚本、2 个 JAR 依赖包,以及 properties 配置文件、工程配置和 txt 使用说明等,源码、编译输出与说明文档分区清楚,便于对照学习与二次开发。这套资料目前已有 2649 人浏览学习,说明方案在教学场景中较具参考价值。通过阅读其中设计内容并查看源码,可以掌握 MySQL 表结构设计、Java 连接数据库的常用写法,理解教务场景中课程、学生、教师等核心数据关系;也可以基于现有框架自行扩展功能,结合使用说明快速熟悉项目结构与开发思路,为真实项目开发积累实践基础。

1. 教务管理系统课程设计:从选课超员开始理解 MySQL 与 Java 的分工

拿到数据库课程设计题目的时候,我们组定的是教务管理系统,技术栈就是 MySQL 和 Java。一开始觉得无非是建几张学生表、课程表,再写个增删改查交差;真正动手才发现,选课并发超容量、成绩被后提交的人覆盖、删除课程被外键挡住,这些问题全都发生在数据库设计边界上。这篇方案会按需求分析、总体架构、数据库设计、Java 实现、避坑排查、验收答辩的顺序展开,把每个模块的取舍讲清楚。适合正在做数据库课设、想拿 MySQL 与 Java 串出一套完整系统的读者,照着改就能用。

2. 需求分析与总体架构设计:三类角色、六大模块与三层结构

这一章先回答两个问题:系统要做什么,系统怎么做出来。很多课设失败不是因为代码写不完,而是需求从一开始就没定准,最后做出来的功能和题目要求对不上。

2.1 角色梳理与权限边界

教务管理系统通常有三类角色,权限边界必须分清,否则后面登录模块没法写。

角色核心权限典型操作
学生查询本人信息登录、浏览可选课程、选课、退课、查看成绩
教师维护本人授课数据查看授课列表、录入成绩、修改草稿状态成绩、发布成绩
管理员基础数据维护维护学生/教师/课程档案、分配开课任务、重置密码

权限判断在 Java 侧做,层叫 service,方法开头先校验当前登录角色和资源归属。比如教师修改成绩时,必须校验该教学班是不是这个老师名下,不能只依赖前端按钮隐藏。

2.2 功能模块划分与业务规则

项目划分为六个功能模块:登录认证、基础信息管理、课程与开课管理、选课退课、成绩管理、统计查询。每个模块对应一组用例,写文档时建议给用例编号,比如 UC-LOGIN-01 表示登录功能第一条用例,这样需求文档可以直接对应到后面的测试用例。

业务规则要单独列出来,这部分是数据库约束和 Java 校验的依据。常见规则包括:同一学生对同一教学班不能重复选课;选课人数不能超过教学班容量;退课要释放容量;成绩取值范围是 0 到 100;成绩只有从草稿状态变为发布状态后学生才可见;只有授课教师能录入和修改成绩。规则建议编号管理,BR-01 到 BR-06,答辩时评委会问“你有哪些业务约束”,直接念编号和含义即可。

2.3 技术选型与三层架构

技术组合选 Java Swing + JDBC + MySQL。选 Swing 而不是 Web 是因为课程设计重点在数据库,C/S 程序部署和演示都简单,评委机器上没有浏览器依赖问题。Java 侧不需要引入 MyBatis,直接用 JDBC 写数据访问层,能把 SQL 的锅和 Java 的锅分开,答“为什么不用框架”时也更有底气。

三层结构划分如下:界面展示层负责登录框、选课表格、成绩录入面板;业务逻辑层集中处理选课事务、成绩状态流转、权限判断;数据访问层只负责执行 SQL 并包装结果。连接全部走一个 DBUtil 工具类,不让每个界面直接创建连接。工程结构参考后面 Java 实现章节,先记住三句话:界面不写 SQL,事务不进 DAO,连接不被多个线程共用。

3. 数据库设计:六张核心表、三层约束与一组常用脚本

数据库设计是整个课设的得分大头。设计稿里要能看到实体关系、字段约束、索引和事务场景,而不仅仅是几张建表语句。

3.1 核心表结构与实体关系

这套方案用六张核心表:学生表 student、教师表 teacher、课程表 course、教学班表 teaching、选课成绩表 sc、管理员表 admin。重点理解为什么需要 teaching 表。

有的课程设计会把课程和老师直接绑在课程表里,这样同一门课不同老师上、不同学期开课就没办法表达。拆出 teaching 表后,course 只存课程基础信息,teaching 存“某学期某老师在某教室开这门课”,sc 表再指向 teaching。这样选课容量放在教学班层面,改容量不会影响课程主数据。

学生表关键字段包括 id、student_no、name、gender、birth_date、enroll_year、phone、password_hash、status。教师表类似,包含 teacher_no、title、department、password_hash。课程表字段包括 course_no、course_name、credit、course_type、status。教学班表字段包括 course_id、teacher_id、semester、capacity、selected_count、classroom、class_time、status。选课成绩表绑定 student_id 和 teaching_id,grade 允许为空表示未出分,status 区分已选、已录、已发布三个阶段。

3.2 建表 SQL 与关键约束

初始化数据库时统一使用 InnoDB 引擎和 utf8mb4 字符集。下面给出三张重点表的建表语句,完整脚本能在配套资源里直接导入。

CREATE DATABASE IF NOT EXISTS edu_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE edu_db; CREATE TABLE teaching ( id INT AUTO_INCREMENT PRIMARY KEY, course_id INT NOT NULL, teacher_id INT NOT NULL, semester VARCHAR(20) NOT NULL, capacity INT NOT NULL DEFAULT 40, selected_count INT NOT NULL DEFAULT 0, classroom VARCHAR(50), class_time VARCHAR(100), status TINYINT DEFAULT 1, KEY idx_teaching_course (course_id), KEY idx_teaching_teacher (teacher_id), CONSTRAINT fk_teaching_course FOREIGN KEY (course_id) REFERENCES course (id), CONSTRAINT fk_teaching_teacher FOREIGN KEY (teacher_id) REFERENCES teacher (id) ) ENGINE=InnoDB; CREATE TABLE sc ( id INT AUTO_INCREMENT PRIMARY KEY, student_id INT NOT NULL, teaching_id INT NOT NULL, grade DECIMAL(5,2) COMMENT 'NULL代表未出分', status VARCHAR(20) NOT NULL DEFAULT 'SELECTED' COMMENT 'SELECTED/GRADED/PUBLISHED', UNIQUE KEY uk_sc_student_teaching (student_id, teaching_id), KEY idx_sc_teaching (teaching_id), CONSTRAINT fk_sc_student FOREIGN KEY (student_id) REFERENCES student (id), CONSTRAINT fk_sc_teaching FOREIGN KEY (teaching_id) REFERENCES teaching (id), CONSTRAINT chk_grade CHECK (grade >= 0 AND grade <= 100) ) ENGINE=InnoDB;

这段 SQL 已经体现三个设计点。第一,sc 表的唯一索引 uk_sc_student_teaching 从数据库层面保证一个学生对一个教学班只能有一条选课记录,这是对抗并发重复选课的兜底。第二,教学班的 selected_count 是冗余字段,用它做容量判断比每次 count(*) 快,代价是必须放在事务里和选课、退课一起更新。第三,CHECK 约束在 MySQL 8.0 里会生效,5.7 版本会忽略,所以 Java 层仍然要校验成绩范围。

3.3 事务边界与视图脚本

数据库里至少要设计三个事务场景:学生选课、退课、成绩发布。选课事务同时做两件事:向 sc 表插入选课记录,把 teaching 表的 selected_count 加一。退课事务相反:删除 sc 记录,把 selected_count 减一。成绩发布事务只更新 sc 表的状态,不碰成绩原字段。事务边界写在 Java 的 service 层,因为选课还要先读 capacity,不是简单的一条 INSERT。

视图可以简化统计查询,建议加一个学生成绩视图,把学生、教学班、课程、教师信息拼在一起,方便后面各种联合查询复用。

CREATE VIEW v_student_score AS SELECT s.student_no, s.name AS student_name, c.course_no, c.course_name, te.semester, t.name AS teacher_name, sc.grade, sc.status FROM sc JOIN student s ON sc.student_id = s.id JOIN teaching te ON sc.teaching_id = te.id JOIN course c ON te.course_id = c.id JOIN teacher t ON te.teacher_id = t.id;

某个学生要查自己的选课和成绩,只需要对 v_student_score 做一次条件查询,不用每次重写四个 JOIN。视图不提升性能,但能让 Java 侧代码干净很多,答辩时说出“我用视图封装了复杂关联查询”,是一个明确的加分动作。

3.4 初始化数据脚本

演示最怕空表,所以 init.sql 要带测试数据。管理员、学生、老师、课程、教学班、选课记录全部准备齐全。密码不要明文存放,初始化时用 MySQL 自带的 SHA2 函数直接生成摘要。

INSERT INTO student (student_no, name, gender, birth_date, enroll_year, phone, password_hash, status) VALUES ('S20240001', 'A同学', 'M', '2004-03-12', 2024, '13800000001', SHA2('S20240001123456', 256), 1); INSERT INTO teacher (teacher_no, name, title, department, phone, password_hash, status) VALUES ('T2024001', 'B老师', '讲师', '计算机系', '13900000001', SHA2('T2024001123456', 256), 1);

这里把“账号 + 初始密码 123456”拼起来做 SHA-256,初始密码可以统一,登录后由管理员安排修改。Java 侧登录时用同样算法拼一次再比对,两边就能对上,初始化脚本也可以重复执行,不会出现明文密码被人直接看到的问题。

4. Java端实现:JDBC连接、事务选课与成绩状态机

数据库设计完成后,Java 端负责把规则落地。这一章会给出工程结构、连接工具类、选课事务代码、成绩录入和登录验证,都是可以直接抄进工程里改的版本。

4.1 工程目录与数据库连接层

工程结构建议保持简单但分层清晰。我自己习惯这样的目录:

edu-system/ ├── config/ │ └── db.properties ├── util/ │ └── DBUtil.java ├── dao/ │ ├── TeachingDao.java │ └── ScoreDao.java ├── service/ │ ├── UserService.java │ ├── CourseService.java │ └── ScoreService.java ├── ui/ │ ├── LoginFrame.java │ ├── StudentMainFrame.java │ └── TeacherMainFrame.java └── resource/ └── init.sql

config 放数据库连接配置,util 放工具类,dao 只执行 SQL,service 写业务规则,ui 只负责事件转发。DBUtil 的核心代码非常固定:

public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/edu_db" + "?useSSL=false&characterEncoding=utf8&serverTimezone=GMT%2B8"; private static final String USER = "root"; private static final String PASSWORD = "123456"; public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }

URL 里的三个参数各有作用。characterEncoding=utf8 解决中文乱码,serverTimezone=GMT%2B8 解决 MySQL JDBC 驱动对时区的误判,useSSL=false 避免本地开发时出现证书相关告警。关闭连接时用 try-with-resources 或者 finally,防止界面操作频繁导致连接数耗尽。

4.2 选课事务:锁、容量检查与唯一索引兜底

选课是最容易翻车的地方,代码必须把事务边界画对。下面是一个完整的选课方法,核心逻辑是先用 SELECT FOR UPDATE 锁住教学班行,再做容量检查和插入:

public boolean selectCourse(int studentId, int teachingId) { Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); conn.setTransactionIsolation( Connection.TRANSACTION_READ_COMMITTED); PreparedStatement lockPs = conn.prepareStatement( "SELECT selected_count, capacity " + "FROM teaching WHERE id = ? FOR UPDATE"); lockPs.setInt(1, teachingId); ResultSet rs = lockPs.executeQuery(); if (!rs.next()) { conn.rollback(); return false; } int selectedCount = rs.getInt(1); int capacity = rs.getInt(2); if (selectedCount >= capacity) { conn.rollback(); return false; } PreparedStatement insertPs = conn.prepareStatement( "INSERT INTO sc(student_id, teaching_id, grade, status) " + "VALUES (?, ?, NULL, 'SELECTED')"); insertPs.setInt(1, studentId); insertPs.setInt(2, teachingId); insertPs.executeUpdate(); PreparedStatement updatePs = conn.prepareStatement( "UPDATE teaching SET selected_count = selected_count + 1 " + "WHERE id = ? AND selected_count < capacity"); updatePs.setInt(1, teachingId); if (updatePs.executeUpdate() == 0) { conn.rollback(); return false; } conn.commit(); return true; } catch (Exception e) { try { conn.rollback(); } catch (SQLException ignore) { } return false; } finally { conn.close(); } }

这段代码解决三个问题。SELECT FOR UPDATE 让两个事务同时选课时变成串行执行,第二个事务会等第一个事务提交后才读到最新人数。UPDATE 语句再次带 selected_count < capacity 条件,是防止主键锁范围之外的意外。唯一索引 uk_sc_student_teaching 负责最后一道防线,万一两个请求都插入了同一条选课记录,数据库会抛重复键异常,事务回滚。

4.3 成绩录入:状态机与归属校验

成绩模块容易出现两个问题:发布后还能被改,别的老师能改非自己授课班的成绩。解决方法是把状态写进 UPDATE 条件里:

String sql = "UPDATE sc SET grade = ?, status = 'GRADED' " + "WHERE id = ? " + "AND status IN ('SELECTED', 'GRADED') " + "AND teaching_id IN (" + " SELECT id FROM teaching WHERE teacher_id = ?" + ")";

这条 SQL 的含义很直观:只有当前状态是已选或已录草稿时才允许更新,发布状态 PUBLISHED 被排除在外;同时要求教学班必须属于当前登录教师。executeUpdate 返回 0 时,统一提示“成绩不可修改或没有权限”,不用继续往下执行。批量录入成绩时,把这条 SQL 放到 PreparedStatement 里,循环 addBatch,每五百条执行一次 executeBatch,演示几十个学生的成绩录入不会卡顿。

4.4 登录认证:哈希存储而不是明文

登录逻辑可以复用同一个哈希工具方法,账号当盐使用,避免两个不同用户同样密码产生相同摘要:

public static String sha256(String userNo, String password) throws Exception { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] bytes = md.digest( (userNo + password).getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : bytes) { sb.append(String.format("%02x", b)); } return sb.toString(); }

登录时把页面输入的账号密码拼起来哈希,再去对应角色表查 password_hash,匹配成功则创建会话对象。这样做比存明文强很多,也能和初始化脚本里的 SHA2 函数结果对齐。线上系统会推荐 BCrypt 等慢哈希,课程设计用 SHA-256 加盐足够解释清楚。

5. 常见问题排查:中文乱码、并发超选与成绩覆盖的五个翻车点

这一章的内容全部来自实际调试过程。课设遇到的绝大多数报错都集中在下面五个位置,提前知道怎么排查能省出大量时间。

5.1 中文乱码:数据库表和连接配置不一致

现象是插入中文后数据库里显示问号,或者查询条件带中文查不到数据。原因通常是建库字符集和 JDBC 连接字符集不一致,比如表用的 latin1,连接用的 utf8,两边翻译不到一起去。解决方法是建库时统一写成DEFAULT CHARACTER SET utf8mb4,连接 URL 加characterEncoding=utf8。注意 MySQL 的 utf8 其实是 utf8mb3 的别名,如果要做演示数据的 emoji,要使用 utf8mb4,否则会报字符集过长错误。

5.2 数据库连接失败:驱动版本和时区提示

现象是启动程序就报 ClassNotFoundException,或Communications link failure,或Unknown database。ClassNotFoundException 说明驱动 jar 没放进 classpath;URL 没加时区参数,MySQL 8 驱动会报 serverTimezone 错误;Unknown database 说明数据库还没执行建库脚本。解决思路是:先确认使用 mysql-connector-java 对应版本的驱动,再把连接参数补全到像 4.1 节那样完整。我第一次做课设时,因为没放驱动 jar 折腾了一晚上,最后发现只是构建路径问题,这种低级错误最亏。

5.3 并发选课超容量:先查后插不锁行

现象是模拟一百个学生同时选一个容量四十的班,最终选课人数超过四十。原因是两个事务同时读到剩余名额大于零,判断通过后一起插入。解决方法是采用第 4 章的 SELECT FOR UPDATE 方案,把容量判断和插入放进同一个事务,同时对 teaching 行加锁。想在演示时证明这一点,可以开两个线程同时执行选课方法,观察最终 sc 记录数不超过 capacity,这时候能在答辩中顺手讲清楚行锁和表锁的区别。

5.4 成绩被覆盖:丢失更新问题

现象是两个老师同时录入同一学生成绩,后提交的人把先提交的人的成绩覆盖掉。原因是默认读已提交隔离级别下,事务没有锁住成绩行,后写者直接覆盖。解决思路分两种:如果希望简单,就在更新语句里加状态条件,只有草稿状态允许修改,发布后禁止再改;如果希望更通用,可以给 sc 表加 version 字段,用乐观锁方式实现,更新时带版本号条件,影响行数为零说明有并发冲突。课设采用前者已经足够,答辩时提到乐观锁方案会是很漂亮的加分项。

5.5 删除课程外键失败:父子表顺序控制

现象是删除 course 时数据库报Cannot delete or update a parent row。原因很简单,sc 通过教学班引用了 course,直接删父表必然被外键挡住。解决方法是先删除 sc 中的选课记录,再删除 teaching 中的教学班记录,最后删除 course,整个过程包在事务里。我一般建议把这种多级删除写成一个管理员的“强制删除”方法,而不是直接给界面一个裸的 DELETE 按钮,防止误操作把成绩数据一次性清空。生产系统用逻辑删除更稳,课设能演示清楚物理外键和删除顺序足够了。

6. 验收与答辩:一键初始化、并发测试与高频追问应对

课设交的不只是代码,还有可复现的演示过程。我的验收习惯是:把数据库删掉,重新执行一遍 init.sql,再跑功能测试,确保评委看到的就是一个干净环境。

验收项通过标准
初始化脚本重复执行不报错,测试数据完整
登录认证三类角色都能登录,错误密码被拒绝
选课退课超容量被拦截,重复选课被拦截,退课后空余名额恢复
成绩流程教师录入草稿、发布后学生可见,发布后不可修改
并发一致性多线程抢选不超容量,成绩更新不覆盖

演示顺序上,我一般先登录学生账号选一门课,再打开两个窗口同时抢同一门课,展示容量不变;然后切换教师账号录入成绩并发布,最后用学生账号刷新看到成绩。答辩时先讲业务规则,再讲表结构和两个关键事务,最后讲异常设计。评委常追问为什么不用 MyBatis、为什么事务要放 service 层、为什么不用存储过程做选课,按自己的设计意图回答就能站得住。

以前我总把验收拖到最后一天,结果演示前一晚发现初始化脚本在另一台机器上跑不通。从那以后,每次课设我都会先在全新的空数据库里强制走一遍初始化,再跑一次并发选课测试,确认无误才提交。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进