PHP无法跨请求加锁,超卖需数据库或外部服务保障原子性;核心是将查库存、判断、扣减压缩为不可分割操作;常用方案包括MySQL悲观锁(SELECT ... FOR UPDATE)、乐观锁(版本号校验)和Redis分布式锁(SET+Lua),推荐组合使用。

PHP本身不提供跨请求的锁能力,超卖问题必须靠数据库或外部服务来保障原子性。核心思路是:把“查库存→判是否够→扣减”这三步压缩进一个不可分割的操作里,不能靠PHP变量、缓存或简单if判断。
数据库悲观锁(最常用、最可靠)
利用MySQL InnoDB的SELECT ... FOR UPDATE在事务中锁定目标行,其他并发请求会等待锁释放。必须满足两个前提:使用InnoDB引擎、操作包裹在事务内(BEGIN/COMMIT)。
- ThinkPHP写法示例:
Db::transaction(function () use ($goodsId, $num) {<br> $goods = Db::name('goods')<br> ->where('id', $goodsId)<br> ->lock(true) // 等价于 FOR UPDATE<br> ->find();<br> if (!$goods || $goods['stock'] < $num) {<br> throw new Exception('库存不足');<br> }<br> $result = Db::name('goods')<br> ->where('id', $goodsId)<br> ->where('stock', '>=', $num) // 关键兜底条件<br> ->update(['stock' => Db::raw("stock - {$num}")]);<br> if (!$result) {<br> throw new Exception('库存已被抢完');<br> }<br>}); - 原生PDO写法也类似:先
prepare("SELECT stock FROM goods WHERE id = ? FOR UPDATE"),再检查并执行带WHERE stock >= ?的UPDATE,最后用rowCount()判断是否真改了数据 - 注意:WHERE里的库存校验不能省,防止锁失效、网络重试或事务回滚后重复执行
乐观锁(适合读多写少场景)
不加锁,靠版本号或时间戳比对实现“先读后验”。更新时带上原始版本条件,数据库只在条件匹配时才执行,失败需业务层处理(如提示重试)。
- 表结构需增加
version字段(INT类型,默认0) - 查询时拿到当前
stock和version,业务逻辑判断库存是否充足 - 执行UPDATE时必须包含
WHERE id = ? AND version = ? AND stock >= ?,成功则version + 1 - 若
rowCount() === 0,说明被其他请求抢先更新,当前操作失效
Redis分布式锁(高并发兜底或预减)
纯数据库锁在万级QPS下容易成为瓶颈,Redis可作前置过滤或辅助控制。但直接用SET + DEL极不安全,必须用原子指令组合。
立即学习“PHP免费学习笔记(深入)”;
- 加锁:用
SET key random_value NX EX seconds,NX保证唯一性,random_value用于防误删 - 解锁:必须用Lua脚本,先GET比对value再DEL,避免A进程删掉B的锁
- 典型用法:用户下单前先尝试获取商品锁,成功再走数据库扣减;或用Redis的
DECR做库存预扣(后续异步核销) - 锁过期时间要明显大于业务最大耗时(比如下单逻辑预计800ms,设为5s),且业务代码内必须有超时保护
组合方案更实用(推荐)
单一方案都有局限。中等并发(几百~几千TPS)用数据库悲观锁+二次WHERE校验已足够;万级QPS建议分层:Redis预扣减 → 消息队列削峰 → 数据库最终落库 + 异步对账补偿。
- 例如秒杀场景:Redis中维护
stock:1001初始值100,用户请求先DECR,返回≥0才进入下单流程;失败直接返回“已售罄” - 订单创建成功后,通过MQ异步通知库存服务完成DB最终扣减,避免主链路阻塞
- 定时任务扫描未支付订单,自动释放Redis预占库存,并回滚DB中已扣但未支付的记录



















