☰
学生选课系统数据库设计与并发控制实战
2026/10/27 2:18:55 网站建设 项目流程

简介:本资源是一套完整的数据库课程设计实践项目,面向计算机专业本科生及Java全栈初学者,聚焦关系型数据库应用开发能力训练,解决学生选课业务场景下的多角色协同管理问题。压缩包共98个文件,含66个编译后class文件、21个核心Java源码、1个MySQL建库建表脚本(educate.sql)、1个本地SQLite数据库文件(educateSystem.db)、1份详实的设计报告(doc)及配套配置文件,整体1.98MB,结构清晰,涵盖CS架构下客户端与服务端完整实现。已有11833人学习下载,体现其在教学实践中的广泛认可。读者可直接导入IDE运行系统,参考规范化的数据库设计(含用户、课程、选课、成绩等10+张关联表)、三类角色权限控制逻辑、Java Swing界面交互实现,以及配套文档中对功能模块划分、ER图、SQL语句说明与测试用例的完整记录,是数据库原理与Java开发融合落地的优质范例。

1. 学生选课信息管理系统:为什么一个“老掉牙”的题目,反而最容易在毕设答辩里稳住全场?

你翻过十份数据库课设文档,八份写着“图书管理系统”,剩下两份是“超市收银系统”——但真正能跑通全部业务流程、经得起导师现场连表查数据、还能在答辩时被追问“并发选课怎么防超限”而不卡壳的,不到三成。这个学生选课信息管理系统不是为了炫技,它直击教学管理中最真实的一组矛盾:教务规则刚性(比如课程容量不可突破、先修课必须满足、时间冲突必须拦截),而学生操作却是高并发、低延迟、强交互的。用 MySQL 做底层数据守门员,Java 做业务逻辑中控台,不是因为它们最潮,而是因为这套组合能把“事务一致性”“外键约束”“连接池复用”“SQL 注入防御”这些数据库课设核心得分点,全嵌进一个可演示、可调试、可截图、可写进答辩 PPT 的闭环里。它适合两类人:一类是想扎扎实实把 ER 图、范式分解、索引优化、JDBC 封装、Swing/JavaFX 界面联动全走一遍的本科生;另一类是需要一份结构清晰、文档完整、代码分层合理、能直接改个表名就交差的课程设计救急者。别小看这个“老题目”——它不考你会不会写 Spring Boot 自动装配,它考你知不知道SELECT ... FOR UPDATE在选课提交时为什么比UPDATE更安全,也考你有没有在Course表里给capacity字段加 CHECK 约束,而不是只靠 Java 层 if 判断。


2. 需求拆解与数据库设计:从教务规则到三张核心表的硬约束落地

2.1 教务语义 → 数据库约束:哪些规则必须进 DDL,哪些只能靠代码兜底?

学生选课不是 CRUD 的简单叠加,它是多角色、多状态、多校验的协同过程。我们把原始需求逐条映射为数据库可执行的强制约束:

  • 课程容量刚性:某门课最多容纳 60 人,超一人即拒。这不能只靠 Java 层if (currentCount < 60)判断——高并发下必然超限。必须在 MySQL 层用CHECK (enrolled_count <= capacity)(MySQL 8.0.16+ 支持)或更稳妥的触发器 +BEFORE INSERT拦截;
  • 先修课依赖:选《数据库原理》前必须已修《数据结构》。这不是外键能解决的,因为“已修”是学生-课程的历史记录状态,需通过SELECT COUNT(*) FROM enrollment WHERE student_id = ? AND course_id = ? AND status = 'PASSED'查询验证,属于业务层强校验;
  • 时间冲突拦截:同一时段不能选两门课。这需要关联course和schedule表做NOT EXISTS子查询,且必须在事务内完成,否则查完再插仍可能冲突;
  • 身份隔离:学生只能查自己课表,教师只能管自己开的课。权限控制粒度在行级,靠WHERE student_id = ?或WHERE teacher_id = ?实现,而非 RBAC 大框架。

提示:所有带“必须”“禁止”“不可超过”的教务条款,优先考虑能否转为 DDL 约束(PRIMARY KEY、FOREIGN KEY、UNIQUE、CHECK、NOT NULL)。DDL 是数据库的“宪法”,比任何 Java 代码都可靠。只有当约束逻辑跨表、含状态判断、需调外部服务时,才退到应用层校验,并务必配日志和回滚机制。

2.2 核心三表设计:字段命名、索引策略与范式权衡

本系统以student、course、enrollment为基石,拒绝过度设计,也不妥协关键字段:

-- 学生表:主键自增,学号唯一,姓名非空,学院索引加速按院查人 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, stu_id VARCHAR(12) NOT NULL UNIQUE COMMENT '学号,如20230001', name VARCHAR(20) NOT NULL, college VARCHAR(30) NOT NULL, major VARCHAR(30), INDEX idx_college (college) ); -- 课程表:课程号为主键,名称非空,容量必填,状态枚举控制开课/停开 CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_code VARCHAR(10) NOT NULL UNIQUE COMMENT '课程编号,如CS101', name VARCHAR(50) NOT NULL, credit TINYINT NOT NULL DEFAULT 2 COMMENT '学分', capacity SMALLINT NOT NULL DEFAULT 60, teacher_id INT NOT NULL COMMENT '授课教师ID,关联teacher表', status ENUM('OPEN', 'CLOSED', 'FULL') NOT NULL DEFAULT 'OPEN', FOREIGN KEY (teacher_id) REFERENCES teacher(id) ); -- 选课关系表:联合主键防重复,状态字段支持退课/重修/缓考等扩展 CREATE TABLE enrollment ( student_id INT NOT NULL, course_id INT NOT NULL, enroll_time DATETIME DEFAULT CURRENT_TIMESTAMP, status ENUM('ENROLLED', 'DROPPED', 'PASSED', 'FAILED') NOT NULL DEFAULT 'ENROLLED', PRIMARY KEY (student_id, course_id), FOREIGN KEY (student_id) REFERENCES student(id) ON DELETE CASCADE, FOREIGN KEY (course_id) REFERENCES course(id) ON DELETE CASCADE, INDEX idx_student_status (student_id, status), INDEX idx_course_status (course_id, status) );

设计说明:

  • enrollment表不设自增主键,而用(student_id, course_id)联合主键——这是关系型数据库建模的铁律:多对多关系表,主键必为双方外键组合。它天然杜绝同一学生重复选同一门课,且删除学生时自动清空其所有选课记录(ON DELETE CASCADE);
  • status字段用ENUM而非TINYINT,好处是语义明确、存储省空间、且 MySQL 会校验值合法性(插入'INVALID'直接报错),比 Java 层枚举转换少一层出错可能;
  • 两个复合索引idx_student_status和idx_course_status是性能命脉:查某学生所有在读课程(WHERE student_id = ? AND status = 'ENROLLED')、查某课程当前选课人数(WHERE course_id = ? AND status = 'ENROLLED')均能走索引,避免全表扫描。

2.3 扩展表与边界处理:教师、教室、课表如何不拖垮主流程?

系统初期可只实现核心三表,但为后续扩展留好接口:

  • teacher表仅存id,name,title,不存所授课程——课程归属由course.teacher_id关联,符合第三范式;
  • classroom表独立存在,但course表不直接关联教室,而是通过schedule表(课程-教室-时段多对多)解耦。这样同一门课不同班级可排不同教室,且调课只需改schedule记录,不影响course主数据;
  • schedule表含week_day TINYINT(1=周一)、session_start TINYINT(1=第1节)、duration TINYINT(持续节数)、classroom_id INT,配合course_id构成唯一排课单元。查“周三第3-4节有哪些课”时,用WHERE week_day = 3 AND session_start BETWEEN 3 AND 4即可,无需模糊匹配字符串。

这种设计让核心选课流程(增删改查enrollment)完全不依赖schedule表,即便排课数据异常,也不影响学生正常选退课——这是系统健壮性的关键分层。


3. Java 应用层实现:三层架构下的事务控制与防并发翻车

3.1 分层结构与包组织:为什么 DAO 不该持有 Connection,Service 必须包事务?

项目采用经典三层:controller(界面交互)、service(业务逻辑)、dao(数据访问)。目录结构严格对应:

src/ ├── main/ │ ├── java/ │ │ └── com.example.enroll/ │ │ ├── controller/ // Swing JFrame / JavaFX Controller │ │ ├── service/ // EnrollmentService, CourseService │ │ └── dao/ // StudentDao, CourseDao, EnrollmentDao │ └── resources/ │ └── db.properties // 数据库连接参数

关键设计原则:

  • Dao类只做单表 CRUD,方法签名如Student findById(int id),绝不包含 SQL 拼接、事务控制、跨表 join。Connection 由上层传入或由 DataSource 获取,Dao 不负责生命周期;
  • Service类是事务边界。所有涉及多表修改的操作(如“选课”需查课余量 + 插 enrollment + 更新 course.enrolled_count)必须封装在一个@Transactional方法内(若用 Spring)或手动conn.setAutoCommit(false)控制。这是防止“查到有空位→别人抢光→你还插进去”的唯一手段;
  • Controller只负责取 UI 输入、调 Service、刷 UI 结果,不做任何业务判断。例如“选课按钮点击”事件,只调enrollmentService.enroll(studentId, courseId),成功则刷新课表,失败则弹JOptionPane.showMessageDialog("选课失败:" + e.getMessage())。

3.2 选课核心逻辑:手写 JDBC 事务的七步法(附可运行代码)

以下为EnrollmentService.enroll()的核心实现,不依赖任何框架,纯 JDBC,每一步都对应一个真实踩坑点:

public boolean enroll(int studentId, int courseId) { Connection conn = null; PreparedStatement psSelect = null; PreparedStatement psInsert = null; PreparedStatement psUpdate = null; ResultSet rs = null; try { conn = dataSource.getConnection(); conn.setAutoCommit(false); // 1. 关闭自动提交,开启事务 // 2. 查课程当前已选人数(FOR UPDATE 锁住该行,防并发超限) String sqlSelect = "SELECT enrolled_count, capacity, status FROM course WHERE id = ? FOR UPDATE"; psSelect = conn.prepareStatement(sqlSelect); psSelect.setInt(1, courseId); rs = psSelect.executeQuery(); if (!rs.next()) { throw new RuntimeException("课程不存在"); } int enrolledCount = rs.getInt("enrolled_count"); int capacity = rs.getInt("capacity"); String status = rs.getString("status"); if (!"OPEN".equals(status)) { throw new RuntimeException("课程未开放选课"); } if (enrolledCount >= capacity) { throw new RuntimeException("课程已满"); } // 3. 检查学生是否已选(防重复) String sqlCheck = "SELECT 1 FROM enrollment WHERE student_id = ? AND course_id = ? AND status = 'ENROLLED'"; psSelect.close(); psSelect = conn.prepareStatement(sqlCheck); psSelect.setInt(1, studentId); psSelect.setInt(2, courseId); if (psSelect.executeQuery().next()) { throw new RuntimeException("您已选修此课程"); } // 4. 插入选课记录 String sqlInsert = "INSERT INTO enrollment (student_id, course_id) VALUES (?, ?)"; psInsert = conn.prepareStatement(sqlInsert); psInsert.setInt(1, studentId); psInsert.setInt(2, courseId); psInsert.executeUpdate(); // 5. 更新课程已选人数(原子操作) String sqlUpdate = "UPDATE course SET enrolled_count = enrolled_count + 1 WHERE id = ?"; psUpdate = conn.prepareStatement(sqlUpdate); psUpdate.setInt(1, courseId); int updated = psUpdate.executeUpdate(); if (updated != 1) { throw new RuntimeException("更新课程人数失败"); } // 6. 提交事务 conn.commit(); return true; } catch (SQLException e) { // 7. 回滚并包装异常 if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } throw new RuntimeException("选课失败:" + e.getMessage(), e); } finally { // 关闭资源(此处省略 close 逻辑,实际需 try-with-resources 或显式 close) closeResources(rs, psSelect, psInsert, psUpdate, conn); } }

参数与逻辑说明:

  • FOR UPDATE是灵魂:它在SELECT时对course行加写锁,直到commit或rollback才释放。其他线程同时执行相同SELECT ... FOR UPDATE会被阻塞,确保“查-判-更”三步原子化。若不用此锁,高并发下必然超限;
  • enrolled_count字段必须存在且初始为 0,不能靠SELECT COUNT(*) FROM enrollment WHERE course_id = ?实时计算——那会锁整张表,性能灾难;
  • 所有PreparedStatement参数用setInt()而非字符串拼接,彻底杜绝 SQL 注入。即使学生 ID 是字符串(如20230001),也应定义为VARCHAR并用setString(),保持类型一致;
  • closeResources()是必须的工具方法,确保ResultSet、Statement、Connection按反序关闭,避免连接泄漏——这是课设里最常被忽略的“隐形炸弹”。

3.3 界面交互设计:Swing 表格与数据库的实时同步策略

UI 层用JTable展示课表,但绝不能每次点“刷新”就SELECT * FROM enrollment全量重载——数据量大时卡顿,且丢失用户当前滚动位置。我们采用增量同步:

  • 初始化时加载学生全部在读课程:SELECT c.*, e.enroll_time FROM enrollment e JOIN course c ON e.course_id = c.id WHERE e.student_id = ? AND e.status = 'ENROLLED';
  • “选课”成功后,不 reload 整个表格,而是tableModel.addRow(new Object[]{courseCode, courseName, credit, enrollTime});
  • “退课”成功后,遍历tableModel行,找到对应course_id并removeRow(i);
  • 所有数据库操作(增删)后,立即调用tableModel.fireTableDataChanged()通知视图更新,保证内存与 DB 一致。

注意:Swing 是单线程模型,所有 UI 更新必须在 Event Dispatch Thread(EDT)中执行。若数据库操作在子线程(如SwingWorker),fireTableDataChanged()必须包裹在SwingUtilities.invokeLater()中,否则抛IllegalStateException。


4. 避坑指南:选课系统里最常翻车的五个点及血泪解法

4.1 现象:选课时偶尔超限,但日志显示“查到余量=1”,插完发现余量=2

原因:没用SELECT ... FOR UPDATE,多个线程并发执行“查余量→插记录→更余量”,查的时候都是1,插完都变成2。
解决:必须在查余量的 SQL 后加FOR UPDATE,且整个流程在同一个事务内。若用 MyBatis,需配置<select ... forUpdate="true">或在 XML 中写明FOR UPDATE。

4.2 现象:退课后课程余量没增加,或者增加两次

原因:退课逻辑写了UPDATE course SET enrolled_count = enrolled_count - 1,但没加WHERE status = 'ENROLLED'条件,导致DROPPED或PASSED记录也被减;或事务未提交就执行了两次退课。
解决:退课 SQL 必须带状态过滤:UPDATE course SET enrolled_count = enrolled_count - 1 WHERE id = ? AND (SELECT COUNT(*) FROM enrollment WHERE course_id = ? AND status = 'ENROLLED') > 0;且退课操作本身也要事务包裹。

4.3 现象:输入学号20230001查询无结果,但数据库里明明有

原因:学号字段定义为INT,而20230001作为整数存储会丢失前导零(变成 2023001),或超出INT范围(MySQLINT最大 2147483647,20230001安全,但202300001就溢出)。
解决:学号、课程号等业务编码一律用VARCHAR,长度按实际最长定(如VARCHAR(12)),Java 层用String接收,PreparedStatement 用setString()。

4.4 现象:Swing 界面点击按钮无响应,控制台无报错

原因:数据库连接失败(URL 错、密码错、MySQL 服务未启)被catch吞掉,或JDBC驱动 jar 包未加入 classpath。
解决:在dataSource.getConnection()后加一行System.out.println("DB connected: " + conn.isValid(2));;驱动包确认放入lib/目录,并在 IDE 运行配置中勾选“Add libraries to build path”。

4.5 现象:导出课表 Excel 时中文乱码,或数字列显示为科学计数法

原因:Apache POI 写 Excel 时未设置单元格格式,且未指定文件编码为 UTF-8。
解决:创建Workbook后,对字符串列单元格调用cell.setCellStyle(stringStyle)(stringStyle用createCellStyle().setDataFormat(workbook.createDataFormat().getFormat("@")));导出文件名用URLEncoder.encode(fileName, "UTF-8")编码。


5. 答辩验收 checklist:从导师视角看你是否真懂这套系统

5.1 数据库层面:他一定会问的三个问题及满分回答

导师不会问“你用了什么技术”,他会盯着你的 DDL 和数据流问本质。以下是高频问题与应答逻辑:

问题低分回答(踩坑)高分回答(体现设计思维)
“课程容量限制,你是在 Java 层判断还是数据库层?”“我在 Java 里写了 if (count < capacity) 就允许选。”“我双保险:数据库层用 CHECK 约束 + BEFORE INSERT 触发器做最终拦截;Java 层做前置校验并提示友好文案。CHECK 是底线,Java 是体验。”
“如果两个学生同时选最后一门课,怎么保证不超限?”“我加了 synchronized,应该没问题。”“synchronized 只锁 JVM 内单实例,集群部署就失效。我用 MySQL 的 SELECT ... FOR UPDATE 对 course 行加锁,确保查-判-更原子化,这是分布式环境唯一可靠的方案。”
“enrollment 表为什么用联合主键而不是自增 ID?”“老师说要这样写。”“因为它是纯粹的关联表,业务意义就是‘学生A选了课程B’。联合主键天然保证不重复,删除学生时 ON DELETE CASCADE 自动清理,且查询‘学生所有课’时索引效率最高——这是关系模型的本质要求,不是约定俗成。”

提示:答辩时,把SHOW CREATE TABLE enrollment的输出截图放进 PPT 第二页。导师看到PRIMARY KEY (student_id, course_id)和ON DELETE CASCADE,立刻知道你懂范式。

5.2 代码层面:他可能现场让你改的一行关键代码

导师大概率会打开你的EnrollmentService.java,指着enroll()方法问:“如果我想在选课成功后,自动给学生发邮件通知,你打算在哪加?”
错误示范:在conn.commit()后直接写sendEmail()—— 若邮件服务挂了,事务已提交,无法回滚,数据与通知不一致。
正确做法:引入“本地消息表”。在enroll()事务内,INSERT INTO message_queue (type, target_id, status) VALUES ('ENROLL_NOTIFY', ?, 'PENDING');另起一个定时任务(或监听 binlog),扫描message_queue中status = 'PENDING'的记录,发邮件成功后UPDATE message_queue SET status = 'SENT'。这样保证“选课成功”和“通知发出”最终一致。

5.3 演示层面:如何用 3 分钟让导师相信你真跑通了

不要从登录开始演示。直接打开 MySQL Workbench,执行:

-- 步骤1:确认课程余量 SELECT course_code, name, enrolled_count, capacity FROM course WHERE id = 1; -- 步骤2:用你的 Java 程序选课(此时导师看屏幕) -- 步骤3:立刻查表验证 SELECT * FROM enrollment WHERE student_id = 1001 AND course_id = 1; SELECT enrolled_count FROM course WHERE id = 1;

如果步骤1显示enrolled_count=59, capacity=60,步骤3显示新记录且enrolled_count=60,导师会点头——这证明你理解了数据闭环。比讲十分钟 MVC 分层更有说服力。

从那以后我每次做课设,都会在答辩前强制走一遍这个“三步验证”:查现状 → 操作 → 查结果。不是为了应付,而是因为只有亲眼看到数据在库里真实变化,才敢说“我做完了”。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询