redis扣减库存超卖的根本原因不是redis问题,而是应用层将get、判断、set拆成非原子的三步操作,在单线程redis上人为制造竞态;正确做法是用decrby配合返回值判断,或使用lua脚本保证检查与扣减原子执行。

直接说结论:Redis扣减库存出现超卖,根本不是Redis的问题,而是代码没用对——把 GET + IF + SET 拆成三步执行,就等于在单线程引擎上手动制造了竞态条件。
为什么get + decrement分开写会超卖
很多人写成这样:
String stock = stringRedisTemplate.opsForValue().get("sku:1001");
int stockNum = Integer.parseInt(stock);
if (stockNum > 0) {
stringRedisTemplate.opsForValue().set("sku:1001", String.valueOf(stockNum - 1));
}
问题出在三步之间没有原子性:
-
GET返回 1,线程 A 和 B 同时拿到 - 都判断
stockNum > 0成立 - 都执行
SET,最终只扣了 1 次,但两个请求都以为自己成功了
哪怕 Redis 是单线程的,应用层这三步操作之间仍存在时间窗口。这不是 Redis 不行,是你没让它“一口气干完”。
用DECR或DECRBY必须配合返回值判断
Redis 原生命令如 DECR 是原子的,但光调用还不够——你得靠它的返回值做业务决策:
- 不要只调用
stringRedisTemplate.opsForValue().decrement("sku:1001")就完事 - 必须捕获返回值:
Long afterStock = template.opsForValue().decrement(key) - 然后判断:
if (afterStock >= 0) { /* 扣减成功 */ } else { /* 回滚或提示库存不足 */ } - 注意:如果初始值是字符串(比如 "10"),
DECR会报ERR value is not an integer,务必确保存的是整数类型
真正防超卖的底线方案:Lua 脚本
当业务逻辑稍复杂(比如要先查库存、再扣减、再写日志 key、再判断是否归零发通知),仅靠单个命令不够。这时必须用 Lua:
- Lua 在 Redis 中是原子执行的,整个脚本要么全跑完,要么不跑
- 典型脚本长这样(保存为
deduct_stock.lua):
local stock = redis.call('GET', KEYS[1])
if not stock then
return -1
end
local stockNum = tonumber(stock)
if stockNum
- Java 调用时传入 key,接收返回值判断:
Long result = (Long) redisTemplate.execute(script, Collections.singletonList("sku:1001")) - 返回
-1:key 不存在;0:库存不足;>0:扣减后剩余量 - 别手写脚本字符串拼接,容易引号/换行出错;建议用
Resource加载外部 .lua 文件
分布式环境下还要警惕这些坑
单机用 synchronized 或 DECR 能压测过关,一上生产集群就翻车,常见盲区:
- Redis 主从异步复制:写主库成功,但从库还没同步,读从库查到旧库存 → 解决:所有库存读写都打主节点(禁用读从)
- 库存初始化时机不对:服务重启后没重载库存,Redis 空了但数据库还有 → 解决:启动时用
@PostConstruct或监听ContextRefreshedEvent初始化 - Lua 脚本没设超时或没处理
NOSCRIPT异常 → Redis 集群模式下首次执行需SCRIPT LOAD,后续用EVALSHA,异常时要 fallback 到EVAL - 没配过期时间:库存 key 永不过期,但商品下架后还占内存 → 建议加
EXPIRE,或用业务维度命名(如stock:20260903:sku:1001)
最易被忽略的一点:Lua 脚本里不能调用 redis.call("KEYS", "...") 这类慢命令,也不能有循环或 sleep,否则会阻塞整个 Redis 实例——防超卖的前提,是别把 Redis 自己搞挂了。











