高并发系统架构设计:Spring Boot 多级限流、库存预扣减与异步下单全链路实战

做电商的,谁没被秒杀场景毒打过?瞬时流量一进来,数据库直接 CPU 100%,接着就是连接池打满、系统雪崩,最后背锅的永远是研发。

这篇文章不扯虚的,咱们直接聊聊怎么从零搭一个能扛住十万甚至百万 QPS 的秒杀系统。核心思路就一个:漏斗模型,层层拦截。把无效请求尽早干掉,让真正能到数据库的请求少之又少。


1. 秒杀到底在防什么?

做秒杀,其实就是在跟这几个问题死磕:

  1. 瞬时流量洪峰:平时几百 QPS,秒杀瞬间几十万 QPS,系统根本扛不住。
  2. 超卖和重复下单:并发扣库存,一不留神库存就成负数了;或者同一个用户手抖点了两次,生成两个订单。
  3. 恶意刷单与雪崩:黄牛用脚本狂刷,或者某个非核心接口响应慢了,把整个调用链拖死。

应对这些破事,架构上必须做到:高频数据全放内存(Redis),同步流程全拆异步(MQ),数据库只做最终兜底。


2. 前端与网关:把垃圾流量挡在门外

前端是第一道防线,核心目的就是增加机器脚本的攻击成本

2.1 防小白与低级脚本

按钮点击后置灰加倒计时,这是防小白用户疯狂连击。再弹个滑块验证码,能挡住 80% 以上没做逆向的低级自动化脚本。

2.2 接口防裸奔:动态签名

为了防止接口被直接抓包调用,前端请求必须带动态签名。

别用 MD5 了,现在早就不安全了,建议上 HMAC-SHA256。

Sign = HMAC-SHA256(userId + timestamp + nonce + params, secretKey)

  • timestamp:时间戳,后端校验误差不能超过 5 秒,防重放。
  • nonce:随机串,配合 Redis 用一次就作废。
  • secretKey:别写死在代码里,通过接口动态下发,增加破解难度。

后端搞个 AOP 拦截器统一校验,签名不对直接扔。


3. 网关层限流:Sentinel 集群与热点防护

网关(比如 Spring Cloud Gateway)是流量入口,这里的限流必须精准。

3.1 集群流控的坑

单机限流在分布式部署下没用,得用 Sentinel 的集群流控。通常搞个独立的 Token Server,网关节点做 Client。

避坑指南:Token Server 的网卡和 CPU 很容易成为瓶颈。实战中,如果 Token Server 扛不住,我们往往会退而求其次,采用"单机限流 + 网关层按比例分配阈值"的妥协方案。

3.2 热点参数防护

秒杀时,某个爆款(比如 iPhone)的流量可能占总量的 90%。如果只限总 QPS,其他正常商品就被误杀了。

必须用 Sentinel 的热点参数限流,把 skuId 拎出来单独配规则。比如全局 QPS 限制 1 万,但 skuId=1001 单独限制 5000。

java 复制代码
// Sentinel 热点参数规则配置
ParamFlowRule rule = new ParamFlowRule("seckill_resource")
    .setParamIdx(0) // 第0个参数是 skuId
    .setCount(5000); 
// 给特定爆款加个例外项
ParamFlowItem item = new ParamFlowItem("1001", "8000", "int"); 
rule.setParamFlowItemList(Collections.singletonList(item));
ParamFlowRuleManager.loadRules(Collections.singletonList(rule));

4. Redis 预扣减:Lua 脚本与那些容易踩的坑

过了网关,请求到了业务服务。这时候千万别直接查 DB 扣库存,必须把库存预热到 Redis 里做预扣减。

4.1 为什么必须用 Lua?

扣库存包含:查库存、判重、扣减、记录用户。在并发下,这几步如果不原子,绝对超卖。虽然 Redis 单线程执行命令是原子的,但多个命令组合不是。用 Lua 脚本把这几步包起来,Redis 服务端会一次性执行,中间不会被插队。

4.2 Lua 脚本实战与优化

很多教程里用 Set 来存已购用户(sismember),这在并发低的时候没问题。但如果几百万人抢,这个 Set 会撑爆内存,而且 sismember 在大 Set 下性能也会下降。

实战优化 :直接用 setnx 生成独立的用户维度的 key,或者用 Bitmap。这里我们用独立 key 的方式,更轻量。

lua 复制代码
-- KEYS[1]=库存key, KEYS[2]=已购前缀key, ARGV[1]=数量, ARGV[2]=用户ID
local stock_key = KEYS[1]
local bought_prefix = KEYS[2]
local buy_num = tonumber(ARGV[1])
local user_id = ARGV[2]

-- 1. 防重复下单(用独立 key 代替 Set,避免内存膨胀)
local buy_key = bought_prefix .. ":" .. user_id
if redis.call('exists', buy_key) == 1 then
    return -2 
end

-- 2. 查库存
local stock = tonumber(redis.call('get', stock_key))
if stock == nil or stock < buy_num then
    return -1 
end

-- 3. 扣库存
redis.call('decrby', stock_key, buy_num)

-- 4. 标记已购买(设置个过期时间,比如活动结束就清理)
redis.call('set', buy_key, 1, 'EX', 86400)

return 1 

4.3 Java 端调用

java 复制代码
public boolean deductStock(Long skuId, Long userId, int num) {
    String stockKey = "seckill:stock:" + skuId;
    String boughtPrefix = "seckill:bought:" + skuId;
    
    Long result = redisTemplate.execute(
        deductStockScript, 
        Arrays.asList(stockKey, boughtPrefix), 
        String.valueOf(num), 
        String.valueOf(userId)
    );
    
    if (result == 1) return true; 
    if (result == -1) throw new BizException("手慢了,已售罄");
    
    throw new BizException("您已参与过,请勿重复下单");
}

5. MQ 异步削峰:事务消息的取舍

Redis 扣减成功后,如果同步去 DB 建订单,DB 瞬间就死了。必须扔进 MQ 异步处理。

5.1 事务消息的 RT 陷阱

为了保证 Redis 扣减和 MQ 消息的一致性,很多人喜欢用 RocketMQ 的事务消息

老司机的吐槽 :事务消息需要发两次网络请求(半消息 + 提交),RT 直接增加十几毫秒。在十万 QPS 的秒杀场景下,这几毫秒的 RT 损耗是致命的。

实战取舍:大厂核心链路通常用"普通消息 + 本地消息表/定时对账"来搞。但为了讲清楚分布式事务的闭环,这里还是拿事务消息举例,大家自己落地时记得评估 RT 损耗。

5.2 事务消息代码

java 复制代码
public void seckillOrder(Long skuId, Long userId) {
    // 1. Redis 预扣减
    boolean deducted = redisService.deductStock(skuId, userId, 1);
    if (!deducted) return;

    // 2. 构建消息
    Message msg = new Message("SECKILL_ORDER_TOPIC", "TagA", 
        JSON.toJSONString(orderInfo).getBytes());
    
    // 3. 发送事务消息
    TransactionSendResult sendResult = rocketMQTemplate.sendMessageInTransaction(
        "seckill-tx-group", msg, orderInfo
    );
    
    if (sendResult.getSendStatus() != SendStatus.SEND_OK) {
        // 这里如果失败了,其实需要补偿 Redis 库存,或者走定时任务对账
        throw new BizException("系统繁忙,请稍后重试");
    }
}

Consumer 端拿到消息后,再去慢悠悠地建订单、扣真实库存。


6. 数据库兜底:别搞花里胡哨,稳字当头

Consumer 消费消息时,最终要落到 DB。

6.1 乐观锁的坑

很多文章教大家在扣库存时加个 version 字段做乐观锁。

千万别这么干! 秒杀这种极高并发下,version 冲突率极高,会导致大量更新失败和重试。

直接靠库存数量本身做条件就够了:

sql 复制代码
UPDATE seckill_sku 
SET stock = stock - #{num} 
WHERE sku_id = #{skuId} 
  AND stock >= #{num} 
  AND end_time > NOW();

只要 stock >= #{num},在 DB 层面就绝对不可能超卖。如果影响行数是 0,说明卖光了或者活动结束了。

6.2 异常补偿

如果 MQ 消费失败(比如 DB 宕机),Redis 库存扣了但订单没生成,怎么办?

  • 延迟队列 :发个 5 分钟的延迟消息,去查订单表。没生成订单,就回滚 Redis 库存(INCR 库存,DEL 用户标记)。
  • 定时对账:搞个定时任务,扫那些"预扣减成功但没订单"的流水,超时直接回滚。这是最稳的兜底方案。

7. 保命手段:降级、隔离与动态黑名单

系统快扛不住的时候,得有自保能力。

  • 线程池与信号量隔离:秒杀接口必须用独立的线程池。就算秒杀线程池满了,也不能影响商品详情、评价这些非核心接口。超出信号量限制的请求,直接 Fast Fail,返回"活动太火爆"。
  • 动态黑名单:结合风控,发现某个 IP 或 UserID 请求频率离谱,或者没有鼠标轨迹,直接在网关层拉黑,后续请求全挡掉。
  • 一键降级:配置中心(Nacos/Apollo)搞个开关。一旦 CPU 或 Load 飙到阈值,直接关掉积分计算、优惠券发放、短信通知,把资源全让给秒杀核心链路。

8. 全链路压测:别自己手写正则替换表名

单机压测根本测不出分布式环境下的网络延迟和中间件瓶颈,必须上全链路压测。

8.1 流量染色与影子库

在 HTTP Header 里打个标(比如 X-Test-Flag: 1),这个标要在全链路的 RPC、MQ 里通过 ThreadLocal 透传。

为了防止压测数据污染线上,需要构造影子表(如 seckill_sku_shadow)。

8.2 数据源路由的坑

有些教程教人写 MyBatis 拦截器,用正则表达式把 SQL 里的表名替换成影子表。

避坑 :自己写正则替换表名绝对会踩坑!遇到表别名、子查询、多表 JOIN,正则直接懵逼,线上必出 Bug。

实战做法:直接上 ShardingSphere 的影子库功能,或者在压测框架底层做数据源路由,别自己造轮子。


9. 监控告警:怎么知道系统快挂了

没有监控的秒杀系统就是在裸奔。

  • 核心指标:盯紧 QPS、RT(重点看 P99 和 P95,P99 飙升说明有长尾请求或 GC 停顿)、成功率。秒杀接口成功率低于 99.9% 必须告警。
  • 工具链:Prometheus + Grafana 看指标,SkyWalking 查慢 SQL 和链路超时,ELK 盯 Error 日志。
  • 告警策略:别只设绝对阈值(比如 RT > 500ms),还要搞同环比告警(比如当前 QPS 比昨天同时段跌了 30%)。告警直接接钉钉/企微机器人,严重故障直接电话语音呼叫。

总结

秒杀就是一场流量过滤的游戏。核心就五个字:防、限、削、异、兜

前端和网关把垃圾流量防住、限住;Redis 把同步压力削成内存操作;MQ 把峰值异步化;最后 DB 和补偿机制稳稳兜底。

把这套链路理顺,原本只能扛几百 QPS 的单体应用,也能轻松应对十万级并发。架构设计没有完美的方案,只有最适合当前业务和团队能力的取舍。希望这些踩坑换来的经验,能帮你在下一次秒杀大促中少掉几根头发。


相关推荐
风中的小熊生气20 小时前
Spring Boot 面试知识(二):分层、IoC、异常、配置与 Redis
java·springboot
EatFan1 天前
Java接入微信支付保姆式教程(三):SpringBoot 接入微信支付并完成统一下单
微信小程序·微信支付·springboot
liulilittle2 天前
为什么采用全局管理缓存及状态:麻将客户端状态管理
服务器·网络·游戏·客户端·异步·mahjong·麻将
敲代码的嘎仔3 天前
用 Redis 合并写 + DelayQueue 延迟检测,把视频播放进度写库频率降到 1/60
java·开发语言·数据库·redis·缓存·音视频·高并发
敲代码的嘎仔3 天前
互动问答系统实战:两级评论模型、ES 搜索集成、Caffeine 多级缓存全记录
java·开发语言·数据库·elasticsearch·缓存·mybatis·高并发
隐擎fox3 天前
高性能网络爬虫架构设计:基于 Python 的长连接复用与分布式会话池调度实践
分布式·python·网络协议·tcp/ip·高并发·网络爬虫、
Java爱好狂.6 天前
Java初学者如何设计一个高并发系统?
程序员·高并发·架构师·并发编程·java面试·java面试题·java八股文
爱和冰阔落8 天前
【Linux】epoll 真的是 O(1) 吗?从阻塞 I/O、select/poll 到 LT/ET 与 Reactor 一次讲透
linux·运维·高并发·tcp
她说..8 天前
MySQL 与 Java 的 JSON 数据处理
java·mysql·json·springboot