redis+lua是防超卖最可靠方案,因get+decrby非原子操作会导致库存为负;必须用eval将查、判、减封装为单次原子执行,并在db扣减失败时用lua回滚redis库存。

直接用 PHP 读库存、判断、再扣减,必然超卖。Redis + Lua 是目前最可靠、落地最广的解法,前提是 Lua 脚本写对、PHP 调用方式正确、失败路径能兜住。
为什么不能用 $redis->get() + $redis->decrBy() 两步走
因为这两步不是原子的:两个请求几乎同时 GET 到库存 1,都判断“够减”,然后都执行 DECRBY,结果库存变成 -1。Redis 单线程只保证单个命令原子,不保证多个命令组合原子。
常见错误现象:
- 库存扣成负数(比如显示“-2”)
- 前端提示“抢购成功”,但订单创建失败或被回滚
- 日志里反复出现“DB 扣减 affected rows = 0”,说明 Redis 和 DB 状态已脱节
必须把“查 + 判 + 减”压进一个 EVAL 调用里,由 Redis 在服务端一次性执行完。
eval() 调用时 KEY 和 ARGV 的数量与顺序不能错
PHP 的 eval() 方法签名是:$redis->eval($script, $numKeys, $key1, $key2, ..., $arg1, $arg2, ...)。第一个参数是脚本,第二个是 KEY 的个数,之后按顺序先传所有 KEY,再传所有 ARGV。
容易踩的坑:
- 误把商品 ID 当作 KEY 传进去,实际 KEY 应该是完整键名,如
stock:1001,不是1001 - 把数量当 KEY 传,导致脚本里
ARGV[1]拿到的是空或错误值 -
$numKeys写错(比如传 2,但脚本只用了KEYS[1]),Redis 会报ERR Error running script (call to f_...): @user_script:3: user_script:3: Wrong number of args
正确示例:
$script = "if tonumber(redis.call('GET', KEYS[1])) >= tonumber(ARGV[1]) then return redis.call('DECRBY', KEYS[1], ARGV[1]) else return -1 end";
$result = $redis->eval($script, 1, 'stock:1001', 2); // 注意:1 表示 1 个 KEY,'stock:1001' 是 KEY,2 是 ARGV[1]
Lua 脚本里必须显式处理 nil 和类型转换
Redis 的 GET 返回 nil(不是 0 或空字符串),tonumber(nil) 是 nil,后续比较会出错。不加防护的脚本在库存键不存在时可能返回 nil 或报错,而不是你期望的 -1。
推荐写法要点:
- 用
redis.call('GET', KEYS[1]) or '0'避免nil - 用
tonumber(...)强转,并检查是否为数字(if not stock or stock ) - 不要依赖 PHP 传入的
ARGV一定是整数字符串,tonumber(ARGV[1])更安全
精简健壮版脚本:
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local need = tonumber(ARGV[1])
if not stock or not need or stock
<h3>扣减成功后 DB 同步失败,必须同步回滚 Redis</h3>
<p>这是最容易被忽略的致命环节。Redis 扣了,MySQL 因死锁、超时、唯一约束冲突等原因失败,如果不补回库存,就会永久性少货——用户再也抢不到,而数据库里其实还有。</p>
<p>关键动作:</p>
- DB 操作必须包在
try/catch里 -
catch块中立即执行$redis->incrBy('stock:1001', $quantity),把刚扣的加回去 - 加回操作也建议用 Lua 封装,防止回滚本身又被并发干扰
- 记录明确日志,包含
productId、quantity、error message,便于事后对账
没有这一步,Redis 就不是“缓存”,而是“单点故障源”。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











