秒杀库存扣减不能只靠数据库乐观锁,因为其在高并发下重试率高、一致性依赖业务逻辑;而redis+lua通过单线程原子执行“读-判-减”三步,彻底规避竞态,是高性能与强一致性的事实标准解法。

为什么秒杀库存扣减不能只靠数据库乐观锁
数据库行锁在高并发下会成为瓶颈,大量请求排队等待锁释放,超时失败率飙升;乐观锁依赖 version 字段或 WHERE stock > 0 条件更新,但 MySQL 在高并发下仍可能因间隙锁、主从延迟或事务重试导致超卖——这不是代码写得不够好,而是架构层就该隔离写热点。
Redis + Lua 是事实标准解法:Lua 脚本在 Redis 单线程中原子执行,天然规避并发竞争;库存读取、判断、扣减三步合为一指令,不依赖网络往返与客户端状态。
- 必须用
EVAL或EVALSHA执行 Lua,不能拆成多个 Redis 命令(如GET+DECR) - Lua 脚本里禁止调用
redis.call("KEYS", ...)这类非确定性命令,否则集群模式下会报错ERR evalsha is not supported in cluster mode - Hyperf 的
Redis组件默认使用 phpredis,支持eval,但需确认连接未启用 pipeline 模式(pipeline 会拦截 EVAL 导致脚本不执行)
如何写一个安全可复用的秒杀 Lua 脚本
脚本核心逻辑是:检查 key 是否存在 → 读当前库存 → 判断是否 ≥1 → 扣减并返回结果。关键不是“能不能扣”,而是“扣完后要不要回写过期时间”和“是否允许负库存”。以下是最简可用版本:
local stock = redis.call("GET", KEYS[1])
if not stock then
return -1 -- 库存 key 不存在
end
if tonumber(stock) <p>注意点:</p>
-
KEYS[1]必须传入完整 key(如"seckill:stock:1001"),不能拼接,否则集群分片失效 - 不要在 Lua 里用
redis.call("EXPIRE", ...)—— 秒杀开始前就该用SETEX设好 TTL,避免每次扣减都触发一次额外命令 - 返回值用整数(-1/0/1)而非字符串,PHP 端易判别;Hyperf 中
$redis->eval($script, [$key], 1)的第三个参数是 key 数量,必须匹配KEYS实际长度
在Hyperf里怎么调用并处理返回结果
别把 Lua 脚本硬编码在 Controller 里。建议建 app/Command/SeckillStockCommand.php 封装调用逻辑,再通过 DI 注入 Redis 实例:
public function execute(string $skuId): int
{
$key = "seckill:stock:{$skuId}";
$script = file_get_contents(BASE_PATH . '/resources/lua/seckill_decr.lua');
$result = $this->redis->eval($script, [$key], 1);
<pre class="brush:php;toolbar:false;">// 注意:phpredis 返回的是 mixed 类型,int 或 false
if ($result === false) {
throw new RuntimeException('Redis eval failed');
}
return (int) $result;}
常见错误场景:
- 脚本路径写错导致
file_get_contents返回 false →eval接收空字符串,Redis 报错ERR Error running script (call to f_...): @user_script:1: user_script:1: attempt to concatenate a nil value - 没做
is_int($result)判定直接 switch,遇到null或false会进错分支 - Hyperf 默认 Redis 连接是短连接,高并发下频繁重连;应配置
pconnect为 true,并设好pool.min_connections防止连接池耗尽
为什么还要加本地缓存+预热,光靠 Redis 不够
Redis 再快,单实例 QPS 也有上限(通常 8w–10w),而秒杀瞬时流量常达数十万。真实场景中,90% 的请求其实在还没触达 Redis 前就该被拦掉。
做法是:用 Hyperf 的 MemoryTable 或 Swoole\Table 在 Worker 进程内缓存「活动是否开启」「商品是否售罄」等只读状态,配合定时任务每 5 秒从 Redis 同步一次:
- 用户请求进来先查
MemoryTable,命中则直接返回失败(如activity:closed),不发 Redis - 首次未命中时才查 Redis 并写入本地表;避免每个请求都穿透到 Redis
- 注意
MemoryTable不跨进程,所以预热必须在所有 Worker 启动后触发,推荐用OnWorkerStart事件 + 延迟几秒执行
真正难的不是写对一行 DECR,而是让这行命令尽量少被执行。











