直接用decr高并发扣库存会超卖,因其无条件减一;watch+multi重试率高;lua脚本原子执行“get-判断-decr”,避免竞态,需注意keys传参、tonumber转换、key过期等细节。

为什么直接用 DECR 在高并发扣库存时会出错
因为 DECR 本身不带条件判断,它只管减一,不管当前值是不是已经为 0 或负数。实际业务中,你得先 GET 库存,判断是否 > 0,再 DECR —— 这两步之间存在竞态窗口。多个客户端同时读到 “1”,都去减,结果变成 -2,超卖就发生了。
有人想用 WATCH + MULTI 解决,但 Redis 的乐观锁在高冲突场景下失败率飙升,大量请求重试,吞吐反而下降,还增加客户端复杂度。
EVAL 执行 Lua 脚本实现原子化库存扣减
Lua 脚本在 Redis 中是原子执行的:整个脚本运行期间,其他命令不会插入执行。你可以把“读、判断、减、返回结果”全塞进一个脚本里,由 Redis 保证一致性。
常见写法如下:
EVAL "if tonumber(redis.call('GET', KEYS[1])) > 0 then return redis.call('DECR', KEYS[1]) else return -1 end" 1 stock:sku123
说明:
-
KEYS[1]是库存 key(如stock:sku123),必须通过KEYS传入,不能硬编码或用ARGV - 返回值是数字:
0表示原值为 1、扣减后为 0;-1表示库存不足,扣减未发生 - 注意
>要写成>(HTML 实体)或直接用>(在 Redis CLI 或支持 UTF-8 的客户端里可直写)
用 redis.call('GET') 而不是 redis.call('DECR') 后再判断
DECR 本身会修改值,如果直接 DECR 再检查返回值,等于“先扣再验”,一旦扣过头,还得回滚(Redis 不支持事务回滚),逻辑就乱了。
所以必须先 GET,再判断,再 DECR —— 这三步在 Lua 里串行执行,无中间状态暴露。另外注意:
-
redis.call('GET', ...)返回的是字符串,要用tonumber()转成数字比较,否则"0"和0判断会出错 - 不要用
redis.pcall包裹GET,除非你明确要捕获异常;普通读不存在的 key 会返回nil,tonumber(nil)是nil,和0比较结果为false,符合预期 - 如果 key 不存在,
GET返回nil→tonumber(nil)是nil→nil > 0为false→ 脚本返回-1,行为安全
性能差异和上线前必须验证的点
单次 Lua 脚本执行比纯 DECR 多几微秒,但省去了网络往返 + 客户端逻辑 + 重试开销。压测表明,在 5000+ QPS 扣减场景下,Lua 方案成功率稳定在 100%,而 WATCH/MULTI 方案失败率常超 30%。
上线前务必确认:
- 脚本中所有 key 都通过
KEYS显式传入,否则集群模式下会报(error) MOVED或ASK错误 - 不要在脚本里用
redis.call('KEYS', ...)—— 这是禁用命令,会导致脚本中断并报错 - 脚本长度别超过 10KB(虽然 Redis 默认限制是 1MB,但长脚本影响传播和加载速度)
- 首次上线建议用
EVALSHA+SCRIPT LOAD组合,避免每次传输脚本内容
最易被忽略的是 key 的生命周期管理:如果库存 key 设置了过期时间,而 Lua 脚本执行期间刚好过期,GET 会返回 nil,逻辑按“无库存”处理——这未必符合业务预期,得提前对齐缓存策略。











