☰
Java教务系统实战:排课冲突检测与选课并发控制
2026/10/9 8:54:07 网站建设 项目流程

简介:这份资源是面向高校计算机相关专业学生与Java Web开发初学者的数据学院教务管理系统完整源码,基于SSM框架与Vue前端技术构建,可用于课程设计、毕业设计或企业级项目练手。压缩包共465个文件,约8.88MB,其中128个Java文件承载后端业务逻辑,49个Vue组件与24个JS脚本构成前端交互界面,另有22个XML配置、1个SQL建表脚本及若干SVG、PNG等静态素材,覆盖从数据库到页面的完整链路。系统采用Spring、SpringMVC、MyBatisPlus与MySQL 5.7组合,前端引入ElementUI与Ajax异步通信,JDK版本为1.8,开发工具兼容Eclipse、MyEclipse与IDEA,并附带Maven依赖管理。资源内含用户信息、图片素材、视频素材等模块,目录结构清晰,便于按功能模块拆解学习。目前已有176人浏览学习,适合需要理解SSM整合流程、前后端分离实践或快速搭建教务管理原型的开发者参考。

1. 数据学院教务系统:从课表冲突到成绩归档,一套 Java Web 系统到底要扛住什么

每学期排课那两周,教务老师最怕的不是课多,而是同一间机房被三个班同时占上,或者一位老师被排到两栋楼连着的两节课。数据学院教务系统要解决的,就是这类看着琐碎、错一次就要全盘重排的问题。它本质是一套基于 Web 的教务管理平台,把学生、教师、课程、教室、班级、成绩这些实体用数据库串起来,再用 Java 后端把排课、选课、成绩录入、报表导出这些流程固化下来。适合谁看?正在做 Java 课程设计的学生、要交付一套教务管理系统的开发者,以及想拿一个真实业务练 Spring Boot + MyBatis 的工程师。源码层面的价值不在代码量,而在业务约束怎么落到表结构和接口上。

2. 教务系统的数据模型:先想清楚表关系,再谈 Java 代码

2.1 六个核心实体与它们之间的约束

教务系统翻车,十有八九是表没设计好。我一般先把实体列出来:学生(student)、教师(teacher)、课程(course)、教学班(teaching_class)、教室(classroom)、选课记录(course_selection)。注意“课程”和“教学班”是两回事——课程是“数据结构”这门课,教学班是“2024 秋 数据结构 周一 1-2 节 张老师 机房 A305”,一个课程可以开多个教学班。选课记录挂在教学班下面,不是挂在课程下面,这一点想错,后面成绩就没地方放。

关键约束有三条:一个学生同一学期不能选同一门课的两个教学班;一个教室同一时间段只能被一个教学班占用;一个教师同一时间段只能上一个教学班。这三条如果只靠 Java 代码判断,并发一上来就出问题,所以数据库层要加唯一索引兜底。

2.2 建表 SQL 与索引设计

下面这段 SQL 是教务系统里最核心的几张表,字段类型和索引都按实际排课场景调过:

-- 学生表:学号做主键,班级和入学年份用于分班查询 CREATE TABLE student ( student_id VARCHAR(20) PRIMARY KEY COMMENT '学号', name VARCHAR(50) NOT NULL, class_name VARCHAR(50) NOT NULL COMMENT '行政班', enroll_year INT NOT NULL, major VARCHAR(50) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 教学班表:课程+教师+教室+时间段的组合 CREATE TABLE teaching_class ( class_id BIGINT AUTO_INCREMENT PRIMARY KEY, course_id BIGINT NOT NULL, teacher_id VARCHAR(20) NOT NULL, classroom_id BIGINT NOT NULL, semester VARCHAR(20) NOT NULL COMMENT '如 2024-2025-1', day_of_week TINYINT NOT NULL COMMENT '1-7', start_slot TINYINT NOT NULL COMMENT '第几节开始', end_slot TINYINT NOT NULL, capacity INT NOT NULL DEFAULT 60, UNIQUE KEY uk_room_time (classroom_id, semester, day_of_week, start_slot), UNIQUE KEY uk_teacher_time (teacher_id, semester, day_of_week, start_slot) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 选课记录:学生与教学班的多对多 CREATE TABLE course_selection ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_id VARCHAR(20) NOT NULL, class_id BIGINT NOT NULL, select_time DATETIME DEFAULT CURRENT_TIMESTAMP, score DECIMAL(5,2) DEFAULT NULL, UNIQUE KEY uk_student_class (student_id, class_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

逻辑说明:teaching_class上的两个唯一索引是排课冲突的最后一道防线,Java 层先查再插,数据库再兜底,双保险。course_selection的uk_student_class防止重复选课,score允许为空表示还没录入。参数上,day_of_week用 1-7 而不是字符串,方便按周几做区间查询;start_slot和end_slot分开存,排课时判断时间重叠就是start_slot < other.end_slot AND end_slot > other.start_slot,比存一个字符串好算得多。

2.3 用 MyBatis-Plus 根据实体类反向生成建表语句

热搜里有人问“mybatisplus 根据 java 实体类生成创建表的 sql 语句”,教务系统开发初期确实可以这么干,省得手写一遍。做法是给实体类加注解,然后用 MyBatis-Plus 的TableInfoHelper拿元数据拼 SQL:

// 实体类上用 @TableName 和 @TableField 标注 @Data @TableName("teaching_class") public class TeachingClass { @TableId(type = IdType.AUTO) private Long classId; @TableField("course_id") private Long courseId; @TableField("day_of_week") private Integer dayOfWeek; // 省略其余字段 } // 生成建表语句的工具方法 public static String buildCreateTableSql(Class<?> entityClass) { TableInfo tableInfo = TableInfoHelper.getTableInfo(entityClass); StringBuilder sb = new StringBuilder("CREATE TABLE ") .append(tableInfo.getTableName()).append(" ("); tableInfo.getFieldList().forEach(f -> { sb.append(f.getColumn()).append(" ") .append(f.getType().toLowerCase()).append(", "); }); sb.setLength(sb.length() - 2); return sb.append(");").toString(); }

逻辑说明:TableInfoHelper.getTableInfo能拿到实体类映射的表名和字段列表,f.getType()返回 Java 类型,实际项目里要写一个 Java 类型到 MySQL 类型的映射表,比如Long -> BIGINT、String -> VARCHAR(255)。参数上注意@TableField不写时默认驼峰转下划线,但教务系统里classId这种字段最好显式写清楚,避免生成出class_id还是classid的歧义。这个工具只适合初期快速搭表,正式环境还是手写 SQL 加索引,因为自动生成管不了唯一约束和注释。

3. 排课与选课接口:Java 后端怎么把冲突挡在数据库之前

3.1 排课冲突检测的三种实现与选型

排课冲突检测,常见做法有三种:一是纯 Java 内存判断,把所有教学班查出来在内存里两两比对;二是每次插入前用 SQL 查重叠;三是数据库唯一索引兜底。我一般三个都用:内存判断用于给教务老师实时提示“这个时间段冲突了”,SQL 查询用于提交时的二次校验,唯一索引用于并发下的最终防线。

内存判断的代码大概长这样:

public boolean hasConflict(TeachingClass newClass, List<TeachingClass> existing) { for (TeachingClass c : existing) { if (!c.getSemester().equals(newClass.getSemester())) continue; if (!c.getDayOfWeek().equals(newClass.getDayOfWeek())) continue; // 时间重叠判断:新班开始 < 旧班结束 且 新班结束 > 旧班开始 boolean timeOverlap = newClass.getStartSlot() < c.getEndSlot() && newClass.getEndSlot() > c.getStartSlot(); if (!timeOverlap) continue; // 教室或教师任一相同即冲突 if (c.getClassroomId().equals(newClass.getClassroomId()) || c.getTeacherId().equals(newClass.getTeacherId())) { return true; } } return false; }

逻辑说明:先按学期和周几过滤,减少比对量;时间重叠用区间相交公式,注意是严格小于和大于,相邻两节不算冲突。参数上startSlot和endSlot是节次编号,比如 1-2 节就是 start=1、end=2,3-4 节是 start=3、end=4,这样 2 和 3 不重叠,符合直觉。这个方法的坑在于existing列表如果只查了当前学期的数据,跨学期排课就会漏判,所以查询条件里学期一定要带上。

3.2 选课接口的并发控制:别让最后一个人选超

选课是教务系统里并发最高的场景,开放选课那几分钟,几百人同时点。如果代码写成“先查容量再插入”,两个人同时查到还剩 1 个名额,就都插进去了,结果超选。正确做法是用数据库行锁或者乐观锁:

// 用 MyBatis-Plus 的 update 带条件更新,影响行数为 0 说明没抢到 @Update("UPDATE teaching_class SET capacity = capacity - 1 " + "WHERE class_id = #{classId} AND capacity > 0") int decreaseCapacity(@Param("classId") Long classId); // Service 层 @Transactional public void selectCourse(String studentId, Long classId) { int affected = teachingClassMapper.decreaseCapacity(classId); if (affected == 0) { throw new BizException("名额已满"); } courseSelectionMapper.insert(new CourseSelection(studentId, classId)); }

逻辑说明:decreaseCapacity把“判断容量”和“扣减容量”合并成一条原子 SQL,capacity > 0是条件,数据库保证同一行只有一个事务能扣成功。参数上@Transactional保证扣容量和插选课记录在同一个事务里,任何一步失败都回滚。坑在于如果选课记录插入因为唯一索引失败(重复选课),事务回滚会把容量加回去,这是对的,但要在异常处理里区分“名额已满”和“重复选课”,给前端不同提示。

3.3 成绩录入与批量导入的接口设计

成绩录入分两种:单个录入和 Excel 批量导入。单个录入简单,UPDATE course_selection SET score = ? WHERE student_id = ? AND class_id = ?。批量导入要注意的是,Excel 里学生可能填错学号,或者成绩超出 0-100 范围。我一般先解析 Excel 到内存,逐行校验,把错误行收集起来返回给前端,正确的行再批量更新。

public ImportResult batchImport(MultipartFile file, Long classId) { List<ScoreRow> rows = ExcelUtil.parse(file, ScoreRow.class); List<String> errors = new ArrayList<>(); List<ScoreRow> valid = new ArrayList<>(); for (int i = 0; i < rows.size(); i++) { ScoreRow r = rows.get(i); if (r.getScore() == null || r.getScore() < 0 || r.getScore() > 100) { errors.add("第" + (i + 2) + "行成绩不合法"); continue; } if (!studentMapper.existsById(r.getStudentId())) { errors.add("第" + (i + 2) + "行学号不存在"); continue; } valid.add(r); } if (!valid.isEmpty()) { courseSelectionMapper.batchUpdateScore(classId, valid); } return new ImportResult(valid.size(), errors); }

逻辑说明:i + 2是因为 Excel 第一行是表头,数据从第二行开始,报错行号要跟用户看到的对上。batchUpdateScore用 MyBatis 的<foreach>拼批量 UPDATE,比循环单条更新快一个数量级。参数上注意classId要传进来,防止把成绩更新到别的教学班。坑在于 Excel 解析时数字单元格可能被读成1.0这种浮点,成绩字段用BigDecimal接,别用Double。

4. 教务系统避坑:那些排课、选课、成绩录入里血泪经验

4.1 现象:排课保存成功但查出来时间不对

原因:day_of_week和start_slot在 Java 里用了Integer,前端传的是字符串"1",MyBatis 自动转换时如果字段类型不匹配,可能存进去变成 0 或者报错被吞掉。解决:Controller 层用@RequestBody接 DTO,DTO 里字段类型和数据库一致,加@NotNull校验,别直接用 Map 接参数。

4.2 现象:选课开放瞬间数据库连接池被打满

原因:每个选课请求都开了事务,事务里又有多次查询,连接持有时间过长。解决:把非核心查询移出事务,比如学生信息校验可以在事务外先做;连接池最大连接数调到 50-100,同时给选课接口加限流,用 Redis 或者本地Semaphore控制并发数。

4.3 现象:成绩批量导入后部分学生成绩没更新

原因:batchUpdateScore的<foreach>拼出来的 SQL 太长,超过了 MySQL 的max_allowed_packet,后半截被截断。解决:分批提交,每 500 条一批;或者调大max_allowed_packet到 16M。我一般选分批,不动数据库配置。

4.4 现象:教室唯一索引导致排课插入失败但提示不友好

原因:Java 层内存判断漏了某个条件,数据库唯一索引拦住了,抛的是DuplicateKeyException,前端看到一堆堆栈。解决:全局异常处理器里捕获DuplicateKeyException,根据索引名uk_room_time或uk_teacher_time返回“教室时间冲突”或“教师时间冲突”,别把原始异常抛给前端。

4.5 现象:学期切换后旧数据还能被选课接口查到

原因:选课接口查询教学班时没带semester条件,或者带了但前端传的学期格式和数据库不一致。解决:学期字段统一用2024-2025-1这种格式,接口层强制校验学期参数,查询 SQL 里semester必须作为必填条件,不能省。

5. 从能跑到好用:教务系统的验证方法与一个压测技巧

系统写完,怎么验证它真的能扛住选课?我一般分三步。第一步,功能验证:用 JUnit 写排课冲突的单元测试,把“同教室同时间”“同教师同时间”“相邻节次不冲突”“跨学期不冲突”这四种情况都覆盖,跑通再往下。第二步,数据验证:造一批测试数据,比如 200 个学生、20 个教学班,手动跑一遍选课流程,看容量扣减和选课记录是否一致,用 SQL 对账:

-- 对账:教学班已选人数是否等于选课记录数 SELECT tc.class_id, tc.capacity, (SELECT COUNT(*) FROM course_selection cs WHERE cs.class_id = tc.class_id) AS selected FROM teaching_class tc WHERE tc.capacity < 0 OR tc.capacity > 60;

这条 SQL 查的是容量异常(小于 0 或超过初始值)的教学班,正常情况应该返回空。第三步,压测:用 JMeter 或者写个简单的多线程脚本,模拟 200 个学生同时选同一个教学班,看最终选课记录数是否等于容量,有没有超选。我习惯用CountDownLatch写个 Java 压测类,比装 JMeter 快:

int threads = 200; CountDownLatch latch = new CountDownLatch(threads); ExecutorService pool = Executors.newFixedThreadPool(threads); for (int i = 0; i < threads; i++) { final String sid = "2024" + String.format("%04d", i); pool.submit(() -> { try { courseService.selectCourse(sid, 1L); // 教学班 ID 为 1 } catch (Exception e) { // 名额已满的异常忽略 } finally { latch.countDown(); } }); } latch.await(); // 最后查 course_selection 里 class_id=1 的记录数,应该等于 capacity

逻辑说明:CountDownLatch让所有线程尽量同时开始,模拟并发选课。参数上线程数设成容量的 3-4 倍,比如容量 60 就开 200 个线程,能压出超选问题。跑完查SELECT COUNT(*) FROM course_selection WHERE class_id = 1,如果大于 60,说明并发控制有漏洞,回去检查decreaseCapacity的 SQL 是不是漏了capacity > 0。

最后说个我自己的习惯:教务系统这类项目,我从来不在 Service 层写“先查后改”的逻辑,所有涉及数量、状态变更的操作,一律用带条件的原子 UPDATE,影响行数为 0 就抛异常。这个习惯帮我省了至少三次线上超选事故的后悔药。排课冲突检测可以放内存做提示,但最终提交必须过数据库唯一索引。希望帮到你。

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

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

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

立即咨询