thinkphp 5.1 分布式防超卖需用原子机制:mysql行锁(select for update+事务)适用于中等qps;redis lua预扣减适合高并发秒杀;乐观锁(where stock>=n更新)适合低冲突场景;须规避redis未校验入队、unsigned字段误用、连接池超时缺失及缺对账等陷阱。

ThinkPHP 5.1 处理分布式环境下的并发超卖,核心是打破“查-判-改”三步分离带来的竞态,不能只靠单机事务或本地锁。分布式意味着请求可能打到不同服务器,数据库连接、Redis 实例、甚至缓存节点都可能跨物理节点,必须依赖能被所有节点共同感知的原子性机制。
用 MySQL 行锁(SELECT FOR UPDATE)+ 显式事务
这是最直接、一致性最强的方案,适用于库存变更频率不高、QPS 在几百到一两千的中等压力场景:
- 必须先调用 $model->startTrans() 开启事务,否则
lock(true)生成的SELECT ... FOR UPDATE会自动提交,锁瞬间释放,完全无效; - 查询必须命中主键或唯一索引,例如
where('id', $id),用EXPLAIN确认执行计划 type 是const或eq_ref,避免因索引缺失导致升级为间隙锁甚至表锁; - 扣减操作(
update())放在同一事务内,不需重复加锁——行锁由前面的find()持有,直到commit()或rollback()才释放; - 判断库存时,要严格比对
$goods['stock'] >= $buyNum,不能只判是否大于 0; - 失败时立即
$model->rollback(),避免长时间占用锁资源。
用 Redis 原子操作(Lua 脚本)预扣减
适合高吞吐秒杀类场景,把库存判断和扣减压缩成一个不可分割的动作,绕过数据库瓶颈:
- 不能分两次调用:先
GET再DECRBY—— 中间存在毫秒级窗口,必然超卖; - 必须用 Lua 脚本封装读取、判断、扣减三步,例如:
if tonumber(redis.call("GET", KEYS[1])) >= tonumber(ARGV[1]) then redis.call("DECRBY", KEYS[1], ARGV[1]); return 1 else return 0 end; - 脚本通过
$redis->eval($script, 1, $key, $num)执行,返回 1 表示成功预扣,0 表示库存不足; - 预扣成功后,再异步落库(如写订单、更新 DB 库存),并配套对账任务,修复 Redis 与 DB 长期不一致的风险。
用数据库乐观锁(WHERE 条件校验)
轻量、无锁等待,适合更新频繁但冲突概率低的场景,比如日常购物而非抢购:
- 直接执行
Db::name('goods')->where('id', $id)->where('stock', '>=', $num)->setDec('stock', $num); - 检查返回值:affectedRows === 1 才算真正扣减成功,为 0 就是库存不足,不是异常而是业务拒绝;
- 不要先
SELECT再UPDATE,那仍是竞态起点; - 如果业务已含 version 字段,也可用
WHERE id = ? AND version = ?更新并自增 version,但纯库存扣减没必要多一次字段维护。
避免常见陷阱
很多超卖不是技术没用对,而是细节踩坑:
- Redis 队列不能只做 LPUSH + BRPOP:入队前没校验库存,队列里塞了 1000 个订单,结果库存只剩 1,消费者全会扣成负数;
- MySQL 字段设为
unsigned只能防止负数写入报错,并不能阻止两个请求同时读到 1、各自扣成 0 —— 这是逻辑层问题,不是数据类型能解决的; - 没配好 Redis 连接池或超时(如 BRPOP 不设 timeout),会导致消费者挂死、队列积压、库存“假冻结”;
- 没做定期对账:Redis 预扣成功但 DB 落库失败,若无补偿机制,库存就永久少掉,必须有定时任务扫描不一致记录并修复。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











