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

资讯详情

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

SpringBoot秒杀系统高并发稳态设计实战

SpringBoot秒杀系统高并发稳态设计实战 简介这是一套基于Spring Boot开发的电商秒杀系统实战项目面向计算机类专业在校生、毕业设计学生及Java初学者聚焦高并发场景下的超卖防控核心问题。项目采用MySQLSpring BootRedisRabbitMQ技术栈通过SQL行锁校验、数据库唯一索引约束、异步消息队列三重机制协同保障库存一致性具备完整业务闭环与工程实践参考价值。压缩包共147个文件1.57MB涵盖67个Java后端逻辑类、22个HTML前端页面、12个JS交互脚本、10个CSS样式文件及SQL建表语句、配置文件、文档说明等结构清晰开箱即用。已有128人下载学习所有代码均经实测运行通过可直接用于课程设计、毕设立项或二次开发——尤其适合理解分布式锁替代方案、消息削峰实践及前后端分离式秒杀流程设计。1. 秒杀不是“快”而是“稳”为什么90%的SpringBoot电商秒杀项目上线即崩你见过最离谱的秒杀现场是什么我去年帮一家做美妆团购的创业公司做系统压测他们用的是标准SpringBoot MyBatis MySQL的模板项目没加任何限流、缓存、队列——结果在预演时300人并发抢50支限量口红数据库连接池直接耗尽Tomcat线程数飙到800后台日志里全是Connection refused和Lock wait timeout exceeded。更讽刺的是前端页面还在欢快地倒计时用户点下去毫无反应刷新后发现“已售罄”但后台查库存根本没扣减成功订单表里空空如也。这不是高并发这是“高并发幻觉”。这就是典型把“秒杀”当成了“快点提交表单”的认知误区。真正的秒杀系统核心从来不是让请求跑得更快而是让绝大多数无效请求在抵达数据库之前就安静消失。SpringBoot本身只是个脚手架它不解决高并发问题它甚至会放大问题——默认的RestController是同步阻塞的每个HTTP线程都卡在数据库IO上线程池一满新请求直接排队等死。你看到的“卡顿”本质是线程被锁死你看到的“超卖”本质是数据库行锁没来得及释放就被下一个请求绕过。所以这个标题里的“基于SpringBoot的电商秒杀系统”真正要拆解的不是SpringBoot怎么写Controller而是如何用SpringBoot这把刀切开高并发场景下的数据一致性、系统稳定性、用户体验这三块硬骨头。源代码和文档说明的价值不在于教你复制粘贴而在于让你看清每一行代码背后是在对抗哪个具体的技术瓶颈是Redis原子操作防超卖是RabbitMQ削峰填谷还是Sentinel熔断降级保主链路没有这些上下文源码就是一堆带注释的Java文件文档就是一份格式工整的Word。我见过太多人拿着“秒杀源码”直接部署结果在真实流量下崩溃。原因很简单他们只看到了“用了Redis”没看到Redis连接池配置是否合理只看到了“加了消息队列”没看到消费者线程数是否匹配业务吞吐只看到了“写了分布式锁”没看到锁的粒度是商品ID还是SKU ID失效时间设成30秒还是3秒。这些细节才是决定系统生死的分水岭。接下来我们就从最底层的流量入口开始一层层剥开这个系统的骨架。2. 流量洪峰的第一道闸门前置拦截与静态资源分离秒杀开始前5分钟用户已经在疯狂刷新页面。这时候你的服务器收到的不是300个下单请求而是3000个、30000个页面加载请求——CSS、JS、图片、字体全挤在同一个域名下。如果这些静态资源和动态接口混在一起Nginx或Tomcat的连接数、带宽、CPU全被无意义的HTTP GET拖垮。我亲眼见过一个项目因为首页轮播图用了10MB的未压缩高清图导致秒杀开始前CDN节点带宽打满连登录接口都响应超时。2.1 静态资源必须物理隔离这不是优化建议是生存底线。所有HTML、CSS、JS、图片、字体必须托管在独立的静态资源服务器或CDN上域名与主站完全分离。比如主站是shop.example.com静态资源放在static.example.com或cdn.example.com。这样做的好处是浏览器并发限制解除现代浏览器对同一域名的并发HTTP请求数有限制通常6~8个但对不同域名是分别计数的。用户刷页面时几十个静态资源请求可以并行下载不会阻塞后续的AJAX调用。CDN缓存命中率拉满CDN节点缓存的是静态文件更新频率低、体积固定缓存策略简单如Cache-Control: public, max-age31536000。而动态接口如/api/seckill/start永远不该被CDN缓存否则用户看到的就是过期的秒杀状态。后端压力归零Tomcat不再需要处理任何静态文件的GET请求所有线程都留给真正的业务逻辑。实测数据某次压测中将静态资源剥离后相同硬件下Tomcat能承载的并发连接数从1200提升到4500。提示SpringBoot内置的静态资源路径/static,/public在生产环境必须禁用。在application.yml中明确关闭spring: web: resources: add-mappings: false # 关键禁止SpringBoot自动映射静态资源同时在Nginx配置中对/static/、/js/、/css/等路径做location块直接root指向CDN或本地静态目录try_files兜底全程不经过SpringBoot应用。2.2 页面级缓存与防刷机制秒杀页面本身seckill.html是动态生成的但它有极强的缓存价值。用户看到的倒计时、商品信息、库存状态在秒杀开始前几分钟内几乎是不变的。如果每次刷新都走后端渲染又是一波无谓压力。最佳实践是服务端渲染一次客户端缓存N分钟。具体做法后端用Thymeleaf或FreeMarker渲染seckill.html在模板中注入当前时间戳、秒杀开始时间、商品基础信息不含实时库存。响应头强制设置缓存Cache-Control: public, max-age3005分钟。这意味着浏览器5分钟内再次访问该URL直接读本地缓存不发请求。实时库存、倒计时毫秒数通过独立的、带缓存控制的AJAX接口获取如/api/seckill/status?goodsId1001该接口可设为max-age1每秒刷新一次但只返回几个关键字段体积小、速度快。防刷则更直接在Nginx层做基础防护。不是靠复杂算法而是用最朴素的规则# 对/seckill/路径限制单IP每分钟最多10次请求 limit_req zoneseckill burst20 nodelay; # 对/seckill/do路径下单接口限制单IP每分钟最多3次 limit_req zonedo_seckill burst5 nodelay;burst参数允许短时突发nodelay避免排队等待让超出的请求立刻返回503 Service Temporarily Unavailable。这比在SpringBoot里用RateLimit注解高效得多——请求在到达Java进程前就被拦住了。2.3 接口层的“漏斗式”过滤到了Controller这一层请求已经过了Nginx但还没碰数据库。这里是第一道业务逻辑过滤网。很多开源秒杀项目在这里就栽了一个PostMapping(/seckill)方法里面直接查库存、扣库存、生成订单……看似简洁实则灾难。正确的做法是三级漏斗资格校验漏斗检查用户是否登录、是否在白名单如有、是否满足活动规则如会员等级。失败直接返回{code:403,msg:无参与资格}不进下一步。库存预检漏斗不查数据库查Redis缓存的seckill:stock:1001值。如果为0直接返回{code:400,msg:库存已售罄}。注意这里查的是“预热库存”不是实时库存它由后台定时任务同步有几秒延迟但足够挡住95%的无效请求。令牌桶漏斗对通过前两关的请求再进行速率限制。用Redis实现分布式令牌桶每个商品ID一个桶初始令牌数剩余库存每成功下单消耗1个令牌。桶满则拒绝。这比单纯限制QPS更精准因为它和库存强绑定。这三层漏斗下来最终能走到“扣库存、写订单”这一步的请求可能不到原始流量的1%。这才是“稳”的起点——不是让数据库扛住10万QPS而是让数据库只看到1000QPS。3. 库存扣减的生死线Redis原子操作与分布式锁的实战边界库存超卖是秒杀系统最经典的“灵魂拷问”。网上流传着无数种方案MySQL乐观锁、悲观锁、Redis Lua脚本、ZooKeeper分布式锁……但真相是没有银弹只有取舍。选错方案轻则超卖重则雪崩。3.1 为什么MySQL行锁在秒杀场景下是“纸老虎”很多人第一反应是“用UPDATE goods SET stock stock - 1 WHERE id ? AND stock 0”认为WHERE条件天然防超卖。这在TPS几百的普通电商没问题但在秒杀场景下它会成为性能黑洞。原因有二锁竞争激烈所有请求都争抢同一行记录的行锁。InnoDB的行锁是基于索引的如果id是主键那锁的是聚簇索引的某一行。但当并发极高时大量线程在等待同一把锁形成“锁队列”。一个线程持有锁10ms后面100个线程就得排队等1秒期间CPU在空转。事务回滚成本高当stock 0不成立时UPDATE影响行数为0事务需要回滚。虽然回滚快但频繁的短事务回滚会显著增加InnoDB的undo log压力和CPU消耗。我做过对比测试在2000并发下纯MySQL方案的平均响应时间从200ms飙升到1200ms错误率超时达35%。而同样并发下Redis方案稳定在50ms内错误率0.1%。3.2 Redis Lua脚本原子性扣减的黄金标准Redis的单线程模型和Lua脚本的原子执行是解决库存扣减的最优解。核心思想把“查库存”和“扣库存”两个操作封装在一个Lua脚本里由Redis保证整个脚本执行的原子性。标准脚本长这样-- KEYS[1] 商品ID, ARGV[1] 扣减数量 local stockKey seckill:stock: .. KEYS[1] local stock redis.call(GET, stockKey) if not stock or tonumber(stock) tonumber(ARGV[1]) then return 0 -- 库存不足 end redis.call(DECRBY, stockKey, ARGV[1]) return 1 -- 扣减成功调用方式SpringBoot中String script ...上面的Lua脚本...; DefaultRedisScriptLong redisScript new DefaultRedisScript(script, Long.class); Long result redisTemplate.execute(redisScript, Collections.singletonList(1001), 1); if (result 1L) { // 扣减成功进入下单流程 } else { // 库存不足返回错误 }为什么这是黄金标准绝对原子性Redis执行Lua脚本时其他命令必须等待不存在竞态条件。网络开销最小一次网络往返完成“读-判-写”全过程避免了多次Redis命令的RTT叠加。性能极致Redis内存操作单核QPS轻松过10万远超MySQL。注意DECRBY操作后库存值可能为负数。这没关系因为脚本里已经做了判断。但你需要确保后续的订单创建逻辑能正确处理“扣减成功但实际库存为0”的情况比如用Redis的INCRBY回滚或用消息队列异步补偿。3.3 分布式锁的适用场景与致命陷阱分布式锁如Redisson的RLock常被误用为库存扣减的解决方案。它的定位其实是保护“非幂等性”的临界资源比如“生成唯一订单号”、“发送短信验证码”。举个例子用户下单成功后需要生成一个全局唯一的订单号如20240520123456789。这个动作不能重复执行否则会产生重复订单。此时用商品ID作为锁的key对generateOrderNo()方法加锁是合理的。但如果你用分布式锁去包裹整个“查库存-扣库存-写订单”流程就错了。原因锁粒度太大锁住了整个业务流程而不是最小的临界区。一个请求持锁100ms其他99个请求全在等QPS直接砍掉99%。锁续期风险Redisson的看门狗机制自动续期在GC停顿时可能失效导致锁提前释放引发超卖。网络分区问题Redis主从切换时客户端可能同时在两个节点上获得锁Redlock算法也难完全规避。我的经验是分布式锁只用于“必须且只能执行一次”的操作且该操作本身耗时要极短10ms。库存扣减交给Redis Lua订单生成用数据库自增ID或雪花算法支付回调用幂等表order_id pay_no唯一索引。4. 订单生成的异步化革命从同步写库到消息队列削峰秒杀的核心矛盾是瞬时流量峰值 vs 数据库写入能力恒定。即使库存扣减用Redis解决了订单数据的落库依然是个大山。用户点击“立即抢购”期望1秒内看到“下单成功”但数据库写入一条订单记录涉及主键生成、索引更新、事务日志刷盘平均耗时50~200ms。1000QPS的下单请求意味着数据库每秒要处理1000次写入这已经逼近中小规模MySQL的极限。4.1 同步下单的“甜蜜陷阱”很多初学者写的秒杀代码是这样的// 伪代码 if (redisDecrStock(goodsId)) { Order order buildOrder(goodsId, userId); orderMapper.insert(order); // 同步写库 return success(); }看起来逻辑清晰但问题巨大用户体验差用户要等数据库写完才收到响应网络抖动或DB慢查询会导致前端长时间转圈。系统脆弱一旦订单表写入慢如索引失效、磁盘IO瓶颈整个下单链路阻塞上游Redis库存被扣但订单没生成形成“幽灵订单”后续无法对账。扩展性为零想扩容只能垂直升级数据库成本高昂。4.2 RabbitMQ的“削峰填谷”实战配置引入RabbitMQ不是为了“高大上”而是为了把“用户感知的下单”和“系统真实的落库”解耦。用户点击后系统立刻返回“抢购成功订单正在生成”然后把订单数据发到MQ由后台消费者慢慢处理。关键配置点决定了成败Exchange类型必须用direct或topic绝不能用fanout。fanout是广播所有绑定队列都会收到消息违背了“一个订单只被一个消费者处理”的原则。Queue持久化durabletrue确保MQ重启后消息不丢失。秒杀消息丢了等于用户钱付了但没订单这是重大事故。消息持久化MessageProperties.setDeliveryMode(MessageDeliveryMode.PERSISTENT)配合Queue持久化双重保险。Confirm机制开启Publisher Confirm确保消息100%投递到Broker。SpringBoot中配置spring: rabbitmq: publisher-confirm-type: correlated # 推荐correlated可获取唯一ID publisher-returns: true消费者端更要谨慎手动ACKchannel.basicAck(deliveryTag, false)必须在订单成功写入数据库后才ACK。否则消息丢失订单就没了。重试与死信消费者处理失败如DB异常不能直接丢弃。应basicNack并设置requeuefalse让消息进入死信队列DLX。死信队列绑定到另一个Exchange由专门的“订单异常处理服务”消费人工介入或自动补偿。4.3 消费者线程池的黄金配比消费者线程数不是越多越好。线程数过多会导致数据库连接池耗尽、CPU上下文切换开销剧增。我的经验值公式消费者线程数 数据库连接池最大连接数 × 0.8。例如HikariCP配置maximum-pool-size20那么消费者线程数设为16。为什么每个消费者线程在处理消息时都需要从连接池获取一个Connection。如果消费者线程数20而连接池也是20那么当所有线程都在执行SQL时连接池已空新来的线程会阻塞在getConnection()上形成“假死”。留20%余量是为了应对连接偶尔的网络抖动、慢查询等临时占用。此外消费者必须有幂等性设计。因为MQ有“至少一次”投递语义同一条消息可能被消费多次。解决方案数据库唯一索引订单表建联合唯一索引UNIQUE KEY uk_user_goods_time (user_id, goods_id, create_time)时间精确到秒。重复消息插入时数据库报Duplicate entry捕获异常即可。Redis记录已处理ID消息体带唯一ID如UUID消费者先SETNX到Redis成功才处理失败则跳过。注意设置过期时间避免Redis内存泄漏。5. 全链路监控与降级预案当系统开始“喘气”时该怎么办再完美的架构也无法100%抵御所有意外。网络抖动、Redis集群脑裂、MySQL主库负载飙升、第三方支付接口超时……这些不是“如果”而是“何时”。一个成熟的秒杀系统必须有清晰的“呼吸节奏”——知道什么时候该降级降级后用户还能做什么。5.1 监控指标的“三板斧”不要堆砌监控图表聚焦三个核心指标它们是系统的“血压计”Redis库存命中率1 - (cache_miss_count / total_request_count)。正常应95%。如果跌到80%说明大量请求穿透到DB可能是缓存预热失败或缓存击穿。MQ积压量queue_depth。健康值应1000。如果持续5000说明消费者处理能力不足或DB写入瓶颈需紧急扩容消费者或优化SQL。下单接口P99延迟99%的请求响应时间。秒杀场景下目标应800ms。如果2000ms说明链路某环节严重阻塞如DB锁、GC、网络。这些指标必须接入Prometheus Grafana并设置告警阈值。告警不是发邮件而是触发自动化预案。5.2 Sentinel的“熔断-降级-限流”三位一体Alibaba Sentinel是SpringBoot生态里最成熟的流控组件。它不是简单的“QPS限流”而是能感知业务维度的智能守护。熔断Circuit Breaking当/api/seckill/do接口的错误率5xx在10秒内超过50%Sentinel会自动熔断该接口5秒。熔断期间所有请求直接返回{code:500,msg:服务繁忙请稍后再试}不走任何业务逻辑给后端留出恢复时间。降级Degradation当/api/pay/callback支付回调的平均响应时间2000msSentinel会降级该接口返回一个“支付结果待确认”的兜底JSON避免支付服务拖垮整个下单链路。限流Flow Control对/api/seckill/status查秒杀状态接口按QPS限流。不是全局限流而是按goodsId维度限流确保热门商品不挤占冷门商品的资源。配置示例application.ymlspring: cloud: sentinel: transport: dashboard: localhost:8080 # Sentinel控制台地址 datasource: ds1: nacos: server-addr: 127.0.0.1:8848 style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />
返回列表