Spring Boot学生选课系统实战:从数据库设计到并发扣减踩坑记录
发布时间:2026/10/2 3:08:31来源:尧图网络
帮人做管理系统这类项目我前后接过好几个但学生选课管理系统是其中最能体现“业务细节决定成败”的一个。上学期帮朋友团队做了一套基于Spring Boot的学生选课管理系统从需求梳理、数据库设计到并发压测改进前后折腾了大概三周最后不光顺利交了课程设计还被一个内部实验班拿去实际用了一轮。这个过程中踩了不少坑也沉淀了一些值得分享的方案。这篇文章就把这套系统的设计思路、核心实现和踩坑记录完整写下来。如果你正在做类似的课设、毕设或者想在短时间内搭出一套完整可用的Spring Boot项目这篇应该能让你少走很多弯路。1. 需求剖析选课业务的矛盾不在技术在流程1.1 三类角色与核心诉求选课系统看起来只是“学生选课、管理员管理”六个字可真到落地的时候三个角色的诉求是完全不一样的。学生最关心的就三件事能不能快速查到课程、能不能顺利选上、不想选的时候能不能退。很多初版系统只做了“增删改查”结果选课的时候要么查不到课程实时容量要么退了课容量不恢复学生马上就炸了。教师端其实需求更简单基本就是看自己开了哪些课、导出选课学生名单。真正复杂的是管理员要维护课程基础信息、设置每个课程的开课学期和容量、控制选课时间段、看选课统计。这套系统在设计的时候没有盲目堆功能先把角色边界画清楚学生端、教师端、管理员端三套视图互不越权。学生只能看到“当前学期、状态为选课中”的课程教师只能维护自己名下的课程管理员拥有全部基础数据管理权限。这样权限一收紧后面很多安全问题天然就少了一半。1.2 功能边界与用例清单做课设或者真实交付时最怕的是把需求无限扩大。选课系统如果再加上绩点计算、排课算法、学分预警复杂度会成倍上升但对大部分场景来说根本没这个必要。我们最终确定的功能清单是这样的学生登录、浏览可选课程、按课程名/教师名搜索、选课、退课、查看已选课程列表教师登录、查看我的课程、查看某门课程的选课学生名单管理员登录、课程信息维护、学生信息维护、发布/关闭选课、查看全局选课统计这个范围看起来朴素但已经覆盖了选课业务的主链路。实际测试的时候所有用例都围绕“一门课从创建到被选满再到退课回收容量”这条线走一遍逻辑通了系统的大头就完成了。2. 技术选型Spring Boot、持久层与前端方案的取舍2.1 为什么主框架是Spring Boot选课系统这种典型的管理类Web应用Spring Boot几乎是当前最稳妥的答案。它内嵌Tomcat不用单独部署容器自动配置省掉了一堆XMLMaven依赖管理让项目可以被任意一台装了JDK的机器直接构建。更关键的是生态成熟遇到任何问题几乎都能搜到对应方案。版本选择上我个人建议用Spring Boot 2.7系和JDK 8而不是盲目上新版Spring Boot 3.x。虽然3.x已经稳定但它强制要求JDK 17很多读者本机还是JDK 8环境跑不起来会卡在第一步。如果只是做管理系统这类CRUD密集型项目2.7.18功能完全够用文档也最多踩坑成本最低。2.2 持久层选型MyBatis-Plus 还是 JPA这是每次做项目都要纠结一遍的题。我的选择是MyBatis-Plus。原因很直接它的BaseMapper能省掉大量重复的单表SQL同时保留自定义SQL的能力适合选课系统这种“既有简单CRUD又有复杂条件查询”的场景。Spring Data JPA写起来也快但碰到多表联查和动态SQL时要么写JPQL要么写原生SQL反而绕远路。MyBatis-Plus还有一个实际优势分页插件开箱即用。学生端查询课程列表往往要支持分页JPA需要自己封装PageableMyBatis-Plus配置一个PaginationInnerInterceptor就完事对新手友好得多。2.3 前端与项目分层前端我选了Thymeleaf做服务端渲染配合Bootstrap。后台管理类系统不太需要SPA的交互复杂度服务端渲染的好处是部署简单、不需要配Node环境、也不会出现跨域问题。一个jar包跑起来直接访问页面给课程设计答辩演示的时候非常稳。项目目录结构保持经典分层Controller接口层、Service业务层、Mapper数据层、Entity实体、DTO传输对象、Interceptor拦截器。不要为了炫技引入一堆设计模式分层清晰、命名规范比任何花活都实用。3. 数据库设计选课系统的表结构与约束设计3.1 用户与课程表设计数据库是这类系统最值得花时间的地方。选课系统的核心表其实只有四张学生表student、教师表teacher、课程表course、选课记录表select_course。管理员可以直接放一张单独的admin表不用硬塞进用户体系里搞统一认证课设场景没那个必要。课程表是业务核心字段设计一定要提前想清楚容量和时间段。下面是关键字段的建表SQLCREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL COMMENT 课程编号, course_name VARCHAR(100) NOT NULL COMMENT 课程名称, teacher_id BIGINT NOT NULL COMMENT 授课教师ID, credit DECIMAL(3,1) DEFAULT 2.0 COMMENT 学分, capacity INT NOT NULL DEFAULT 60 COMMENT 课程容量, selected_count INT NOT NULL DEFAULT 0 COMMENT 已选人数, week_day TINYINT NOT NULL COMMENT 星期几1-7, begin_section TINYINT NOT NULL COMMENT 开始节次, end_section TINYINT NOT NULL COMMENT 结束节次, term VARCHAR(20) NOT NULL COMMENT 开课学期如2025-2026-1, status TINYINT NOT NULL DEFAULT 0 COMMENT 0未发布 1选课中 2已结束, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里容易被忽视的是week_day begin_section end_section三个字段它们共同决定了课程的上课时间是后面做“选课冲突检测”的数据基础。还有一种更标准的设计是单独建一张上课时间表一门课多个时间点但课程设计场景用三个字段搞定每周一次上课已经足够。3.2 选课表与联合唯一键选课记录表是整个系统防重复选课的“最后一道保险”。核心建表SQL如下CREATE TABLE select_course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL COMMENT 学生ID, course_id BIGINT NOT NULL COMMENT 课程ID, select_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;关键就是uk_student_course这个联合唯一索引。哪怕业务代码里漏掉了“是否已选过”的判断数据库层也能兜住不会出现同一个人把同一门课选两次的记录。这个设计在后端并发场景里极其重要后面讲并发踩坑时还会再提到。3.3 时间冲突检测的字段方案时间冲突检测逻辑是选课系统里比较有业务含量的部分。思路是学生选课时取出他所有已选且状态正常的课程逐条判断是否存在时间重叠。判断规则是“两门课在同一星期几且上课节次区间有交集”。节次区间的重叠判断用一个很简单的条件a.begin_section b.end_section AND b.begin_section a.end_section。如果成立就说明冲突。这个条件等价于两个闭区间有交集比写一堆if else清晰得多。如果后续要支持单门课程多个时间段只需要把时间信息拆成子表冲突检测逻辑遍历子表即可扩展时不会伤筋动骨。4. 核心实现登录鉴权、选课/退课接口与管理端4.1 登录与角色拦截器认证方案我用的是Session 拦截器没有引入Spring Security。课设级别项目引入Security会带来大量配置噪音而拦截器十几行代码就够用。实现思路登录接口校验账号密码后把用户ID和角色放入Session自定义拦截器拦截所有/student/**、/teacher/**、/admin/**路径分别检查对应的角色。如果没登录就跳转到登录页角色不对就跳转到无权访问页面。public class StudentInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object role request.getSession().getAttribute(role); if (role ! null student.equals(role)) { return true; } response.sendRedirect(/login); return false; } }这类代码写起来虽简单但要注意别忘了在WebMvcConfigurer里注册拦截器并配置好放行路径。否则静态资源和登录页会被一起拦住就会遇到“明明能访问登录页却一直跳登录页”的经典问题。4.2 学生选课接口事务与容量校验选课接口是整个系统里唯一有并发压力的地方。核心流程是四步判断课程状态、检查是否已选过、检查容量、检查时间冲突全部通过后插入选课记录并更新课程已选人数。第一版我写的是典型的“先查后改”代码结果压测时出了问题后面第5节会具体说。这里直接上修正后的稳定版本核心是利用条件更新语句保证容量不会被超卖Transactional public Result selectCourse(Long courseId) { Long studentId UserContext.get().getId(); Course course courseMapper.selectById(courseId); if (course null || course.getStatus() ! 1) { return Result.error(课程不存在或不在选课时间内); } // 检查是否已选过 Integer count selectCourseMapper.countByStudentAndCourse(studentId, courseId); if (count ! null count 0) { return Result.error(你已经选过这门课了); } // 检查时间冲突 ListCourse selectedCourses courseMapper.selectCoursesByStudentId(studentId); for (Course selected : selectedCourses) { if (isTimeConflict(course, selected)) { return Result.error(上课时间与[ selected.getCourseName() ]冲突); } } // 条件更新已选人数小于容量才更新成功 int rows courseMapper.increaseSelectedCount(courseId); if (rows 0) { return Result.error(课程已满选课失败); } selectCourseMapper.insert(new SelectCourse(studentId, courseId)); return Result.success(选课成功); }increaseSelectedCount对应的SQL是UPDATE course SET selected_count selected_count 1 WHERE id #{courseId} AND selected_count capacity这段UPDATE保证只有剩余名额大于0时才会更新成功数据库层面挡住了超选的可能。注意我在Service方法上加了Transactional这样插入选课记录和更新课程人数要么一起成功要么一起回滚不会出现“课程人数加了但选课记录没有”的脏数据。4.3 退课与容量回收退课的复杂度比选课低很多但同样要在事务里做两件事删除选课记录、恢复课程已选人数。需要注意删除操作要按student_id course_id定位防止学生退掉别人的课。这里有一个容易出问题的细节先判断这条选课记录是否存在如果不存在直接提示“未选该课”。因为前端表格中每个学生只能看到自己已选的课正常情况不会出现误传但接口是公开的不能假设请求一定来自正常页面流程。4.4 管理端课程发布与统计管理员端本质是两个功能课程维护和选课状态控制。课程维护就是常规的CRUD用MyBatis-Plus的BaseMapper可以直接省掉大部分SQL。选课状态控制更值得讲管理员把课程状态从0改为1学生端才看得到这门课选课统一结束后改为2学生端就不能再操作。这个状态字段设计得好选课时间窗口就等价于status1的时间段并不需要额外的时间配置表。统计报表部分我用了一个简单的聚合查询SELECT c.course_name, t.name AS teacher_name, c.capacity, c.selected_count, (c.selected_count / c.capacity * 100) AS percent FROM course c LEFT JOIN teacher t ON c.teacher_id t.id WHERE c.term #{term} ORDER BY percent DESC拿到百分比后后端渲染成Bootstrap进度条管理员一眼就能看出哪些课热门、哪些课没人选演示效果也很好。如果想做得更炫一点可以把这份数据接给ECharts画柱状图这就是后续的扩展点了。5. 并发踩坑抢课瞬间的超选与重复选问题5.1 问题复现与根因第一次压测就暴露问题了。我用JMeter模拟50个学生同时对同一门只剩1个名额的课程发起选课请求理论上只能有1个人成功结果数据库里插进去了3条选课记录课程表的selected_count变成了3。根因很典型第一版逻辑里的“容量校验”分了两个步骤——先用SELECT查capacity和selected_count判断“是否已满”再用UPDATE执行selected_count1。在并发场景下多个请求同时做了SELECT都发现还剩1个名额于是都认为自己能选随后都执行了UPDATE。这就是经典的“先查后改”竞态问题。只要两步之间存在间隙并发请求就能钻空子。5.2 方案一条件更新挡住超卖修复超卖最有效的办法是把容量校验和更新合并成一条原子SQL也就是第4节里写的selected_count capacity条件更新。InnoDB执行UPDATE时会自动对目标行加行锁因此50个并发请求最终会串行执行只有第一个请求能把1改成2后面的请求全部因为条件不满足而更新0行。这种方案实际工程实践里叫“乐观锁的SQL变体”虽然它本质上靠行锁实现但代码层面没有任何显式锁的痕迹简单粗暴性能损耗也可控。不是所有场景都适用但选课系统的容量扣减用它基本是最优解。5.3 方案二唯一约束与幂等兜底容量超卖解决之后压测又暴露出第二个问题同一个学生并发点了两次同一门课生成了两条选课记录。这个问题的根源在于“是否已选过”的判断也是先查后改同样存在竞态窗口。这也是我没采用事务内手动加锁方案的原因之一。修复方式就是在数据库层面加uk_student_course联合唯一索引。插入第二条重复记录时MySQL会直接报DuplicateKeyExceptionService方法里捕获一下转换成友好提示“你已选过这门课”。这样代码不用反复查库性能还更好。记住一句话能用数据库约束解决的问题不要指望业务代码在并发情况下永远正确。public Result selectCourse(Long courseId) { try { // ...业务校验和条件更新 selectCourseMapper.insert(new SelectCourse(studentId, courseId)); } catch (DuplicateKeyException e) { return Result.error(你已经选过这门课了); } return Result.success(选课成功); }5.4 事务边界与异常回滚并发问题修完后我顺手梳理了一遍所有涉及写操作的方法把事务边界检查了一遍。最容易踩的坑是Transactional只对当前方法体里的异常生效如果内部调用同一个类的另一个方法Spring AOP代理会失效导致事务不生效。选课方法里我直接让所有数据库操作都在同一个Service方法内完成避免自调用问题。另一个细节是事务里不要写耗时的外部调用。比如选课成功后如果想发站内信或短信通知一定不要放在同一个事务里否则一个通知接口卡顿会导致整个事务长时间占用数据库连接选课高峰直接拖垮系统。正确做法是事务内只做必要的数据变更事务提交后异步通知或者干脆不做通知。6. 跑通与交付源码、文档和部署实操指南6.1 环境准备与配置清单如果拿到源码想自己跑起来环境其实很常规。我用的是JDK 8、Maven 3.6、MySQL 5.7、IDEA这几样组合最稳。创建一个course_selection数据库后执行项目里附带的init.sql完成建表、初始化和测试数据的导入。配置文件application.yml里重点检查数据源这一段server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/course_selection?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 mvc: hiddenmethod: filter: enabled: true mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true这里有一个我常强调的细节连接MySQL的URL一定要加serverTimezoneAsia/Shanghai否则新版MySQL驱动默认连接美国时区时间字段全部差8小时。这个坑新手基本都会踩排查半天发现是时区问题。6.2 初始化数据与账号init.sql里我预置了三类账号方便演示管理员admin / admin123学生student001 / 123456教师teacher001 / 123456。课程数据准备了8门课覆盖了不同教师、不同容量、不同上课时间其中特意设了一门口味冲突的课和一门只剩1个名额的课用于现场演示选课冲突和选满拦截的效果。这种设计很管用。课程设计答辩时老师随机找个学生账号去操作每一步都有数据支撑而不是对着空表演示增删改查。6.3 运行与测试的完整流程启动项目最简单的方式是命令行mvn spring-boot:run如果想调试直接IDEA里运行主类CourseSelectionApplication。启动成功后访问http://localhost:8080/系统会自动跳转到登录页。建议按照这个顺序做一轮完整验收测试先用管理员登录查看所有课程数据把一门课程状态改成“选课中”退出登录用学生账号登录确认能在课程列表里看到这门课选课成功后进入“我的课程”页面确认记录存在再点一次同一门课确认被“已选过”拦截选一个与已选课程时间冲突的课确认被冲突检测拦截退掉已选课程重新进入课程列表确认“已选人数”减1用教师账号登录查看该课程学生名单里新增了刚才的学生这七步走完系统的核心链路就没有问题了。6.4 后续扩展选课发布、冲突检测增强与统计大屏如果想让系统更上一层楼有几个方向值得做。最实用的是引入Spring Schedule定时任务管理员预置一个选课开始时间和结束时间系统自动切换课程状态不用人工半夜守在后台点开关。再进一步可以做学生端选课时间窗口配置不同年级分批选课避免所有学生同一时间涌入。冲突检测也可以做得更精细。当前版本只判断星期和节次区间如果有的学校分单双周上课就要在course表加一个week_type字段冲突判断时先过滤单双周是否重叠。统计报表如果数据量大可以换成从库查询或引入缓存把这些操作从主库解耦出来。我个人的经验是选课系统这类CRUD项目技术难度永远不是瓶颈对业务的理解和遇到问题时的排查思路才是真正拉开差距的地方。整个项目做下来最值钱的部分恰恰是那个并发抢课的问题排查过程。把这段经历吃透哪怕以后不做选课系统做任何有库存、有并发扣减的业务你都会第一时间想到条件更新、唯一约束、事务边界这三板斧。
网站建设高端定制企业官网