decr和decrby是原子操作但不校验业务逻辑,必须通过lua脚本或严格检查返回值来防止超卖;lua脚本需包含incr回滚且key须预先初始化并设置过期时间。

DECR 命令本身不会拒绝负数,必须靠应用层判断
Redis 的 DECR 和 DECRBY 是原子操作,但它们不校验业务逻辑——库存扣到 -1、-100 都照常执行。这意味着「预扣除」不能只靠 DECR,必须紧接着读取返回值并判断是否小于 0。常见错误是执行完 DECR 就直接走后续流程,结果超卖。
典型误用:
DECR goods:stock:1001
这行命令返回 -1,但如果你没接住这个值,就等于放过了超卖信号。
正确做法是把 DECR 放在 Lua 脚本里,或在客户端严格检查返回值:
- 使用 Lua 脚本可保证「读-改-判」三步原子性,避免竞态
- 若用客户端逻辑,必须用单次
DECR命令(不要先GET再DECR) - 返回值为整数,
0表示刚好卖完,-1及以下表示已超卖
用 Lua 脚本实现带库存兜底的预扣减
这是最稳妥的方式:把库存检查、扣减、结果判断全部塞进一个 EVAL 请求,由 Redis 串行执行,彻底避开并发干扰。
脚本示例(扣减 1 件):
if redis.call("DECR", KEYS[1]) >= 0 then
return 1
else
redis.call("INCR", KEYS[1]) -- 回滚:加回去
return 0
end
调用方式(如库存 key 为 goods:stock:1001):
EVAL "..." 1 goods:stock:1001
注意点:
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
- 脚本里用
redis.call("INCR", ...)回滚,不能省;否则失败后库存就永久少 1 - 返回
1表示预扣成功,0表示库存不足,业务层据此决定是否放行下单 - 该脚本不处理初始化问题——key 不存在时
DECR默认按 0 处理,结果为 -1,会触发回滚,所以务必提前用SET goods:stock:1001 100 EX 3600初始化
为什么不用 WATCH + MULTI?
有人想用乐观锁:WATCH 库存 key,MULTI 里 DECR 再判断。但这条路在秒杀场景下基本不可行。
原因很实际:
- 高并发下
WATCH冲突率极高,大量请求会因EXEC返回nil而重试,加重 Redis 和客户端负担 - 重试逻辑难写稳——重试次数、间隔、退避都要控制,一不小心就拖垮服务
-
WATCH只监视 key,但秒杀还涉及用户限购、黑名单等其他状态,无法一并纳入原子范围
结论:别为「看起来更标准」选 WATCH,Lua 脚本在性能和确定性上都更合适。
DECR 后紧接着 GET 是错的,延迟和竞争都会出问题
有些实现先 DECR goods:stock:1001,再发一条 GET goods:stock:1001 判断结果。这存在两个硬伤:
- 两次网络往返之间有时间窗口,其他客户端可能已经改了值
- 即使本地拿到 -1,也无法撤回刚才的
DECR,只能被动接受超卖
更糟的是,如果用 pipeline 打包这两条命令,Redis 仍会分开执行,中间没有原子保障。唯一能信任的,是 DECR 命令本身的返回值——它代表执行后的当前值,且与操作强绑定。
所以永远优先用返回值做判断,而不是额外查一次。
真正容易被忽略的点是:Lua 脚本里的回滚不是可选项,而是必选项;而初始化库存时漏设过期时间,会导致 key 永久残留,下次活动前不清理就会沿用旧值。这两个地方不出问题则已,一出就是线上事故。










