2208 字
约 7 分钟
14
S12-A23 · 缓存的原子性:`INCR` 与"先读再写"的差别

一个"库存预扣"用 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. 自测

  1. 三种"原子"层级里,哪一种是"最常见的错误形态"?为什么它"低并发永远正确"?
  2. 用时刻表解释"读判写"为什么会超卖——关键发生在哪一步?
  3. 三种"正确实现"里,哪一种最常用?它的两个优点是什么?
  4. "先 DECRBY 再回补"的技巧有什么缺陷?
  5. Lua 脚本的三个约束分别对应什么问题?为什么"用 TIME"会导致主从不一致?
  6. 判断"读判写是否够用"的那个问题是什么?
S12-A23 · 缓存的原子性:`INCR` 与"先读再写"的差别
https://www.clxhxhhr.top/posts/6627/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。