秒杀库存不能用普通Redis INCR/DECR,因高并发下GET+DECR非原子操作易超卖;必须用Lua脚本在服务端原子执行“读判扣返”,并注意Hyperf配置serialize=false、numkeys传1、显式类型转换及状态码规范返回。

为什么秒杀库存不能用普通 Redis INCR/DECR
因为高并发下多个协程可能同时读到相同库存值,再各自减 1,导致超卖。Hyperf 的 RedisClient 虽然协程安全,但 GET + DECR 是两步操作,中间没有原子性保障。
必须把“读库存→判断是否充足→扣减→返回结果”整个逻辑塞进 Redis 服务端执行,Lua 脚本是唯一可靠方案。
- Hyperf 默认的
redis.default配置必须启用serialize为false(否则 Lua 返回的整数会被自动转成字符串,影响判断) - 脚本里不能调用
redis.call('GET')再手动比对——要用tonumber(redis.call('GET', KEYS[1]))显式转类型 - Hyperf 的
RedisClient::eval()第二个参数是numkeys,不是 keys 数组长度,必须传1(哪怕你传了 2 个 key,只要只用第一个做库存键,就填 1)
库存预减 Lua 脚本怎么写才不踩坑
脚本要返回明确的状态码:0 表示扣减成功,-1 表示库存不足,-2 表示 key 不存在(比如商品未上架)。不要返回布尔或字符串,避免 PHP 层解析错乱。
local stockKey = KEYS[1]
local remain = tonumber(redis.call('GET', stockKey))
if not remain then
return -2
end
if remain <= 0 then
return -1
end
redis.call('DECR', stockKey)
return 0
注意:DECR 会直接减 1,如果你需要按订单数量扣减(比如抢购 3 件),得改成 redis.call('INCRBY', stockKey, -tonumber(ARGV[1])),并在 PHP 层传入 [$quantity] 作为 argv。
在 Hyperf 里怎么安全调用 eval 并处理结果
别直接在控制器里写 $redis->eval(...),要封装成 Service 方法,统一处理连接异常、超时、返回值校验。
- 调用时传参顺序固定:
eval($script, [$stockKey], $numKeys = 1),$numKeys必须显式传1,否则 Redis 会把所有参数当 key 处理 - 返回值是整数,但 PHP 可能收到 float 类型(尤其 Redis 扩展版本低时),建议用
(int)$result强转再 switch - 如果返回
-2,说明商品根本没初始化库存,应记录告警日志,而不是抛异常中断流程 - 不要在 Lua 里做耗时操作(如 HGETALL、KEYS *),Hyperf 的 Redis 协程客户端对 eval 有默认 5s 超时,超时会直接报
Redis server timeout
库存回滚和补偿机制怎么设计
预减成功 ≠ 下单成功。用户下单失败(支付超时、地址校验不通过等)必须回滚库存,但不能简单 INCR——得确认该次预减确实没被后续流程消费。
推荐方案:预减时用 SET stock:1001 "1698765432123" NX EX 60 记录一个带时间戳的唯一 token,回滚时用 EVAL 先 GET 对比 token 再 DEL + INCR,避免重复回滚。
更稳妥的做法是把预减动作写入本地消息表,用定时任务扫描超时未支付订单并触发异步回滚,这样即使 Redis 挂了也不丢数据。
真正难的不是脚本怎么写,而是怎么让「预减 → 创建订单 → 支付回调 → 库存确认」这一整条链路的状态可追溯、可幂等、可对账。Lua 只解决最前面那 1ms 的竞争,后面每一步都得加 guard。


















