直接用DECR不够用,因其仅保证原子递减但无法在库存为0时拒绝请求并跳过后续逻辑;若先GET再DECR则存在竞态导致超卖;必须用Lua脚本将“判断库存>0→扣减→记录用户”三步封装为单线程原子操作,才能真正防超领。

为什么直接用 DECR 不够用?
优惠券库存扣减看似只是个计数器递减,但实际要同时满足:原子性、超领拦截、领取成功后写用户记录。单纯靠 DECR 只能保原子性,无法在库存为 0 时拒绝请求并跳过后续逻辑;更麻烦的是,如果先查 GET 再 DECR,必然存在竞态——两个请求几乎同时读到 “1”,都执行 DECR,结果变成 -1,超发。
必须把「判断 + 扣减 + 记录」三步收进一个 Lua 脚本里,由 Redis 单线程串行执行,才能真正防超领。
EVAL 脚本里怎么安全判断并扣减?
核心是用 redis.call("GET", KEYS[1]) 拿当前库存,再用 tonumber() 转数字(避免 nil 或字符串比较出错),然后显式判断是否 > 0。注意:不能用 redis.call("DECR", KEYS[1]) 后再判断返回值,因为 DECR 在库存为 0 时会返回 -1,已经扣过了。
- 正确顺序:先
GET→ 判断是否 > 0 → 是则DECR→ 再SADD用户集合或HSET领取记录 - 返回值建议统一:成功返回 1,失败返回 0,方便业务层直接判断
- KEYS[1] 存库存(如
"coupon:1001:stock"),ARGV[1] 存用户 ID(如"u_8892"),避免硬编码
if tonumber(redis.call("GET", KEYS[1])) > 0 then
redis.call("DECR", KEYS[1])
redis.call("SADD", KEYS[2], ARGV[1])
return 1
else
return 0
end
为什么推荐用 SADD 而不是 HSET 记用户?
记录谁领了券,本质是「去重写入」,SADD 天然幂等且 O(1);而 HSET 虽能存更多字段(如时间),但单次调用不保证原子写入多个字段,且集合操作比哈希更轻量。
- 用
SADD coupon:1001:users u_8892,重复执行不会报错,也不会多写 - 如果真要记时间,可在 Lua 里用
redis.call("TIME")拿秒级时间戳,再拼成字符串写入 set(如"u_8892|1717023456"),但得自己解析 - 别用
LPUSH:列表无去重,高并发下可能重复插入同一用户
本地测试时容易忽略的兼容性坑
Redis 6.0+ 默认开启 ACL,脚本里调用的命令必须在用户权限范围内;另外 Lua 的 math.random 在 Redis 中被禁用,别试图加随机延迟来“缓解”竞争——它根本跑不起来,会报 Lua script attempted to access a non-existent global variable。
- 确保运行脚本的 Redis 用户有
GET、DECR、SADD权限(ACL 中对应read和write类命令) - 脚本里不要用
os.time()或math.random(),可用redis.call("TIME")替代 - 本地用
redis-cli --eval测试时,注意参数顺序:redis-cli --eval coupon.lua coupon:1001:stock coupon:1001:users , u_8892,逗号前后空格不能少
真正上线前,务必用 redis-benchmark -n 10000 -c 100 --eval 压测脚本响应和成功率——很多问题只在并发量上来时才暴露。











