是的,lock(true) 本质就是 for update 的快捷写法,底层均生成 SELECT ... FOR UPDATE 语句,在 InnoDB 中加排他锁,但仅在显式事务中生效。

ThinkPHP 的 lock(true) 和 forUpdate() 本质一样吗?
是的,lock(true) 就是 forUpdate() 的快捷写法,底层都生成 SELECT ... FOR UPDATE SQL。两者在 InnoDB 中效果完全一致,都是加排他锁(X Lock),阻止其他事务读写该行(除非开启脏读)。但注意:它们**只在显式事务中生效**,脱离 Db::startTrans() 或未关闭自动提交时,锁会立即释放,形同虚设。
为什么用了 lock(true) 还是超卖?常见原因有哪些?
超卖不是锁没起作用,而是锁没「锁对地方」或「锁没锁住时间」。重点排查以下几点:
-
WHERE条件没走主键或唯一索引 → InnoDB 升级为表锁,所有商品查询互相阻塞,反而放大问题 - 事务开启后先做非数据库操作(如调外部 HTTP、sleep、复杂计算)→ 锁持有时间过长,排队请求积压
- 查询后用 PHP 判断库存(
if ($goods['stock'] > 0)),再执行update→ 两次语句之间存在窗口期,锁已释放 - MySQL 隔离级别是
READ-COMMITTED→FOR UPDATE在 RC 级别下不加间隙锁,可能被幻读干扰(虽不影响库存减法,但影响范围判断)
如何写出真正安全的库存扣减代码?
核心原则:**把校验和更新压缩进原子 SQL,锁只持有一条语句的时间**。示例:
Db::startTrans();
try {
// 1. 直接查并锁,且 WHERE 中带库存条件
$goods = Db::name('goods')
->where('id', $goodsId)
->where('stock', '>', 0) // 关键:条件进 SQL,不是 PHP 判断
->lock(true)
->find();
if (!$goods) {
throw new \Exception('库存不足');
}
// 2. 原子更新:库存减1,同时更新 version 或 updated_at(防ABA)
$result = Db::name('goods')
->where('id', $goodsId)
->where('stock', '>', 0) // 再次校验,双重保险
->update([
'stock' => ['dec', 1],
'version' => ['exp', 'version + 1']
]);
if ($result === false || $result === 0) {
throw new \Exception('库存已被抢完');
}
Db::commit();
} catch (\Exception $e) {
Db::rollback();
}
这段代码里,lock(true) 只锁住满足 stock > 0 的那行;后续 update 的 where 条件再次过滤,确保不会扣成负数。两处条件缺一不可。
立即学习“PHP免费学习笔记(深入)”;
生产环境必须加的三个配置项
光写对代码不够,MySQL 层面要兜底:
- 设置锁等待超时:
SET innodb_lock_wait_timeout = 5,避免一个慢事务拖垮整个接口 - 确认表引擎是
InnoDB,MyISAM 不支持行级锁 - 检查
goods.id是否为主键或有唯一索引——这是避免表锁的硬性前提,别信“应该走了索引”这种猜测,用EXPLAIN实际验证
最易忽略的是:锁只对「当前事务可见的数据版本」生效,如果业务里混用了缓存(比如 Redis 缓存了旧库存),那再严的悲观锁也拦不住超卖——锁保护的是数据库,不是你的缓存。



















