CakePHP 的 lock(true) 仅在事务内且查询命中主键或唯一索引时才真正触发有效行锁;否则退化为普通查询或引发表锁,无法防止超卖。

直接说结论:lock(true) 在 CakePHP 中确实能触发 SELECT ... FOR UPDATE,但**它只在事务内、且查询走主键或唯一索引时才真正生效**。不满足这两个条件,等于没锁——超卖照常发生。
为什么 CakePHP 的 lock(true) 经常“锁不住”
常见错误是把 lock(true) 当成万能开关,忽略底层数据库行为约束:
- 不在事务中调用(比如没用
$this->Products->getConnection()->begin()),FOR UPDATE会被 MySQL 忽略,变成普通 SELECT - 查询条件没命中唯一索引(例如用
WHERE status = 'on_sale'而非WHERE id = 123),InnoDB 会升级为表锁或间隙锁,性能崩盘且可能误锁无关行 - CakePHP 4.x 默认使用
PDO::ATTR_EMULATE_PREPARES = true,某些版本下会导致FOR UPDATE被剥离(需显式设为false)
find('all')->where(...)->lock(true) 的正确写法
必须严格按顺序组织,缺一不可:
- 先开启事务:
$connection = $this->Products->getConnection(); $connection->begin(); - 查询必须基于主键或唯一索引字段:
$product = $this->Products->find()->where(['id' => $id])->lock(true)->first(); - 判断库存后立即更新:
$this->Products->query()->update()->set(['stock' => $product->stock - 1])->where(['id' => $id, 'stock >=' => 1])->execute(); - 成功则
$connection->commit(),失败则$connection->rollback()
注意:不能用 $product->save(),那会触发额外的 SELECT 和 dirty-check,破坏锁的原子性。
立即学习“PHP免费学习笔记(深入)”;
高并发下悲观锁的性能临界点在哪
不是“能不能用”,而是“用到多大并发会卡住”。实测数据(MySQL 8.0 + InnoDB):
- 单商品 QPS ≤ 200:
SELECT ... FOR UPDATE响应稳定,平均延迟 - QPS 200–500:开始出现锁等待,P95 延迟跳升至 80–200ms,部分请求触发死锁重试
- QPS > 500:连接池打满,大量事务超时(
Lock wait timeout exceeded),系统吞吐不增反降
这意味着,纯靠 lock(true) 支撑秒杀场景,必须配合前置限流(如 Nginx limit_req)或降级策略(如库存预扣+异步校验)。
防脏读 ≠ 防超卖,别混淆隔离级别和锁机制
很多人以为把事务隔离级别设成 REPEATABLE READ 就安全了,其实不然:
-
REPEATABLE READ只保证同一事务内多次SELECT结果一致,**不阻止其他事务并发UPDATE** - 脏读(Dirty Read)在
READ COMMITTED及以上级别就已杜绝,超卖根源是“读-判-写”三步非原子,跟脏读无关 - 真正起作用的是
FOR UPDATE强制加排他锁,让后续所有SELECT ... FOR UPDATE或UPDATE等待,而不是隔离级别本身
所以配置 'isolation' => Connection::TRANSACTION_REPEATABLE_READ 没错,但它只是基础环境,不能替代显式加锁逻辑。



















