Skip to content

Commit 7fddbe5

Browse files
committed
auto published by siyuan-plugin-publisher
1 parent 0413fc9 commit 7fddbe5

1 file changed

Lines changed: 98 additions & 0 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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

Comments
 (0)