根本原因是MySQL优化器决定的跨表加锁顺序不可控,且t2参与ON条件时会被加S锁并持锁至事务结束;事务A与B可能反向加锁形成A→B→A循环等待,即使只更新t1。

多表关联更新(UPDATE ... JOIN)在高并发下极易触发死锁,根本原因不是语法问题,而是 MySQL 优化器决定的跨表加锁顺序不可控,且只要 t2 出现在 ON 条件中,InnoDB 就会对它扫描到的所有行(甚至间隙)加 S 锁——这些锁要等到整个事务结束才释放。
为什么 UPDATE t1 JOIN t2 的加锁顺序不可控
优化器不按 SQL 书写顺序加锁,事务 A 可能先锁 t2.id = 100 再锁 t1.id = 50,事务 B 却反向执行。只要主键或索引值有重叠,立刻形成 A→B→A 循环等待。
- 即使只更新
t1,t2也会被加 S 锁(非独占,但阻塞 X 锁),且锁持续到事务结束 -
EXPLAIN FORMAT=TRADITIONAL能确认实际驱动表和索引使用情况,但无法保证所有事务走同一路径 - 没有索引的关联字段(如
t2.t1_id)会强制全表扫描 + 大量间隙锁,死锁概率飙升
用 ORDER BY t1.id 能统一加锁路径吗
ORDER BY t1.id 不是为排序结果,而是向优化器“暗示”按 t1 主键物理顺序访问数据,从而提升各事务走同一加锁路径的概率——但这个暗示仅在特定条件下生效:
- 被驱动表
t2的关联字段(如t2.t1_id)必须有索引(至少普通索引),否则优化器大概率放弃该路径 - 务必对完整语句跑
EXPLAIN,确认t1走主键、t2走 ref/range 类型索引,rows在千级以内 -
ORDER BY在IN子句中无效,但应用层生成 ID 列表时确保升序即可
拆成单表 UPDATE 并显式控制 ID 顺序
比“暗示”更可靠的做法是彻底绕开 JOIN:应用层先查出要更新的主键列表,再拼成 IN 语句批量更新。关键在于 ID 列表必须升序排列,且每批不超过 max_allowed_packet(通常建议 ≤500):
- 第一步:
SELECT id FROM t2 WHERE ... ORDER BY id拿到需更新的t1主键(务必加ORDER BY id) - 第二步:把返回的 ID 列表按升序分组,每组生成一条
UPDATE t1 SET ... WHERE id IN (1,2,3) - 若
t2.t1_id无索引,这条SELECT本身就会全表扫描+锁大量间隙,得先补索引
最容易被忽略的锁持有时间陷阱
死锁常发生在“看起来很快”的语句上——比如 UPDATE accounts JOIN user_logs 只影响 3 行,但事务里夹着一次 HTTP 调用或 Redis 读写,导致锁持有时间从几毫秒拉长到几百毫秒。
真正卡住系统的往往不是慢查询,而是那个在事务里默默等了 8 秒的外部依赖。务必检查事务边界:所有非 DB 操作必须移出 BEGIN…COMMIT 块。哪怕加锁顺序完全一致,锁持有时间过长也会触发 Lock wait timeout exceeded,而非死锁,但表现类似且更难定位。


















