|
| 1 | +--- |
| 2 | +title: 一次 5,200 并发压测暴露的秒杀一致性问题:从“Redis 已扣减但 DB 未落库”到可复现修复 |
| 3 | +date: '2026-03-05 21:57:29' |
| 4 | +updated: '2026-03-05 22:02:55' |
| 5 | +tags: |
| 6 | + - 后端 |
| 7 | + - 并发 |
| 8 | + - 秒杀 |
| 9 | +permalink: >- |
| 10 | + /post/flash-consistency-issue-exposed-by-a-5-200-concurrent-stress-test-from-redis-has-been-21axl2.html |
| 11 | +author: Lu |
| 12 | +--- |
| 13 | + |
| 14 | + |
| 15 | + |
| 16 | +# 一次 5,200 并发压测暴露的秒杀一致性问题:从“Redis 已扣减但 DB 未落库”到可复现修复 |
| 17 | + |
| 18 | +## 背景 |
| 19 | + |
| 20 | +这次排查不是从线上报警开始,而是从一次标准化压测开始。链路是典型的秒杀实现:请求先走 Redis Lua 脚本做资格校验与扣减,再把订单事件写入 Redis Stream,最后由异步消费线程落 MySQL。目标很明确:验证在高并发下是否满足不超卖、一人一单,以及 Redis 与 DB 的最终一致。 |
| 21 | + |
| 22 | +压测参数是 `5,200` 并发请求,`1,000` 唯一用户。第一次跑完后出现了一个非常关键、但容易被忽视的信号:业务成功数是 `1000`,数据库订单数是 `999`,Redis 库存为 `0`,DB 库存却为 `1`。这个“差 1”不是偶然噪声,而是异步链路出现了实质性丢单。 |
| 23 | + |
| 24 | +--- |
| 25 | + |
| 26 | +## 问题是怎么被发现的 |
| 27 | + |
| 28 | + 我没有先改代码,而是先把证据链拉齐:压测统计、SQL 对账、Redis 库存、应用日志四份数据必须互相能解释。 |
| 29 | + 一旦把这四份数据对上,问题就非常清楚了:系统并没有超卖,但存在“Redis 已成功扣减,DB 未成功落单”的不一致窗口。 |
| 30 | + |
| 31 | + 随后日志给出两条直接线索: |
| 32 | + |
| 33 | +1. 消费线程出现 `NullPointerException`,调用点在 `proxy.createVoucherOrder(...)`。 |
| 34 | +2. 日志里出现过 `userId: null` 的订单消费异常。 |
| 35 | + |
| 36 | + 这两条线索指向两个不同层面的缺陷:一个是并发竞态,一个是消息污染。再往下看事务逻辑,还存在第三个风险点:DB 扣库存失败后没有阻断后续下单。 |
| 37 | + |
| 38 | +--- |
| 39 | + |
| 40 | +## 底层原理:为什么会出现这些问题 |
| 41 | + |
| 42 | +### 1) 并发竞态:`AopContext.currentProxy()` 懒赋值 + 异步线程读取 |
| 43 | + |
| 44 | + 原实现把 `proxy` 放在请求线程里通过 `AopContext.currentProxy()` 懒赋值,然后异步消费线程直接使用这个共享字段。 |
| 45 | + 这个设计的底层问题有两层: |
| 46 | + |
| 47 | + 第一层是生命周期错位。消费线程在服务启动后就持续运行,而 `proxy` 的初始化依赖“某次请求先到”,这本身就把系统正确性绑在了请求时序上。 |
| 48 | + 第二层是并发可见性问题。这个共享可变字段没有受控发布(没有明确 happens-before 保证),在高并发下消费线程可能先读到 `null`,导致空指针,最终形成“Redis 扣减了但 DB 没落单”。 |
| 49 | + |
| 50 | +### 2) 消息语义污染:Stream 初始化记录被当业务订单消费 |
| 51 | + |
| 52 | + 为了创建 Stream,初始化逻辑写了一条 `type=init` 记录。这条记录在 Redis 语义上就是一条普通消息。 |
| 53 | + 消费端又是按 `VoucherOrder` 结构反序列化,如果消息缺失 `userId/voucherId/id` 等字段,就会得到空值对象。继续往下走锁和下单逻辑,必然引发异常或脏流程。 |
| 54 | + 本质上这是“控制消息和业务消息没有隔离”导致的消费污染。 |
| 55 | + |
| 56 | +### 3) 事务保护不足:DB 扣库存失败没有及时中止 |
| 57 | + |
| 58 | + `update ... set stock = stock - 1 where stock > 0` 本质是乐观条件更新(CAS 语义)。它返回 `false` 就表示本次库存扣减没有成功。 |
| 59 | + 如果这一步失败却仍继续 `save(order)`,会制造“库存状态与订单状态分叉”的一致性风险。即使概率低,也必须作为硬约束处理。 |
| 60 | + |
| 61 | +--- |
| 62 | + |
| 63 | +## 修复思路与步骤 |
| 64 | + |
| 65 | + 修复的原则是:先修结构性问题,再做性能优化;先保证一致性,再谈吞吐。 |
| 66 | + |
| 67 | + 第一步,把 `proxy` 从“请求线程懒赋值”改成“容器注入稳定引用”,彻底移除时序依赖。 |
| 68 | + 第二步,在消费入口增加消息完整性校验,非法消息直接丢弃并告警。 |
| 69 | + 第三步,在事务方法里把 `success` 作为硬门闩,扣库存失败立即返回,不允许继续落订单。 |
| 70 | + |
| 71 | +--- |
| 72 | + |
| 73 | +## 这次排查方法为什么有效 |
| 74 | + |
| 75 | + 真正起作用的不是某个技巧,而是排查顺序正确: |
| 76 | + 先复现,再定位; |
| 77 | + 先修一致性,再跑性能; |
| 78 | + 每次只改一层并回归验证。 |
| 79 | + |
| 80 | + 这种方式可以避免“边猜边改”的低效循环,尤其适合异步系统。 |
| 81 | + |
| 82 | +## 以后如何避免同类问题 |
| 83 | + |
| 84 | + 我把这次经验沉淀为三条工程约束: |
| 85 | + |
| 86 | +1. 任何异步消费者依赖都必须是容器稳定注入,禁止请求路径懒初始化共享状态。 |
| 87 | +2. 消息队列必须有明确消息契约,控制消息与业务消息分流;消费入口先做结构校验再执行业务。 |
| 88 | +3. 所有条件更新(如扣库存)都必须检查返回值,失败时立即终止后续状态写入。 |
| 89 | + |
| 90 | + 再补一句最重要的:压测不能只看 TPS 和 P95,必须带 SQL + Redis 对账。 |
| 91 | + 没有对账,所有“高性能”都可能只是统计幻觉。 |
| 92 | + |
| 93 | +## 结语 |
| 94 | + |
| 95 | + 这次问题本质上不是 Redis、MySQL 或 Stream 的单点故障,而是并发时序、消息语义和事务边界三者叠加造成的一致性缺口。 |
| 96 | + 修复的关键也不是“补一个 if”,而是把系统从“偶尔正确”改成“可证明正确”。 |
| 97 | + |
| 98 | + 这也是后端工程里最值得投入的能力:当并发上来时,系统还能按你设计的方式工作,而不是按运气工作。 |
0 commit comments