秒杀库存超卖源于GET+SET非原子性,须用Lua脚本将读判扣写封装为原子操作;队列只存精简订单信息;EVALSHA需预加载并容错回退;扣减后须设过期时间并手动补偿回滚。

秒杀时库存超卖,是因为 GET + SET 不是原子操作
Redis 的普通读写命令之间没有锁,两个请求同时 GET 到库存为 1,都判断“够用”,接着都 DECR 或 SET,结果变成 -1。这不是 Redis 慢,是逻辑漏洞。
必须把“读库存→判断→扣减→写回”整个流程塞进一个原子执行单元里。Lua 脚本是唯一靠谱的选择——它在 Redis 服务端一次性解析、执行,中间不会被其他命令打断。
-
EVAL是入口,脚本内容和 key 参数要严格对应,key 必须显式传入,不能硬编码在 Lua 里 - 脚本里用
redis.call("GET", KEYS[1])读,用redis.call("DECR", KEYS[1])扣,别用INCRBY -1——语义不清,容易看错 - 返回值建议用
if stock > 0 then return 1 else return 0 end,让客户端靠数字判断成败,别返回字符串
用 LPUSH + BRPOP 做队列,但别直接丢订单进 Redis 队列
秒杀请求量大时,如果每个请求都 LPUSH 一条订单数据到 Redis 队列,Redis 内存会暴涨,还可能因单条 value 过大(比如含用户完整信息)拖慢响应。这不是队列不行,是压根没做前置过滤。
真正该进队列的,只是最小必要信息:商品 ID、用户 ID、时间戳。其他字段(收货地址、支付方式)等消费端从下游 DB 补全。
立即学习“Python免费学习笔记(深入)”;
- 前端提交后,先走 Lua 脚本校验库存并预扣(返回 1 才放行),失败直接拒掉,不进队列
- 成功后只
LPUSH一个紧凑 JSON:{"item_id":"1001","uid":"u7723","ts":1715829341},长度控制在 200 字节内 - 消费端用
BRPOP queue_name 30阻塞拉取,超时就重试,别用POP循环轮询
EVALSHA 比 EVAL 快,但得自己管脚本缓存和版本
每次发完整 Lua 脚本过去,Redis 要重新解析编译,QPS 上千时这部分开销明显。用 EVALSHA 可以复用已加载的脚本,但得你自己确保脚本没变、SHA 值对得上。
常见翻车点:本地改了脚本,上线却忘了 SCRIPT LOAD 新版本,结果 EVALSHA 返回 (error) NOSCRIPT No matching script. Please use EVAL.,整个秒杀链路就断了。
- 上线前用
SCRIPT LOAD提前加载脚本,拿到 SHA1 值,存在配置中心或环境变量里 - 代码里先调
EVALSHA sha ...,捕获NOSCRIPT错误,再 fallback 到EVAL并重新 load - 别把脚本存在 Redis 里靠
SCRIPT EXISTS查——多一次 round-trip,反而更慢
扣减成功不等于下单成功,后续步骤失败得回滚 INCR
Lua 脚本里 DECR 成功,只代表“占坑成功”,不是最终成交。如果下游订单落库失败、支付回调超时,库存就得补回去,否则永远少一。
回滚不能靠另一个 Lua 脚本异步执行——两段脚本之间有时间差,可能又被新请求抢走。必须用带过期时间的补偿机制。
- 扣减时用
DECR,同时用EXPIRE给 key 设 10 分钟过期(比订单处理最长耗时多留缓冲) - 下单成功后,立刻
DEL对应的“已占坑”标记 key(比如stock_lock:1001:u7723),避免误回滚 - 没删掉的过期 key,由定时任务扫描
KEYS stock_lock:*(生产禁用!改用SCAN)触发INCR回补,并记录告警
最麻烦的不是写代码,是得想清楚:哪个环节失败会导致什么状态残留,而 Redis 本身不提供事务回滚能力,所有补偿都得手动兜底。










