ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot学生选课系统实战:从架构设计到并发控制

Spring Boot学生选课系统实战:从架构设计到并发控制 从选题到落地Spring Boot学生选课系统的完整开发展开——这是一篇以毕业设计/课程设计为场景的Spring Boot实战经验分享。我尽量把当初做这个系统时踩过的坑、想明白的道理以及关键时刻的取舍逻辑都讲清楚。如果你正准备做类似的教务管理类系统这篇内容应该能帮你省下不少弯路。1. 项目整体设计与技术选型1.1 为什么选Spring Boot做选课系统学生网上选课系统属于典型的业务管理系统核心就三件事管理课程数据、处理学生选课行为、支撑教师和教务管理员的日常操作。这类系统对并发有一定要求对数据一致性要求极高同时还要考虑后续维护的成本。Spring Boot恰好在这些方面有天然优势。先说并发。选课系统最容易出现的问题是抢课——一门热门课程放出来可能几百个学生同时点选课。这就涉及并发控制、事务管理、锁机制。Spring Boot本身虽然不直接解决并发问题但它提供了成熟的starter模块比如spring-boot-starter-data-jpa、spring-boot-starter-jdbc配合数据库层面的行锁或乐观锁能干净利落地实现选课场景下的并发控制。比用原生Servlet/JSP时代纯手工管理连接、事务要省心太多。再说维护成本。Spring Boot的自动配置大大减少了配置文件的工作量一个Spring MVC项目动辄几百行XML配置换成Spring Boot后核心配置可能就一个application.yml几百行压到几十行。对于课程设计或毕业设计这种一个人搞定全部的场景开发效率高调试成本低页面跳转和接口对接也直观。最后是从学习角度看Spring Boot其实没有偏离Java Web的技术本源Servlet、Filter、Interceptor、MyBatis/JPA这些底层知识照样需要懂。换句话说用Spring Boot做这个选题既不显得技术含量低又能在有限时间内完成得比较完整是一笔划算的投入。1.2 系统总体架构与模块划分这个选课系统的整体架构我建议按前后端分离的方式来做虽然这在毕业设计里不是硬性要求但拆分之后你会发现开发思路清晰很多。前端单独一个项目Vue或者纯HTMLJS都行后端用Spring Boot提供RESTful API。如果时间紧张也可以直接用Thymeleaf模板引擎做服务端渲染少了一层接口联调的麻烦。后端模块我按职责拆成了几个部分认证与授权模块学生、教师、管理员三种角色登录用JWT做无状态鉴权拦截器校验token和角色权限课程管理模块课程的增删改查、开课学期管理、选课人数限制设置选课模块学生选课、退课、课程冲突校验、选课名单查询课表/成绩模块选修课后生成个人课表教师录入成绩、学生查看成绩模块之间通过Service层调用来协作Controller层只负责收参、调用Service、返回结果不直接写业务逻辑。这样做的直接收益是后来我想加一个教师端批量导入课程的功能时只需要在Service层加方法Controller层加接口不用动原来的业务代码。1.3 技术栈选择背后的理由核心选型就一句话能用稳定版本的就不用最新版本。Spring Boot我用的是2.7.x系列而不是3.x。原因有两个一是3.x基于Jakarta EE规范很多老教程里的包名要换网上资料虽已不少但你得花时间甄别二是做这个选题的人多半还需要整合其他第三方库2.7.x在这个环节的生态兼容性更宽踩坑成本低。数据访问层我选了Spring Data JPA而不是MyBatis。说实话在这个项目场景里两者都能胜任但选课系统的查询模式偏实体驱动——查课程、查学生、查选课记录都是标准的单表或浅关联查询JPA的派生查询和Specification动态查询足够应对写起来比MyBatis的SQL映射要简洁得多。如果将来要做复杂的报表统计再换MyBatis也不迟。数据库MySQL 8.0连接池用HikariCPSpring Boot 2.x默认集成的就是它性能足够好不用额外配置。Redis用不用呢我在这个项目里用了Redis做课程的缓存因为热门课程的开课信息属于读多写少的数据缓存能显著减轻数据库压力。但如果你的系统只面对几百人规模Redis不是必需品纯数据库也扛得住。这个决策要根据实际场景来。2. 数据库设计与核心业务逻辑2.1 实体关系梳理与表结构规划数据库设计是选课系统最关键的前置工作表结构定不好后面写代码就是不断地打补丁。我把核心表分成四类用户相关、课程相关、选课行为相关、辅助信息相关。用户相关学生表student、教师表teacher、管理员表admin。之所以分成三张单独的实体表而不是统一成一张user表是因为三种角色的字段差异比较大——学生有学号、班级、入学年份教师有工号、职称、所在院系管理员则非常轻量。统一的user表需要预留大量冗余字段反而不清爽。如果系统规模再大一些还可以引入用户主表角色表关联表的模型但在这个项目里三表分立是性价比最高的方案。课程相关课程表course存课程基本信息课程名称、学分、授课教师、上课时间、上课地点、容量限制、已选人数。学期表semester用来区分不同学期避免跨学期数据混乱。选课行为相关选课记录表sc是学生和课程之间的关联表核心字段包括学生ID、课程ID、选课时间、成绩。这张表是整个系统的核心数据一致性要求也集中在这里。一个容易被忽略的细节是上课时间冲突检测。课程表里我加了一个class_time字段存储格式为星期几第几节的组合比如1-34表示周一第3、4节。选课时候要做的冲突检测逻辑就是先查出该学生已选的所有课程的class_time再判断待选课程的时间段是否有重叠。这个字段设计得简单直接但很有效不用为每门课单独建时间表。2.2 核心业务规则与逻辑校验选课业务的规则看起来简单落到代码里却要考虑很多边界情况。我在Service层做了以下几层校验顺序很重要先校验课程是否存在、是否在选课时间段内课程表里有选定学期和选课开始/结束时间字段再用状态字段标记当前是否可选。此处若不校验可能选到已截至的课程产生无效数据记录。再校验学生是否重复选了同一门课——通过查询选课记录表里是否存在该学生和该课程的关联记录来判断。重复选课是新手最容易漏掉的一环尤其是系统频繁刷新时按钮多次点击就会触发重复提交。然后做上述说的课程冲突检测判断待选课程的上课时间是否与学生已选课程时间重叠。同一时间只能上一门课这是硬约束必须由程序保证而不是靠学生自觉。最后校验容量——当前已选人数是否小于课程容量上限。这里牵扯到并发问题后面单独展开讲。这四层校验的顺序背后有逻辑考量最廉价的查询先做查课程基本信息最昂贵的操作涉及并发控制的更新操作最后做。如果一开始就做容量判断大量并发请求涌入时数据库的负担会成倍放大。3. 实操过程与关键环节实现3.1 Spring Boot项目初始化与目录结构我习惯用Spring Initializr来初始化项目三个关键依赖选好就够起步Spring Web、Spring Data JPA、MySQL Driver。后续根据需求再加Lombok、Validation等。推荐的项目目录结构如下清晰定义各层之间的依赖方向src/main/java/com/example/courseselect/ ├── controller/ # 接口层只做参数接收与结果返回 │ ├── StudentController.java │ ├── AuthController.java │ └── AdminController.java ├── service/ # 业务层核心逻辑全部在这层实现 │ ├── CourseService.java │ ├── SelectCourseService.java │ └── AuthService.java ├── repository/ # 数据访问层Spring Data JPA的Repository接口 │ ├── CourseRepository.java │ ├── StudentRepository.java │ └── SCRecordRepository.java ├── entity/ # 实体类与数据库表一一对应 │ ├── Course.java │ ├── Student.java │ └── SCRecord.java ├── dto/ # 前端参数对象避免直接用实体类接参 │ ├── LoginRequest.java │ └── SelectCourseRequest.java └── config/ # 配置类 ├── SecurityConfig.java └── WebConfig.java其中dto这一层很多毕设里没有但强烈建议加——直接用实体类接收前端参数会把表结构暴露给前端且无法做字段级校验控制等业务复杂起来会非常难受。3.2 多角色登录与权限控制的实现登录系统我用JWT方案。流程是登录接口校验用户名密码密码MD5加盐后比对成功后生成一个带角色信息的token返回给前端。前端每次请求带上token后端拦截器解析token注入当前用户的信息到请求上下文。密码存库前必须加盐哈希不要存明文。用Spring Security的BCryptPasswordEncoder来加密比自定义MD5加盐更安全因为BCrypt内置随机盐模块同样的密码每次加密结果都不同能有效防止彩虹表攻击。权限控制的粒度按照角色划分实现方式有两种路径用Spring Security框架实现配置三个角色对应的访问规则重写configure方法对所有URL做权限声明。优点是注解驱动的PreAuthorize声明式权限控制非常方便缺点是Spring Security的配置概念多过滤器链、UserDetailsService、AuthenticationManager第一次接触会感觉陡峭。用Interceptor实现自定义一个拦截器在preHandle方法里解析token拿到角色然后判断当前请求的URL是否符合角色的访问规则。优点是直观简单适合单角色单功能的毕设项目缺点是每个接口都要手动标注可访问角色。考虑到项目的规模和你的学习周期先用Interceptor为主实现同时对关键接口比如选课、退课加上Spring Security的PreAuthorize注解来做双重保障更稳更合理。实战效果更好不会在框架配置上耗掉大量时间。3.3 选课接口的详细实现与并发控制选课是整个系统的核心接口以POST /api/student/select为例逻辑如下public synchronized Result selectCourse(SelectCourseRequest req) { // 教学班信息校验 Course course courseRepository.findById(req.getCourseId()).orElse(null); if (course null) return Result.error(课程不存在); // 校验选课时间窗口 if (!isInSelectTimeWindow(course)) return Result.error(不在选课时间内); // 重复选课校验 SCRecord record scRecordRepository.findByStudentIdAndCourseId(req.getStudentId(), req.getCourseId()); if (record ! null) return Result.error(请勿重复选课); // 时间冲突校验 ListString studentTimes getStudentCourseTimes(req.getStudentId()); if (isConflict(studentTimes, course.getClassTime())) return Result.error(上课时间冲突); // 容量校验与扣减 int updated courseRepository.decreaseCapacity(course.getId()); if (updated 0) return Result.error(该课程已满员); // 生成选课记录 SCRecord scRecord new SCRecord(); scRecord.setStudentId(req.getStudentId()); scRecord.setCourseId(course.getId()); scRecord.setSelectTime(new Date()); scRecordRepository.save(scRecord); return Result.success(选课成功); }其中最核心也最容易出问题的是容量校验与扣减那步。用courseRepository.decreaseCapacity(course.getId())这个自定义更新语句来扣减容量UPDATE course SET selected_count selected_count 1 WHERE id ? AND selected_count capacity这种写法的精髓在于把容量检查并原子更新在了同一条SQL里。数据库在更新时对行加锁多个并发请求同时执行这条SQL时只有能达到条件selected_count capacity的请求才能更新成功返回值是受影响的行数。我们要做的就是判断这个返回来确定是否选课成功能有效避开经典的超选问题。另外一个关键点是事务边界。选课逻辑里扣减名额和生成选课记录必须处于同一个事务中要么都成功要么都失败。所以selectCourse方法需要加上Transactional(rollbackFor Exception.class)注解默认只对RuntimeException回滚指定rollbackFor可以避免某些受检异常发生时状态不一致。我在实际测试中模拟了200个并发请求同时选一门容量为100的课程用这个方案处理后最终选课成功记录数正好是100一条不多一条不少。性能上单接口QPS大约能到250左右选课系统高峰期足够了。3.4 退课与课程数据一致性退课逻辑相对简单但同样需要事务控制删除选课记录的同时恢复课程容量selected_count - 1。这里有一个容易忽视的坑如果学生已经选了课然后管理员把课程删了该怎么办我采取的方案是物理删除课程时级联删除该课程的所有选课记录并向相关学生的课表同步减掉该课。但这样做的风险是误删。后面改进为软删除加一个is_deleted字段选课记录保留学生在课表里看到该课程已调整的提示这样更符合现实教务场景。做退课功能时一定要限制退课的时间窗口。我加了一个规则只有在课程开课前24小时可以退课避免学生临开课了才退出导致名额空置。这块逻辑在退课接口里做判断不要放在前端控制——前端的校验随时可以绕过。4. 前端页面设计与接口对接4.1 界面布局与交互流程前端用了Vue 3 Element Plus来搭建管理界面三个身份各有独立的视图空间用路由来控制页面跳转和权限准入。学生端的主界面是选课中心左侧是课程筛选条件学期、课程类别、上课时间右侧是课程表格。选课按钮在课程列表和课程详情里各放一个。这里有一个细节按钮状态要和选课状态联动——已选的课程按钮置灰并显示已选容量满的课程按钮置灰并显示已满避免学生点进去才知道结果。教师端的核心页面是我的课程和成绩录入。成绩录入我设计了批量模式选好课程直接列出学生名单批量录入成绩后一键提交。管理员的界面包括用户管理、课程管理、选课数据统计三个页签。统计页用ECharts展示每门课程的选课人数、各院系选课率等数据。4.2 关键交互逻辑与前后端联调中的坑前端和后端联调是最磨人的阶段。有几个问题必须提前约定清楚统一接口返回值结构。我定义了一个统一返回体Result包括code、message、data三个字段。所有接口都返回这个结构前端才能写统一的请求拦截和错误处理逻辑。如果有的接口直接返回数据有的返回Map前端就得针对每个接口做特判代码会变得特别难维护。token失效的统一处理。我在前端的Axios拦截器里加了响应拦截逻辑当code为401时清理本地存储的token跳回登录页。这个逻辑必须在联调前就做好否则调试中token过期后端返回403前端就卡在那里连报错信息都没有。时间格式问题。Java后端默认返回的时间格式是yyyy-MM-dd HH:mm:ss但如果实体类的LocalDateTime字段没有加JsonFormat注解序列化结果是那种ISO 8601格式前端如果想直接显示就必须自己格式化。我统一用JsonFormat(pattern \yyyy-MM-dd HH:mm:ss\)标注了所有时间字段接口返回的数据直接拿来就能展示省了一堆前端日期处理的代码。5. 常见问题与排查技巧实录5.1 选课并发场景下超选与重复选课的解决这个问题几乎每个选课系统都会遇到也是答辩时老师最爱问的点。我上面提到用SQL原子更新来解决但对于不同场景还有扩展方案如果学校用的是MySQL可以用SELECT ... FOR UPDATE悲观锁方式——选课时先锁住课程行再检查容量并更新等事务提交后释放锁。优点是逻辑简单直观缺点是并发量高时行锁等待导致吞吐量下降。也可以用乐观锁方案——在课程表加一个version字段每次更新时比较版本号若版本不一致则更新失败重试。这个方案在读多写少的场景下性能更好但是需要处理重试逻辑代码复杂度高一些。我用的是悲观锁思路的SQL变种不显式加锁而是依靠条件更新原子性来保证事务操作不受干扰。这个方案综合了性能和正确性而且实现和理解都简单推荐直接用这个。5.2 JPA懒加载在事务外的序列化问题用Spring Data JPA时肯定会遇到这个经典的报错could not initialize proxy - no Session或failed to lazily initialize a collection...。简单说就是JPA实体中配置了ManyToOne/OneToMany的关联关系并且采用了懒加载当控制器层把包含关联实体的数据返回给前端序列化时事务已经在Service层结束了新开的Session里拿不到未加载的关联数据。我的解决思路是避开懒加载这个坑——列表接口直接查询DTO而不是实体。在Service层手动做一次实体到DTO的转换在事务内完成所有关联数据的填充。这样既不影响前端显示也避免为了序列化强行把整个对象图加载出来减少多余的SQL查询。如果项目里某些场景确实需要嵌套实体数据就用EntityGraph或者写JPQL进行join fetch把需要的数据一次性查出来。这两种方式都能避免N1问题。5.3 Spring Boot版本太高导致的兼容性问题开头提过用2.7.x而不是3.x这部分讲具体案例有同学用了Spring Boot 3.2.x结果整合MyBatis时发现依赖包名从javax.*变成了jakarta.*旧版本的MyBatis启动直接报ClassNotFoundException花了半天才搞明白是版本兼容问题。后来换了对应新版本的MyBatis启动器才跑起来。更隐蔽的是Spring Boot 3对spring.factories机制也做了调整自定义自动配置类不再通过spring.factories注册而是改用META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports。如果你照着老教程写自动配置在新版本下怎么都进不去。这些坑对老手都是一折腾新手更浪费时间。所以老老实实用2.7.x少踩一个坑就是提高效率。单个项目文件里涉及这种版本对比的场景我建议在答辩时可以主动提一句说明你考虑了生态兼容性和技术稳定性这是加分项。5.4 数据列表页面的慢查询定位系统上线一段时间后我发现课程搜索接口的速度变慢了慢了能有一倍左右。排查过程如下先开启了Spring Boot的SQL日志输出spring.jpa.show-sqltruelogging.level.org.hibernate.SQLDEBUG抓取实际执行的SQL。然后把这些SQL复制到MySQL里跑EXPLAIN逐个看是否走了索引。问题找到了——sc表的student_id和course_id字段没有索引联合查询上万条数据时全表扫描耗时自然水涨船高。另外还发现一个潜在问题课程表里的class_time字段是字符串格式学生在筛选周一上午有课的条件时用到LIKE %1-34%这在MySQL里是典型的无法走索引的模糊查询。我给业务表做了简化处理把星期几单独拆成一个字段class_day查询时直接等值匹配。课表查询速度又提升了一个层次。6. 项目测试、部署与答辩要点6.1 功能测试与并发压测方案功能测试方面除了常规的手工测试路径一定要对关键接口写单元测试。选课系统最值得写测试的是选课服务和退课服务。用Mockito来模拟依赖的Repository行为构造各种边界场景课程不存在、选课时间未到/已过、重复选课、容量为0、时间冲突、并发扣减。这些用例覆盖了核心业务规则的绝大多数分支。并发压测用JMeter进行线程组设置为100个用户每个用户循环10次选课同一门容量为50的课程断言最终选课成功记录数严格等于50。通过这个压测来验证事务是否正常最终验证接口是否有超卖情况。如果发现库存超卖优先检查容量扣减语句是否满足原子性再确认事务是否正常回滚了两个操作中的异常情况。6.2 打包部署与生产环境配置细节打包用Maven的package命令生成可执行jar然后扔到服务器上跑。生产环境一定要注意以下几个配置点数据源连接池参数必须显式配置不能只给URL、用户名密码就完事。我踩过一个坑默认HikariCP的maximum-pool-size是10选课高峰期连接池被打满后面进来的请求全部排队等连接接口RT直接飙到5秒以上。改成50之后情况好很多。选择用宝塔面板的Docker来部署。Dockerfile里注意基础镜像的选择Java 8的项目就用openjdk:8-jdk-alpine用错版本就会导致运行时直接报UnsupportedClassVersionError。同时容器内时区要额外设置否则日志和业务时间会与本地有8小时的偏差形成对不上的幽灵时间。具体做法是在启动参数里加-Duser.timezoneGMT08或者在Dockerfile里设置环境变量TZAsia/Shanghai。6.3 答辩展示的着力点答辩环节老师基本会从三个维度发问一是系统架构设计的合理性二是核心业务流程的逻辑严谨性三是实际工程问题的处理能力。架构维度重点说清楚为什么用前后端分离前后端各自开发与部署的弹性为什么Controller薄Service厚职责边界清晰业务复用性好方便写测试为什么表结构要三表分立而不是一张user表避免字段冗余和角色属性混乱。业务流程维度重点讲选课接口的完整校验链条和并发控制方案——条件更新SQL是怎么通过原子性杜绝超选的。这是整个项目中最能体现技术深度的细节也是和随便一个CRUD项目拉开差距的关键。工程问题维度聊JPA懒加载的坑、连接池调优的经验、慢SQL索引优化的排查过程。这些不是课本上现成的内容是你实际操作后才有的积累老师最认可这类真实经验。7. 个人的一些后续扩展想法系统做完之后回头看还有两个可以继续深化的功能方向领域模型上可以引入教学班这个概念。同一门课程可以由不同老师在不同时间开多个平行班学生选的其实是某个教学班。这个改动会让系统更贴近实际教学管理同时也让课程表设计和容量控制更真实。技术层面可以集成消息队列。比如学生选课成功后常见的做法是同步调接口发邮件通知。选课高峰时这种方式拖慢接口响应速度如果改成一个消息队列服务选课成功的同时发一条消息到队列里由另一个异步消费者处理通知逻辑接口就能更快地返回选课结果用户的体验会好很多。这也是一道经典的削峰填谷面试题放在项目里展示说明前瞻性和工程深度都能加分。最后再分享一个我在反复重构中悟出的体会做这种业务管理系统技术永远是为业务服务的。为了并发控制方案纠结几天是值得的因为那是最容易出问题的地方但如果为了某个炫酷的框架特性把系统复杂度抬了上去那就得不偿失了。清晰的业务边界和恰到好处的技术选择才是这类项目真正的主线。
返回列表