拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Spring Boot选课系统高并发实战:条件更新、乐观锁与缓存设计

简介面向课程设计或毕业设计场景的Java学习者这份基于Spring Boot的选课系统覆盖学生、教师、管理员三类角色的完整业务闭环学生可完成选课、查课与成绩学分查看教师负责授课查询与成绩评定管理员还能进行用户和权限管理。系统采用Spring Boot、MyBatis、Layui与MySQL技术栈后端按Controller、Service、Mapper等层次组织便于理解权限控制与选课流程。压缩包共447个文件核心包括150个GIF动图、Java源码、XML配置、HTML页面、JS脚本以及SQL脚本和说明文档整体仅2.57MB便于快速部署与二次开发。目前已有1758人学习下载是一份结构完整、适合上手实践的同类参考项目。1. 选课系统远不止增删改查难点在并发下的名额分配选课系统大概是 Spring Boot 项目里出现频率最高的一类题目但真把它做好难点从来不在课程信息的增删改查上。选课高峰期的并发请求同一秒打到后端如果缺少对余量扣减的控制就会出现超卖和重复选课这是单靠接口逻辑检查无法避免的竞态问题。这里要讲的是表结构如何为并发做设计选课和退课的服务层怎么写管理端批次怎么管以及上线前用压测验证什么。适合正在做毕业设计、学校排课系统或企业内部选课平台的开发者也适合想系统理解 Spring Boot 并发实现的工程师。2. 数据模型选课系统表结构怎么设计才能扛住并发2.1 三张核心表各自职责不同选课系统最少需要三张表课程表、学生表、选课记录表。课程表存课程信息和名额学生表存学生基础信息选课记录表是学生和课程之间的关联记录。很多人会把「选课人数」做成动态查询每次进入详情页都用count(course_selection)算一次这个做法在选课高峰期会拖垮数据库。表关键字段作用courseid、name、teacher_name、total_capacity、remaining、version、status课程基本信息与名额控制remaining 是热点字段studentid、student_no、name、major学生信息student_no 做唯一约束course_selectionid、course_id、student_id、selected_at学生与课程的关联关系我一般会把余量直接冗余在课程表里remaining字段初始等于total_capacity。扣减时用条件更新而不是先查再改。如果每次选课都先去course_selection表做聚合统计再算出余量并发一高就会出现两个请求同时看到同一个余量值而实际名额已经不够的情况。余量冗余不是反范式是应对高频单行更新的实际需求。2.2 联合唯一索引是防重选的兜底代码层面的「这个学生有没有选过这门课」检查存在竞争窗口。两个请求同时进来都查到没有选课记录然后都执行插入就会产生两条一模一样的记录。解决这个问题的前提是数据库层面有约束。CREATE TABLE course_selection ( id BIGINT AUTO_INCREMENT PRIMARY KEY, course_id BIGINT NOT NULL, student_id BIGINT NOT NULL, selected_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_stu_course (student_id, course_id), CONSTRAINT fk_sel_course FOREIGN KEY (course_id) REFERENCES course(id), CONSTRAINT fk_sel_student FOREIGN KEY (student_id) REFERENCES student(id) );这里的UNIQUE KEY uk_stu_course (student_id, course_id)是防重选的最后一层防线。业务代码判重可能因为时序问题失效但数据库唯一约束不会。插入重复记录时数据库会抛DuplicateKeyException代码只需要捕获这个异常转成业务提示。有人为了省一张关联表把选课学生用逗号拼接存在课程表的一个字段里这种设计在并发场景下完全不可控改数据要解析字符串统计要写临时逻辑不要在选课系统里这么干。2.3 JPA 实体映射与乐观锁版本号实体映射时课程表需要加一个version字段用来配合乐观锁。选课记录表不需要版本号因为竞争的资源是课程名额不是记录本身。Entity Table(name course) public class Course { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String name; Column(name total_capacity) private Integer totalCapacity; Column(name remaining) private Integer remaining; Version private Integer version; Column(name status) private Integer status; }Version是 JPA 规范提供的乐观锁注解。Hibernate 在普通save操作时会自动把version拼进 UPDATE 语句的 WHERE 条件并自动把版本号加一任何一个事务先提交另一个事务再提交就会因为版本号不匹配而失败。把version放在课程表是因为超卖的本质是多个事务同时更新课程表里的remaining字段版本号跟着这个字段走才有效。3. 选课核心业务条件更新、事务边界与错误处理3.1 选课四步里最容易出错的是余量扣减一次标准选课可以拆成四步查课程校验课程存在且处于开放选课状态校验学生是否已选、余量是否足够扣减课程余量插入选课记录第 1、2 步是纯查询和校验问题不大。第 3 步是真正的并发控制点。如果代码写成「先查出 remaining判断大于 0再 UPDATE 减一」两个请求同时读到 remaining 1都通过判断然后都执行 UPDATE余量会变成 -1超卖就发生了。问题是查询和更新之间存在时间窗口。解决思路不是压缩这个窗口而是让 UPDATE 语句自己带着条件去判断。数据库的行锁和条件判断是原子的把余量校验放进 UPDATE 的 WHERE 子句相当于把「检查并扣减」合并成一个原子操作。3.2 用 remaining 0 的条件更新挡超卖Spring Data JPA 里可以用Modifying配合 JPQL 写条件更新。public interface CourseRepository extends JpaRepositoryCourse, Long { Modifying(clearAutomatically true, flushAutomatically true) Query(UPDATE Course c SET c.remaining c.remaining - 1, c.version c.version 1 WHERE c.id :id AND c.remaining 0) int decreaseRemaining(Param(id) Long id); }选课服务里这样用Service RequiredArgsConstructor public class CourseSelectionService { private final CourseRepository courseRepository; private final CourseSelectionRepository selectionRepository; Transactional public void selectCourse(Long studentId, Long courseId) { Course course courseRepository.findById(courseId) .orElseThrow(() - new BizException(课程不存在)); if (course.getStatus() null || course.getStatus() ! 1) { throw new BizException(该课程未在选课时间内); } if (selectionRepository.existsByStudentIdAndCourseId(studentId, courseId)) { throw new BizException(请勿重复选课); } int affected courseRepository.decreaseRemaining(courseId); if (affected 0) { throw new BizException(课程名额已满); } try { selectionRepository.save(new CourseSelection(studentId, courseId)); } catch (DuplicateKeyException e) { throw new BizException(请勿重复选课); } } }decreaseRemaining返回的affected是受影响行数。如果 UPDATE 的 WHERE 条件不满足也就是remaining 0行数为 0说明名额已经满了直接抛业务异常。remaining 0这个条件保证了同一时刻只有一个事务能成功扣减到最后一个名额。提示Modifying的clearAutomatically必须设为true。否则更新后事务内再查同一个 Course 实体拿到的是持久化上下文里更新前的旧值。flushAutomatically确保 UPDATE 先于后续查询刷到数据库。Version在这里的作用是兜底。JPQL 的 UPDATE 不会像 Hibernate 自动生成的 UPDATE 那样管理版本号所以这个语句里手动把version加一。如果以后有别的模块也更新课程表版本号冲突会以受影响行数为 0 的形式暴露出来不会静默覆盖。锁方案对比方案适用场景主要问题synchronized 方法锁单实例部署集群部署后每个实例各有一把锁完全失效SELECT ... FOR UPDATE单库单点操作持锁期间占用数据库连接高并发时容易堆积死锁Redis SETNX 分布式锁多实例互斥要处理锁过期、续期、删除误操作remaining 0 条件更新单行热点更新受影响的请求需要被拒绝并提示重试选课场景是典型的单行热点更新有余量字段可判断条件更新是最省事的方案不需要引入额外的锁组件。3.3 Transactional 的粒度和回滚语义selectCourse方法整体加了Transactional意味着扣减余量和插入选课记录要么同时成功要么同时失败。比如选课记录因为唯一约束插入失败事务回滚已经扣减的余量也会恢复不会出现「扣了名额但查不到选课记录」的问题。事务粒度要控制好事务里只做数据库操作。不要在事务里调用外部接口、等待锁、执行耗时算法。选课高峰期数据库连接本来就紧张事务时间越长连接占用越久连接池打满后其他请求全部排队接口响应时间会线性恶化。另外selectCourse里第一步findById查出来的 course 对象如果后续没有变化在事务里不参与更新实际参与更新的是decreaseRemaining的 JPQL事务提交时才会把 UPDATE 发送到数据库。一个常见误用是在 Controller 层就加Transactional把参数校验、权限判断也包进事务。权限和校验没有数据库操作不加事务反而减少连接占用时间。事务应该从服务层方法开始到方法结束为止。3.4 退课先删记录再返额同一事务回滚退课的逻辑与选课对称但步骤顺序相反。Transactional public void cancelCourse(Long studentId, Long courseId) { int deleted selectionRepository.deleteByStudentIdAndCourseId(studentId, courseId); if (deleted 0) { throw new BizException(选课记录不存在); } courseRepository.increaseRemaining(courseId); }deleteByStudentIdAndCourseId返回删除行数为 0 说明这条记录不存在可能是重复退课或者选课 ID 错误。increaseRemaining对应一个普通的余量加一 UPDATE不需要条件判断因为释放名额永远往大走。先删记录再返额是因为这个顺序的中间态对用户更合理。用户退课成功后立刻看到余量释放记录已经不存在了。如果顺序反过来先返额再删记录中间一段时间课程名额是虚高的其他学生能选进去而配额还没扣回来就会出现总额不一致。两个操作在同一个事务里中间态本来不会被外部观察到但保持顺序合理能让日志和排查思路更清晰。4. 管理端接口与选课批次权限、状态机与定时任务4.1 REST 接口设计与 PreAuthorize 权限控制管理端负责课程信息维护和选课批次开关学生端负责选课和退课。权限上通常分成 ADMIN 和 STUDENT 两个角色。RestController RequestMapping(/api/admin/courses) PreAuthorize(hasRole(ADMIN)) public class AdminCourseController { PostMapping public void createCourse(RequestBody CourseCreateRequest request) { // 创建课程remaining 初始等于 total_capacity } PutMapping(/{id}/status) public void changeStatus(PathVariable Long id, RequestParam Integer status) { courseRepository.updateStatus(id, status); } }PreAuthorize(hasRole(ADMIN))是 Spring Security 的方法级权限注解。Spring Boot 项目里需要加EnableMethodSecurity开启方法安全否则注解不生效。学生端的选课接口放在另一个 Controller标注hasRole(STUDENT)即可。权限控制在切面层完成Service 里不需要再重复判断角色。管理端创建课程时remaining必须等于total_capacity不能出现容量 50、余量却写成 100 的脏数据。这个字段在创建时一次性赋值后续只依赖 UPDATE 增减管理端不要提供直接修改余量的入口否则会绕过并发控制。4.2 用状态字段控制选课批次别删库重建选课系统的批次管理常见做法是用课程表上的status字段做状态机而不是把课程数据删掉重建。删除再重建会丢历史选课记录计费、学分、对账全部对不上。status含义学生端表现0草稿课程未发布不可见、不可选1开放中可见、可选2已结束可见、不可选状态切换只允许 0 到 1、1 到 2 这两个方向。从 2 回到 0 是重新开课应该生成新的课程批次 ID而不是复用同一行数据。这个约定写在 Repository 里状态更新语句只接受合法目标状态非法跳转直接返回 0。Spring Boot 的自动配置让数据源和事务管理开箱即用管理端的课程维护接口在实体类上加Valid参数校验就能挡住大部分脏数据比如total_capacity必须大于 0、course_no不能重复。配置项可以用ConfigurationProperties统一管理比如「每学期最多选课门数」「退课截止时间」改配置文件就能生效。4.3 定时任务 Redis 分布式锁避免多实例重复执行选课批次自动开放是典型的定时任务场景。Component public class CourseBatchTask { Scheduled(cron 0 0 8 * * MON) public void openSelection() { String lockKey course:batch:open; Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(30)); if (!Boolean.TRUE.equals(locked)) { return; } try { courseRepository.openAll(); } finally { redisTemplate.delete(lockKey); } } }Scheduled(cron 0 0 8 * * MON)表示每周一上午 8 点执行。单实例部署时这个任务没问题但生产环境往往部署多个实例定时任务会在每个实例上各执行一次等于开放操作做了多遍。Redis 的setIfAbsent带过期时间保证同一时刻只有一个实例拿到锁执行完释放其他实例直接跳过。锁的过期时间要大于任务的最大执行时间这里是 30 秒。如果任务执行超过 30 秒锁自动过期另一个实例可能重复执行但开放选课的状态切换本身是幂等的重复执行最多是设置相同的状态不会产生脏数据这也是状态机设计带来的额外收益。如果业务不能在过期时间内完成考虑用 Redisson 的看门狗续期机制。5. 缓存设计、压测校验与高并发排错顺序5.1 课程余量热点加缓存短 TTL 加延迟双删选课高峰期热门课程详情页会被重复点击每次请求都查数据库会浪费连接。课程信息的查询接口可以加一层 Redis 缓存。public Course getCourseCached(Long id) throws JsonProcessingException { String key course:info: id; String json redisTemplate.opsForValue().get(key); if (json ! null) { return objectMapper.readValue(json, Course.class); } Course course courseRepository.findById(id).orElse(null); if (course ! null) { redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(course), Duration.ofMinutes(5)); } return course; }缓存只服务查询接口选课和退课操作仍然走数据库的条件更新。管理端更新课程信息时先更新数据库再删除缓存这就是常用的 Cache Aside 模式。删缓存比更新缓存安全因为并发下更新缓存会覆盖较新的数据库值删除缓存则只是让下一次查询回源数据库。如果删缓存失败就会出现缓存里的余量与数据库不一致。简单方案是延迟双删更新数据库后删除缓存过 500 毫秒再删一次。这个窗口能覆盖大部分主从同步延迟也避免了单次删除失败后的长期脏数据。TTL 设成 5 分钟是保底手段即使双删都失败缓存最短 5 分钟后也会自动失效。5.2 JMeter 压测参数与超卖校验 SQL上线前要做并发压测以一门课 20 个名额、200 名学生同时提交选课为例配置项推荐值说明线程数200模拟并发选课人数Ramp-Up 时间10 秒让线程逐渐启动避免瞬时限流干扰结果循环次数1每个学生只选一次HTTP 请求超时5 秒快速暴露慢接口和连接阻塞压测结束后不要只看 TPS 和响应时间先验证数据对不对。用下面的 SQL 检查每门课是否超卖SELECT c.id, c.total_capacity, c.remaining, (SELECT COUNT(*) FROM course_selection cs WHERE cs.course_id c.id) AS selected_count FROM course c WHERE c.id 1;断言条件selected_count total_capacity并且remaining 0。两个条件同时满足才算通过。如果selected_count大于total_capacity说明条件更新没有生效如果remaining出现负数说明扣减逻辑绕过条件判断直接执行了。5.3 排错顺序先看库约束再看事务和缓存遇到选课系统的问题我一般按这个顺序排查出现重复选课记录第一步查course_selection表上有没有唯一索引。没有唯一索引代码层面判重再精确也只是概率问题。出现超卖查扣减余量是不是用了条件更新。UPDATE course SET remaining remaining - 1 WHERE ... AND remaining 0这种写法必须有缺了它必超卖。出现缓存余量与数据库不一致看更新数据库后删除缓存执行了没有删缓存异常有没有被捕获吞掉。出现事务回滚但余量少扣打开 SQL 日志看 UPDATE 的执行顺序。Modifying的 JPQL 如果写错或者操作了不存在的列Hibernate 会在 flush 阶段抛异常事务回滚后余量不会变化这时先看 UPDATE 语句本身是不是能被 Hibernate 正确解析。这四类问题有一个共同点都不是运行时随机出现的而是设计阶段埋下的。唯一索引、条件更新、删缓存、事务边界选课系统的四个关键设计点各自对应一类故障排查时逐个对照即可。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门