根本原因是MySQL优化器动态选择驱动表导致跨表加锁顺序不可控,事务A与B以相反顺序加锁形成循环等待;应拆分为单表UPDATE并按主键升序执行,或确保JOIN关联字段有索引且用EXPLAIN验证执行计划。

多表关联更新(UPDATE ... JOIN)在 MySQL 8.0 中极易引发死锁,根本原因不是语法错,而是优化器决定的跨表加锁顺序不可控——事务 A 可能先锁 t2 再锁 t1,事务 B 却反向加锁,只要操作的主键有重叠,立刻形成循环等待。
为什么 UPDATE JOIN 的加锁顺序不取决于 SQL 书写顺序
MySQL 优化器会基于执行计划动态选择驱动表和被驱动表,UPDATE t1 JOIN t2 ON ... 并不保证先锁 t1。实际加锁路径由索引选择、数据分布、统计信息共同决定。即使你把 t1 写在前面,优化器仍可能选 t2 当驱动表,导致对 t2 先加 S 锁(哪怕只更新 t1),而这些 S 锁要等到整个事务结束才释放。
常见错误现象:Deadlock found when trying to get lock; try restarting transaction
- 事务中只要
t2的关联字段(如t2.t1_id)没索引,就会触发全表扫描 + 大量间隙锁,极大提高死锁概率 -
EXPLAIN FORMAT=TRADITIONAL显示t2走了ALL类型?那基本等于主动邀请死锁 - 即使只影响几行,若事务里夹着 HTTP 调用或 Redis 查询,锁持有时间从毫秒拉长到秒级,等待超时(
Lock wait timeout exceeded)就比死锁更常发生
用 ORDER BY 强制统一加锁路径(仅限必须保留 JOIN 场景)
这不是为了排序结果,而是向优化器“暗示”按被驱动表主键物理顺序访问数据,从而收敛加锁路径。但该技巧非常脆弱,需严格满足条件:
-
ORDER BY必须作用于被驱动表的主键(例如ORDER BY t1.id),不能是t2.xxx或非索引列 -
t2的关联字段必须有索引(至少普通索引,推荐唯一索引),否则优化器大概率忽略该 hint - 执行前务必用
EXPLAIN确认:被驱动表走了主键或唯一索引,且没有出现Using temporary; Using filesort - 该方法无法消除
t2上的 S 锁,只能降低不同事务触发行的顺序分歧
拆成单表 UPDATE 并显式控制 ID 顺序(最可靠方案)
彻底绕开跨表加锁不确定性,让每条语句只锁一张表。核心是应用层生成升序 ID 列表,并分批执行:
- 第一步:用
SELECT t1.id FROM t1 JOIN t2 ON ... WHERE ... ORDER BY t1.id拿到需更新的主键列表(ORDER BY在这一步生效) - 第二步:将 ID 列表按升序切分,每批 ≤ 500 个(避开
max_allowed_packet限制),拼成UPDATE t1 SET ... WHERE id IN (1,2,3,...) - 若
t2关联字段无索引,这步SELECT本身就会全表扫描+锁大量间隙——必须先补索引 - 示例中
UPDATE accounts SET balance = balance - 10 WHERE id IN (101, 102, 105)的 ID 顺序无效,关键在应用层生成时已确保升序
最容易被忽略的锁持有时间陷阱
死锁高频发生在“看起来很快”的语句上。比如一条 UPDATE orders JOIN order_items 只改 3 行,但事务里顺手调了一次外部 API,锁就卡住 8 秒。这时哪怕加锁顺序完全一致,也大概率触发 Lock wait timeout exceeded。
真正卡住系统的往往不是慢查询,而是那个在事务里默默等了 8 秒的外部依赖。务必检查事务边界:所有非 DB 操作必须移出 BEGIN...COMMIT 块。另外,innodb_print_all_deadlocks = ON 必须开启,否则生产环境死锁日志只会覆盖,根本没法归因。


















