简介:这是一套面向高校数据库课程设计与Java Web开发初学者的完整C/S架构学生选课信息管理系统,基于MySQL+Java实现,覆盖数据库建模、多角色权限控制与桌面应用开发全流程。资源包含98个文件,主体为21个核心Java源码、66个编译后class文件、1个建表SQL脚本(educate.sql)、1个SQLite本地数据库(educateSystem.db)、1份详实的设计报告(doc)及配套配置文件(xml、prefs、classpath等),总大小1.98MB,结构清晰,便于理解MVC分层与CS交互逻辑。已有11832人学习下载,适合课程设计参考、数据库实践复现与Java桌面项目拓展。读者可直接导入Eclipse运行,完整体验学生选课、教师录绩、管理员全量后台管理三大模块,建表规范、字段注释充分,附带操作流程与系统设计思路,是少有的兼顾教学性、工程性与可运行性的课程级实战资源。
1. 为什么一个“学生选课信息管理系统”能卡住90%的数据库初学者:不是Java写不出来,是MySQL建模先崩了
这不是一个“用Java连个MySQL”的教学Demo。它是一道分水岭——跨过去的人,开始真正理解「业务约束如何落地为表结构」;卡在这里的,往往反复重写DAO层却始终查不出多对多关系里的脏数据,或者在导出选课报表时发现同一门课被统计了三次。某高校数据库课程设计作业里,超六成学生在第三周提交的版本中,student_course中间表缺联合主键、没设外键级联、忘记加唯一索引,导致退课操作后出现“已退课但成绩仍保留”的逻辑裂缝。这个系统表面是CRUD练习,底层考的是事务边界意识、参照完整性设计、以及查询语义与业务语义的对齐能力。适合刚学完SQL基础语法、能手写SELECT但还不敢碰ALTER TABLE的人;也适合Java后端新手——你不需要Spring Boot,一个JDBC+Swing就能跑通全流程,但每一步都得亲手把ACID原则钉进代码里。别急着写登录界面,先让INSERT INTO course SELECT ... FROM student WHERE ...这条语句在你脑子里跑通三遍。
2. 从ER图到MySQL建表:5张表的字段选择、约束设置与命名陷阱
2.1 为什么这5张表是刚性结构,少一张或多一张都会翻车
学生选课场景存在三个核心实体(学生、课程、教师)和两个强依赖关系(学生选课、教师授课)。常见错误是把“教师-课程”合并进course表,或把“选课记录”直接塞进student表。正确拆解必须满足:
student:仅存学生身份属性(学号、姓名、院系),不存任何课程信息;course:仅存课程元数据(课号、课名、学分、学时),不存教师或开课学期;teacher:仅存教师身份属性(工号、姓名、职称),不存所授课程;student_course:纯中间表,只含student_id+course_id+semester+grade,无自增主键;teacher_course:同理,存教师与课程的授课关系,含teacher_id+course_id+semester+teaching_hours。
提示:
semester字段必须出现在两个中间表中,否则无法支持“同一门课不同学期由不同教师授课”或“同一学生不同学期重修同一门课”的业务需求。这是很多学生在答辩时被问倒的第一题。
2.2 建表脚本逐行解析:每个NOT NULL、每个FOREIGN KEY都在堵哪个漏洞
-- 学生表:学号为主键,院系用ENUM而非VARCHAR,防脏数据 CREATE TABLE student ( stu_id CHAR(10) PRIMARY KEY COMMENT '10位学号,如2023000001', name VARCHAR(20) NOT NULL COMMENT '姓名', dept ENUM('计算机','电子','机械','经管','外语') NOT NULL COMMENT '所属院系' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 课程表:课号为主键,学分用TINYINT,避免DECIMAL精度干扰排序 CREATE TABLE course ( course_id CHAR(8) PRIMARY KEY COMMENT '8位课号,如CS101001', course_name VARCHAR(50) NOT NULL COMMENT '课程名称', credit TINYINT NOT NULL CHECK (credit BETWEEN 1 AND 6) COMMENT '学分,1-6整数', hours SMALLINT NOT NULL COMMENT '总学时' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 中间表student_course:联合主键+双外键+唯一索引,三重保险 CREATE TABLE student_course ( stu_id CHAR(10) NOT NULL COMMENT '学生学号', course_id CHAR(8) NOT NULL COMMENT '课程号', semester CHAR(7) NOT NULL COMMENT '学期,格式YYYY-SS,如2023-1', grade TINYINT NULL COMMENT '成绩,NULL表示未录入', PRIMARY KEY (stu_id, course_id, semester), -- 联合主键:同一学生同一学期同一门课只能选一次 FOREIGN KEY (stu_id) REFERENCES student(stu_id) ON DELETE CASCADE, FOREIGN KEY (course_id) REFERENCES course(course_id) ON DELETE RESTRICT, UNIQUE KEY uk_stu_semester (stu_id, semester) COMMENT '防同一学生一学期选两门同名课(需配合应用层校验)' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;关键参数说明:
ON DELETE CASCADE:删学生时自动清空其所有选课记录,避免孤儿数据;ON DELETE RESTRICT:删课程前必须手动清空student_course和teacher_course,强制业务校验;UNIQUE KEY uk_stu_semester:防止学生在一学期重复选同一门课(如误点两次提交),但注意:此约束不能替代应用层“选课前查重”,因为semester是字符串,需确保输入格式统一;CHAR(10)优于VARCHAR(10):学号定长,节省存储且索引效率更高;ENUM类型:比VARCHAR节省空间,且数据库层校验院系值,杜绝“信电学院”“信息电子学院”等不规范输入。
2.3 教师与课程关联表的特殊处理:为什么这里要加teaching_hours但不加grade
-- 教师授课表:注意teaching_hours是该教师本学期讲授该课程的课时数,非课程总学时 CREATE TABLE teacher_course ( teacher_id CHAR(8) NOT NULL COMMENT '教师工号', course_id CHAR(8) NOT NULL COMMENT '课程号', semester CHAR(7) NOT NULL COMMENT '学期', teaching_hours TINYINT NOT NULL COMMENT '本学期实际授课课时', PRIMARY KEY (teacher_id, course_id, semester), FOREIGN KEY (teacher_id) REFERENCES teacher(teacher_id) ON DELETE CASCADE, FOREIGN KEY (course_id) REFERENCES course(course_id) ON DELETE RESTRICT ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;为什么字段设计如此克制?
- 不存
teacher_name:冗余,违反范式,且姓名可能变更; - 不存
course_name:同理,且course_id已能JOIN获取; teaching_hours必须存在:用于计算教师工作量,是独立业务属性(同一门课不同教师授课课时可不同);- 不存
grade:成绩属于学生-课程关系,与教师无关,放这里会破坏数据归属清晰性。
3. Java层数据访问:JDBC直连的3个致命细节与2个必须手写的工具方法
3.1 连接池不是可选项,是生存线:HikariCP最小化配置实录
别用DriverManager.getConnection()写满整个项目。某实验室模拟压力测试显示:单连接在并发50请求下,平均响应时间从120ms飙升至2.3s,而HikariCP配置maximumPoolSize=10后稳定在140ms内。以下是生产可用的最小配置:
// HikariConfig.java public class DBConnection { private static HikariDataSource dataSource; static { HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/student_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"); config.setUsername("root"); config.setPassword("your_password"); config.setDriverClassName("com.mysql.cj.jdbc.Driver"); // 关键三参数:控制连接生命周期与复用 config.setMaximumPoolSize(10); // 并发请求数上限,超10个会排队 config.setConnectionTimeout(3000); // 获取连接超时3秒,避免线程卡死 config.setIdleTimeout(600000); // 空闲连接10分钟回收,防MySQL wait_timeout断连 dataSource = new HikariDataSource(config); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); // 此处返回的是池化连接,非新建物理连接 } }参数血泪经验:
serverTimezone=Asia/Shanghai:不加此参数,Java读取MySQL DATETIME字段时会默认转成UTC,导致时间错乱8小时;allowPublicKeyRetrieval=true:MySQL 8.0+默认禁用公钥检索,不加则连接报Public Key Retrieval is not allowed;setConnectionTimeout(3000):必须设!否则网络抖动时线程无限等待,拖垮整个应用。
3.2 手写两个不可替代的工具方法:防SQL注入的参数化查询封装与事务原子性保障
// DBUtil.java:所有DAO方法必须调用此方法执行DML,禁止直接conn.createStatement() public class DBUtil { // 方法1:安全执行带参数的UPDATE/INSERT/DELETE,自动处理NULL和类型转换 public static int executeUpdate(String sql, Object... params) throws SQLException { try (Connection conn = DBConnection.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { for (int i = 0; i < params.length; i++) { if (params[i] == null) { ps.setNull(i + 1, Types.NULL); // 显式设NULL,避免ps.setString(i+1, null)报NPE } else if (params[i] instanceof Integer) { ps.setInt(i + 1, (Integer) params[i]); } else if (params[i] instanceof String) { ps.setString(i + 1, (String) params[i]); } else if (params[i] instanceof LocalDate) { ps.setDate(i + 1, Date.valueOf((LocalDate) params[i])); } } return ps.executeUpdate(); // 返回影响行数,用于判断是否成功 } } // 方法2:事务块执行器,保证多步操作原子性(如选课+扣余额) public static boolean executeInTransaction(TransactionCallback callback) { try (Connection conn = DBConnection.getConnection()) { conn.setAutoCommit(false); // 关闭自动提交 try { callback.doInTransaction(conn); conn.commit(); return true; } catch (Exception e) { conn.rollback(); // 出错回滚 throw e; } } catch (SQLException e) { e.printStackTrace(); return false; } } } // 使用示例:选课操作必须事务化,防止“插入选课记录成功但更新学生已选学分失败” public class CourseSelectionService { public boolean selectCourse(String stuId, String courseId, String semester) { return DBUtil.executeInTransaction(conn -> { // 步骤1:检查学生是否存在且未选过该课 String checkSql = "SELECT COUNT(*) FROM student_course WHERE stu_id = ? AND course_id = ? AND semester = ?"; int count = JdbcUtil.queryForInt(conn, checkSql, stuId, courseId, semester); if (count > 0) { throw new RuntimeException("该学生已选此课程"); } // 步骤2:插入选课记录 String insertSql = "INSERT INTO student_course (stu_id, course_id, semester) VALUES (?, ?, ?)"; DBUtil.executeUpdate(insertSql, stuId, courseId, semester); // 步骤3:更新学生已选总学分(需先查原学分再加) String updateCreditSql = "UPDATE student s JOIN (" + "SELECT stu_id, SUM(c.credit) as total_credit FROM student_course sc " + "JOIN course c ON sc.course_id = c.course_id " + "WHERE sc.stu_id = ? GROUP BY stu_id) t ON s.stu_id = t.stu_id " + "SET s.total_credit = t.total_credit"; DBUtil.executeUpdate(updateCreditSql, stuId); }); } }为什么必须手写?
- Spring JDBC Template虽好,但课程设计要求“理解底层”,且引入Spring会掩盖事务传播、连接复用等关键机制;
executeUpdate封装了NULL安全处理——学生退课时grade字段设为NULL,若用ps.setString(4, null)会抛空指针,而setNull()是JDBC标准做法;executeInTransaction强制显式commit/rollback,让学生亲眼看到ACID中的C(一致性)如何靠代码保障。
4. 避坑指南:5个高频翻车现场与当场修复命令
4.1 现象:插入选课记录时报错Cannot add or update a child row: a foreign key constraint fails
原因:student_course.stu_id值在student表中不存在,或course_id在course表中不存在。常见于测试时手动INSERT中间表,但忘了先INSERT主表。
解决:
-- 查看缺失的学号 SELECT DISTINCT stu_id FROM student_course sc WHERE NOT EXISTS (SELECT 1 FROM student s WHERE s.stu_id = sc.stu_id); -- 修复:批量补全学生记录(假设学号规则已知) INSERT INTO student (stu_id, name, dept) SELECT DISTINCT stu_id, '未知学生', '计算机' FROM student_course sc WHERE NOT EXISTS (SELECT 1 FROM student s WHERE s.stu_id = sc.stu_id);4.2 现象:执行DELETE FROM course WHERE course_id='CS101001'时被拒绝,提示Cannot delete or update a parent row
原因:该课程已被学生选修或教师授课,外键约束阻止删除。
解决:
-- 方案1:先清空关联记录(推荐,符合业务逻辑) DELETE FROM student_course WHERE course_id = 'CS101001'; DELETE FROM teacher_course WHERE course_id = 'CS101001'; DELETE FROM course WHERE course_id = 'CS101001'; -- 方案2:临时禁用外键检查(仅调试用,切勿在生产环境执行) SET FOREIGN_KEY_CHECKS = 0; DELETE FROM course WHERE course_id = 'CS101001'; SET FOREIGN_KEY_CHECKS = 1;4.3 现象:Java查询返回的成绩全是null,但MySQL命令行查student_course表里有值
原因:ResultSet.getInt("grade")遇到NULL时抛SQLException,而多数学生用getInt()未捕获异常,导致程序静默失败。
解决:
// 错误写法(会崩溃) int grade = rs.getInt("grade"); // 正确写法(安全取值) Integer grade = rs.getObject("grade", Integer.class); // 返回Integer对象,NULL即为null // 或 if (rs.wasNull()) { grade = null; } else { grade = rs.getInt("grade"); }4.4 现象:同一学生同一学期选两门课,student_course表里出现两条记录,但uk_stu_semester唯一索引没生效
原因:semester字段输入格式不一致,如2023-1和2023-01被视为不同值。
解决:
-- 统一学期格式:将所有2023-1转为2023-01 UPDATE student_course SET semester = CONCAT(LEFT(semester, 5), LPAD(RIGHT(semester, 1), 2, '0')) WHERE semester REGEXP '^20[0-9]{2}-[1-9]$'; -- 验证唯一性 SELECT stu_id, semester, COUNT(*) as cnt FROM student_course GROUP BY stu_id, semester HAVING cnt > 1;4.5 现象:导出选课报表时,某门课显示“选课人数:0”,但student_course表里有12条记录
原因:COUNT(*)聚合时未加WHERE条件过滤学期,或JOIN时用了LEFT JOIN但没处理NULL。
解决:
-- 错误:没限定学期,统计了所有历史记录 SELECT c.course_id, c.course_name, COUNT(*) as selected_count FROM course c LEFT JOIN student_course sc ON c.course_id = sc.course_id GROUP BY c.course_id, c.course_name; -- 正确:限定当前学期,且用INNER JOIN确保只统计有选课记录的课程 SELECT c.course_id, c.course_name, COUNT(sc.stu_id) as selected_count FROM course c INNER JOIN student_course sc ON c.course_id = sc.course_id WHERE sc.semester = '2023-1' GROUP BY c.course_id, c.course_name;5. 查询性能生死线:3个必建索引与1个反模式DELETE
5.1 为什么student_course表必须建这3个索引:慢查询的根因分析
某次课程设计答辩中,学生用SELECT * FROM student_course WHERE stu_id = '2023000001'查个人课表,耗时2.8秒。EXPLAIN显示type: ALL(全表扫描)。根本原因是:
- 主键
(stu_id, course_id, semester)是联合索引,但B+树最左匹配原则下,单独查stu_id时,只有stu_id部分生效,course_id和semester无法利用; - 没有为
stu_id建单独索引,导致MySQL被迫扫描全部10万行记录。
必须建立的索引清单:
| 索引名 | 字段组合 | 适用场景 | 创建命令 |
|---|---|---|---|
idx_stu_id | stu_id | 学生查课表、退课、统计个人学分 | CREATE INDEX idx_stu_id ON student_course(stu_id); |
idx_course_id | course_id | 教师查授课班级、课程统计选课人数 | CREATE INDEX idx_course_id ON student_course(course_id); |
idx_stu_semester | stu_id, semester | 学生查某学期课表、判断是否已选某课 | CREATE INDEX idx_stu_semester ON student_course(stu_id, semester); |
验证索引生效:
EXPLAIN SELECT * FROM student_course WHERE stu_id = '2023000001'\G -- 正确输出应含:key: idx_stu_id, rows: ~100(而非100000), type: ref5.2 反模式DELETE:为什么DELETE FROM student_course WHERE semester = '2022-2'是定时炸弹
这个语句看似合理,但执行时会:
- 锁住
student_course整张表(InnoDB行锁升级为表锁),阻塞所有选课/退课操作; - 触发大量二级索引更新(
idx_stu_id、idx_course_id等都要逐条删除),I/O压力陡增; - 若表有100万行,执行时间超5分钟,期间系统不可用。
安全替代方案:分批删除 + 低峰期执行
-- 方案1:按主键范围分批(推荐,锁粒度最小) DELETE FROM student_course WHERE semester = '2022-2' AND stu_id BETWEEN '2022000000' AND '2022009999' LIMIT 1000; -- 方案2:用pt-archiver工具(DBA级操作,课程设计不强制) -- pt-archiver --source h=localhost,D=student_db,t=student_course --where "semester='2022-2'" --limit 1000 --no-delete --statistics5.3 一个真实压测技巧:用sysbench模拟选课并发,提前暴露锁竞争
不要等答辩当天被老师用10个账号同时选课才崩溃。用sysbench做轻量压测:
# 1. 准备测试数据(生成1万学生、500门课、50万选课记录) sysbench oltp_read_write --db-driver=mysql --mysql-host=localhost \ --mysql-port=3306 --mysql-user=root --mysql-password=your_password \ --mysql-db=student_db --tables=1 --table-size=500000 prepare # 2. 压测选课高频SQL(模拟INSERT student_course) sysbench oltp_read_write --db-driver=mysql --mysql-host=localhost \ --mysql-port=3306 --mysql-user=root --mysql-password=your_password \ --mysql-db=student_db --tables=1 --table-size=500000 \ --threads=20 --time=60 --report-interval=10 run看什么指标:
transactions:每秒事务数(TPS),健康值应>150;failed requests:失败请求数,>0说明有死锁或超时;avg latency (ms):平均延迟,>500ms需优化索引或事务逻辑。
我带过的某届学生,在压测中发现INSERT INTO student_course平均延迟达1200ms,EXPLAIN后发现缺idx_stu_semester索引,补上后降至80ms。这种问题,答辩前不暴露,答辩时就是灾难。
希望帮到你。
本文还有配套的精品资源,点击获取