尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SpringBoot选课系统高并发实战:Redis+Lua+MQ+Sentinel架构设计

SpringBoot选课系统高并发实战:Redis+Lua+MQ+Sentinel架构设计 简介面向高校选课场景基于 SpringBoot 的高并发选课系统是一套完整的毕业设计源码重点解决集中选课期间数据库读写压力大、系统易崩溃以及抢课公平性难保障等实际问题。项目围绕高并发读写、接口 QPS 提升与选课安全进行针对性设计适合需要完成毕业设计或想深入学习高并发业务落地的 Java 开发者。压缩包共 138 个文件大小约 5.93 MB其中以 54 个 Java 源码文件为核心配合 HTML/CSS/JS 前端页面、SQL 数据库脚本、YML 配置文件以及 PNG 运行截图基本涵盖从数据库设计到前端交互和后端接口联调的关键环节。资源还包含多个接口调试 map 文件和 woff/ttf 图标字体便于二次开发和页面美化。已有 90 人学习/下载可作为快速理解选课系统业务建模、并发控制和项目结构组织的不错参考范本。1. 抢课不是高并发是瞬时尖峰——SpringBoot 选课系统先想清楚这一点高校选课和电商秒杀的关键差别秒杀是用户自己蹲点选课是教务公布一个时间点全校学生在同一秒发起请求。选课系统面对的不是均匀流量而是瞬时尖峰——平时几百 QPS开抢那几秒冲到几千上万随即回落。按峰值扩容长期空转不按峰值扩容数据库连接和应用线程会在开抢瞬间被打满。SpringBoot 不解决高并发只提供快速搭建 Web 服务的方式。决定系统扛不扛得住的是请求链路的每层设计Redis 读名额消息队列把落库移出同步链路MySQL 只做最终写入限流和幂等挡掉无效请求。下文按「响应模型 → 核心接口 → 压测调参 → 兜底」顺序给出能直接落地的 SpringBoot 高并发选课系统方案。2. 选课系统的响应模型Redis 预扣 消息队列落库SpringBoot 里怎么搭2.1 同步校验撑不住 5000 人同时抢问题出在哪按传统 CRUD 思路写选课接口流程是这样Controller 收请求Service 先查一次数据库确认名额再查一次确认学生没选过然后 UPDATE 课程表把名额减一最后 INSERT 一条选课记录。四次数据库交互按每次平均 5 毫秒算一个请求串行至少要 20 毫秒而一个数据库连接同一时刻只能服务一个请求。SpringBoot 里 Tomcat 默认线程池是 200HikariCP 连接池默认是 10。200 个线程同时进来10 个连接瞬间被打满其余线程全部阻塞在等连接的队列里接口响应时间从 20 毫秒劣化成几十秒。更隐蔽的问题是精确超卖。两个请求同时读到剩余名额是 1都走完自己的判断各自执行 UPDATE 和 INSERT结果 2 个人选进同一门只剩 1 个名额的课。除非 SQL 里带WHERE stock 0且先扣减后校验否则这种错误在业务代码里根本发现不了。压测时大量 500 和超时通常不是 SQL 写错而是连接排队排到几十秒。结论是选课链路里凡是「读多写少」的状态放到 Redis凡是能延后执行的拆到消息队列MySQL 只保留最终写入和事后对账。这个模型不复杂但决定了后面所有代码的组织方式。2.2 Redis 管名额、MQ 管落库、MySQL 管最终一致先明确各组件分工再写代码避免边写边纠结组件管什么为什么是它Redis课程剩余名额、已选学生集合、短时防重标记单线程执行 Lua 脚本天然原子读写在亚毫秒级RabbitMQ选课成功的异步落库消息削峰消费者按自己的速度写库不拖慢接口MySQL选课记录、课程表的最终数据唯一索引兜底防重也是对账的依据Caffeine课程名册、容量展示值高峰时连 Redis 都不想打直接读本机缓存Redis 放的是「临时的最终状态」开抢前由定时任务把课程容量预热进去抢课过程中名额扣减和已选登记全部在 Redis 完成抢课结束后由消费端和定时任务把已选集合同步回 MySQL。这样接口的响应路径上没有一次数据库访问RT 基本等于一次 Redis 往返加一次 MQ 发送。这里要提醒Redis 存的是快照型状态不是权威数据。Redis 宕机或键丢失时必须能从 MySQL 反推重建。所以预热任务和对账任务不是可选项是这套模型的一部分。2.3 最小链路Controller → Service → RabbitMQ → Consumer先准备 SpringBoot 配置把 Redis、RabbitMQ、Tomcat 的关键参数一次到位spring: rabbitmq: host: 127.0.0.1 port: 5672 publisher-confirm-type: correlated # 等 broker 回执拿不到就能触发补偿 data: redis: host: 127.0.0.1 lettuce: pool: max-active: 50 # 按 Tomcat 线程数的 1/4 到 1/2 起步 max-wait: 200ms # 超过 200ms 拿不到连接说明上游已过载 server: tomcat: threads: max: 400 # 超过 400 收益递减优先加实例 accept-count: 512 # TCP 层排队长度防突发丢连接publisher-confirm-type: correlated每个消息都等一次回执有损耗但它保证发送失败能被感知。选课消息丢了影响学生本学期成绩这个损耗值得。lettuce.pool.max-active不建议配到和线程数一样多Redis 单实例连接过多反而抬高延迟。核心选课服务把「校验、扣减、登记」封装成一次 Redis 调用拿到成功结果立刻返回同时向 MQ 发送落库消息Service public class CourseSelectService { private final StringRedisTemplate redis; private final RabbitTemplate rabbit; public ResultBoolean select(Long studentId, Long courseId) { // 前置校验选课时间窗、已选数量全部是 Redis 操作 if (!windowService.isOpen(courseId)) { return Result.fail(不在选课时间窗内); } // 核心扣减走 Lua一个操作完成“查重 扣名额 登记” Long code stockRedisScript.execute( redisScript, List.of(stockKey(courseId), registerKey(courseId)), String.valueOf(studentId), String.valueOf(courseCapacity(courseId))); if (code null || code ! 1) { return Result.fail(code ! null code -1 ? 请勿重复选课 : 名额已满); } // 异步落库失败由对账任务补齐 rabbit.convertAndSend(course.exchange, course.select, new CourseSelectMessage(studentId, courseId)); return Result.success(true); } }这段代码的核心是「先让 Redis 拍板再让 MQ 慢慢落库」。脚本返回 1 成功、-1 重复、0 没名额只有返回 1 才发消息。即使消息发送失败接口也照样返回成功学生不需要重试后续分钟级对账任务会把 Redis 里已登记的记录补进 MySQL。消费者只做一件事写库重复消息由唯一索引忽略Component public class CourseSelectConsumer { RabbitListener(queues course.select.queue) public void onMessage(CourseSelectMessage msg) { // INSERT IGNORE 是 MySQL 语法重复插入不抛错返回受影响行数为 0 courseSelectMapper.insertIgnoreStudentCourse(msg.studentId(), msg.courseId()); } }注意Redis 的登记集合和数据库唯一索引是两道独立防线。即便 Redis 键被清空导致 Lua 脚本误判放行数据库这层也不会让同一个学生选进同一门课两次。3. 选课接口的三道闸Sentinel 限流、幂等防重、Lua 原子扣减3.1 用 Sentinel 给选课接口做集群限流开抢瞬间即使 Redis 足够快也不能放所有请求进来。一门课只剩 10 个名额800 人在抢放进去的 790 个全是无效请求它们会把线程池和连接队列拖到崩溃边缘。常见做法是用 Sentinel 给选课接口加 QPS 维度的流控阈值按「接口能稳定处理的最高 TPS × 1.2」来定。SpringBoot 集成 Sentinel引入官方 Starter然后在入口方法上加注解dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-sentinel/artifactId /dependencySentinelResource( value course_select, blockHandler selectBlock, // 触发限流时走这里返回“人多请重试” fallback selectFallback) // 业务异常时走这里不把堆栈暴露给前端 public ResultBoolean select(Long studentId, Long courseId) { // 上一章的扣减核心逻辑 } public ResultBoolean selectBlock(Long studentId, Long courseId, BlockException e) { return Result.fail(选课请求过多请稍后重试); }参数说明blockHandler和fallback的返回类型、参数列表必须和原方法一致且方法必须是publicblockHandler末尾要多一个BlockException参数。流控规则建议在 Sentinel 控制台动态配置选课场景用「QPS 维度 直接拒绝」不要用慢调用比例尖峰场景下慢调用指标反应太慢等它统计出来线程池已经满了。不引 Sentinel 也可以自己用 Redis 加 Lua 写令牌桶效果类似但要自己解决规则发布和监控。选课系统是「峰值几分钟、平时没流量」的场景流量治理代码越少越好我一般直接用 Sentinel规则放控制台而不是写死在代码里调整阈值不用重新发版。3.2 防重Redis 短时幂等 数据库唯一索引兜底选课重复请求有三个来源前端双击按钮、学生刷新页面重发、MQ 或网关重试。只靠业务代码判断库里有记录不可靠因为判断和写入之间存在时间窗口。第一道防重用 Redis 的 SETNX 做短时幂等键设计为select:dup:{studentId}:{courseId}Boolean firstTry redis.opsForValue() .setIfAbsent(dupKey(studentId, courseId), 1, Duration.ofSeconds(30)); if (Boolean.FALSE.equals(firstTry)) { return Result.fail(请求处理中请勿重复提交); }提示不要用get set两步判断两步之间不是原子的两个并发请求可能同时通过检查。setIfAbsent只有在键不存在时才写入并返回 true天然只有第一个请求能进来。这个 30 秒窗口覆盖学生一次抢课的正常重试时间。窗口过期后同样的学生再次提交会再走一次完整流程此时由数据库唯一索引决定是否真的能选上。第二道防重依赖表结构course_select表用(student_id, course_id)建唯一索引。数据库锁是唯一没有并发窗口的防线配合INSERT IGNORE使用返回影响行数为 0 说明记录已存在直接返回成功即可不影响学生对「已选上」的认知。3.3 Lua 脚本把「查名额、扣名额、登记学生」做成一个原子操作选课最核心的并发是剩余名额的竞争。用GET再DECR两步会出现两个请求同时读到剩余 1、都执行 DECR、最终变成负数。Redis 单线程执行特性允许用一段 Lua 脚本合并三个操作执行期间不会有其他命令插入-- KEYS[1] 课程剩余名额 keyKEYS[2] 已选学生 set -- ARGV[1] 学生 id if redis.call(SISMEMBER, KEYS[2], ARGV[1]) 1 then return -1 -- 已选过直接拒绝 end local left tonumber(redis.call(GET, KEYS[1]) or 0) if left 0 then return 0 -- 没名额 end redis.call(DECR, KEYS[1]) redis.call(SADD, KEYS[2], ARGV[1]) return 1 -- 扣减成功Java 侧用DefaultRedisScriptLong执行DefaultRedisScriptLong stockScript new DefaultRedisScript(); stockScript.setResultType(Long.class); stockScript.setScriptText(loadLuaFromClasspath(course_select.lua)); Long code redis.execute(stockScript, List.of(course:stock: courseId, course:selected: courseId), studentId.toString());参数说明setResultType(Long.class)必须显式声明否则 RedisTemplate 按字节解码返回的 1 会被解成乱码List.of里的顺序必须与 Lua 脚本中KEYS[1]、KEYS[2]严格对应ARGV从第三个参数开始。脚本里or 0处理课程未预热时取到nil的情况按 0 处理不会误放行请求。三道闸的分工可以这样理解关卡手段拦的什么入口限流Sentinel QPS 阈值超出系统承载的无效请求短时幂等Redis SETNX 30s TTL同一个人一秒钟内的重复提交原子扣减Lua 脚本多人对同一剩余名额的竞争杜绝超卖4. 压测选课接口线程池、连接池的参数和排查方向4.1 用 JMeter 造出 5000 人的抢课瞬间代码写完必须压测而且压测要模拟「同一时间开闸」不是慢慢加并发。JMeter 线程组参数这样设JMeter 参数设置值说明线程数5000选课高峰人数的 50%100%Ramp-Up0所有线程同一毫秒发起模拟开闸循环次数1抢课是一次性动作HTTP 请求POST /api/courses/select参数随机 studentId 和 courseIdRamp-Up 填 0 是关键。填 10 秒等于给系统预留了热身时间测不出尖峰问题。线程的参数用 CSV 数据源读不同学生号避免 5000 个请求全是同一个人那样全被幂等挡住真实并发能力完全看不出来。跑完先看聚合报告三列Error %、90% 和 99% 响应时间、吞吐量。选课系统的合理预期是错误率低于 0.5%99% 响应时间低于 1 秒吞吐量大于选课人数除以预计持续秒数。错误率超过 5% 时不要急着加机器先回配置找原因。4.2 三个最容易被打穿的位置Tomcat 线程、Redis 连接、数据库连接压测出问题后按顺序看三个监控点。Tomcat 线程server.tomcat.threads.max默认 200。线程数全程顶着上线且大量请求排队时先用accept-count加大半连接队列再把线程数调到 400。超过 400 后 CPU 上下文切换成本超过收益优先加实例而不是加线程。Redis 连接Lettuce 是响应式连接单连接能复用但压测时报RedisConnectionException频繁出现说明连接创建跟不上。spring.redis.lettuce.pool.max-active从 50 起步上限不建议超过 200max-wait设 200ms超过这个时间说明上游已经过载继续等没意义。数据库连接HikariCPmaximum-pool-size默认 10。选课链路同步阶段不碰数据库但消费端写库、对账任务、导出任务会抢连接。给消费端和后台任务单独配一个DataSource或独立的连接池避免后台任务把核心写库连接占满。压测时一次只动一个参数跑完一轮对比再动下一个。4.3 压测指标怎么定TP99 和错误率哪个先看很多人一上来盯平均值平均值在尖峰场景没有参考价值。5000 个请求4900 个 50ms 返回100 个堵 30 秒平均值依然好看但堵住的 100 个学生体验是彻底失败的。选课系统盯两个指标99% 分位响应时间和错误率。先通过 Actuator 暴露关键指标再在压测过程中观察management: endpoints: web: exposure: include: health,metrics,threaddump# 压测中实时看 http 请求的耗时分布确认瓶颈在接口还是外部依赖 curl -s localhost:8080/actuator/metrics/http.server.requests | grep -A3 course_select这个命令能看到course_select接口的最近耗时分布。如果 TP99 高而错误率低优先怀疑排队检查线程池和连接池如果错误率高优先怀疑限流阈值过小或 Redis 慢查询执行SLOWLOG GET 10看是不是脚本执行卡在某个KEYS指令上。定位清楚是排队还是拒绝再决定调下一步参数。5. 选课高峰的兜底本地缓存、降级入口和补偿开关5.1 Caffeine 缓存课程容量让查询别打在 Redis 上开抢前学生端会不停刷新课程列表看剩余名额。这类读请求量远大于抢课请求量全打到 Redis 会占用连接挤压真正的扣减操作。用 Caffeine 在应用节点缓存课程容量展示值五分钟过期Bean public CacheLong, CourseCapacity courseCapacityCache() { return Caffeine.newBuilder() .expireAfterWrite(Duration.ofMinutes(5)) .maximumSize(5_000) .build(); }说明这个缓存只服务于展示不参与扣减。扣减永远走 Redis Lua 脚本不能因为本地缓存读到 1 个名额就放行。展示端延迟五分钟无所谓扣减端错一个名额就是教学事故。5.2 用一个配置开关在抢课结束后关掉入口选课时间窗结束后的请求该拒绝就拒绝别让系统空转。把开关放到 Nacos 这类配置中心配合RefreshScope动态刷新没有配置中心就放数据库配置表接口每次读最近一条Component ConfigurationProperties(prefix course.select) public class SelectSwitch { private boolean open; // 选课总开关 private String currentPhase; // BEFORE / DURING / AFTER }接口第一行检查开关关闭就直接返回。比在代码里写死时间判断灵活代码里的时间判断可以靠本地改时间绕过教务临时调整还要改代码发版配置开关只需要在控制台改一个值。5.3 失败补偿定时任务对账 Redis 与 MySQL最后一道保险是分钟级对账任务用Scheduled实现Scheduled(fixedDelay 60_000, initialDelay 30_000) public void reconcile() { SetString selectedInRedis redis.opsForSet().members(registerKey(courseId)); for (String sid : selectedInRedis) { if (!dbExists(sid, courseId)) { courseSelectMapper.insertIgnore(Long.valueOf(sid), courseId); } } }fixedDelay是上次执行完等 60 秒再跑必须设initialDelay否则应用启动瞬间对账任务会立刻跑一轮和抢课流量撞在一起。这个任务把「MQ 堆积未消费」和「消息发送失败」造成的数据缺口自动补齐。学生端抢课结果以 Redis 为准Redis 到 MySQL 的延迟只要在一个选课周期内收敛教务统计和成绩同步都来得及。本文还有配套的精品资源点击获取
返回列表