高并发抽奖需用Redis+Lua原子脚本实现库存校验与扣减,避免超发;队列仅作请求缓冲,中奖写库须异步幂等;ThinkPHP调用需启用redis.use_lua、正确传参、禁用redis.log。

抽奖接口被刷爆,直接查数据库肯定崩
高并发下抽奖最怕的不是逻辑复杂,而是多个请求同时读库存、扣库存、发奖,结果超发或重复中奖。ThinkPHP 默认用 MySQL 事务扛不住这种场景,锁表、死锁、慢查询全来。核心矛盾是「库存校验 + 扣减」必须原子,而 PHP-FPM 进程之间无法共享内存锁。
实操建议走 Redis + Lua:把库存检查、扣减、中奖记录写入全部塞进一个 EVAL 脚本里执行,Redis 单线程保证原子性,不依赖 PHP 层加锁。
- 别用
GET+SET两步判断库存——中间可能被其他请求插队 - Lua 脚本里用
redis.call('decr')直接扣减并返回新值,再判断是否 ≥0,一气呵成 - 脚本返回值设计为整数:-1=库存不足,0=扣减失败(比如已抽过),1=成功,避免 JSON 解析开销
Redis 队列只是缓冲,不能替代原子扣库存
有人想用 LPUSH/BRPOP 把抽奖请求塞队列,后台 Worker 慢慢处理。这能削峰,但解决不了根本问题:队列消费时依然要查库存、扣库存,如果多个 Worker 并发处理,照样超发。
正确做法是队列只做「请求收纳」,真正决定中不中奖、扣不扣库存,必须回到 Lua 脚本里完成。队列里的每个任务只负责调用那个原子脚本,然后根据返回值写中奖记录或丢弃。
立即学习“PHP免费学习笔记(深入)”;
- 队列 key 命名带活动 ID,比如
lottery:queue:2024_spring,避免混用 - Worker 启动时用
SETNX抢一个lottery:worker:lock:2024_spring,防多实例重复消费同一批 - 不要在队列里存完整用户数据,只存
user_id和activity_id,脚本里再查必要信息(如是否已参与)
ThinkPHP 调用 Lua 脚本的三个硬坑
TP6/TP8 自带 think\cache\driver\Redis 支持 eval,但默认配置和调用方式容易踩雷。
- Redis 连接必须启用
redis.use_lua(TP6.1+),否则eval会降级为多次网络往返,失去原子性 - Lua 脚本里不能用
redis.log(),TP 的 Redis 驱动不支持,会报错ERR Error running script (call to f_...): @user_script:1: user_script:1: attempt to call a nil value - 脚本参数传入要用
KEYS[1]和ARGV[1],别写死 key 名;TP 调用时第一个参数是 key 列表数组,第二个才是 argv 数组,顺序反了就取不到值
示例调用:
$redis = Cache::store('redis')->handler();
$result = $redis->eval(
'local stock = redis.call("get", KEYS[1]); if not stock or tonumber(stock) <= 0 then return -1 end; local res = redis.call("decr", KEYS[1]); return res >= 0 and 1 or -1;',
['lottery:stock:1001'], // KEYS
[$userId] // ARGV
);
中奖结果写入 MySQL 必须异步且幂等
Lua 脚本只管原子扣库存,中奖记录写 MySQL 是后续动作,必须和脚本解耦,否则拖慢 Redis 响应,还可能因 DB 慢导致脚本超时。
写库操作放到队列里异步做,但要注意:同一个用户可能因重试触发多次脚本调用,所以 MySQL 插入前必须先 INSERT IGNORE 或用 ON DUPLICATE KEY UPDATE,主键或唯一索引得包含 user_id + activity_id。
- 别在 Lua 脚本里调
redis.call("hset", ...)记录中奖详情——字段多、序列化麻烦,且 Redis 不是持久化主库 - 异步写库的任务里,查一遍
lottery_record表确认是否已存在,比单纯靠唯一键更稳妥(防止索引失效或漏建) - 用户端看到“恭喜中奖”后,前端要禁用按钮并加 loading,避免手抖连点触发多次请求
最麻烦的其实是库存预热和补发逻辑:活动开始前 Redis 库存没设好,或者 Lua 扣减后 MySQL 写失败需要补偿——这些没法靠脚本自动兜底,得单独跑定时任务对账。











