多表关联更新死锁是加锁顺序不可控与锁范围超预期的必然结果;应避免跨表同时加锁,首选拆成单表UPDATE并按ID升序分批执行,且事务中禁止外部依赖。

多表关联更新(UPDATE ... JOIN)死锁不是偶发异常,而是加锁顺序不可控 + 锁范围超出预期的必然结果;最可靠解法是彻底避免跨表同时加锁,而不是调优或重试。
为什么UPDATE t1 JOIN t2加锁顺序不可控
MySQL优化器决定先锁哪张表、按什么范围加锁,和SQL里t1写在前面还是t2写在前面完全无关。事务A可能先锁t2.id = 100再锁t1.id = 50,事务B却反过来——只要两事务操作的主键/索引值有重叠,立刻形成A→B→A循环等待。更麻烦的是:哪怕只更新t1,只要t2出现在ON条件里,InnoDB就会对t2中扫描到的所有行(甚至间隙)加S锁,且这些锁要等到整个事务结束才释放。
用ORDER BY id强制统一加锁路径(仅限必须保留JOIN时)
这个ORDER BY不是为了排序结果,而是向优化器“暗示”按被驱动表主键的物理顺序访问数据,从而提升所有事务走同一加锁路径的概率。但它有严格前提:
-
ORDER BY必须作用于被驱动表(通常是左表)的主键,不能是关联字段或非索引列 - 若
t2的关联字段没索引,优化器可能直接放弃该路径,改走全表扫描+间隙锁 - 执行前务必用
EXPLAIN FORMAT=TRADITIONAL确认t1走了主键索引,且t2走了唯一索引或至少普通索引
拆成单表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读写,导致锁持有时间从几毫秒拉长到几百毫秒。这时哪怕加锁顺序完全一致,也容易因等待时间过长触发Lock wait timeout exceeded。真正卡住系统的往往不是慢查询,而是那个在事务里默默等了8秒的外部依赖。所以务必检查事务边界:所有非DB操作必须移出BEGIN…COMMIT块。


















