UPDATE JOIN加锁顺序由优化器动态决定而非SQL书写顺序,事务间加锁路径不一致易导致死锁;ORDER BY仅是优化器访问顺序暗示,需t2.t1_id有索引才生效;最可靠方案是拆分为升序SELECT ID后分批单表UPDATE。

UPDATE JOIN 的加锁顺序根本不由 SQL 书写顺序决定
你写 UPDATE t1 JOIN t2 ON t2.t1_id = t1.id,不代表 MySQL 就先锁 t1 再锁 t2。优化器会根据统计信息、索引可用性、扫描成本等动态决定驱动表和被驱动表,进而决定加锁先后。事务 A 可能先锁 t2.id = 100 行再锁 t1.id = 50,事务 B 却反向执行——只要两事务涉及的主键/索引值有重叠,立刻形成 A→B→A 循环等待。
常见错误现象:Deadlock found when trying to get lock; try restarting transaction,但 EXPLAIN 显示语句走索引、影响行数很少,说明不是性能问题,而是加锁路径不可控。
关键点:哪怕你只更新 t1 字段,只要 t2 出现在 ON 条件里,InnoDB 就会对 t2 中所有扫描到的行(甚至间隙)加 S 锁,且这些锁要等到整个事务结束才释放。
ORDER BY id 只是“暗示”,不是强制保证
ORDER BY t1.id 的作用不是排序结果,而是向优化器传递一个访问顺序信号:按 t1 主键物理顺序扫描,提升所有事务走同一加锁路径的概率。但它有效是有前提的:
- 被驱动表
t2的关联字段(如t2.t1_id)必须有索引,否则优化器大概率放弃该路径,改走全表扫描 + 大量间隙锁 - 执行前必须用
EXPLAIN FORMAT=TRADITIONAL确认:t1走了主键索引,t2走了唯一索引或至少普通索引 -
ORDER BY在IN子句中无效;但应用层生成 ID 列表时确保升序即可
拆成单表 UPDATE + 升序 ID 批量是最可靠方案
绕开 JOIN,把“查 ID → 拼 IN → 更新”三步拆开,由应用层显式控制加锁顺序,比依赖优化器“暗示”稳定得多:
- 第一步:
SELECT id FROM t2 WHERE ... ORDER BY id拿到需更新的t1主键(务必加ORDER BY id) - 第二步:把返回的 ID 列表按升序分组,每组不超过
max_allowed_packet限制(通常建议 ≤ 500) - 第三步:生成
UPDATE t1 SET ... WHERE id IN (1,2,3,...),每批单独执行
注意:t2.t1_id 若无索引,这第一步 SELECT 本身就会全表扫描+锁大量间隙,得先补索引。
最容易被忽略的锁持有时间陷阱
死锁常发生在“看起来很快”的语句上——比如 UPDATE accounts JOIN user_logs 只影响 3 行,但事务里夹着一次 HTTP 调用或 Redis 读写,导致锁持有时间从几毫秒拉长到几百毫秒。这时哪怕加锁顺序完全一致,也容易因等待时间过长触发 Lock wait timeout exceeded。
真正卡住系统的往往不是慢查询,而是那个在事务里默默等了 8 秒的外部依赖。务必检查事务边界:所有非 DB 操作必须移出 BEGIN…COMMIT 块。


















