1. 压测现场:5,200 并发是怎么打出来的
压测结束后,我盯着两个数字反复看了好几遍才反应过来出了问题:Redis 剩余库存是 0,DB 已售记录是 93。总库存只有 100 件,也就是说有 7 件商品被人在 Redis 里“买走了”,却从头到尾没有在数据库里留下任何订单。这不是超卖,但它是另一种同样要命的秒杀一致性问题——Redis 已扣减但 DB 未落库。
这次压测不是心血来潮,而是针对一个单品秒杀活动做的全链路验收。商品 ID 1001,真实库存 100 件,活动设计就是 30 秒内售罄。我对压测的要求从一开始就写得很清楚:不只要测“扛不扛得住 5200 并发”,更要测“扛住之后业务数据对不对”。事实证明,这个要求决定了我接下来一整天的走向。
1.1 压测环境与全链路拓扑
先交代下压测现场的资源形态,后面所有数据都有意义。
- 业务服务:4 台 8C16G 云主机,部署 Nginx + Spring Boot,单实例最大线程池 500。
- 缓存层:Redis 6.2 集群,三主三从,哨兵模式。
- 数据库:MySQL 8.0,一主一从,连接池上限 20,单次事务隔离级别读已提交(RC)。
- 消息队列:独立部署的 MQ 集群,消费组 10 个消费者线程。
- 压测机:4 台 JMeter 施压节点,每台开 1300 个线程,20 秒 ramp-up 拉满,目标并发稳定在 5200。
压测脚本的流量模型模拟了真实秒杀场景的“瞬时爆发”:预热阶段先灌入 100 件库存到 Redis,持续压测 30 秒,期间所有请求都打同一个接口/api/seckill/{skuId},不带登录态,直接用 userId 参数区分。
监控方面,压测全程同时盯着 Redis QPS、DB 慢查询、活跃连接数、MQ 消费 Lag、应用线程池活跃数五个维度。压测结束那一刻,Redis 监控显示 QPS 峰值到了 32000/s,DB 慢查询列表刷了十几条 update 语句,平均执行时间 1.8 秒,最慢的一条 3.5 秒。当时第一反应是“DB 是瓶颈,但要看看数据最终是不是一致的”,结果一看就傻眼了。
1.2 库存模型:Redis 预占、DB 落库,中间隔着一个 MQ
这个系统的库存模型说简单也简单,说复杂也复杂。简单在于只有两个源:Redis 承担高并发下的预占扣减,DB 是最终库存事实。复杂在于两者之间不是同步写,而是靠 MQ 异步衔接。
完整链路是这样:
- 用户请求到达秒杀接口。
- 服务端调用 Redis Lua 脚本原子扣减
seckill:stock:1001这个 key。 - 扣减成功返回剩余库存,业务代码认为“用户已抢到”,响应成功。
- 同一时刻,代码组装一个
preDeductKey(userId + skuId + 时间戳),向 MQ 发送一条“预占成功”消息。 - 订单服务消费这条消息,执行 DB 落库:扣减 MySQL 库存,插入订单记录。
- 如果 DB 落库失败,重试;重试也失败,进死信队列。
这个模型理论上把 Redis 扛高并发的能力和 DB 事务可靠性结合起来了。但 5200 并发一打,每一步之间的“假设”全被戳穿了。后面我会把三个故障点逐个掰开讲。
1.3 为什么不用“分布式锁 + DB 扣库存”的常规方案
聊到秒杀,很多人第一反应是用 Redis 分布式锁包住“查库存 + 扣库存”两步,锁住了就不会超卖。这个方案在低并发下完全没问题,但秒杀场景真正的压力不在于“逻辑错”,而在于“DB 扛不住”。
如果扣减动作最终落在 MySQL 上,即使分布式锁把并发粒度压到每把锁只放一个线程进来,100 件库存仍然意味着至少 100 次 DB 事务串行落到同一行记录上。每笔事务包含一次 update 行锁 + insert 订单,5200 并发打到服务端时,大部分请求到不了 DB,但到达的那部分会在行锁上排队,连接池一旦占满,后面的等待全变超时。数据库连接池不是万能的,它在高并发下的表现是“连接排满 → 请求超时 → 连接被强制回收 → 应用层报错”,最终结果一样是部分用户成功但不落库。
所以我认同“Redis 做预占”这个方向:把 5200 并发中真正能抢到的那 100 个请求放进 Redis 内存操作里,剩下 5100 个请求直接返回“已售罄”,DB 只有在消息消费时才会被写入。这个设计没有问题,问题出在设计落地时对“可靠性”三个字的轻视。
2. 对账结果:Redis 剩余 0,DB 已售 93,缺口 7 件
压测完我对数据的第一个动作不是看 RT,不是看吞吐,而是跑对账。这是做秒杀系统必须养成的一个本能:任何压测结束后,第一件事就是把 Redis、DB、MQ 三个数据源拉出来互相核。
对账结果很刺眼:
| 数据源 | 结果 |
|---|---|
Redis 库存 keyseckill:stock:1001 | 剩余 0 |
| DB 订单表中 sku_id=1001 的记录数 | 93 |
| DB 库存表中 sku_id=1001 剩余库存 | 7 |
| 预占成功响应数(接口返回“抢购成功”) | 100 |
| MQ 消费组 Lag | 0 |
| 死信队列消息数 | 4 |
100 个用户收到了“抢购成功”的响应,Redis 也真实扣减了 100 次,但 DB 只落库 93 笔订单。7 笔订单凭空消失了。这不是一个可以靠“最终一致性会自动收敛”糊弄过去的差异,因为 MQ Lag 已经是 0,消息队列里没有任何待消费数据,也就是说这条链路上已经“静止”了,不会再自动补单。
2.1 初步排查:先排除消息积压和命令超时
当时第一步做的排查,不是看代码,而是看监控确认“静止状态”。
查 MQ 消费 Lag,确实归零。这意味着消息要么被消费成功,要么被消费端主动丢弃或 ack 掉了。查 Redis 慢命令,压测期间没有超过 50ms 的 Lua 扣减命令,说明预占这一步没有性能问题。查应用日志里有没有“扣减成功后发送 MQ 失败”的报错,结果发现了 2 条MQ send timeout日志,但对应的 try-catch 里只打了 error 日志,没有任何补偿动作。
这已经说明一个问题:Redis 扣减成功的 100 笔里,有 2 笔在发送 MQ 时失败了,而且这 2 笔的 Redis 预占没有回滚。它们就是“已扣未落”的第一批牺牲品。
剩下的 5 笔缺口,MQ 消息确实发出去了,Lag 也归零了,说明已经到过消费端。那消费端做了什么?
持怀疑态度比直接下结论重要。我当时没有急着翻消费代码,而是先从死信队列拉出了 4 条消息。4 条消息对应 4 笔预占,加上上面 2 笔发送失败的,正好解释 6 笔缺口。还剩 1 笔去哪了?
最后从消费端日志里找到答案:有 3 条消息在消费时被主动“忽略”了,日志写着“库存已满/已扣完,忽略消息”,消息被直接 ack。其中 2 条进入死信队列的,和这 3 条被忽略的,有一部分是同一笔,去掉重叠后一共有 7 笔唯一缺口。
2.2 把“扣减”和“落库”拉一条时间线
为了看清楚 7 笔缺口是怎么在 30 秒内产生的,我把日志按traceId和preDeductKey对齐,拉了一条时间线。
- 第 1 秒:5200 并发涌入,Redis Lua 扣减到 0,100 笔预占全部完成。
- 第 1 至 3 秒:前 30 笔消息消费成功,DB 落库正常,订单连续插入。
- 第 3 秒开始:消费端查 Redis 库存,发现 key 已经为 0,判定“活动已经结束”,把后续消息直接忽略。
- 第 5 秒:DB 连接池开始出现排队,active 连接数拉满 20,部分 update 进入行锁等待。
- 第 8 秒:2 条消息消费逻辑抛出锁等待超时异常,进入重试。
- 第 12 秒:重试第 2 次,仍超时。
- 第 20 秒:重试第 3 次,失败,消息进入死信队列。
- 第 30 秒:压测结束,MQ Lag 归零,系统看起来一切正常。
这条时间线把问题钉死在一个点上:秒杀链路里每个环节都以为“前一步成功 = 后一步一定能成功”,但事实是每一步都有独立的失败场景,而我当时只给整条链路准备了一套重试机制,并且这套机制本身还有 bug。
3. 定位链路:三个故障点如何合谋吃掉 7 件库存
先说结论:这 7 件库存不是被一个 bug 吃掉的,而是被三个不同层级的故障点合谋吃掉的。每一个单独看都不致命,但它们在同一轮压测里同时发生,数据就彻底对不上了。
3.1 根因 A:MQ 发送半成功,Redis 白扣了
第一个故障点发生在预占成功之后、发消息之前。
当时发送 MQ 的代码长这样:
@Resource private MqProducer mqProducer; public void afterPreDeduct(String preDeductKey, Long skuId, Long userId) { try { mqProducer.send("seckill_order_topic", preDeductKey, skuId, userId); } catch (Exception e) { log.error("发送MQ消息失败, preDeductKey={}", preDeductKey, e); } }表面上这段代码没有语法问题,但它在架构上有两个致命假设。
第一,它假设 MQ 发送失败时,Redis 的预占可以被回滚。实际上afterPreDeduct方法异常时,catch 块只打日志,没有调用 Redis 回加库存的接口。于是 Redis 已经扣减了 1 件,但这条预占没有任何后续处理,用户界面却已经收到了“抢购成功”的响应。
第二,它假设 MQ 发送只要不抛异常就算成功。但 MQ 发送的 “timeout” 异常有很多种,有些是 broker 端已经接收但响应超时,有些是网络闪断根本没到 broker。当时的生产环境遇到的是第一种:消息其实已经进了 broker,客户端抛了 timeout,业务代码直接当成失败丢掉,而消费端因为消息确实存在,最终还是落库了。这就是“半成功”的真实含义——你以为丢了,其实没丢;但你以为没丢的时候,它可能真丢了。
要理解这个问题,得先明白一个道理:Redis 预占和 MQ 发送,是两个独立的操作,中间没有任何事务边界。Redis 扣减成功不能保证 MQ 发送成功,MQ 发送成功也不能保证消费端落库成功。任何一环掉了,都会产生“已扣未落”的记录。
3.2 根因 B:消费端拿 Redis 当前库存当“有效性校验”
第二个故障点在消费端,而且是最隐蔽的一个。
消费消息时的核心代码原本是这样的:
public void onMessage(String preDeductKey, Long skuId, Long userId) { // 校验 Integer remain = redisTemplate.opsForValue().get("seckill:stock:" + skuId); if (remain == null || remain <= 0) { log.warn("库存已扣完,忽略消息: {}", preDeductKey); return; } // DB 落库 orderService.createSeckillOrder(preDeductKey, skuId, userId); }这段代码的出发点不算差:开发时觉得,如果 Redis 都已经没有库存了,这条消息对应的库存肯定也被别人抢走了,没必要再落库,省一次 DB 查询。但这里混淆了两件本质不同的事:预占凭证与实时库存。
Redis 的seckill:stock:1001在秒杀一开始后很快就变成 0,但它变 0 不意味着“这条消息对应的用户没抢到”。用户是先抢到了 Redis 预占,才发送的消息。消费端拿 Redis 当前的库存状态去判断一条历史预占消息是否有效,在时间顺序上完全错位。
打个比方:你排队时领到了“第 99 号”的号牌,但因为前面叫号太快,轮到你时柜台已经宣布“今天的号发完了”。柜台看的是“现在还有没有号”,而判断你能否办理业务的依据应该是你手里那张号牌本身。消费端把“剩余库存”这个实时状态当成了唯一判断标准,等于把 99 号的号牌扔进了垃圾桶。
这 3 笔被“忽略”的订单,就是被这段校验逻辑砍掉的。它们对应的用户在 Redis 里扣减成功,但消费端看到库存为 0,直接 ack,连 DB 碰都没碰。
3.3 根因 C:DB 连接池打满与行锁等待,让落库排不上号
第三个故障点是纯性能问题,但它放大了前两个问题的影响。
压测到第 5 秒开始,DB 活跃连接数持续打满 20。秒杀落库要更新seckill_stock表的同一行,行锁竞争非常激烈。单个 update 平均执行时间从 20ms 一路涨到 1.8 秒,最长达到 3.5 秒。
对于健康的消息消费来说,10 个消费者线程处理 100 条消息,每一条几十毫秒,不到 1 秒就能消费完。但行锁竞争出现后,每条消息在 DB 上要排队 2 秒以上,10 个线程全部阻塞,消费速度断崖式下跌。
这里产生了一个决定性影响:MQ 消息积压期间,消费端把 Redis 当前库存(已经为 0)作为判断条件的那段校验逻辑,等到了它想看到的“库存为 0”的状态,继续按“无效消息”丢弃。如果消息落在压测前半段,Redis 库存还是正的,校验逻辑反而会放行;落在后半段,就会被误杀。这解释了为什么被丢弃的不是随机 3 条,而是集中在压测中后段。
另外 2 条进入死信的消息,是消费端在处理 DB 更新时反复抛Lock wait timeout exceeded,重试第 3 次仍然失败,被 MQ 按照预设的重试策略投递到死信队列。死信队列没有关联任何消费者,相当于消息进入了“冷宫”,不会再被捞出来处理。
三个故障点叠加后的完整链路就是:2 笔在源头就丢了,3 笔在消费端被误杀,2 笔在 DB 压力下重试耗尽。加在一起,7 笔缺口。
4. 把问题变成可复现用例,再上修复
定位到根因之后,我没有马上动手改代码。因为秒杀这类并发问题,如果不在修复前把问题复现出来,改完之后根本没法证明“改对了”还是“碰巧压测数据对了”。这就引出标题里“可复现修复”的价值所在。
4.1 最小复现:三个故障注入点,稳定复现“已扣未落”
可复现修复的第一步,是把 5200 并发压测里暴露的问题缩成一个单元测试或者一个小规模压测用例,让它在每次运行时稳定报错。
我做了三组故障注入用例:
@Test public void should_recover_when_mq_send_fail() { // 故障注入:MQ 发送必失败 mockMqProducer.failAlways(); seckillService.flashSale(1001L, 10001L); // Redis 已扣减 Integer remain = redis.opsForValue().get("seckill:stock:1001"); assertThat(remain).isEqualTo(0); // 修复前:DB 无订单,对账缺口 1;修复后:应该有补偿记录或对账后落库 assertThat(orderMapper.countByPreDeductKey("1001-10001")).isEqualTo(1); }这个用例里,MQ 发送必然失败,但用户已经把 Redis 库存预占掉了。修复前这个用例必失败,因为没有任何机制把 DB 缺口补回来。
第二个用例模拟消费端误伤:
@Test public void should_not_ignore_message_when_redis_stock_is_zero() { // Redis 库存设为 0,模拟秒杀中后期 redis.opsForValue().set("seckill:stock:1001", 0); // 直接向消费端投递一条历史预占消息 consumer.onMessage("1001-10001", 1001L, 10001L); // 修复前:订单表为空,消息被忽略;修复后:应创建订单 assertThat(orderMapper.countByPreDeductKey("1001-10001")).isEqualTo(1); }第三个用例模拟 DB 连接池异常,让落库抛超时,验证消息进入死信后是否还有第二重补偿机制能把它捞回来。
三组用例全部能在修复前稳定复现“Redis 已扣减但 DB 未落库”的问题。这三组用例就是后面所有改动的验收标准,比靠肉眼盯压测数据靠谱得多。
4.2 修复一:扣减单据化,预占记录和落库状态分离
第一个修复动作,是把“Redis 扣了一笔”这件事从无状态操作变成有状态记录。
我新增了一张表seckill_pre_deduct:
CREATE TABLE seckill_pre_deduct ( id BIGINT PRIMARY KEY AUTO_INCREMENT, pre_deduct_key VARCHAR(64) NOT NULL COMMENT '预占唯一凭证', sku_id BIGINT NOT NULL, user_id BIGINT NOT NULL, quantity INT DEFAULT 1, status TINYINT DEFAULT 1 COMMENT '1=已预占 2=已落库 3=已回滚', retry_count INT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_pre_deduct_key (pre_deduct_key) );业务逻辑同步调整:
- Redis Lua 扣减成功后,先插入
seckill_pre_deduct表,状态为 1(已预占)。 - 插入成功才向 MQ 发消息,把
preDeductKey作为消息体。 - 插入失败则回加 Redis 库存,保证预占不白扣。
- 消费端落库成功后再把状态更新为 2(已落库)。
这张表成了整条链路的“账本”。Redis 扣减是快照,MQ 是传输,DB 是最终事实,而预占表是串联这一切的主线。任何一步失败,这张表里都会留下一条状态不等于 2 的记录,对账任务就能发现它。
可能有人会问:Redis 扣减和 DB 插入预占表也不是一个事务,如果插入表成功了但 Redis 没扣,怎么办?这种情况我通过 Lua 脚本保证 Redis 扣减在插入之前完成,插入失败再回加。插入预占表的动作本身非常轻量,失败概率低,且失败后回加 Redis 是明确可执行的补偿。
4.3 修复二:落库只认预占单,不认 Redis 当前库存
消费端代码改掉那个致命校验:不再查询 Redis 当前库存来决定一条消息是否有效。
新逻辑只在预占表上做判断:
public void onMessage(String preDeductKey, Long skuId, Long userId) { PreDeductRecord record = preDeductMapper.selectByKey(preDeductKey); if (record == null) { log.error("预占记录不存在: {}", preDeductKey); return; } if (record.getStatus() == 2) { // 已经落库,幂等跳过 return; } if (record.getStatus() == 3) { // 已回滚,不再处理 return; } orderService.createSeckillOrder(record); preDeductMapper.updateStatus(preDeductKey, 2); }这里有几个关键点。
判断依据从 Redis 实时库存换成了数据库里的预占状态,这从根本上消除了“库存为 0 就丢消息”的误伤。落库成功后再更新状态为 2,保证同一笔预占不会被重复扣两次库。preDeductKey上有唯一索引,即使消息被 MQ 重复投递,第二条消息进来时发现状态已经是 2,直接幂等跳过。
这套逻辑把“消费端是否处理消息”和“Redis 当前还有没有库存”彻底解耦。哪怕 Redis 里seckill:stock:1001早就变成 0 了,消费端收到一条合法的预占记录,照样正常落库。
4.4 修复三:对账兜底任务,把最后一公里补上
前两个修复解决了“源头丢弃”和“消费误杀”两类问题,但还没有解决“死信无人处理”和“消息确实在传输中丢失”这类绝对兜底问题。最后一公里,是一组定时任务。
对账任务每 30 秒执行一次,逻辑分为三步:
- 扫描
seckill_pre_deduct表中status = 1且created_at超过 2 分钟的记录。 - 逐条去 DB 订单表核对是否存在对应订单。
- 不存在订单 → 尝试直接落库,成功后更新状态为 2;落库失败 → 回加 Redis 库存,更新状态为 3。
死信队列里的消息不需要依赖人工处理了,因为对账任务会定期发现“预占单还挂在 status=1”,直接把薄记补上。即使 MQ 消息在传输中彻底丢失,只要 Redis 扣减时写了预占表,对账任务就一定能把账对平。
实现时有一个关键细节必须注意:回加 Redis 库存前,一定要判断 DB 剩余库存是否足够。
public void compensateRollback(PreDeductRecord record) { // 先查 DB 剩余库存 Integer dbStock = stockMapper.getRemainingStock(record.getSkuId()); if (dbStock >= record.getQuantity()) { // DB 还有余量,回加 Redis redisTemplate.opsForValue().increment("seckill:stock:" + record.getSkuId(), record.getQuantity()); preDeductMapper.updateStatus(record.getPreDeductKey(), 3); } // 否则保持 status=1,交给告警人工介入 }如果不做 DB 剩余库存判断,在库存几乎售罄时回加 Redis,会把 Redis 的库存数字加出一个“卖超”的空间,后续请求可能从 Redis 抢到库存,但 DB 已经没有余量可扣,反而造成另一种不一致。回滚动作只应该在确实还有库存余量时执行。
5. 同样的 5,200 并发,修复后究竟还差多少
修复完成后的验证没有省略,直接上了和之前完全一样的压测:4 台施压机,1300 并发每台,30 秒时长,100 件库存。
5.1 回归压测数据
| 数据源 | 修复前 | 修复后 |
|---|---|---|
| Redis 剩余库存 | 0 | 0 |
| DB 订单数 | 93 | 100 |
| DB 剩余库存 | 7 | 0 |
| 预占记录表 status=2 | 无表 | 100 |
| 预占记录表 status=1 | 无表 | 0 |
| 死信队列消息 | 4 | 0 |
| 对账缺口 | 7 | 0 |
回归压测跑完,三个数据源完全对齐。预占表 100 条全部是终态 status=2,死信队列里一条消息都没有。等对账任务多跑了几轮,确认没有任何新增的 status=1 记录后,我才松了一口长气。
更重要的是,那三组故障注入用例在修复后全部变绿。这说明问题不是“碰巧在压测中没出现”,而是“即使在故障条件下也有稳定的补偿机制”。这也是我从这次压测里学到的最重要的一点:压测只能暴露问题,真正让问题从根上消失的,是能稳定复现它的测试用例和围绕用例设计的兜底机制。
5.2 秒杀一致性的三条提醒
这次压测折腾完之后,我给自己总结了几条硬规矩,也分享给要做类似秒杀系统的同行:
第一,不要让 Redis 同时承担“校验”和“承诺”两个职责。Redis 预占成功只说明用户拿到了一个“资格”,这个资格必须以预占单据的形式记录下来。消费端判断单据有效性时,永远不要拿 Redis 的实时库存状态做依据,那是一个先有鸡还是先有蛋的时间逻辑陷阱。
第二,每一条异步链路都要有状态载体。消息队列不是可靠的持久化存储,它只是一个传输管道。链路里至少要有一张表记录“这条数据处理到哪一步了”。没有状态载体,消息丢了就是真丢了,没有第二条路可以找回来;有了状态载体,丢了的消息还能靠对账任务重新捞起来。
第三,压测必须配对账脚本。只看 QPS、吞吐、RT 这些经典指标,很容易漏掉数据一致性这个最致命的问题。每次压测结束后,把 Redis、DB、MQ 三个数据源拉出来对一遍账,比任何监控告警都有效。我后来把对账脚本固化成了一个独立的巡检任务,不只是压测时跑,线上每半小时也会自动跑一轮,有任何缺口立即告警。
如果只是看 Redis 监控,这次压测的结论可能是“系统稳如老狗”——QPS 高、响应快、无超卖。但真实情况是 7 笔订单丢了,用户收到了成功提示却没有买到商品。这种错误比超卖更隐蔽,用户投诉时客服连订单都查不到,排障成本极高。
所以我很认同一个观点:秒杀系统的成功标准从来不是“扛住了多少并发”,而是“并发过后账目是否分毫不差”。Redis 和 DB 的一致性缺口,不会因为压测结束就自己消失,它只会静静地躺在那里,等着某一次活动上线的客服电话把问题烧到你面前。