Spring Boot做大学生课程选课管理系统,这个编号为springboot152的课题在高校毕设和课程设计里都算常客,相关代码网上一搜一大把,但真正能稳定跑起来、业务逻辑经得起推敲的版本并不多。我前段时间完整做了一遍类似的系统,从需求分析、数据库建模到并发控制,踩了不少坑,也整理出一套比较靠谱的落地思路。这篇就把整个设计和实现过程拆开讲,给正在做选课系统或者想拿Spring Boot练手的同学一个直接从代码层面可参考的方案。
先亮一个观点:选课系统的难度根本不在增删改查,而在"选课"这个动作本身的并发与一致性。同开放选课时段几千人抢一个容量几百的课程列表,怎么保证余量不超卖、同一学生课表不冲突、选课记录不重复,这些才是系统的灵魂,也是面试官最愿意追问的点。
1. 从需求出发:选课系统到底在解决什么问题
1.1 角色与功能矩阵
做任何系统之前,先把角色和功能的边界画清楚。这个选课系统我按"角色-功能"拆成三个维度:
| 角色 | 主要功能 | 设计要点 |
|---|---|---|
| 学生 | 登录、浏览课程、选课、退课、查看课表、查看成绩、修改密码 | 选课是核心动作,退课要有时间限制 |
| 教师 | 登录、查看授课列表、维护课程信息、录入/修改成绩、查看选课名单 | 成绩录入要防重复,批量录入要事务保护 |
| 管理员 | 学生管理、教师管理、课程管理、选课轮次配置、选课统计 | 负责全局参数,比如开放时间、课程容量 |
这个拆分看起来很直白,但很多项目就是在这一步偷懒,把课程管理和选课逻辑混在一个Controller里,后面加功能就越来越痛。我坚持每个角色独立Controller,权限层面再用拦截器隔离,后续扩展新功能时改动范围是可控的。
还有两个需求细节容易被忽略:第一,选课要有时间窗口,非开放期不能选也不能退;第二,课程要有"未开放/选课中/已结束"三种状态,管理员配置后才能进入选课期。这些都属于业务规则的骨架,应该在一开始就设计进数据模型里,而不是写到页面里临时判断。
1.2 技术选型:为什么是Spring Boot + MyBatis + MySQL
选课系统属于典型的中小型Web应用,并发量不会高到需要分布式架构,但业务逻辑比企业展示站复杂得多,Spring Boot正好命中这个量级。选择具体组件时我的判断如下:
- 后端框架用Spring Boot,理由很直接:内嵌Tomcat、自动配置、Starter机制,一个Maven依赖就搞定Web环境,比传统SSH省掉大量XML配置。开发效率在课设和毕设这种时间敏感的场景下是最重要的。
- 持久层用MyBatis而不用JPA,核心原因是SQL可控。选课统计要查"课程+教师+已选人数",复杂的多表查询写SQL比JPQL直观;另外国内绝大多数同类项目都用MyBatis,遇到问题找参考更顺利。
- 数据库用MySQL,免费稳定,5.7及以上版本支持行级锁和事务,对选课这种并发更新场景足够。
- Redis在第一版里没有引入。选课高峰对数据库的压力可以用"带条件的原子UPDATE"加连接池调优解决,没必要为这个规模引入额外组件。
前端方案上,我选了Thymeleaf做服务端渲染,而不是前后端分离。一个人全栈开发时,Thymeleaf直接渲染课程列表和选课结果,开发效率更高;选课系统的页面复杂度不高,不需要Spa。而且服务端渲染的类似项目在社区里资料最多,遇到问题容易搜到答案。
1.3 工程分层与包结构
项目采用经典的Controller-Service-Mapper三层结构,包结构长这样:
com.example.course ├── controller # 参数接收和结果封装,不写业务逻辑 ├── service # 业务逻辑,事务边界都在这一层 │ └── impl ├── mapper # MyBatis Mapper接口 ├── entity # 数据库实体类 ├── dto # 前端交互数据传输对象 ├── common # 统一返回结果、全局异常、工具类 ├── config # 拦截器、静态资源配置、时间格式化 └── CourseApplication.java分层原则我一直在强调:Controller不写SQL、不写业务判断,只做参数校验和调用;Service不写页面跳转,只处理业务与事务;Mapper的SQL必须确定返回类型。实际开发时把规则定清楚,比事后重构省心得多。一个容易踩的坑是Service里夹带Controller的ModelAndView逻辑,一旦耦合,接口复用和单元测试都会很痛苦。
2. 数据库设计:把并发和约束提前揉进表里
2.1 核心表结构与字段说明
数据库是选课系统的地基。大量跑不起来的项目,问题几乎都出在表设计。我给出一个经过验证的最小核心表集合。
学生表student字段:id主键、student_no学号唯一、name姓名、password加密后密码、department_id所属系、create_time创建时间。教师表teacher结构和学生表类似,teacher_no唯一即可。
课程表course是这个系统里最核心的表,字段包括:id主键、name课程名、teacher_id授课教师外键、capacity容量、selected_count已选人数、credit学分、status选课状态。其中selected_count承担着容量判断的重任,是后面处理并发选课的关键字段。
课程时间安排表course_schedule,一门课可以有多个上课时间点,表字段有:id、course_id、day_of_week星期几、period_start开始节次、period_end结束节次、classroom教室。这个表是课表冲突检测的数据来源。
选课记录表selection_record:id主键、student_id、course_id、schedule_id、status选课状态、create_time选课时间。我特意让选课记录关联schedule_id而不是只关联course_id,因为一门课在不同时间可能有多段排课,冲突检测精确到节次,记录这个字段会让退课和冲突判断的粒度非常清晰。
成绩表grade单独建,字段为student_id、course_id、score。不把成绩直接挂在selection_record上,这样即使学生退课,历史成绩也能在独立表里保留,语义上更干净。
2.2 防止重复选课的唯一索引设计
重复选课是选课系统最高发的bug。我先说结论:唯一约束必须落在数据库层面,不能只靠Java代码里的"先查后插"。
ALTER TABLE selection_record ADD UNIQUE KEY uk_student_course (student_id, course_id, status);这里有个容易掉进去的坑:MySQL的唯一索引对NULL是放行的,如果有人把status字段设计成允许NULL,这个唯一索引就形同虚设。所以status必须定义为NOT NULL DEFAULT 1,只有"已选"状态才会触发唯一冲突,才能兜住重复数据。
当学生退课再把status改成0,再重新选同一门课时,旧的0记录和新的1记录不冲突,逻辑说得通。如果希望一个学生一门课只保留一条记录,那可以去掉status直接用(student_id, course_id)做唯一索引,退课就变成更新同一行记录的状态。我最终选择保留status的方案,因为需要留退课历史,后续做选课数据统计时能还原全过程。
2.3 索引与查询优化注意点
选课系统的主要查询包括:选课页列出所有可选课程并显示剩余名额、查看学生已选课程列表、教师查看自己课程下的学生名单。这些查询量都不大,单机MySQL加上合理索引就够了。
我实际加了四类索引:course表的status和selected_count联合索引用于筛选可选课程;selection_record表的student_id加status索引用于快速查询学生已选列表;course_schedule表的course_id索引避免全表扫描;student表的学生号建唯一索引用于登录。
一个优化教训是:不要对varchar长度很大的字段建索引,比如课程介绍、课程详情这类长文本字段完全不需要索引。另外联合索引遵循最左前缀原则,查询时尽量把student_id写在前面,单独按status过滤会走不上索引。
3. 核心功能实现:登录、选课、冲突检测
3.1 登录认证与角色拦截
认证方案我用了轻量级Session,没有引入Spring Security。不是Security不好,而是这个项目的规模下,引入它意味着要理解过滤器链、自定义登录处理器、权限表达式一堆概念,学习成本和调试成本都高。自己实现信得过的一套拦截器,逻辑清晰又够用。
具体做法:LoginController接收用户名密码,校验通过后把用户对象和角色写入Session;AuthInterceptor拦截所有需要鉴权的接口路径,检查Session中是否存在登录标记,没有则返回401;RoleInterceptor再校验角色是否有访问目标接口的权限。拦截器注册时特别注意路径匹配,我最初图省事拦截了所有路径,结果css和js静态资源全被拦了,页面样式全丢,需要在WebMvcConfigurer里把静态资源和登录接口显式排除掉。
密码存储我用了Spring自带的BCryptPasswordEncoder,注册和改密都加密存储,登录用matches方法比对。BCrypt自带盐,比MD5加盐省心,而且就算数据库泄露,撞库成本也高得多。
3.2 选课核心流程与容量控制
选课流程拆成五步:参数校验、容量校验、时间冲突校验、插入选课记录并更新数量、返回选课结果。前两步之间有个致命的时间窗口:两个并发请求同时通过容量校验,然后同时插入记录,最终总量就会超过capacity。这就是经典超选问题。
解决超选的核心手法是不在Service里写"先select后update",而是用一条带条件的原子UPDATE:
int affected = courseMapper.increaseSelectedCount(courseId); if (affected == 0) { throw new BizException("课程名额已满"); }对应Mapper的SQL:
<update id="increaseSelectedCount"> UPDATE course SET selected_count = selected_count + 1 WHERE id = #{courseId} AND selected_count < capacity </update>当受影响行数为0时,代表课程在这个时间点已经满了,事务回滚。我用Jmeter模拟过200个并发线程抢一门容量只有60的课,最终selected_count恒定在60,没有超出一条。整套选课流程还要包进事务,Spring的@Transactional默认只在RuntimeException时回滚,所以Service方法里不要自己吞异常,否则事务感知不到,就会出现选课记录插进去了但库存没更新的人为故障。
3.3 课表冲突检测:时间重叠算法
冲突检测是选课系统另一道核心关卡。课程表里每门课可能对应多个三元组(day_of_week, period_start, period_end),学生已选课程累加起来也是一组三元组。冲突检测的本质,就是判断两组节次集合有没有交集。
我在Service里把学生已选所有课程的schedule列表取出来,和待选课程的每个schedule逐对比较:
public boolean isTimeConflict(CourseSchedule newSchedule, List<CourseSchedule> existingSchedules) { for (CourseSchedule existing : existingSchedules) { if (existing.getDayOfWeek() != newSchedule.getDayOfWeek()) { continue; } if (newSchedule.getPeriodStart() <= existing.getPeriodEnd() && newSchedule.getPeriodEnd() >= existing.getPeriodStart()) { return true; } } return false; }为什么这个逻辑放在Java里而不是SQL里?因为已选课程数据是会话级的,一个学生一学期最多几十个节次,两层循环的时间复杂度是O(n*m),量级小到可忽略。成SQL自连接反而可读性差、测试困难。这种"数据量小就用代码处理"的取舍,在项目开发里很常见。
一个容易忽视的细节:如果系统覆盖多个校区,不同校区的节次编号可能不统一,冲突检测除了day_of_week,还要加上campus字段先判断校区。另外实验课和理论课分开排课时,节次编号必须全校区统一,才能直接比较区间。
3.4 退课与成绩录入
退课是选课的逆操作,同样要放在事务里。退课时必须把selected_count减回去,而且要先判断当前是否在退课期内。我在course表里加了select_deadline字段,退课前做时间判断,超过截止时间就拒绝操作。退课操作我用一条原子UPDATE把selected_count减一,并且把selection_record的status置为0,再清掉对应的课表冲突缓存,保证学生能马上重选其他课程。
教师端成绩录入的典型坑是批量录入时的重复提交。我采用insert...on duplicate key update的方式加载成绩,以(student_id, course_id)为唯一键,重复提交同一份成绩只会更新分数,不会产生重复记录。录入接口还要校验教师只能操作自己名下的课程,这个校验放在Service层,通过Session里的教师ID和课程表里的teacher_id比对完成。
4. 前端页面与交互稿设计
4.1 页面结构与菜单渲染
前端技术栈是Thymeleaf加Bootstrap,页面数量不多,核心就这些:登录页,带角色选择;学生首页课程列表加搜索条件;已选课程页带退课按钮;课表页按星期一到周日渲染一周的表格;教师课程页和选课名单页;管理员的学生、教师、课程管理页。
菜单根据登录角色动态渲染。Controller登录时把role写入model,页面用Thymeleaf的th:if判断角色来显示菜单项。这里有一个实际经验:角色不要用不统一的字符串,定义成枚举或常量,比较时统一使用。我踩过前后端大小写不一致导致的权限判断失败,排查了很久才发现是"Student"和"student"写岔了。
页面布局上,学生首页主要是一个课程表格,列包括课程名称、授课教师、学分、容量和剩余名额、选课状态。搜索条件放在表格上方,支持课程名模糊匹配和教师名筛选。剩余名额的显示要实时,不能缓存,所以我选课时用异步请求刷新名额。
4.2 选课交互的细节处理
选课按钮的交互状态需要清楚区分:已选、可选、已满、未开放。我用Thymeleaf的th:classappend根据status动态渲染按钮色和disabled属性。这里踩过一个典型问题:表单直接同步提交时,双击按钮会触发两次请求,虽然数据库唯一索引能挡住重复记录,但用户体验很糟糕。
后来改成异步fetch提交,选课结果通过返回的JSON局部刷新到页面的状态栏,不再整页刷新。选课成功的提示用Bootstrap的Alert组件显示一条状态条,并刷新剩余名额和按钮状态;选课失败时显示"课程已满"或"课表冲突"的具体原因。异步请求还有一个好处,是用户能看到明确的请求结果,不会被浏览器默认提交打断。
关于课表页的渲染,我按周一到周日排列七列,每行一节,单元格里显示课程名称和教室。如果一天内同时段有多门课,说明数据有问题,通常是因为排课数据在录入时没做冲突校验,管理员录入课程排课时也应该用同一套isTimeConflict方法做提示,前端表格渲染时再做一次兜底检查。这个兜底检查帮我提前发现过两门课共用同一教师同一时段的数据错误。
5. 常见问题与排查实录
5.1 并发超选:一个真实bug的复盘
压测阶段我用Jmeter模拟200个线程抢60个名额,第一次确实复现了超选:SQL查出来的selected_count已经到60了,实际插入却跑到了80条。定位过程不复杂:把选课服务的日志打印出来,发现两个线程在同一毫秒都查到selected_count=59,于是同时进入插入逻辑。原因就是"读-判断-写"三步之间没有原子性。
修复就是换成带条件的原子UPDATE,这个问题彻底消失。这次踩坑让我记住一个原则:涉及共享资源状态变更的代码,永远不要设计成先查再改,要么用数据库原子操作,要么引入分布式锁,前者在单体应用里足够高效。
5.2 事务失效的三种典型场景
我在项目中遇到过三类事务不回滚的问题,典型到值得单列:
- try-catch把异常吞掉,事务管理器感知不到异常。
- Service内部方法自调用,@Transactional失效。因为Spring事务基于AOP代理实现,内部this调用不会经过代理。
- 数据库表使用MyISAM引擎,根本不支持事务。这个很隐蔽,建表时如果没指定ENGINE=InnoDB,默认可能落到MyISAM。
排查事务问题最快的方式是全局异常处理器里打日志,看异常栈;再去数据库看数据状态,如果记录插入了但计数没更新,基本就是事务边界没圈住。修复后一定要用多线程并发测试重复验证,而不是只靠单次点击。
5.3 部署与运行环境坑记录
项目打包用mvn package,jar包直接java -jar部署。实际运行中遇到过几个配置问题,值得记录:
- 服务器8080端口被占用,用server.port=8090规避。
- MySQL时区问题,JDBC连接串少配了serverTimezone=Asia/Shanghai,导致时间字段差8小时。最直接的观感是学生深夜选课时,系统认为已经是第二天早上,选课窗口提前关闭。这个坑排查起来很迷惑。
- 上传文件大小限制,如果涉及课程封面之类,Spring Boot默认单文件1MB,多文件10MB,需要调整application.yml里的multipart配置。
5.4 安全与权限检查清单
选课系统直接面向学生,安全红线不能省。我整理了一份自检清单:密码必须加密,不用明文;登录接口加简单防暴力破解,登录失败连续几次锁定几分钟;接口权限严格区分,学生绝对不能调用教师录入成绩的接口;SQL统一使用MyBatis的#{}预编译参数,不用字符串拼接。最后一条尤其重要,参数拼接进SQL一旦有注入点,学生可以绕过登录直接查整个成绩表。
关于权限绕过,我建议在每个Controller的类名上直接用@RequireRole注解标明角色,拦截器里通过反射读取注解做校验。这样新加接口时,权限定义就写在代码旁边,不会漏。
6. 最后的经验分享
整套系统做完,我的体会是选课系统的难点从来不是CRUD,而是两个维度:数据一致性确保容量不超卖,业务约束管好选课时间、课表冲突和角色权限。把数据库设计做扎实,把事务和并发控制想清楚,这个项目就成功了大半。
最后分享三个实用小技巧:第一,开发时可以把选课开放时间配置成当前时间前后一分钟的临时值,随时验证选课逻辑,不必等特定时刻;第二,给所有接口都加上统一返回结构,前端处理和后续调试都会轻松很多;第三,数据库迁移脚本从第一天就纳入代码管理,不要手动执行SQL改表,否则团队协作时表结构很快就对不上了。
如果还想做生产级扩展,后续可以考虑引入Redis做选课期间的分布式锁,或用消息队列把选课请求排队削峰,加定时任务做选课数据统计。但对毕业设计和普通课设来说,本文这套Spring Boot加MyBatis的完整方案已经足够稳定,照着这个思路一步步实现,能省下大量调试时间。