ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot选课系统高并发防超卖实战:Redis预扣减与JPA乐观锁

Spring Boot选课系统高并发防超卖实战:Redis预扣减与JPA乐观锁 1. 选课系统为什么成了高并发练手的最佳靶子每年到了选课季教务系统的崩溃几乎成了保留节目。我参与过两个高校选课系统的重构也在面试里拿这个场景考过不下五十个候选人发现一个很有意思的现象大部分人能把 Spring Boot 的增删改查写得漂漂亮亮但一聊到同一门课 300 个名额5000 人同时点选就卡壳了。这不是框架的问题是思维还停留在单机 CRUD 的层面。这篇内容就是围绕一个完整的Spring Boot 学生选课系统展开从最基础的数据建模一路讲到高并发下的防超卖方案。关键词里出现的Spring Boot、Java、MySQL、JPA、高并发会贯穿始终。它适合三类人正在做课程设计但想做出点深度的学生、准备面试想拿选课场景当案例的求职者、以及第一次接触并发控制想找个真实场景练手的开发者。我不会只给你一堆代码而是把每个设计决策背后的为什么讲清楚——为什么用 JPA 而不是 MyBatis-Plus、为什么乐观锁在某些场景下会失效、为什么 Redis 预扣减和数据库扣减必须配合使用。选课系统这个场景妙就妙在它天然包含了几乎所有企业级系统的核心矛盾读多写少查课表的人远多于选课的人、热点数据热门课程瞬间被抢、数据一致性名额不能超也不能少、事务边界选课涉及选课记录和课程名额两张表。把这四个问题吃透你再去理解电商秒杀、库存扣减、抢票系统会发现底层逻辑是相通的。所以别把它当成一个简单的课设把它当成一次高并发思维的实战训练。2. 数据建模选课系统的表结构到底该怎么设计2.1 三张核心表与它们之间的关系很多人一上来就急着写 Controller结果表结构没设计好后面改到怀疑人生。选课系统的核心表其实就三张学生表student、课程表course、选课记录表course_selection。但魔鬼在细节里。先看课程表最容易踩的坑是把已选人数和课程容量直接放在一张表里就完事。看起来没问题但你要考虑已选人数是实时变化的而课程容量是固定的。当并发上来时所有人都在更新同一行的selected_count字段这一行就成了热点行数据库行锁会把所有请求串行化性能直接崩掉。CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_code VARCHAR(32) NOT NULL UNIQUE COMMENT 课程编号, course_name VARCHAR(128) NOT NULL, teacher_name VARCHAR(64), capacity INT NOT NULL DEFAULT 0 COMMENT 课程容量, selected_count INT NOT NULL DEFAULT 0 COMMENT 已选人数, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, select_start_time DATETIME COMMENT 选课开始时间, select_end_time DATETIME COMMENT 选课结束时间, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_code (course_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;选课记录表的设计有个关键决策要不要加唯一索引。答案是必须加而且要在(student_id, course_id)上建联合唯一索引。这一条索引能在数据库层面兜住同一个学生重复选同一门课的问题比你在代码里查一遍再插入要可靠得多——因为查和插之间存在时间窗口并发下照样会插进去两条。CREATE TABLE course_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, select_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT NOT NULL DEFAULT 1 COMMENT 1已选 2已退, UNIQUE KEY uk_student_course (student_id, course_id), INDEX idx_course (course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;2.2 为什么容量和已选数要分开考虑这里我要展开讲一个反直觉的点。capacity和selected_count虽然在同一行但它们的更新频率完全不同。capacity基本不变selected_count每次选课都变。如果你用 JPA 的Version做乐观锁每次更新selected_count都会让版本号加一这本身没问题但你要意识到乐观锁在高冲突场景下会导致大量失败重试。我实测过一组数据300 个名额5000 个并发请求如果纯靠乐观锁失败率能到 90% 以上因为大部分请求读到的版本号早就过期了。所以后面我会讲真正的防超卖方案是Redis 预扣减 数据库最终扣减的组合拳乐观锁只是最后一道保险。另外选课记录表里的status字段值得说道。退课不是物理删除而是把状态改成 2。为什么因为你要保留选课历史而且退课后名额要还回去物理删除会让审计变得困难。但这里有个坑唯一索引uk_student_course会导致退课后无法再次选同一门课。解决方案是把唯一索引改成(student_id, course_id, status)的联合唯一或者退课时做逻辑删除但用特殊值处理。我倾向于前者简单直接。2.3 JPA 实体映射的几个细节用 JPA 做映射时Version注解加在version字段上就能开启乐观锁。但要注意JPA 的乐观锁只在save或merge时生效如果你用Modifying写原生 SQL 更新乐观锁是不起作用的。这一点很多人不知道踩了坑还找不到原因。Entity Table(name course) public class Course { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String courseCode; private String courseName; private Integer capacity; private Integer selectedCount; Version private Integer version; // getter/setter 省略 }还有个细节selectedCount的更新最好用UPDATE course SET selected_count selected_count 1 WHERE id ? AND selected_count capacity这种原子 SQL而不是先查出来加一再存回去。前者是数据库层面的原子操作后者在并发下必然出问题。JPA 里可以用Modifying配合Query实现。3. 从零搭建 Spring Boot 工程骨架3.1 依赖选型JPA 还是 MyBatis-Plus关键词里出现了spring data jpa 和 mybatis-plus 的区别这确实是选型时绕不开的问题。我的建议是选课系统这种以单表操作为主、关联查询不多的场景JPA 完全够用而且开发效率更高。JPA 的Version乐观锁、saveAndFlush、方法名派生查询这些特性能让你少写很多样板代码。MyBatis-Plus 的优势在于复杂 SQL 的可控性和性能调优空间如果你的系统有大量多表关联统计比如按院系统计选课率那 MyBatis-Plus 会更顺手。但选课系统的核心矛盾是并发控制不是复杂查询所以 JPA 是更合适的选择。pom.xml的核心依赖dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.24.3/version /dependency /dependenciesRedisson 是用来做分布式锁的如果你只是单机部署用 Redis 的SETNX也能凑合但 Redisson 的看门狗机制能自动续期避免锁过期导致的并发问题生产环境强烈建议用它。3.2 配置文件里那些容易忽略的参数application.yml里有个参数我必须单独拎出来说HikariCP 的连接池大小。默认是 10在选课高峰期这个数字远远不够。但也不是越大越好连接池过大会导致数据库连接数爆掉。经验公式是连接数 CPU核心数 * 2 磁盘数一般设置 20 到 50 之间比较稳妥。spring: datasource: url: jdbc:mysql://localhost:3306/course_system?useSSLfalseserverTimezoneAsia/ShanghairewriteBatchedStatementstrue username: root password: your_password hikari: maximum-pool-size: 30 minimum-idle: 10 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000 jpa: hibernate: ddl-auto: update show-sql: false properties: hibernate: jdbc: batch_size: 50 redis: host: localhost port: 6379 lettuce: pool: max-active: 50 max-idle: 20rewriteBatchedStatementstrue这个参数很多人不知道它能让 MySQL 驱动把批量插入重写成一条 SQL性能提升非常明显。ddl-auto生产环境千万别用update应该用validate或者干脆手动管理表结构否则 Hibernate 可能在你不知情的情况下改表。3.3 分层结构与统一响应封装工程结构我建议按controller / service / repository / entity / dto / config分层。这里重点说统一响应封装因为选课接口的返回结果需要携带明确的业务状态码前端才能区分选课成功名额已满重复选课这些情况。public class ResultT { private int code; private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.code 200; r.message success; r.data data; return r; } public static T ResultT fail(int code, String message) { ResultT r new Result(); r.code code; r.message message; return r; } }业务状态码我习惯这样定义200 成功、400 参数错误、409 名额已满、410 重复选课、500 系统异常。用 HTTP 状态码的语义来映射业务状态前端处理起来更自然。4. 选课核心逻辑从朴素实现到并发安全4.1 最朴素的实现为什么必然超卖先看一个教科书式的选课实现这段代码在单线程下完全正确但并发下必然出问题Transactional public void selectCourse(Long studentId, Long courseId) { Course course courseRepository.findById(courseId).orElseThrow(); if (course.getSelectedCount() course.getCapacity()) { throw new BusinessException(名额已满); } // 检查是否重复选课 if (selectionRepository.existsByStudentIdAndCourseId(studentId, courseId)) { throw new BusinessException(重复选课); } course.setSelectedCount(course.getSelectedCount() 1); courseRepository.save(course); selectionRepository.save(new CourseSelection(studentId, courseId)); }问题出在检查和更新之间存在时间窗口。假设课程还剩 1 个名额线程 A 和线程 B 同时读到selectedCount 299都判断 299 300 通过然后都执行加一最终selectedCount变成 301超卖了。这就是经典的check-then-act竞态条件。4.2 乐观锁方案能解决但不够优雅加上Version后JPA 会在更新时自动带上版本号条件UPDATE course SET selected_count 300, version 1 WHERE id ? AND version 0如果线程 B 的版本号已经过期更新影响行数为 0JPA 会抛出OptimisticLockException。这样确实能防住超卖但代价是大量请求失败。我压测过5000 并发抢 300 名额乐观锁方案的失败率超过 85%而且失败后如果做重试会进一步加剧数据库压力。所以乐观锁适合冲突概率较低的场景比如修改个人信息。选课这种高冲突场景需要更前置的拦截手段。4.3 悲观锁方案简单但吞吐量堪忧SELECT ... FOR UPDATE是另一种思路直接在数据库层面加行锁Lock(LockModeType.PESSIMISTIC_WRITE) Query(SELECT c FROM Course c WHERE c.id :id) Course findByIdWithLock(Param(id) Long id);这样线程 A 拿到锁后线程 B 必须等待串行执行。正确性没问题但吞吐量极低。300 个名额意味着最多 300 次成功操作但 5000 个请求全部要排队数据库连接池瞬间被打满后面的请求直接超时。悲观锁适合冲突极高但操作极快的场景选课的操作不算快涉及两张表写入所以也不是最优解。4.4 Redis 预扣减把流量挡在数据库之前真正生产级的方案是分层拦截。第一层用 Redis 做预扣减把绝大部分无效请求挡在数据库之外。思路是这样的系统启动时或定时任务把课程库存加载到 Rediskey 是course:stock:{courseId}value 是剩余名额。选课时先用 Lua 脚本原子性地判断并扣减private static final String STOCK_LUA local stock redis.call(get, KEYS[1]) if stock false then return -1 end if tonumber(stock) 0 then return 0 end redis.call(decr, KEYS[1]) return 1; public boolean preDeduct(Long courseId) { DefaultRedisScriptLong script new DefaultRedisScript(STOCK_LUA, Long.class); Long result redisTemplate.execute(script, Collections.singletonList(course:stock: courseId)); return result ! null result 1; }Lua 脚本在 Redis 里是原子执行的所以判断和扣减之间不会有其他请求插进来。这一步能挡掉 90% 以上的无效请求——名额没了直接返回根本不用碰数据库。但 Redis 扣减成功后数据库扣减仍可能失败比如数据库连接超时。所以必须有一套补偿机制如果数据库扣减失败要把 Redis 里的库存加回去。这个补偿逻辑要放在 catch 块里并且要保证补偿本身可靠。4.5 数据库最终扣减与唯一索引兜底Redis 预扣减通过后才进入数据库操作。这时候用原子 SQL 做最终扣减Modifying Query(UPDATE Course c SET c.selectedCount c.selectedCount 1 WHERE c.id :id AND c.selectedCount c.capacity) int deductStock(Param(id) Long id);返回值为 1 表示扣减成功为 0 表示名额已满可能是 Redis 和数据库不同步导致的。同时选课记录的插入依赖唯一索引兜底如果重复选课数据库会抛DuplicateKeyException捕获后返回友好提示。这三层防护下来超卖基本不可能发生Redis 挡住大部分流量原子 SQL 保证数据库层面不超卖唯一索引保证不重复选课。5. 压测验证数字不会骗人5.1 用 JMeter 模拟真实选课洪峰光说方案好没用得拿数据说话。我用 JMeter 做了一组对比测试场景是 300 个名额、5000 个并发请求、持续 10 秒。测试机是 4 核 8G 的云服务器MySQL 和 Redis 同机部署。方案成功选课数超卖数平均响应时间错误率朴素实现30047120ms0%乐观锁3000890ms85%悲观锁30002100ms12%Redis原子SQL300045ms0.3%数据很直观朴素实现虽然快但超卖了 47 个名额这是绝对不能接受的乐观锁和悲观锁虽然不超卖但响应时间和错误率都很难看Redis 预扣减方案在保证正确性的同时响应时间还降到了 45ms错误率只有 0.3%主要是 Redis 连接超时。5.2 压测中暴露的三个真实问题压测不是跑完看个数字就完事过程中暴露的问题才是最有价值的。第一个问题是Redis 库存和数据库库存不一致。压测跑完后发现 Redis 里还剩 5 个名额但数据库里已经满了。原因是有些请求 Redis 扣减成功但数据库扣减失败补偿逻辑没来得及执行。解决方案是加一个定时对账任务每隔 5 分钟扫描一次把 Redis 库存重置为capacity - selectedCount。第二个问题是连接池被打满。5000 并发下HikariCP 的 30 个连接瞬间被占满后续请求全部超时。后来把connection-timeout从默认的 30 秒改成 3 秒快速失败避免请求堆积。第三个问题是重复选课的判断逻辑。一开始我在代码里先查再插压测发现同一个学生偶尔会插入两条记录。后来加了唯一索引捕获DuplicateKeyException才彻底解决。这印证了一个原则能用数据库约束保证的就不要靠代码逻辑。5.3 限流与降级给系统留条后路即使做了这么多优化选课高峰期的流量仍可能超出系统承载能力。这时候需要限流和降级。我用 Sentinel 做了接口级别的限流选课接口 QPS 限制在 2000超过的直接返回系统繁忙请稍后重试。虽然体验差一点但总比整个系统崩掉强。降级策略是如果 Redis 挂了直接走数据库乐观锁方案虽然慢但能保证正确性如果数据库也扛不住就关闭选课入口只保留查询功能。这种有损服务的思路在电商大促里很常见选课系统同样适用。6. 那些文档里不会写的踩坑记录6.1 JPA 的 save 和 saveAndFlush 到底差在哪这个坑我踩过两次。save只是把实体放入持久化上下文真正的 SQL 可能要到事务提交时才执行。如果你在save之后立刻查数据库可能查不到刚保存的数据。saveAndFlush会强制立即执行 SQL。在选课场景里插入选课记录后如果需要立刻拿到自增 ID就必须用saveAndFlush。但saveAndFlush也不是随便用的每次调用都会触发一次数据库往返批量场景下性能很差。我的经验是单条插入用saveAndFlush批量插入用saveAll配合batch_size配置。6.2 Transactional 失效的几种情况选课方法上加了Transactional就万事大吉了太天真。以下几种情况事务会失效方法不是 public 的、同类内部方法直接调用、异常被 catch 了没重新抛出、数据库引擎不支持事务MyISAM。我遇到过一次选课失败后名额没回滚查了半天发现是异常被 catch 后吞掉了事务根本没触发回滚。正确的做法是在 catch 块里要么重新抛出运行时异常要么手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。6.3 Redis 扣减成功但数据库失败怎么办这是分布式事务的经典问题。我的处理方式是本地消息表 定时补偿。Redis 扣减成功后先在本地消息表插一条待确认记录然后执行数据库扣减。数据库扣减成功就把消息标记为已完成失败就标记为待补偿。定时任务扫描待补偿的记录把 Redis 库存加回去。这套机制不追求强一致而是追求最终一致。在选课场景下短暂的不一致是可以接受的只要最终对账能对上就行。6.4 关于虚拟线程的一点实践关键词里提到了java21 spring boot 3.5 启用虚拟线程我在这个项目里也试了一下。开启方式很简单在application.yml里加spring.threads.virtual.enabled: true。效果是 Tomcat 的线程池不再成为瓶颈因为虚拟线程的创建成本极低。但要注意虚拟线程对synchronized 块不友好会导致载体线程被固定pinning。选课代码里如果用了synchronized做同步建议换成ReentrantLock。我实测下来开启虚拟线程后查询接口的吞吐量提升了约 40%但选课接口提升不明显因为瓶颈在数据库和 Redis不在线程。7. 面试中如何把这个项目讲出彩如果你拿这个项目去面试千万别只说我做了个选课系统用了 Spring Boot 和 MySQL。面试官想听的是你对并发问题的思考深度。我建议按这个逻辑讲先说朴素实现会超卖再说乐观锁和悲观锁的局限最后引出 Redis 预扣减 原子 SQL 唯一索引的三层方案。每一层都要能说出为什么这么选、有什么代价。面试官大概率会追问Redis 和数据库不一致怎么办这时候你就把本地消息表和定时对账讲出来。如果继续追问Redis 挂了怎么办就讲降级到乐观锁的方案。能扛住这三连问这个项目就算讲透了。还有个小技巧主动说出你压测的数据。比如5000 并发下响应时间 45ms错误率 0.3%这种具体数字比任何形容词都有说服力。面试官会觉得你是真的动手做过而不是背的八股文。8. 这套方案还能往哪些方向延伸选课系统的核心是有限资源的并发分配这个模型可以迁移到很多场景。电商秒杀的库存扣减、抢票系统的座位分配、优惠券的限量领取底层逻辑都是一样的Redis 预扣减挡流量、数据库原子操作保正确、唯一索引防重复、定时对账保一致。如果你想继续深挖可以往这几个方向走一是引入消息队列把选课请求异步化进一步削峰二是做分库分表把不同课程的库存分散到不同库突破单库瓶颈三是用分布式锁处理跨节点的并发Redisson 的RLock就是现成的工具。我个人在实际操作中的体会是高并发方案没有银弹每个方案都是在正确性、性能、复杂度之间做权衡。Redis 预扣减方案性能最好但引入了数据一致性问题悲观锁最简单但吞吐量上不去。选哪个取决于你的业务能容忍多大的不一致窗口以及你的团队能维护多复杂的系统。想清楚这个权衡比记住任何具体方案都重要。
返回列表