秒杀场景里,很多人都踩过一个坑——先 rPop 再从 MySQL 查库存。表面上看 rPop 是原子操作,但整个链路“取ID → 查库存 → 扣库存 → 写订单”并不原子。MySQL 那边事务一旦失败(比如唯一索引冲突、主从延迟导致读到旧库存),商品 ID 已经出队了,订单却没生成,这个 ID 就永远消失了——库存漏单,不可逆。而并发更高时,两个请求同时 rPop 拿到同一个 ID,都查到 num = 1,然后都执行成功,超卖就成了必然。

为什么不能先rPop再查MySQL库存
常见错误写法如下:
if ($redis->rPop('goods:queue:1001')) {
$stock = $pdo->query("SELECT num FROM stock WHERE id = 1001")->fetchColumn();
if ($stock > 0) {
$pdo->exec("UPDATE stock SET num = num - 1 WHERE id = 1001");
// … 写订单
}
}
这段代码的问题在于:两个请求同时 rPop,各自拿到同一个 ID,然后都读到 num = 1,接着都执行扣减——超卖就这么发生了。更糟的是 MySQL 事务回滚时,ID 已经出队,再也找不回来。
所以正确的做法必须把库存校验压到 Redis 端,并且和出队动作绑定在一起。要么写 Lua 脚本,把 decr、exists、lRem 封装成一个原子操作,脚本返回值直接决定是否继续;要么队列里存结构化快照数据,比如 {"goods_id":1001,"stock_snapshot":5},消费时拿快照值做比对,而不是实时查 MySQL。这两种方式都能从根本上切断“出队 → 查库”的不安全链条。
Webman里调用Redis Lua脚本的硬性要求
Webman 默认不启用 Lua 支持,这点很多人不知道。如果直接调 eval,Redis 会退化成多次网络往返,原子性直接失效。不配 use_lua => true,脚本写得再严谨也没用。
先检查 config/redis.php 里是否加了这一行:
'use_lua' => true,
调用时要特别注意几点:
KEYS数组传 Redis key(比如['seckill:stock:1001', 'user:participated:1001:123']),顺序一定不能错,脚本里KEYS[1]就是第一个 key。ARGV数组传参数(比如[1, 'token_abc']),顺序错一个,脚本里ARGV[1]就取不对。- 别在脚本里写
redis.log(),Webman 的 Redis 扩展不支持。 - 返回值尽量用整数,比如
1表示成功、-1表示库存不足,省掉 JSON 解析的开销。
这些细节虽然小,但线上跑起来任何一个没注意,脚本逻辑就可能完全走偏。
多Worker下如何避免重复消费同一商品
Webman 默认启动多个 Worker 进程,如果每个 Worker 都定时 rPop,必然出现多个进程抢同一个队列元素。这其实不是 Redis 的问题,而是调度逻辑本身有缺陷。
唯一可靠的解法是加分布式锁,而且锁必须带 value 校验。具体来说:
- 用
SET key value EX 10 NX尝试获取锁,value 设置为当前 Worker 的 PID 或随机 UUID。 - 解锁必须走 Lua 脚本:
EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 queue:lock:1001 abc123。 - 千万别用
SETNX + DEL两步操作,否则 A 进程可能删掉 B 进程的锁,导致锁失效。 - 也别尝试
flock(),Webman 跑在 Swoole 协程下,文件锁根本不起作用。
这套方案看起来繁琐,但线上环境里,它就是兜底的保险。
缓存Key设计不当会直接引发线上事故
写死 Cache::set('goods_list', $data) 这种操作,看着省事,实际等于埋了个定时冲击波。分页参数一变、筛选条件一换,缓存永远不会更新;测试环境和生产环境共用 Redis,key 一撞,数据全乱套。
正确的缓存 Key 必须包含三要素:
- 业务域前缀,比如
seckill:goods:,一眼就能看出是哪块业务。 - 参数签名,用
md5(json_encode([$goods_id, $user_id]))来做,千万别用serialize(),避免 PHP 版本升级导致 hash 变化。 - 版本号,比如
:v3,上线时直接改版本号就能让新缓存生效,比批量DEL seckill:goods:*可控得多。
额外提醒一句:像 $user_id 这类敏感字段,别明文拼进 key 里,防止缓存探测和越权访问。办法是用哈希或加密后再拼接。
这些设计看起来是细节,但线上事故往往就出在这些“看起来没问题”的地方。
