简介:面向高校计算机相关专业学生的教务管理系统数据库课程设计资料包,围绕 MySQL 与 Java 的数据库应用开发展开,适合正在学习数据库原理、Java 编程并需要完成课程设计的人群参考。压缩包共 28 个文件,约 4.45MB,以 Java 源码与编译后的 class 文件为主体,配合 properties 配置、jar 依赖、sql 建库脚本及项目工程文件,覆盖从数据库建模到程序实现的完整结构。已有 2649 人学习,说明其在同类课程设计资料中具有一定参考价值。内容侧重需求分析、数据库设计、系统实现与测试等环节,读者可据此理解教务管理场景下的表结构设计、实体关系梳理和 Java 端数据访问流程,也可在现有工程基础上修改完善,形成个性化实现。整体适合作为课程设计参考案例与开发流程对照材料。
1. 从选课季的一次超卖说起:这套教务系统到底要解决什么
选课开放头三分钟,一门容量 60 的专业课显示已选 63 人,教务老师拿着导出表格挨个打电话劝退。表面看是页面卡,根子在数据库:先查余量再更新,两条语句之间挤进几十个并发请求,超卖就发生了。数据库课程设计做教务管理系统(MySQL + Java),如果只把增删改查堆成界面,答辩时一句“选课超卖怎么防”就能问穿。这套方案要解决的是:在纯 JDBC 的体量里,把学籍、课程、选课、成绩、排课五块业务的数据关系理顺,靠约束、索引和事务边界守住一致性,最终交出一份能演示、能讲清每张表为什么这么设计的完整课设。
2. 需求拆解与数据建模:五张主表撑起学籍、课程与选课三条线
很多课程设计翻车不是代码写不出来,是建表阶段就埋了雷:学生表和选课表用姓名关联、成绩直接塞进学生表、课程和开课班混成一张表。等到要查“某学期某门课的通过率”时,才发现数据根本拆不出来。所以这一章先把业务边界和数据关系定死,后面写 DAO 才有依据。
2.1 角色与业务边界:学生、教师、教务三种视角怎么切
教务系统的用例看着多,归到角色上只有三类,每类角色的操作边界决定了表的读写权限:
- 学生:查自己的学籍信息、浏览开课列表、选课退课、查自己的成绩和课表。
- 教师:查自己任教的开课班、录入和修改成绩、导出班级名单。
- 教务:维护学生与教师档案、下达开课计划、设定选课时间窗、做统计报表。
把这三类视角画成矩阵,会发现一个关键结论:学生、教师都是“读多写少且只写自己那一行”的角色,真正的写冲突集中在选课和成绩录入两个动作上。这直接决定了后面的事务粒度——选课要锁开课班的名额计数,成绩只锁单条选课记录,其余操作走普通事务即可。
还有一个容易漏的边界:学期(term)必须是一等公民。所有业务数据都挂在某个学期下,退课、重修、成绩归档全靠它切分。我一般会把 term 设计成2024-2025-1这种字符串,比用自增 ID 更直观,导数据时也不容易错位。
2.2 表结构与关系:从 E-R 图落到建表语句
核心实体有五个:学生、教师、课程(课程本身的属性)、开课班(某学期某教师开的一门课)、选课记录(学生与开课班的多对多桥接)。课程和开课班必须拆开——一门“数据结构”可以被三位老师在不同学期各开一个班,容量、时间、教室都不同,混在一张表里会写出无数个冗余字段。
-- 学生表:学号做主键,班级和状态用于按届、按在校情况筛选 CREATE TABLE student ( student_id CHAR(12) NOT NULL COMMENT '学号', name VARCHAR(32) NOT NULL COMMENT '姓名', gender CHAR(1) NOT NULL DEFAULT 'U' COMMENT 'M/F/U', class_name VARCHAR(32) NOT NULL COMMENT '行政班级', enroll_year SMALLINT NOT NULL COMMENT '入学年份', status VARCHAR(12) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE/GRADUATED/SUSPENDED', PRIMARY KEY (student_id), KEY idx_class (class_name), KEY idx_year_status (enroll_year, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 开课班:容量、已选人数、时间槽都在这,选课并发的战场 CREATE TABLE teaching_class ( class_id BIGINT NOT NULL AUTO_INCREMENT, course_id CHAR(8) NOT NULL COMMENT '课程号', teacher_id CHAR(10) NOT NULL COMMENT '教师工号', term CHAR(12) NOT NULL COMMENT '学期,如 2024-2025-1', time_slot VARCHAR(16) NOT NULL COMMENT '时间槽,如 MON-3-4', location VARCHAR(32) NOT NULL, capacity SMALLINT NOT NULL DEFAULT 60, selected_count SMALLINT NOT NULL DEFAULT 0 COMMENT '已选人数,冗余计数', status VARCHAR(10) NOT NULL DEFAULT 'OPEN' COMMENT 'OPEN/CLOSED/CANCELLED', PRIMARY KEY (class_id), KEY idx_term_teacher (term, teacher_id), KEY idx_course (course_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 选课记录:学生与开课班的桥接表,成绩也挂在这里 CREATE TABLE enrollment ( enroll_id BIGINT NOT NULL AUTO_INCREMENT, student_id CHAR(12) NOT NULL, class_id BIGINT NOT NULL, enroll_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, status VARCHAR(10) NOT NULL DEFAULT 'SELECTED' COMMENT 'SELECTED/DROPPED', score DECIMAL(5,2) NULL COMMENT '成绩,NULL 表示未录入', PRIMARY KEY (enroll_id), UNIQUE KEY uk_stu_class (student_id, class_id), KEY idx_class_status (class_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;三段 DDL 里有三个设计决定值得在答辩时主动讲:selected_count是冗余计数,用空间换掉每次选课都要COUNT(*)的开销;uk_stu_class唯一索引把“同一学生重复选同一门课”这件事交给数据库兜底,应用层判重只是锦上添花;score允许为 NULL,用 NULL 表示“未录入”,比用 0 分表示更安全,避免统计平均分时把未录入的当成零分算进去。
2.3 约束与索引:让数据库替你挡住脏数据
课程设计最容易忽视的一点是:应用层校验可以被绕过(比如直接用客户端连库改数据),但数据库约束绕不过。我在建表阶段会强制加三类约束:
| 约束类型 | 具体做法 | 挡住的问题 |
|---|---|---|
| 主键 | 学号、工号、开课号全部做主键或自增主键 | 重复录入同一实体 |
| 唯一索引 | (student_id, class_id)、(term, teacher_id, time_slot) | 重复选课、教师同一时段被排两个班 |
| 外键 | enrollment.class_id引用teaching_class.class_id | 删开课班留下孤儿选课记录 |
外键在课设里经常被“性能原因”砍掉,但在这个数据量级下(几千学生、几百开课班),外键带来的完整性收益远大于那点开销。真要追求写入速度,也应该用ON DELETE RESTRICT而不是直接不要——至少保证删不掉的报错能被业务层捕获。
索引的取舍则要看查询。教务后台最常跑的三类查询是:按学期查开课列表、按学生查课表、按开课班统计成绩分布。对应的索引在 DDL 里已经埋好:idx_term_teacher服务排课冲突检测,idx_class_status服务成绩统计,idx_year_status服务学籍报表。索引不是越多越好,每加一个都会拖慢选课时的写入,够用就行。
提示:建表阶段就把
ENGINE=InnoDB和utf8mb4写死在脚本里,别指望服务器默认值,换台机器默认字符集变了,中文姓名就成问号。
3. 技术选型与总体架构:JDBC + DAO 三层结构怎么分职责
选型这件事上,课程设计和工程项目的判断标准不一样。工程项目追求可维护,课设追求每一层都能在答辩时讲清楚它为什么存在。上来就 Spring Boot + MyBatis,老师问一句“说说 MyBatis 的 SqlSession 生命周期”,答不上来反而扣分。这套方案用原生 JDBC 加一层薄薄的 DAO,把连接、事务、SQL 都握在自己手里,代码量可控,逻辑也透明。
3.1 分层与包结构:为什么先不引入 ORM
分三层:ui(控制台菜单或 Swing 界面)、service(业务规则与事务编排)、dao(纯 SQL 与结果集映射)。实体类entity横跨三层传递,工具类util放连接池和配置读取。包结构大致这样:
edu-system/ ├── src/main/java/ │ ├── entity/ Student, Teacher, Course, TeachingClass, Enrollment │ ├── dao/ StudentDao, TeachingClassDao, EnrollmentDao, ScoreDao │ ├── service/ EnrollService, ScoreService, ScheduleService │ ├── ui/ MainFrame / ConsoleMenu │ └── util/ DataSourceHolder, DbConfig └── src/main/resources/ ├── db.properties └── schema.sql不引 ORM 的代价是要手写结果集映射,收益是每个 SQL 的执行时机、参数、返回行数都看得见。像选课这种需要判断“更新影响行数”的场景,ORM 反而绕了一圈。DAO 层我会抽一个BaseDao提供queryList、queryOne、update三个模板方法,子类只写 SQL 和RowMapper,重复代码能压掉一半。
3.2 连接池与事务边界:一次选课要么全成要么全废
选课的业务动作包含两步写:扣减开课班名额、插入选课记录。这两步必须在一个事务里,否则扣了名额没插记录,名额就白白蒸发了,这种 bug 在演示时不一定暴露,但压测几轮就会露馅。
事务边界放在 service 层,DAO 层只负责执行,不碰commit。连接从池里取,用完归还,autoCommit在拿到连接后立刻关掉:
public class EnrollService { /** 选课:名额扣减 + 记录插入,同一事务内完成 */ public EnrollResult enroll(String studentId, long classId) { Connection conn = null; try { conn = DataSourceHolder.getConnection(); conn.setAutoCommit(false); // 事务起点 conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); // 第一步:原子扣减,影响行数为 0 说明名额满或已关闭 int affected = teachingClassDao.tryOccupy(conn, classId); if (affected == 0) { conn.rollback(); return EnrollResult.full(); // 满员,直接返回业务结果 } // 第二步:写选课记录,唯一索引兜住重复选课 enrollmentDao.insert(conn, studentId, classId); conn.commit(); return EnrollResult.ok(); } catch (SQLIntegrityConstraintViolationException dup) { rollbackQuietly(conn); return EnrollResult.duplicated(); // 唯一索引冲突 = 已选过 } catch (SQLException e) { rollbackQuietly(conn); throw new DataAccessException("选课失败", e); } finally { closeQuietly(conn); // 归还池,autoCommit 由池重置 } } }这里有两个细节。一是隔离级别选READ_COMMITTED而不是 MySQL 默认的REPEATABLE_READ:选课场景下不需要可重复读,反而会放大间隙锁的范围,高并发时更容易出现锁等待甚至死锁。二是把SQLIntegrityConstraintViolationException单独捕获——重复选课的判重不靠先SELECT再INSERT,直接让唯一索引报错,一次网络往返省下来,也彻底避开判重和插入之间的竞态。
连接池用 HikariCP 就够,配置文件里把maximumPoolSize设成 10 到 20 之间,课设的并发量根本用不满,设太大反而在演示时暴露出“连接没关”的问题被掩盖。
3.3 建库脚本与配置:从空库到可运行的两步
交付的包里schema.sql和db.properties是最容易被忽略、又最影响别人复现的两个文件。前者要能一条命令把库建起来,后者要让换台机器只改四行就能跑。
# db.properties —— 换环境只改这四行 jdbc.url=jdbc:mysql://localhost:3306/edu_system?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true jdbc.user=edu_app jdbc.password=change_me jdbc.pool.max=15# 初始化:建库 + 建表 + 灌演示数据,一条命令跑完 mysql -u root -p < schema.sql mysql -u root -p edu_system < seed_data.sqlschema.sql里除了建表,我会顺手塞几个视图:v_student_schedule(学生课表)、v_class_score_stat(开课班成绩统计)。视图的好处是答辩演示时可以直接SELECT * FROM v_student_schedule WHERE student_id='...',一句话展示结果,不用现场拼五表 JOIN。同时视图也把复杂 SQL 从 Java 代码里挪到了数据库侧,代码更干净。
注意:
serverTimezone不配会在写入DATETIME时报时区错误,这个坑几乎每个用 MySQL 8 的课设都会踩一次,写死在 URL 里省事。
4. 核心模块实现:选课并发、成绩统计、排课冲突的三段关键代码
前面两章定的是骨架,这一章落的是三个真正会被老师追问的实现点。选课考并发控制,成绩考统计 SQL 的写法,排课考时间冲突的表达方式——三块各有各的门道。
4.1 选课模块:一条带条件的 UPDATE 顶掉整段锁
最直觉的写法是先SELECT selected_count, capacity,Java 里比较一下,再UPDATE。这个写法在单机单人测试下永远正确,一上并发就超卖——两条语句之间有任何间隙,别的请求就能挤进来。血泪经验是:能在一条 SQL 里表达的原子性,就别拆成两条语句加锁。
-- 名额扣减:条件写在 WHERE 里,数据库保证判断与更新是原子的 UPDATE teaching_class SET selected_count = selected_count + 1 WHERE class_id = ? AND status = 'OPEN' AND selected_count < capacity; -- 返回影响行数:1 表示抢到名额,0 表示满员/已关闭/不存在-- 退课:名额回滚,同样用条件更新防止减成负数 UPDATE teaching_class SET selected_count = selected_count - 1 WHERE class_id = ? AND selected_count > 0;这两条语句的关键在于WHERE里的selected_count < capacity。InnoDB 在执行 UPDATE 时会对命中行加排他锁,判断和自增在同一个语句内完成,其他事务要么等待、要么看到更新后的值,不会出现两个事务都读到 59 然后都改成 60 的情况。相比SELECT ... FOR UPDATE再更新,这种写法少一次往返,锁持有时间也更短。
退课还要配合enrollment表的软删除:把status改成DROPPED而不是DELETE,一是保留选课历史便于审计,二是唯一索引uk_stu_class会把“退课后重新选同一门课”挡在门外。解决办法是把唯一索引改成(student_id, class_id, status)——但这样又允许出现两条 DROPPED。更常见的做法是退课时直接物理删除记录,或者唯一索引不带 status、重新选课时复用原记录把 status 改回 SELECTED。我倾向前者,简单直接,历史留痕交给日志表。
4.2 成绩模块:录入校验与班级通过率统计
成绩录入的校验规则不复杂但有讲究:分数必须在 0 到 100 之间、只允许录入SELECTED状态的选课记录、已归档学期不允许修改。前两条用 SQL 的WHERE一并解决,第三条靠 service 层判断学期状态。
-- 录入成绩:状态与分数范围都在 WHERE 里卡死 UPDATE enrollment e JOIN teaching_class tc ON e.class_id = tc.class_id SET e.score = ? WHERE e.student_id = ? AND e.class_id = ? AND e.status = 'SELECTED' AND tc.status <> 'ARCHIVED' AND ? BETWEEN 0 AND 100;统计查询是教务后台的高频场景,一条 SQL 把总数、及格数、平均分、通过率一起算出来,比在 Java 里循环累加优雅得多:
SELECT c.course_name, COUNT(*) AS total_cnt, SUM(CASE WHEN e.score >= 60 THEN 1 ELSE 0 END) AS pass_cnt, ROUND(AVG(e.score), 2) AS avg_score, ROUND(SUM(CASE WHEN e.score >= 60 THEN 1 ELSE 0 END) / COUNT(*) * 100, 2) AS pass_rate FROM enrollment e JOIN teaching_class tc ON e.class_id = tc.class_id JOIN course c ON tc.course_id = c.course_id WHERE tc.term = ? AND e.status = 'SELECTED' AND e.score IS NOT NULL -- 未录入的不参与统计 GROUP BY c.course_id, c.course_name ORDER BY pass_rate ASC; -- 通过率低的排前面,便于重点关注AVG会自动忽略 NULL,所以score IS NOT NULL这个条件在平均分上是冗余的,但在COUNT(*)上不冗余——如果不加,分母会把未录入的记录算进去,通过率被稀释。这种统计上的细微差别,答辩时讲出来是加分项。
4.3 排课模块:时间槽设计与冲突检测 SQL
排课冲突分两种:教师同一时段被排了两个班,同一个班的学生被排了两门同一时段的课。前者在排课时就能检测,后者要在选课时拦截。
时间槽的设计直接影响检测难度。我一般用MON-3-4这种格式,即“星期 + 节次区间”,把一门课一周两次上课拆成两条记录或者用逗号分隔。别用两个独立字段存星期和节次,跨节次的课(比如连上三节)就没法表达了。
-- 教师冲突:同一学期、同一时间槽,该教师是否已有其他班 SELECT COUNT(*) FROM teaching_class WHERE term = ? AND teacher_id = ? AND time_slot = ? AND class_id <> ? -- 排除自己,用于修改场景 AND status = 'OPEN';学生课表冲突的检测更绕一层,需要把待选开课班的时间槽与该生已选的所有开课班比对:
-- 选课前调用:返回冲突的课程名,空结果表示可以选 SELECT c.course_name, tc.time_slot FROM enrollment e JOIN teaching_class tc ON e.class_id = tc.class_id JOIN course c ON tc.course_id = c.course_id WHERE e.student_id = ? AND e.status = 'SELECTED' AND tc.term = ? AND tc.time_slot = (SELECT time_slot FROM teaching_class WHERE class_id = ?);这条查询放在选课事务之前做预检,冲突就提前返回友好提示;即使预检漏了,选课事务本身的名额扣减也不会被影响,只是学生课表会出现时间重叠——属于业务规则没兜住,不是数据一致性问题。真要彻底防住,得在enrollment上加一个触发器或者把时间槽冗余进选课表加唯一索引,但那样会让表结构变复杂,课设阶段权衡下来,应用层预检够用。
提示:时间槽字符串要统一补零对齐(
MON-03-04而不是MON-3-4),否则字符串比较会出错,这是排课模块最隐蔽的一类 bug。
5. 常见问题与排查:课程设计里最容易翻车的五个点
下面这五个坑,我在看过的课设里几乎每份都中过一两个。它们的共同点是:功能测试时全部正常,一到多人并发或者换台机器复现就出问题。
5.1 数据层面的三个坑
现象一:压测时选课人数超过容量。原因是用“先查后改”代替了原子更新,两条语句之间被其他事务插入。解决办法是把判断条件写进UPDATE的WHERE,检查影响行数;如果业务复杂到必须多步判断,就用SELECT ... FOR UPDATE锁住目标行,并且把锁的范围控制到最小,别锁整张表。
现象二:中文姓名存进去变成问号或乱码。原因通常是三层字符集不一致——连接 URL 没写characterEncoding、建表没指定utf8mb4、客户端默认字符集是 latin1。排查顺序是从外到内:先SHOW VARIABLES LIKE 'character_set%'看服务端,再SHOW CREATE TABLE student看表,最后确认 JDBC URL。三处统一成utf8mb4才彻底解决,只改一处往往只是把乱码挪到了另一个环节。
现象三:统计报表的数字对不上。八成是 NULL 在作怪。AVG(score)会跳过 NULL,COUNT(score)也会跳过 NULL,但COUNT(*)不会。写统计 SQL 时先想清楚分母是谁,是“所有选课记录”还是“已录入成绩的记录”,两者算出来的通过率能差十几个百分点。排查办法是把COUNT(*)和COUNT(score)一起查出来对比,数字不等就说明有未录入的。
5.2 代码与演示层面的两个坑
现象四:演示到一半报“连接池耗尽”。原因基本是某条异常路径没关连接——try块里抛异常,finally里没写close,或者commit之后忘了归还。这个问题在单次操作时看不出来,连续点十几下就露馅。解决办法是把连接的获取和关闭收进 try-with-resources 或者统一的模板方法,业务代码里不出现裸的getConnection。排查时可以在池配置里打开泄漏检测,把超过阈值未归还的调用栈打出来。
现象五:本地跑得好好的,拷到同学电脑上启动就崩。九成是路径和配置写死了:db.properties用绝对路径加载、数据库名硬编码、字符集依赖系统默认值。解决办法是把配置文件放进resources用类加载器读,数据库连接信息全部外置,schema.sql开头加上DROP DATABASE IF EXISTS和CREATE DATABASE ... DEFAULT CHARACTER SET utf8mb4。交付前一定在另一台机器上从零跑一遍,这一步省不得,也是我每次交作业前强制走的流程。
6. 验收与答辩:一份能照着走完的演示脚本和数据核对清单
课设答辩通常只有八到十分钟,讲不完所有模块。我的做法是提前写好一份演示脚本,按“一条主线 + 两个亮点”组织:主线走完学生选课到教师录成绩的完整闭环,亮点放在并发控制和统计查询上。
演示前先跑一遍数据核对,四条 SQL 覆盖全部关键约束:
-- 1. 名额是否超卖:有任何一行返回就说明出过问题 SELECT class_id, capacity, selected_count FROM teaching_class WHERE selected_count > capacity OR selected_count < 0; -- 2. 是否存在重复选课 SELECT student_id, class_id, COUNT(*) FROM enrollment GROUP BY student_id, class_id HAVING COUNT(*) > 1; -- 3. 是否存在孤儿选课记录(开课班已删) SELECT e.* FROM enrollment e LEFT JOIN teaching_class tc ON e.class_id = tc.class_id WHERE tc.class_id IS NULL; -- 4. 是否存在超出 0-100 的成绩 SELECT * FROM enrollment WHERE score < 0 OR score > 100;四条全部返回空,数据才算干净。演示时的顺序建议是:先展示表结构和索引(讲设计),再跑一次正常选课(讲流程),接着开两个客户端同时抢最后一个名额(讲并发),最后跑统计查询出通过率(讲 SQL)。全程控制在六分钟内,剩下时间留给提问。
答辩最常被问的三个问题,提前准备好答案:为什么课程和开课班要拆表、唯一索引和业务判重为什么要同时存在、事务隔离级别为什么不用默认值。这三个问题答得出来,基本就稳了。
注意:演示用的数据库提前灌好种子数据,别现场从空库开始录,时间根本不够,而且现场出错很难圆回来。
从那以后我每次交课设,都会在答辩前一晚把schema.sql在一个全新环境里从零跑一遍,再用上面四条核对 SQL 扫一次,确认没有脏数据才敢合眼。这套习惯帮我躲过好几次演示当场翻车。希望帮到你。
本文还有配套的精品资源,点击获取