ThinkPHP 的 lock() 方法默认不锁行,只加表级锁;lock(true) 生成 LOCK IN SHARE MODE,无法防止并发写覆盖,真正行级独占锁需用 extra(' FOR UPDATE') 并置于事务内执行。

ThinkPHP 的 lock() 方法到底锁不锁行?
它默认不锁行,只加表级锁。调用 lock(true) 时底层生成的是 SELECT ... LOCK IN SHARE MODE(共享锁),而你想要的“修改前独占锁定某几行”,得手动拼 FOR UPDATE —— lock() 自身不支持传入这个关键词。
常见错误现象:lock(true) 后并发更新同一条记录仍可能丢数据,因为共享锁允许多个事务同时读、不阻塞其他读,但无法阻止另一个事务也执行 UPDATE(除非对方也带 FOR UPDATE 并抢到锁)。
-
lock(true)→LOCK IN SHARE MODE,适合“查后只读”场景 - 要真正防并发写覆盖,必须用
FOR UPDATE,且必须在事务内执行 - ThinkPHP 6+ 的
lock()不接受字符串参数(如lock('FOR UPDATE')),会直接报错或忽略
手动在查询中嵌入 FOR UPDATE 的两种安全写法
ThinkPHP 不让直接塞 SQL 片段进 where,但允许在 select() 前用 fetchSql() 观察语句,再用原生查询或 Query 对象拼接。最稳的方式是:不用 lock(),改用 query() 或 db()->table()->select() 配合 extra()。
- 方式一(推荐,兼容性好):
Db::table('user')->where('id', 123)->extra('FOR UPDATE')->find() - 方式二(TP6+,更明确):
(new Query())->table('user')->where('id', 123)->extra('FOR UPDATE')->find() - 注意:
extra()是追加到整个 SELECT 末尾的,不是 WHERE 后;它不会自动加空格,所以建议写成extra(' FOR UPDATE')(前面带空格) - 如果用了软删除,
extra()仍生效,但需确保 WHERE 条件已过滤掉delete_time,否则可能锁到逻辑删除行
为什么必须套在事务里?单独 FOR UPDATE 会立即释放
MySQL 的 FOR UPDATE 锁只在事务内持续,一旦查询结束、事务没提交,连接关闭时锁就释放了——这等于白锁。ThinkPHP 默认查询是自动提交的,所以必须显式开启事务。
立即学习“PHP免费学习笔记(深入)”;
- 正确姿势:
Db::transaction(function () { return Db::table('order')->where('id', 456)->extra(' FOR UPDATE')->find(); }); - 错误姿势:把
extra('FOR UPDATE')放在事务外,或者用startTrans()但忘了commit()/rollback() - 性能影响:锁住的行会阻塞其他事务的
FOR UPDATE和UPDATE,但不影响普通SELECT(除非对方也加锁) - 兼容性注意:MySQL 5.7+ 支持;MariaDB 同样支持;SQLite、PostgreSQL 不认这个语法,TP 项目若需多库适配,此处必须条件判断
容易被忽略的三个细节
很多人写了 FOR UPDATE 还出并发问题,往往栽在这三处。
- WHERE 条件没走索引:全表扫描会导致锁整张表,而不是单行。务必确认
EXPLAIN显示type是const或ref,而不是ALL - 事务粒度太大:在事务里做了耗时操作(比如调第三方 API、文件读写),会让锁持有时间过长,拖慢整体吞吐。应尽量把锁行查询和后续 UPDATE 控制在最小代码块内
- 死锁风险:两个事务按不同顺序锁多行(如事务 A 先锁 id=1 再锁 id=2,事务 B 反过来),MySQL 会主动杀掉一个。线上日志里搜
Deadlock found when trying to get lock就是它
行级锁不是银弹,它解决的是“读-改-写”中间态的竞态,但锁哪几行、锁多久、有没有索引兜底,每一步都得对得上,少一个环节就回到裸奔状态。



















