一个"库存预扣"用 Redis 做:先 GET stock,判断够不够,再 DECRBY。压测 200 并发时超卖了 37 件——因为"读"与"写"之间,别的请求也读到了同一个值。
缓存的"原子性"只覆盖"单个命令",不覆盖"你的业务判断"。这个"必然"不是概率问题:并发 200、库存 1,就一定有 199 个请求"读到 1"——于是"超卖"是"确定发生",只是"超多少"取决于时序。
而这类错误的特点很干脆:低并发下永远正确,高并发下必然错。
1. 三种"原子"的层级
| 层级 | 是否原子 |
|---|---|
单命令(INCR/DECRBY/SETNX) |
是 |
多命令(MULTI/EXEC 或 Lua) |
是(作为一个执行单元) |
| "读 + 判断 + 写"(客户端做的) | 否 |
第三项是最常见的错误形态:业务判断在客户端做,而"判断"与"写"之间没有隔离。第三项的"伪装"很好:它读起来像"正常的业务代码"(取值、判断、更新),而"原子性"这个词只出现在"文档与培训"里——于是它不被当成"并发问题"。
2. "读判写"在并发下必然错的机制
| 时刻 | 请求 A | 请求 B |
|---|---|---|
| T1 | GET stock = 1 | |
| T2 | GET stock = 1 | |
| T3 | 判断 1 ≥ 1,可以扣 | |
| T4 | 判断 1 ≥ 1,可以扣 | |
| T5 | DECRBY 1 → 0 | |
| T6 | DECRBY 1 → -1 |
关键时序的"通俗说法"是"两个人同时看到货架上还有 1 件,于是都伸手拿了"——而"伸手"与"结账"之间没有任何"占位"。
关键在 T2:B 读到的是"扣减前"的值。于是"两个请求都认为库存够"——这是"竞态"的最朴素形态。
// 三种"写"的原子性
INCR / DECRBY:单命令原子(但"判断"不在命令里)
SETNX:单命令原子("只有不存在才设置",可用于锁与去重)
MULTI/EXEC:一组命令原子执行(但"读到的值"仍需在事务内判断)
// 结论:要"判断 + 修改"原子,必须把判断放进"服务端的执行单元"
3. 三种"正确实现"
| 实现 | 特点 |
|---|---|
| Lua 脚本(判断 + 修改) | 最常用(单次 RTT,原子) |
WATCH + MULTI(乐观锁) |
适合"冲突少" |
单命令技巧(如 DECRBY 后判负则回补) |
巧妙但难维护 |
第一项是"主流":把"读、判断、写"写进一段 Lua,于是"整段"在 Redis 里原子执行。第一项的"收益"不只是"正确",还有"一次 RTT":三次命令变一次往返,于是在"高频扣减"里延迟也降了。
-- 库存预扣(判断 + 修改,原子)
-- KEYS[1] = stock key, ARGV[1] = 需要的数量
local n = tonumber(redis.call('GET', KEYS[1]) or '0')
local need = tonumber(ARGV[1])
if n < need then return -1 end -- 不足,返回失败
redis.call('DECRBY', KEYS[1], need)
return n - need -- 返回剩余
第三项的"缺陷"在"中间态可见":别的请求会读到"负数库存"——如果"下单校验"也读这个值,会得到"暂时售罄"的假象。
第三项是"技巧":先 DECRBY,若结果为负再 INCRBY 回补——它"不会超卖"(因为终值不会持续为负),但"期间可能短暂为负",且"回补失败会漏"。
4. "Lua 脚本"的三个约束
| 约束 | 说明 |
|---|---|
| 脚本要"短"(阻塞其他命令) | 长脚本会卡住整个实例 |
只用 KEYS 里的 key |
集群下"跨槽"会失败 |
不能有"非确定性"(如 TIME) |
主从复制会不一致 |
第一项是"性能"约束:**Redis 单线程执行命令,脚本执行期间"其他请求排队"**第一项的"量化"是"脚本耗时 × QPS = 排队长度":1ms 的脚本在 5000 QPS 下就是"5 个请求在等"——于是"脚本里有 KEYS *"会直接打挂实例。
——于是"脚本里的循环"很危险。
第三项在"主从"下有真实后果:用 TIME 或 RANDOMKEY 的脚本,在"复制到从库"时可能产生不同结果——于是"主从数据不一致"。
"并发验证"要能"断言终值",否则"看起来对":
// 一段"廉价的竞态验证"(伪代码)
1 初始化 stock = 100
2 起 200 个并发,各"读判写"扣 1
3 断言:终值 ≥ 0 且"成功次数 = 100"
// 若终值为 -37 → 有竞态;若成功次数 = 137 → 判断与扣减分离
// 修完再跑:终值 0、成功 100、其余返回"不足"
5. "分布式锁"与"原子命令"的分工
| 场景 | 选择 |
|---|---|
| 单个计数/库存 | 原子命令或 Lua |
| 跨多个 key 的一致性 | 分布式锁(或 Lua 多 key) |
| 需要"业务代码"参与 | 锁 + 幂等 |
第三项的"替代"是"预扣 + 对账":先用原子命令"预扣"(够就扣),再"异步校验业务规则"(不够就回补)——于是"原子性"只覆盖"计数",业务规则异步兜。
第三项是"边界":如果"判断"需要"查数据库"或"调外部服务",那它无法"塞进 Lua"——此时只能"锁 + 幂等"。
而"锁"本身也有"超时/续期/误删"的问题——所以"能用原子命令解决的,不要用锁"。
"Lua 脚本"要能"单测",而它比"接口测试"更容易做(脚本是纯函数):
// 用 redis-cli --eval 直接测脚本(三行)
redis-cli -p 6379 SET stock 2
redis-cli -p 6379 --eval stock.lua stock , 1 # 返回 1(剩余)
redis-cli -p 6379 --eval stock.lua stock , 1 # 返回 0
redis-cli -p 6379 --eval stock.lua stock , 1 # 返回 -1(不足)
// 收益:脚本的"边界行为"在 30 秒内被固定下来
6. 排错:五种"缓存并发问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 超卖/超额 | 读判写在客户端 | 改 Lua |
| 计数偶尔少 1 | 回补失败 | 改 Lua 或原子命令 |
| 锁失效导致双写 | 锁超时 | 续期 + 幂等 |
| 主从不一致 | 脚本非确定性 | 去掉 TIME/随机 |
| 集群报 CROSSSLOT | 脚本多 key 跨槽 | hash tag |
第一项是"最经典"的:它在"单线程测试"里永远不出现——因为"没有并发"就没有竞态。第一项的"复现方式"是"并发脚本 + 断言"库存 ≥ 0"":200 并发跑 1000 次扣减,只要一次为负就说明有竞态——而它"五分钟能写出来"。
"原子性"的边界还要看"缓存是不是唯一权威":
// 两种"权威"下的做法
缓存是权威(库存只在 Redis):必须原子 → Lua
数据库是权威(缓存只是加速):用"数据库条件更新"兜底
UPDATE stock SET n = n - 1 WHERE n >= 1; -- 影响行数 0 → 不足
// 判据:谁"说了算"——权威在哪,原子性就该落在哪
7. 边界:什么情况"读判写"就够了
| 场景 | 为什么 | 做法 |
|---|---|---|
| 无并发(单线程/低 QPS) | 竞态不发生 | 不必 |
| 只是"近似统计"(浏览量) | 少量偏差可接受 | 不必 |
| 已有"数据库唯一约束"兜底 | 冲突会被拒 | 可简化 |
| 幂等 + 重试可修 | 错了能纠 | 可简化 |
最后一条与"实现成本"有关的边界:Lua 的"成本"主要在"调试与集群兼容":脚本出错时只有"错误类型",没有"行号"——所以"短脚本 + 单测"是必要的工程习惯。
判断口径一句话:"这个'读判写'错了,会不会造成"不可逆的后果"。会(超卖、超额扣款),必须原子;不会(浏览量、缓存预热),近似即可。
8. 自测
- 三种"原子"层级里,哪一种是"最常见的错误形态"?为什么它"低并发永远正确"?
- 用时刻表解释"读判写"为什么会超卖——关键发生在哪一步?
- 三种"正确实现"里,哪一种最常用?它的两个优点是什么?
- "先
DECRBY再回补"的技巧有什么缺陷? - Lua 脚本的三个约束分别对应什么问题?为什么"用
TIME"会导致主从不一致? - 判断"读判写是否够用"的那个问题是什么?