秒杀压测后Redis与DB数据不一致?7笔订单消失的故障定位与修复
发布时间:2026/10/8 3:36:40 作者:尧图编辑部 阅读量:1,286

1. 压测现场5,200 并发是怎么打出来的压测结束后我盯着两个数字反复看了好几遍才反应过来出了问题Redis 剩余库存是 0DB 已售记录是 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/sDB 慢查询列表刷了十几条 update 语句平均执行时间 1.8 秒最慢的一条 3.5 秒。当时第一反应是“DB 是瓶颈但要看看数据最终是不是一致的”结果一看就傻眼了。1.2 库存模型Redis 预占、DB 落库中间隔着一个 MQ这个系统的库存模型说简单也简单说复杂也复杂。简单在于只有两个源Redis 承担高并发下的预占扣减DB 是最终库存事实。复杂在于两者之间不是同步写而是靠 MQ 异步衔接。完整链路是这样用户请求到达秒杀接口。服务端调用 Redis Lua 脚本原子扣减seckill:stock:1001这个 key。扣减成功返回剩余库存业务代码认为“用户已抢到”响应成功。同一时刻代码组装一个preDeductKeyuserId 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 剩余 0DB 已售 93缺口 7 件压测完我对数据的第一个动作不是看 RT不是看吞吐而是跑对账。这是做秒杀系统必须养成的一个本能任何压测结束后第一件事就是把 Redis、DB、MQ 三个数据源拉出来互相核。对账结果很刺眼数据源结果Redis 库存 keyseckill:stock:1001剩余 0DB 订单表中 sku_id1001 的记录数93DB 库存表中 sku_id1001 剩余库存7预占成功响应数接口返回“抢购成功”100MQ 消费组 Lag0死信队列消息数4100 个用户收到了“抢购成功”的响应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 扣减到 0100 笔预占全部完成。第 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 根因 AMQ 发送半成功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 根因 CDB 连接池打满与行锁等待让落库排不上号第三个故障点是纯性能问题但它放大了前两个问题的影响。压测到第 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_deductCREATE 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。死信队列里的消息不需要依赖人工处理了因为对账任务会定期发现“预占单还挂在 status1”直接把薄记补上。即使 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); } // 否则保持 status1交给告警人工介入 }如果不做 DB 剩余库存判断在库存几乎售罄时回加 Redis会把 Redis 的库存数字加出一个“卖超”的空间后续请求可能从 Redis 抢到库存但 DB 已经没有余量可扣反而造成另一种不一致。回滚动作只应该在确实还有库存余量时执行。5. 同样的 5,200 并发修复后究竟还差多少修复完成后的验证没有省略直接上了和之前完全一样的压测4 台施压机1300 并发每台30 秒时长100 件库存。5.1 回归压测数据数据源修复前修复后Redis 剩余库存00DB 订单数93100DB 剩余库存70预占记录表 status2无表100预占记录表 status1无表0死信队列消息40对账缺口70回归压测跑完三个数据源完全对齐。预占表 100 条全部是终态 status2死信队列里一条消息都没有。等对账任务多跑了几轮确认没有任何新增的 status1 记录后我才松了一口长气。更重要的是那三组故障注入用例在修复后全部变绿。这说明问题不是“碰巧在压测中没出现”而是“即使在故障条件下也有稳定的补偿机制”。这也是我从这次压测里学到的最重要的一点压测只能暴露问题真正让问题从根上消失的是能稳定复现它的测试用例和围绕用例设计的兜底机制。5.2 秒杀一致性的三条提醒这次压测折腾完之后我给自己总结了几条硬规矩也分享给要做类似秒杀系统的同行第一不要让 Redis 同时承担“校验”和“承诺”两个职责。Redis 预占成功只说明用户拿到了一个“资格”这个资格必须以预占单据的形式记录下来。消费端判断单据有效性时永远不要拿 Redis 的实时库存状态做依据那是一个先有鸡还是先有蛋的时间逻辑陷阱。第二每一条异步链路都要有状态载体。消息队列不是可靠的持久化存储它只是一个传输管道。链路里至少要有一张表记录“这条数据处理到哪一步了”。没有状态载体消息丢了就是真丢了没有第二条路可以找回来有了状态载体丢了的消息还能靠对账任务重新捞起来。第三压测必须配对账脚本。只看 QPS、吞吐、RT 这些经典指标很容易漏掉数据一致性这个最致命的问题。每次压测结束后把 Redis、DB、MQ 三个数据源拉出来对一遍账比任何监控告警都有效。我后来把对账脚本固化成了一个独立的巡检任务不只是压测时跑线上每半小时也会自动跑一轮有任何缺口立即告警。如果只是看 Redis 监控这次压测的结论可能是“系统稳如老狗”——QPS 高、响应快、无超卖。但真实情况是 7 笔订单丢了用户收到了成功提示却没有买到商品。这种错误比超卖更隐蔽用户投诉时客服连订单都查不到排障成本极高。所以我很认同一个观点秒杀系统的成功标准从来不是“扛住了多少并发”而是“并发过后账目是否分毫不差”。Redis 和 DB 的一致性缺口不会因为压测结束就自己消失它只会静静地躺在那里等着某一次活动上线的客服电话把问题烧到你面前。