不能用$model->where()->setDec(),因为其底层先查后减非原子操作,中间可能被其他请求插队导致超卖;TP5/6默认不开启事务且WHERE条件无行锁,必须用原生SQL配合WHERE stock>0原子扣减。

秒杀库存扣减为什么不能用 $model->where()->setDec()
并发下会超卖,因为 setDec 底层是先查再减,中间可能被其他请求插队。哪怕数据库加了唯一索引或事务,TP5/6 的默认模型操作不自动开启事务,更不会对 WHERE 条件做行级锁。
实操建议:
立即学习“PHP免费学习笔记(深入)”;
- 必须用原生 SQL 配合
UPDATE ... SET stock = stock - 1 WHERE id = ? AND stock > 0,依赖 MySQL 的原子性与 WHERE 条件校验 - TP6 推荐用
Db::table('goods')->where('id', $id)->where('stock', '>', 0)->update(['stock' => Db::raw('stock - 1')]) - 返回影响行数,等于 0 表示库存不足或已被抢完,直接终止后续逻辑
- 不要在模型里封装“扣库存”方法而不显式控制事务,否则高并发时形同虚设
如何防止用户重复提交秒杀请求
前端按钮置灰、防抖只是辅助,真正可靠的是服务端幂等控制。
实操建议:
立即学习“PHP免费学习笔记(深入)”;
- 每个秒杀请求携带唯一
$token(由服务端下发,存 Redis,有效期略长于秒杀页停留时间) - 收到请求后先
Redis::del('seckill:token:'.$token),成功才继续;失败说明已用过或过期 - 不要用用户 ID + 商品 ID 做唯一键——用户可能多设备登录,且秒杀资格需按场次隔离
- 订单表加唯一索引
UNIQUE KEY `uid_gid_time` (`user_id`, `goods_id`, `seckill_activity_id`),作为兜底
Redis 预减库存后怎么跟数据库最终一致
预减只是削峰,不是终态。如果 Redis 扣成功但 DB 写失败(如网络抖动、主从延迟),会导致库存对不上。
实操建议:
立即学习“PHP免费学习笔记(深入)”;
- Redis 预减用
DECRBY,并用GET拿当前值判断是否 ≥ 0;失败立即返回“已抢光” - DB 扣减成功后,再异步发消息(如 TP6 的
event或 Redis List)触发核销任务:比对 Redis 剩余库存与 DB 当前值,差额写回 DB - 核销任务要带重试(最多 3 次)和告警,避免因单次失败长期不一致
- 不要在扣减 DB 后立刻
INCRBY把库存加回去——这会放大不一致风险
TP6 中使用 think-queue 处理秒杀订单异步落库的坑
很多人以为丢进队列就万事大吉,结果发现订单没写入、消息丢失、重复消费全来了。
实操建议:
立即学习“PHP免费学习笔记(深入)”;
- 投递消息前,先在 DB 插一条状态为
pending的订单记录,并设唯一索引防重;队列只负责更新状态和补全字段 - 消费者必须用
try/catch包裹全部逻辑,失败时调用$job->release(2)延迟重试,别直接fail() - 配置
maxTries=3和retryAfter=60,避免瞬时失败被永久丢弃 - Redis 队列连接要用独立连接池,别和业务 Redis 共用,否则秒杀高峰时队列卡住连带拖垮缓存
实际最难的不是代码写法,是 Redis 和 MySQL 之间那几十毫秒的状态差,以及开发者习惯性把“能跑通”当成“能扛住”。留出核对窗口、接受小概率人工干预,比硬撑一致性更现实。



















