一次 5,200 并发压测暴露的秒杀一致性问题:从“Redis 已扣减但 DB 未落库”到可复现修复

4 阅读5分钟

一次 5,200 并发压测暴露的秒杀一致性问题:从“Redis 已扣减但 DB 未落库”到可复现修复

背景

这次排查不是从线上报警开始,而是从一次标准化压测开始。链路是典型的秒杀实现:请求先走 Redis Lua 脚本做资格校验与扣减,再把订单事件写入 Redis Stream,最后由异步消费线程落 MySQL。目标很明确:验证在高并发下是否满足不超卖、一人一单,以及 Redis 与 DB 的最终一致。

压测参数是 5,200​ 并发请求,1,000​ 唯一用户。第一次跑完后出现了一个非常关键、但容易被忽视的信号:业务成功数是 1000​,数据库订单数是 999​,Redis 库存为 0​,DB 库存却为 1。这个“差 1”不是偶然噪声,而是异步链路出现了实质性丢单。


问题是怎么被发现的

我没有先改代码,而是先把证据链拉齐:压测统计、SQL 对账、Redis 库存、应用日志四份数据必须互相能解释。
一旦把这四份数据对上,问题就非常清楚了:系统并没有超卖,但存在“Redis 已成功扣减,DB 未成功落单”的不一致窗口。

随后日志给出两条直接线索:

  1. 消费线程出现 NullPointerException​,调用点在 proxy.createVoucherOrder(...)
  2. 日志里出现过 userId: null 的订单消费异常。

这两条线索指向两个不同层面的缺陷:一个是并发竞态,一个是消息污染。再往下看事务逻辑,还存在第三个风险点:DB 扣库存失败后没有阻断后续下单。


底层原理:为什么会出现这些问题

1) 并发竞态:AopContext.currentProxy() 懒赋值 + 异步线程读取

原实现把 proxy​ 放在请求线程里通过 AopContext.currentProxy() 懒赋值,然后异步消费线程直接使用这个共享字段。
这个设计的底层问题有两层:

第一层是生命周期错位。消费线程在服务启动后就持续运行,而 proxy​ 的初始化依赖“某次请求先到”,这本身就把系统正确性绑在了请求时序上。
第二层是并发可见性问题。这个共享可变字段没有受控发布(没有明确 happens-before 保证),在高并发下消费线程可能先读到 null,导致空指针,最终形成“Redis 扣减了但 DB 没落单”。

2) 消息语义污染:Stream 初始化记录被当业务订单消费

为了创建 Stream,初始化逻辑写了一条 type=init​ 记录。这条记录在 Redis 语义上就是一条普通消息。
消费端又是按 VoucherOrder​ 结构反序列化,如果消息缺失 userId/voucherId/id 等字段,就会得到空值对象。继续往下走锁和下单逻辑,必然引发异常或脏流程。
本质上这是“控制消息和业务消息没有隔离”导致的消费污染。

3) 事务保护不足:DB 扣库存失败没有及时中止

update ... set stock = stock - 1 where stock > 0​ 本质是乐观条件更新(CAS 语义)。它返回 false​ 就表示本次库存扣减没有成功。
如果这一步失败却仍继续 save(order),会制造“库存状态与订单状态分叉”的一致性风险。即使概率低,也必须作为硬约束处理。


修复思路与步骤

修复的原则是:先修结构性问题,再做性能优化;先保证一致性,再谈吞吐。

第一步,把 proxy​ 从“请求线程懒赋值”改成“容器注入稳定引用”,彻底移除时序依赖。
第二步,在消费入口增加消息完整性校验,非法消息直接丢弃并告警。
第三步,在事务方法里把 success 作为硬门闩,扣库存失败立即返回,不允许继续落订单。


这次排查方法为什么有效

真正起作用的不是某个技巧,而是排查顺序正确:
先复现,再定位;
先修一致性,再跑性能;
每次只改一层并回归验证。

这种方式可以避免“边猜边改”的低效循环,尤其适合异步系统。

以后如何避免同类问题

我把这次经验沉淀为三条工程约束:

  1. 任何异步消费者依赖都必须是容器稳定注入,禁止请求路径懒初始化共享状态。
  2. 消息队列必须有明确消息契约,控制消息与业务消息分流;消费入口先做结构校验再执行业务。
  3. 所有条件更新(如扣库存)都必须检查返回值,失败时立即终止后续状态写入。

再补一句最重要的:压测不能只看 TPS 和 P95,必须带 SQL + Redis 对账。
没有对账,所有“高性能”都可能只是统计幻觉。

结语

这次问题本质上不是 Redis、MySQL 或 Stream 的单点故障,而是并发时序、消息语义和事务边界三者叠加造成的一致性缺口。
修复的关键也不是“补一个 if”,而是把系统从“偶尔正确”改成“可证明正确”。

这也是后端工程里最值得投入的能力:当并发上来时,系统还能按你设计的方式工作,而不是按运气工作。