SpringBoot选课排课系统:并发控制与冲突检测
发布时间:2026/11/5 8:04:37 锦皓数字建站

简介面向高校毕业设计与课程实践场景这套 SpringBootVue 选课/排课管理系统提供可直接运行的完整工程适合需要完成选题、答辩演示或学习前后端分离开发的学生与初级开发者。后端基于 SpringBoot 构建 RESTful 接口结合 MySQL 完成课程、学生、教师与排课等数据增删改查并涉及 JWT 身份验证、MyBatis 或 JPA 持久化思路前端采用 Vue.js 的组件化与响应式绑定配合 Vuex 状态管理和 Axios 请求实现用户管理、课程管理、学生选课、教师排课时段与教室冲突规避、选课数据统计等模块。交付内容包含源码、SQL 脚本、设计文档与答辩 PPT文档覆盖架构设计、功能模块、数据库设计与技术选型PPT 便于梳理演示思路压缩包约 17.55MB文件总数与类型明细暂未随页面提供。已有 561 人学习可作为理解 SpringBoot 与 Vue 协作方式、对照数据库脚本和项目结构的实践样本。1. 从一份跑不起来的毕设说起选课排课系统到底该交付什么做 java毕业设计 的同学里有一批人卡在同一个环节压缩包解压了文档也翻过了IDE 一打开数据库连不上、SQL 脚本报错、前端 404最后只留下一个能登录的登录页。这套基于 springboot选课 与 springboot排课 双模式的管理系统交付的不是一个能跑就行的空壳而是从数据模型、接口分层到冲突校验的完整链路任何一段都能拿出来单独讲清楚。它适合两类人一类是要在规定时间内交出一份能答辩的 java毕业设计另一类是带课程设计、想把系统改造成自己学校或培训机构场景的开发者。选课和排课看起来只是两条业务分支但它们的难点完全不同——选课的难点在并发和名额排课的难点在时间与资源的组合约束把这两条线分开设计才不会在后期被改一个功能全线崩拖住。2. 需求与角色拆解三类用户、两条业务主线和一份能扩展的数据模型2.1 三类角色与两条业务主线怎么切选课排课系统的角色划分不复杂管理员、教师、学生三类就够撑起全部用例复杂的是权限边界。管理员管的是全局配置包括学期、开课计划、教室、时间段和账号教师管的是自己的教学班包括查看选课名单、录入成绩、申请调课学生管的是自己的课表包括选课、退课、查学分。这三类人的入口是同一套系统但能看到的菜单、能调的接口必须彻底分开否则一个学生账号能调教师接口答辩时随便点两下就露馅。业务主线有两条而且它们的生命周期不同步。选课主线是教学班发布 → 开放选课 → 学生提交 → 名额扣减 → 截止统计节点集中在几周内压力集中在开放选课的头几分钟。排课主线是课程计划 → 教师与教室分配 → 时间段编排 → 冲突检测 → 课表发布节点在学期开始前压力集中在配置阶段。把两条线塞进同一个 Controller 是最常见的偷懒做法后果是选课业务需要的高并发处理被排课业务的复杂查询拖慢后期想给选课加个缓存都无处下手。通常的做法是拆成course和schedule两个业务包各自有独立的 Service 与数据表只在教学班这个实体上做关联。2.2 数据模型八张核心表怎么摆数据表是整个系统里最不该省的部分。毕设答辩时导师最爱问的就是你的表怎么设计的答不上来基本就废了。这套模型的思路是把课程和教学班拆开course是课程本身的属性比如课程名称、学分、课程类型course_class是某学期实际开出来的一个班带教师、容量、时间、教室。一门课可以开多个班学生选的是班而不是课这个区分不做后面所有统计都会错。-- 课程主表只描述课程本身不涉及学期 CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_code VARCHAR(32) NOT NULL UNIQUE COMMENT 课程编号如 CS1001, course_name VARCHAR(128) NOT NULL COMMENT 课程名称, credit DECIMAL(3,1) NOT NULL DEFAULT 0 COMMENT 学分0.5 的粒度, course_type TINYINT NOT NULL DEFAULT 1 COMMENT 1必修 2选修 3公选, pre_course_id BIGINT NULL COMMENT 先修课程 id可为空, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) COMMENT 课程基础信息; -- 教学班真正被选的对象容量、教师、学期都挂在这里 CREATE TABLE course_class ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL COMMENT 关联 course.id, teacher_id BIGINT NOT NULL COMMENT 授课教师, term_id BIGINT NOT NULL COMMENT 学期 id, class_no VARCHAR(32) NOT NULL COMMENT 教学班号如 2024-1-CS1001-01, capacity INT NOT NULL DEFAULT 60 COMMENT 容量上限, selected_num INT NOT NULL DEFAULT 0 COMMENT 已选人数与选课表冗余但便于扣减, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1已发布 2选课中 3已截止, UNIQUE KEY uk_class_no (class_no), KEY idx_term_teacher (term_id, teacher_id) ) COMMENT 教学班选课的最小单位; -- 选课记录唯一索引是防重复选的最后一道闸 CREATE TABLE course_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, class_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1已选 2已退 3候补, select_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_class (student_id, class_id, status), KEY idx_class (class_id) ) COMMENT 学生选课记录;上面三张表是核心另外还需要student、teacher、classroom、time_slot、schedule五张表。time_slot单独建表而不是直接在排课表里存周一第 1-2 节这种字符串原因是冲突检测要做区间比较字符串没法比大小。把时间段固化成slot_index第几节课从 1 开始加start_time/end_time冲突判断就退化成整数区间重叠一条 SQL 就能查完。selected_num这个冗余字段要不要留是新手最容易纠结的点。留的理由是扣减名额时不必每次COUNT(*)全表扫而且能做乐观锁代价是必须和course_selection强一致。我一般的处理是在选课 Service 里把两个更新放在同一事务内并额外加一个每天晚上跑的校对任务把selected_num重算一遍写日志出现偏差能第一时间发现。提示如果课程设计要求做志愿抽签式选课比如热门公选课先申请再抽签course_selection的status要扩成 4 个状态并把唯一索引改成只对有效记录生效。这个改动要在建模阶段就想清楚后期加会动到一半的查询。2.3 状态机与业务规则清单教学班的状态流转必须固定下来不能允许任意跳转。0 草稿 → 1 已发布 → 2 选课中 → 3 已截止 → 4 已结课只允许顺序推进和有限的回退草稿可删已发布未选课时可回退到草稿。很多毕设的翻车点就在这学生能选到一个已截止的教学班或者管理员把已选满的班删掉外层报外键错误。把状态判断写在 Service 层而不是前端前端只做置灰后端才是真正的守门人。业务规则清单建议直接写成代码注释或配置答辩时拿出来讲很有说服力规则校验位置违反时的处理教学班容量未满Service 乐观锁返回名额已满提示可加入候补课程时间不冲突选课时对已选课表做区间比较返回冲突的具体课程与节次学分上限如 30 学分/学期选课前累计已选学分返回当前已选学分与剩余额度先修课程已通过选课前查成绩表返回缺失的先修课程名同一课程不重复选数据库唯一索引兜底捕获唯一键冲突转成友好提示这五条规则里前三项在前端也要做一次提示但孙后端必须再校验一遍。前端校验是体验后端校验是底线这个关系想反了答辩时演示一个用 Postman 直接调接口绕过去的场景就很难看。3. 技术架构与工程骨架SpringBoot 分层、Vue 路由与鉴权链路3.1 后端分层与包结构后端用 SpringBoot 做主框架好处不是流行而是它把毕设需要的那些东西——拦截器、事务、参数校验、定时任务——都收在一个容器里不用自己拼。分层坚持 Controller / Service / Mapper 三层不要为了炫技加一堆中间层。Controller 只做参数接收与响应包装Service 承载业务规则和事务边界Mapper 只写 SQL任何一层越界都会让后期维护变成灾难。包结构按业务域划分而不是按技术类型划分这是我这几年踩过坑之后固定下来的做法com.example.course ├── common/ # 统一响应体、异常、常量、工具 ├── config/ # 拦截器、跨域、MyBatis、Swagger 配置 ├── auth/ # 登录、JWT 生成与校验 ├── course/ # 课程与教学班controller/service/mapper/entity ├── schedule/ # 排课时间段、教室、冲突检测 ├── selection/ # 选课选、退、候补、统计 └── system/ # 用户、角色、菜单、日志按技术类型划分所有 Controller 一个包、所有 Service 一个包在文件少的时候看着整齐一旦业务到十来个模块改一个需求要在四个包里来回跳这是纯粹的自找麻烦。3.2 鉴权链路JWT 拦截器 前端路由守卫登录成功后签发 JWTtoken 里只放用户 id、角色和过期时间不要把权限列表塞进去否则权限一改就得等 token 过期。后端写一个HandlerInterceptor在preHandle里解析 token、校验签名和过期时间再把用户信息放进ThreadLocalService 层取当前用户就不必层层传参。Component public class AuthInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 预检请求直接放行否则浏览器跨域会先挂掉 if (HttpMethod.OPTIONS.matches(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { writeJson(response, 401, 未登录或登录已过期); return false; } try { // 解析失败会抛异常统一在这里转成 401 LoginUser user jwtUtil.parse(token.substring(7)); UserContext.set(user); // ThreadLocal 存当前用户 return true; } catch (Exception e) { writeJson(response, 401, 登录状态无效请重新登录); return false; } } Override public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object handler, Exception ex) { // 必须清理否则线程池复用时会串用户这是最隐蔽的一类线上事故 UserContext.clear(); } }afterCompletion里的清理是重点。Tomcat 的线程是复用的ThreadLocal不清理下一个请求可能读到上一个用户的身份本地小流量测不出来答辩演示多账号切换时也不一定暴露但这是一个真实存在的数据越权隐患。参数上Authorization头统一用Bearer前缀前端封装请求时自动带上避免每个页面手写。前端用 Vue 配套的路由守卫做第二道拦截路由 meta 里标roles: [ADMIN]全局beforeEach里判断本地存的角色是否匹配不匹配就跳回首页。这道拦截只为体验服务真正的权限判定在后端接口层两边都做才算完整。3.3 前端工程结构与请求封装前端按页面维度组织views下分admin、teacher、student三个目录各自的路由懒加载。所有接口调用集中放在api目录一个业务模块一个文件页面里不出现裸的axios.get。这样接口路径变更时只改一处也方便后面写 mock。// src/utils/request.js —— 统一请求封装 import axios from axios import router from /router const request axios.create({ baseURL: process.env.VUE_APP_BASE_API, // 从环境变量读方便换环境 timeout: 15000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) request.interceptors.response.use( res { const body res.data // 后端统一返回 {code, msg, data}code 非 200 一律当业务失败 if (body.code ! 200) { return Promise.reject(new Error(body.msg || 请求失败)) } return body.data }, err { // 401 单独处理清 token 并回登录页避免死循环刷新 if (err.response err.response.status 401) { localStorage.removeItem(token) router.replace(/login) } return Promise.reject(err) } ) export default request这段封装做了三件事统一注入 token、统一剥壳返回data、统一处理 401。timeout给 15 秒是经验值排课冲突检测这种接口如果超过 15 秒还没返回基本是 SQL 写得有问题早报错比让用户干等强。页面里调用就变成const list await getClassList(params)拿到的直接是数据不用每处都写res.data.data。4. 核心模块实现选课并发控制、排课冲突检测与课表生成4.1 选课三条防超卖的路线怎么选选课是这套系统里唯一存在真实并发压力的地方。开放选课的那几分钟几十上百个学生同时点同一个热门班如果代码写成先查人数、再判断、再插入两次查询之间被别人插队就会出现超卖——容量 60 的班选进 63 个人。这个问题不解决答辩时导师问一句你怎么保证不超卖场面会很尴尬。三条常见路线我一般按项目规模选第一条是数据库唯一索引 计数校验最简单适合并发不高的课程设计。靠uk_student_class防重复选靠UPDATE ... WHERE selected_num capacity的受影响行数判断是否抢到名额一行 SQL 天然原子。第二条是乐观锁在course_class加version字段更新时带版本号失败重试一到两次。适合读多写少、冲突概率中等的场景。第三条是 Redis 预扣库存适合真要做高并发的演示。代价是引入额外组件还要处理缓存与数据库的一致性毕设里如果不是特别要求不建议上讲清楚原理即可。实际落地时我通常把第一条和第二条合起来用Service public class SelectionServiceImpl implements SelectionService { Autowired private CourseClassMapper classMapper; Autowired private CourseSelectionMapper selectionMapper; Override Transactional(rollbackFor Exception.class) public void select(Long studentId, Long classId) { // 第 1 步原子扣减名额受影响行数为 0 说明已满或被并发抢走 int affected classMapper.decreaseStock(classId); if (affected 0) { throw new BizException(该教学班名额已满可选择候补); } try { // 第 2 步插入选课记录唯一索引兜底防重复 selectionMapper.insert(studentId, classId); } catch (DuplicateKeyException e) { // 重复选课会让事务回滚第 1 步的扣减一并撤销 throw new BizException(你已经选过这门课了); } } }对应的 Mapper SQL 是UPDATE course_class SET selected_num selected_num 1 WHERE id #{classId} AND selected_num capacity AND status 2。三个条件缺一不可selected_num capacity保证不超卖status 2保证只在选课中的班可被选。Transactional的rollbackFor Exception.class也别省Spring 默认只回滚运行时异常漏了这行受检异常会导致名额扣了但记录没插进去。这里有一个容易被忽略的细节两条语句的顺序必须是先扣名额、后插记录。反过来先插记录再扣名额扣减失败时虽然事务也能回滚但唯一索引冲突和名额不足会撞在一起异常信息很难区分排查起来就是黑匣子。顺序固定下来出错时能一眼看出是哪一步挂的。4.2 排课冲突检测把时间变成可比较的区间排课的难点不在写代码在把规则说清楚。冲突分三类教师冲突同一教师同一时间上两个班、教室冲突同一教室同一时间排两门课、班级冲突同一个教学班被排到两个时间段。三类冲突的本质都是同一资源在同一时间区间内被占用两次所以只要把时间统一映射成(week_start, week_end, day_of_week, slot_start, slot_end)五元组判断就退化成区间重叠。public class ScheduleValidator { /** * 判断两个排课时间段是否冲突 * param a 待排时间段 * param b 已存在的时间段 * return true 表示冲突 */ public static boolean isConflict(TimeRange a, TimeRange b) { // 周次不重叠直接放行如前 8 周和后 8 周 if (a.getWeekEnd() b.getWeekStart() || b.getWeekEnd() a.getWeekStart()) { return false; } // 星期不同不算冲突 if (!a.getDayOfWeek().equals(b.getDayOfWeek())) { return false; } // 同一天看节次区间是否重叠a.start b.end b.start a.end return a.getSlotStart() b.getSlotEnd() b.getSlotStart() a.getSlotEnd(); } }区间重叠的判定条件a.start b.end b.start a.end是这套逻辑的核心写反了或者漏了等号就会出现第 1-2 节和第 2-3 节被判为不冲突这种玄学 bug。周次判断放在最前面是因为它的剪枝效果最好大部分候选方案在第一步就被排除后面两次比较几乎不花时间。实际查询时不必把所有排课记录捞到内存里跑循环直接在 SQL 里过滤更快-- 查询某教师在指定周次、星期、节次范围内是否已有排课 SELECT COUNT(1) FROM schedule WHERE teacher_id #{teacherId} AND day_of_week #{dayOfWeek} AND week_start #{weekEnd} AND week_end #{weekStart} AND slot_start #{slotEnd} AND slot_end #{slotStart} AND status ! 9; -- 9 表示已取消不参与冲突判断教师、教室、教学班三个维度各跑一次任意一个返回大于 0 就拒绝。三次查询放在同一事务里中间不要穿插写操作避免刚查完就被别人插入的竞态。真要做严谨schedule表上按(teacher_id, day_of_week)和(classroom_id, day_of_week)建联合索引查询能压到毫秒级。4.3 课表生成与导出课表不是存出来的是算出来的。数据库里存的是排课记录哪门课、哪个时间、哪个教室学生课表是按学生已选的教学班把这些记录拼成一张二维表行是节次列是星期一到星期日。前端拿到的结构通常是一个{ 1-1: {...}, 1-2: {...} }的 mapkey 是星期-节次value 是课程信息渲染时按格子填。导出这块毕设里常要求 Excel。做法是用 EasyExcel 或 POI把二维表按 7 列 × N 行的格式写出去第一节合并单元格写第 1-2 节。这里有个小坑合并单元格的区域要用CellRangeAddress显式声明只写值不合并导出的表看起来会像错位。另外中文文件名在响应头里要 URL 编码否则部分浏览器下载下来是乱码这个问题在答辩现场演示时最容易出洋相。5. 避坑与排查环境、事务、联调三类最容易翻车的地方5.1 环境与启动换台电脑就跑不起来现象源码在自己电脑上正常换到同学机器或者答辩机上一启动就报Access denied for user或Unknown database。原因配置文件里的数据库账号密码、端口、库名是写死的SQL 脚本也没按顺序执行。这类问题占了跑不起来故障的一大半。解决把application.yml拆成application-dev.yml和application-prod.yml账号密码走环境变量或本地覆盖交付时附一份README写清楚执行顺序先建库、再跑建表脚本、最后跑初始化数据脚本。初始化数据里至少要有一个管理员账号否则登录页进不去很多人卡在这一步以为是代码问题。注意SQL 脚本里不要用DROP DATABASE用CREATE DATABASE IF NOT EXISTS加DROP TABLE IF EXISTS。前者在某些环境下会因为权限不足直接中断后者可控得多。5.2 事务不生效名额扣了但记录没进去现象选课接口偶尔出现教学班人数涨了但学生课表里没有这门课。原因三种情况最常见——Transactional加在 private 方法上同类内部方法自调用this.xxx()绕过代理异常被catch住没往外抛事务认为一切正常于是提交了。解决注解只加在public方法上跨方法调用走注入的 Bean 而不是thiscatch里要么重新抛出要么手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。这三条我都吃过亏其中自调用那条最隐蔽代码看起来完全正常跑起来就是不回滚。5.3 联调与数据口径前端显示的人数对不上现象列表页显示已选 60/60点进去还能选或者统计报表里的选课总人次和教学班人数之和对不上。原因列表查的是course_class.selected_num详情查的是course_selection的COUNT(*)两个数据源没同步退课逻辑只改了status没减selected_num。解决把有效选课数的定义固定在一处要么统一用冗余字段要么统一用实时统计不要混用。退课接口里改状态和减人数必须在同一事务。再加一个定时校对任务每天凌晨重算一次selected_num并打印差异日志跑上一周就能发现所有口径不一致的地方。5.4 时间与格式导出乱码、日期差一天现象Excel 导出的中文文件名变成乱码或者课表里的日期比数据库里少一天。原因响应头没做 URL 编码MySQL 的DATETIME和 Java 的LocalDateTime在序列化时区没对齐或者前端new Date()解析字符串时用了本地时区。解决文件名URLEncoder.encode(name, UTF-8)并把替换成%20全局配置 Jackson 的时区和格式spring.jackson.time-zoneGMT8与spring.jackson.date-formatyyyy-MM-dd HH:mm:ss前后端统一用yyyy-MM-dd这种带前导零的格式。这类问题不影响功能但演示时特别扎眼。6. 验收与答辩用一张自检清单把系统讲成自己的东西验收不是把功能点一遍而是按角色—流程—边界三个层次走一遍。角色层验证权限隔离用学生账号直接访问管理员接口必须返回 403流程层验证主链路发布教学班 → 学生选课 → 名额扣减 → 退课 → 名额回滚每一步都查数据库确认状态对得上边界层验证极端情况容量为 1 的班两个账号同时抢、选课截止后再选、排课时故意填一个冲突时间段。三层走完系统能不能经得起问基本就有数了。答辩前建议按下面这张表自查一遍每一项都能对应到具体的页面或接口检查项验证方法期望结果登录与鉴权不带 token 调任意业务接口返回 401不返回业务数据权限隔离学生 token 调教师接口返回 403日志有记录选课防超卖容量 1 的班两账号并发选仅一人成功另一人提示名额已满重复选课同一账号连点两次选课第二次提示已选过数据只有一条排课冲突同教师同时间排两个班第二次保存被拒绝并提示冲突对象退课一致性退课后查教学班人数人数减 1课表同步消失数据导出导出学生课表 Excel中文不乱码节次合并正确答辩时最容易被追问的三个点提前准备好答案一是并发怎么处理的答数据库原子更新加唯一索引兜底事务保证两步一致二是冲突检测的判定条件是什么把a.start b.end b.start a.end这个区间重叠公式写出来三是权限怎么防越权答后端拦截器加 Service 层角色校验前端守卫只做体验。这三个问题答得利索基本就过关了。还有一个细节很加分演示前把数据库重置成初始状态准备两三个不同角色的账号用手写的时间线把流程串一遍别临场找菜单。我见过太多人系统做得不错演示时手忙脚乱点错页面导师的第一印象直接打折。血泪经验是每次交付前我都会在一台干净的机器上从零走一遍建库 → 导数据 → 启动后端 → 启动前端 → 登录 → 跑主流程中间任何一步需要额外操作就补进 README。从那以后我每次交东西都强制走一遍这套流程再没出现过在我电脑上是好的这种尴尬。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。