简介:本资源是一套完整的数据库课程设计实践项目,面向计算机专业本科生及数据库初学者,聚焦学生选课业务场景,提供从需求分析、数据库建模到CS架构系统实现的全流程参考。系统基于MySQL+Java开发,支持学生、教师、管理员三类角色,涵盖个人信息维护、课程/选课/成绩/奖惩等核心模块管理,建表规范、逻辑清晰,适合作为课程设计范例或课程实训补充材料。压缩包共112个文件,含21个Java源码、66个编译后Class文件、1个SQL建库脚本、2个Word设计报告文档、14个URL配置及XML/DB/properties等辅助文件,整体1.99MB,结构完整、即下即用。已有16471人学习下载,读者可直接运行调试、对照报告理解E-R建模与JDBC实现细节,并通过GUI类(如StudentGUI、TeacherGUI、AdmisterGUI)深入掌握分层界面设计与权限控制逻辑。
1. 学生选课信息管理系统:为什么一个“老掉牙”的课程设计,反而成了检验数据库建模与Java工程能力的试金石?
你可能刚在课程表里看到《数据库原理与应用》的期末大作业——“学生选课信息管理系统”,心里一沉:这不就是教科书第一章的ER图+三张表(学生、课程、选课)?但现实很快打脸:当A同学用Navicat随手建完三张表,发现“同一门课不能超限选课”要靠代码硬校验、“教师所授课程需归属其所在院系”得加触发器、“退课后学分自动回滚”涉及事务嵌套,“历史选课记录归档策略”又牵扯分区表设计……这些课本里轻描淡写的“约束条件”,全在真实MySQL执行时爆出Deadlock found when trying to get lock或Cannot add or update a child row: a foreign key constraint fails。这不是考你会不会写CREATE TABLE,而是考你能不能把“业务规则”翻译成可落地、可验证、可维护的数据库契约。本方案面向两类人:一是正被课程设计 deadline 追着跑的学生,需要一套能直接编译运行、含完整SQL脚本与分层Java代码、覆盖全部基础功能且预留扩展接口的参考实现;二是想夯实JDBC事务控制、连接池配置、DAO层抽象等实战细节的初阶开发者——它不炫技,但每行代码都踩在MySQL 8.0+与JDK 17的兼容边界上,所有坑都来自某高校实验室连续三年的课程答辩复盘。
2. 从ER图到MySQL物理表:如何让“学生-课程-选课”三张表真正扛住并发选课压力?
2.1 为什么不能照搬课本的三张表?——业务规则驱动的字段增补逻辑
课本常把“选课表”简化为(student_id, course_id)联合主键。但真实场景中,这会导致三个致命问题:
- 无法记录选课时间:教务系统需按时间戳判定“先到先得”,而
INSERT时间不可信(客户端时钟不同步); - 无法支持多次选退:同一学生对同一门课的历史操作需留痕,但联合主键禁止重复插入;
- 无法承载状态机:选课有“待审核→已通过→已退课→已取消”等状态,状态变更需审计日志。
因此,我们重构为四张核心表(含course_selection_history归档表),关键字段设计如下:
| 表名 | 核心字段(含约束) | 设计意图 |
|---|---|---|
student | id BIGINT PRIMARY KEY AUTO_INCREMENT,student_id VARCHAR(12) UNIQUE NOT NULL,major_dept_id INT NOT NULL,total_credits TINYINT DEFAULT 0 CHECK (total_credits BETWEEN 0 AND 160) | student_id用业务ID而非自增ID作主键,避免暴露学号规律;CHECK约束强制学分合法范围 |
course | id INT PRIMARY KEY AUTO_INCREMENT,course_code VARCHAR(10) UNIQUE NOT NULL,max_capacity TINYINT NOT NULL DEFAULT 60,current_enrolled TINYINT NOT NULL DEFAULT 0 | current_enrolled冗余字段+触发器维护,规避COUNT(*)实时统计性能瓶颈 |
course_selection | id BIGINT PRIMARY KEY AUTO_INCREMENT,student_id VARCHAR(12) NOT NULL,course_id INT NOT NULL,status ENUM('PENDING','APPROVED','REJECTED','WITHDRAWN') DEFAULT 'PENDING',created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | ENUM替代VARCHAR节省空间;双时间戳支持状态变更追踪 |
course_selection_history | id BIGINT PRIMARY KEY AUTO_INCREMENT,selection_id BIGINT NOT NULL,student_id VARCHAR(12) NOT NULL,course_id INT NOT NULL,old_status ENUM(...),new_status ENUM(...),operator VARCHAR(20),operated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | 归档表仅存变更记录,不参与高频查询,降低主表锁竞争 |
提示:
current_enrolled字段必须配合BEFORE INSERT/UPDATE触发器更新,否则应用层并发写入会引发数据不一致。触发器逻辑见2.3节。
2.2 MySQL建表脚本:带注释的可执行DDL,直接复制进Workbench运行
-- 创建学生表(注意:student_id为业务主键,非自增) CREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id VARCHAR(12) NOT NULL UNIQUE COMMENT '学号,业务主键', name VARCHAR(20) NOT NULL, gender ENUM('M','F') NOT NULL, major_dept_id INT NOT NULL COMMENT '所属院系ID', total_credits TINYINT NOT NULL DEFAULT 0 CHECK (total_credits BETWEEN 0 AND 160), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_dept_id (major_dept_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- 创建课程表 CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_code VARCHAR(10) NOT NULL UNIQUE COMMENT '课程编码', course_name VARCHAR(50) NOT NULL, credit TINYINT NOT NULL DEFAULT 2, max_capacity TINYINT NOT NULL DEFAULT 60 COMMENT '最大容量', current_enrolled TINYINT NOT NULL DEFAULT 0 COMMENT '当前已选人数,由触发器维护', teacher_id VARCHAR(12) NOT NULL COMMENT '授课教师工号', department_id INT NOT NULL COMMENT '开课院系ID', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CHECK (max_capacity > 0 AND max_capacity <= 200), INDEX idx_dept_id (department_id), INDEX idx_teacher_id (teacher_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- 创建选课主表(状态机核心) CREATE TABLE course_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id VARCHAR(12) NOT NULL, course_id INT NOT NULL, status ENUM('PENDING','APPROVED','REJECTED','WITHDRAWN') DEFAULT 'PENDING', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, -- 复合索引加速常用查询:学生查课表、课程查选课人 INDEX idx_student_status (student_id, status), INDEX idx_course_status (course_id, status), -- 外键约束(需确保父表引擎为InnoDB) CONSTRAINT fk_cs_student FOREIGN KEY (student_id) REFERENCES student(student_id) ON DELETE CASCADE, CONSTRAINT fk_cs_course FOREIGN KEY (course_id) REFERENCES course(id) ON DELETE RESTRICT ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- 创建选课历史归档表(仅存变更事件) CREATE TABLE course_selection_history ( id BIGINT PRIMARY KEY AUTO_INCREMENT, selection_id BIGINT NOT NULL COMMENT '关联course_selection.id', student_id VARCHAR(12) NOT NULL, course_id INT NOT NULL, old_status ENUM('PENDING','APPROVED','REJECTED','WITHDRAWN'), new_status ENUM('PENDING','APPROVED','REJECTED','WITHDRAWN') NOT NULL, operator VARCHAR(20) NOT NULL COMMENT '操作人(系统/教师/学生)', operated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_selection_id (selection_id), INDEX idx_student_time (student_id, operated_at), INDEX idx_course_time (course_id, operated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;逻辑说明与参数说明:
- 所有表指定
ENGINE=InnoDB是硬性要求——只有InnoDB支持外键、事务和行级锁,MyISAM在此场景下必然崩溃; CHARSET=utf8mb4而非utf8:MySQL的utf8实际是utf8mb3,不支持emoji及部分生僻汉字,utf8mb4才是真UTF-8;ON DELETE CASCADE用于student表删除时自动清理其选课记录,但course表用ON DELETE RESTRICT防止误删课程导致选课数据孤儿化;INDEX设计原则:WHERE条件中高频出现的字段必建索引(如student_id、course_id),复合索引按“最左前缀”原则排列(如idx_student_status支持WHERE student_id=? AND status=?,但不支持WHERE status=?)。
2.3 关键触发器:用数据库原生能力守住数据一致性底线
选课成功后,course.current_enrolled必须+1;退课时必须-1。若在Java层用SELECT ... FOR UPDATE再UPDATE,高并发下极易死锁。正确做法是交给MySQL触发器原子执行:
-- 选课成功后更新课程当前人数 DELIMITER $$ CREATE TRIGGER tr_update_enrolled_on_approve AFTER UPDATE ON course_selection FOR EACH ROW BEGIN IF OLD.status != 'APPROVED' AND NEW.status = 'APPROVED' THEN UPDATE course SET current_enrolled = current_enrolled + 1 WHERE id = NEW.course_id AND current_enrolled < (SELECT max_capacity FROM course WHERE id = NEW.course_id); END IF; END$$ DELIMITER ; -- 退课后更新课程当前人数(WITHDRAWN为学生主动退课) DELIMITER $$ CREATE TRIGGER tr_update_enrolled_on_withdrawn AFTER UPDATE ON course_selection FOR EACH ROW BEGIN IF OLD.status = 'APPROVED' AND NEW.status = 'WITHDRAWN' THEN UPDATE course SET current_enrolled = current_enrolled - 1 WHERE id = NEW.course_id; END IF; END$$ DELIMITER ;参数说明与避坑点:
- 触发器必须用
AFTER UPDATE而非BEFORE:BEFORE中NEW.status可能被后续逻辑修改,AFTER确保状态已最终落库; UPDATE course语句中嵌套子查询(SELECT max_capacity ...)是安全的,因max_capacity为静态值,无并发风险;- 严禁在触发器中调用存储过程或访问其他表的复杂逻辑——MySQL触发器执行期间会持有行锁,长事务将阻塞整个表写入。
3. Java层架构设计:为什么分层不是为了炫技,而是为了隔离数据库变更风险?
3.1 分包结构与职责边界:拒绝“一个StudentDao.java包打天下”
项目采用标准MVC分层,但关键在于每层只依赖下一层的抽象接口,而非具体实现。目录结构如下(基于Maven标准):
src/main/java/ ├── cn.edu.example.sis/ # 根包名(虚构) │ ├── model/ # POJO类,与数据库表1:1映射 │ │ ├── Student.java # 字段名严格对应student表列名 │ │ ├── Course.java # 含transient字段如"available_slots" │ │ └── SelectionRecord.java # 封装选课状态机操作 │ ├── dao/ # 数据访问对象,只定义接口 │ │ ├── StudentDao.java # 接口,声明findByName()等方法 │ │ ├── CourseDao.java # 接口,声明findAvailableCourses()等 │ │ └── SelectionDao.java # 接口,声明submitSelection()等核心事务方法 │ ├── dao/impl/ # 接口实现类,此处才引入JDBC依赖 │ │ ├── JdbcStudentDao.java # 实现类,含Connection获取、PreparedStatement组装 │ │ ├── JdbcCourseDao.java # 实现类,含复杂JOIN查询 │ │ └── JdbcSelectionDao.java# 实现类,含@Transactional事务控制 │ ├── service/ # 业务逻辑层,组合多个DAO │ │ ├── StudentService.java # 协调StudentDao与SelectionDao │ │ ├── CourseService.java # 协调CourseDao与SelectionDao │ │ └── SelectionService.java# 核心服务:选课、退课、审核(含状态校验) │ └── controller/ # 控制层(本项目为命令行演示,故简化为MainApp) │ └── MainApp.java # 模拟用户交互流程注意:
dao/impl/包下的实现类不对外暴露,上层service只依赖dao/下的接口。这样当未来切换MyBatis或Hibernate时,只需重写impl/包,service层代码零修改。
3.2 JDBC连接池配置:HikariCP为何是唯一选择?
手动new Connection()在课程设计中尚可,但真实系统必须用连接池。HikariCP因其极简配置与极致性能成为事实标准。application.properties配置如下:
# 数据库连接配置 jdbc.url=jdbc:mysql://localhost:3306/sis_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=your_password jdbc.driver-class-name=com.mysql.cj.jdbc.Driver # HikariCP连接池参数(关键!) spring.datasource.hikari.maximum-pool-size=20 spring.datasource.hikari.minimum-idle=5 spring.datasource.hikari.idle-timeout=30000 spring.datasource.hikari.max-lifetime=1800000 spring.datasource.hikari.connection-timeout=3000 spring.datasource.hikari.leak-detection-threshold=60000参数说明与血泪经验:
maximum-pool-size=20:学生选课峰值并发通常<100,20连接足够,过大反而增加MySQL线程开销;leak-detection-threshold=60000(60秒):强制检测连接泄漏,若某Connection被getConnection()后未close()超60秒,HikariCP抛异常并打印堆栈——这是调试“连接耗尽”的后悔药;allowPublicKeyRetrieval=true:MySQL 8.0+默认禁用公钥检索,此参数解决Could not connect to address=(host=localhost)(port=3306)(type=master)错误。
3.3 核心事务方法:SelectionService.submitSelection()的完整实现
选课不是简单INSERT,而是包含前置校验→状态变更→触发器联动→历史归档的原子操作。JdbcSelectionDao.java中关键方法如下:
// JdbcSelectionDao.java public class JdbcSelectionDao implements SelectionDao { private final DataSource dataSource; // 由Spring注入HikariCP数据源 @Override @Transactional(rollbackFor = Exception.class) // Spring声明式事务 public boolean submitSelection(String studentId, int courseId) throws SQLException { String sql = "INSERT INTO course_selection (student_id, course_id, status) VALUES (?, ?, 'PENDING')"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, studentId); ps.setInt(2, courseId); int affected = ps.executeUpdate(); if (affected == 0) { throw new SQLException("选课申请插入失败,可能学号或课程ID不存在"); } // 获取生成的selection_id,用于后续归档 try (ResultSet rs = ps.getGeneratedKeys()) { if (rs.next()) { long selectionId = rs.getLong(1); // 记录初始状态到历史表(异步?不!必须同步保证ACID) insertHistoryRecord(conn, selectionId, studentId, courseId, null, "PENDING", "SYSTEM"); } } return true; } } // 归档方法(同一事务内执行) private void insertHistoryRecord(Connection conn, long selectionId, String studentId, int courseId, String oldStatus, String newStatus, String operator) throws SQLException { String histSql = "INSERT INTO course_selection_history " + "(selection_id, student_id, course_id, old_status, new_status, operator) " + "VALUES (?, ?, ?, ?, ?, ?)"; try (PreparedStatement ps = conn.prepareStatement(histSql)) { ps.setLong(1, selectionId); ps.setString(2, studentId); ps.setInt(3, courseId); ps.setString(4, oldStatus); ps.setString(5, newStatus); ps.setString(6, operator); ps.executeUpdate(); } } }逻辑说明:
@Transactional确保INSERT选课记录与INSERT历史记录在同一事务中,任一失败则全部回滚;ps.getGeneratedKeys()获取自增主键,避免二次SELECT LAST_INSERT_ID()引发竞态;insertHistoryRecord()传入Connection参数,保证与主操作共享同一数据库连接和事务上下文。
4. 避坑指南:那些让答辩老师当场皱眉的5个高频翻车点
4.1 现象:选课时偶尔出现“选课成功但课程余量没减”,且无法复现
原因:course.current_enrolled字段未用触发器维护,而是在Java层SELECT current_enrolled FROM course后计算+1再UPDATE。高并发下两个线程同时读到current_enrolled=59,均计算为60并写入,实际应为61。
解决:立即删除Java层的SELECT+UPDATE逻辑,严格使用2.3节的触发器。验证方法:用JMeter模拟100线程并发选同一门课,检查current_enrolled最终值是否等于成功选课数。
4.2 现象:student_id为'2023001'的学生能选'2023002'的课,但系统报“学号不存在”
原因:student_id字段在student表中定义为VARCHAR(12),但在Java实体类Student.java中声明为int studentId,导致'2023001'被转为整数再转回字符串时丢失前导零,变成'2023001'(看似一样,实则'02023001'会被截断)。
解决:Java实体类中studentId必须为String类型,且所有DAO层SQL参数用ps.setString()而非ps.setInt()。检查所有PreparedStatement的setXxx()方法与字段类型的匹配性。
4.3 现象:执行SELECT * FROM course_selection WHERE student_id='2023001'极慢,Explain显示type=ALL(全表扫描)
原因:未给student_id字段创建索引。虽然student_id是外键,但MySQL不会自动为其建索引(仅当定义FOREIGN KEY时才对引用列建索引,被引用列需手动建索引)。
解决:执行CREATE INDEX idx_student_id ON course_selection(student_id);。同理,为course_id、status等高频查询字段补索引。
4.4 现象:程序启动时报java.sql.SQLException: The server time zone value 'XXX' is unrecognized
原因:MySQL服务器时区与JDBC驱动不匹配。MySQL默认时区可能是SYSTEM(即系统时区),而JDBC驱动要求明确时区。
解决:在JDBC URL中强制指定时区:?serverTimezone=Asia/Shanghai。同时确认MySQL服务端时区:SELECT @@global.time_zone, @@session.time_zone;,若为SYSTEM,建议改为'+08:00'。
4.5 现象:course_selection表中出现status='APPROVED'但current_enrolled未增加的脏数据
原因:触发器tr_update_enrolled_on_approve中UPDATE course语句未加WHERE条件限制current_enrolled < max_capacity,导致超限选课仍被允许(虽INSERT成功,但触发器更新失败静默忽略)。
解决:检查触发器SQL,确保UPDATE语句包含容量校验条件。在触发器中添加ROW_COUNT()判断:IF ROW_COUNT() = 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Course capacity exceeded'; END IF;强制中断事务。
5. 进阶技巧:用MySQL事件调度器自动归档历史数据,告别手动清理
5.1 为什么需要自动归档?——解决“选课历史表爆炸式增长”问题
course_selection_history表记录每一次状态变更,一学期下来可达百万级。若长期不清理,不仅占用磁盘,更会使SELECT查询变慢(即使有索引,大表扫描成本仍高)。手动DELETE FROM ... WHERE operated_at < '2023-01-01'风险极高:
- 误删:
WHERE条件写错导致全表清空; - 锁表:
DELETE大表时长时间持有表锁,阻塞选课业务; - 性能差:单次
DELETE百万行,事务日志暴涨,MySQL卡死。
正确解法是分区表+事件调度器,实现“冷热分离、自动迁移”。
5.2 按月分区:让MySQL自己管理数据生命周期
MySQL 8.0+支持RANGE COLUMNS分区,按operated_at日期自动路由数据到对应分区:
-- 先修改历史表为分区表(需停服或在线重定义,此处演示停服方案) ALTER TABLE course_selection_history PARTITION BY RANGE COLUMNS(operated_at) ( PARTITION p202309 VALUES LESS THAN ('2023-10-01'), PARTITION p202310 VALUES LESS THAN ('2023-11-01'), PARTITION p202311 VALUES LESS THAN ('2023-12-01'), PARTITION p202312 VALUES LESS THAN ('2024-01-01'), PARTITION p_future VALUES LESS THAN (MAXVALUE) );效果:INSERT INTO course_selection_history (...) VALUES (...,'2023-10-15 14:30:00')自动写入p202310分区,无需应用层感知。
5.3 创建月度归档事件:凌晨2点自动迁移旧分区到归档库
假设你有另一个数据库sis_archive用于存放历史数据。创建事件每月底执行:
-- 开启事件调度器 SET GLOBAL event_scheduler = ON; -- 创建归档事件(每月1日02:00执行) DELIMITER $$ CREATE EVENT ev_archive_old_history ON SCHEDULE EVERY 1 MONTH STARTS '2023-10-01 02:00:00' DO BEGIN DECLARE done INT DEFAULT FALSE; DECLARE partition_name VARCHAR(64); DECLARE cur_date DATE; -- 计算需归档的分区名(上个月) SET cur_date = DATE_SUB(LAST_DAY(NOW()), INTERVAL 1 DAY); SET partition_name = CONCAT('p', DATE_FORMAT(cur_date, '%Y%m')); -- 将分区数据导出到归档库(使用MySQL原生命令,非SQL) -- 注意:此步骤需在MySQL配置中启用secure_file_priv,且路径需存在 SET @sql = CONCAT('INSERT INTO sis_archive.course_selection_history SELECT * FROM sis_db.course_selection_history PARTITION(', partition_name, ')'); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- 清空原分区(比DELETE快100倍,且不锁表) ALTER TABLE sis_db.course_selection_history TRUNCATE PARTITION ?; -- 注:TRUNCATE PARTITION语法在MySQL 8.0.23+支持,低版本需用DROP PARTITION END$$ DELIMITER ;关键参数说明:
ON SCHEDULE EVERY 1 MONTH STARTS '2023-10-01 02:00:00':事件在每月1日2点触发,避开选课高峰;TRUNCATE PARTITION:瞬间清空分区,不走事务日志,毫秒级完成,且释放磁盘空间;INSERT INTO ... SELECT ... PARTITION(...):精准迁移指定分区数据,避免全表扫描。
5.4 验证与监控:确保归档事件真正生效
事件创建后,需验证其状态与执行日志:
-- 查看事件状态 SELECT EVENT_NAME, STATUS, LAST_EXECUTED, ORIGINATOR FROM information_schema.EVENTS WHERE EVENT_SCHEMA = 'sis_db'; -- 查看最近10条事件执行日志(需开启general_log) SHOW VARIABLES LIKE 'general_log%'; SET GLOBAL general_log = 'ON'; -- 临时开启,调试后关闭我的习惯:每次部署新事件后,手动执行一次CALL mysql.event_scheduler_start();,并在日志中搜索ev_archive_old_history确认输出。线上环境我会在MainApp.java启动时加入一行健康检查:if (!eventExists("ev_archive_old_history")) throw new RuntimeException("归档事件未启用!");—— 这比答辩时被问“你们怎么保证历史数据不爆炸”更有说服力。
希望帮到你。
本文还有配套的精品资源,点击获取