根本解法是绕开update join,改用升序id分批单表更新。因mysql优化器动态决定加锁顺序,join必致死锁风险;需先select order by主键获取升序id列表,再分批in更新,并规避外键、http调用等隐式锁陷阱。

多表关联更新(UPDATE ... JOIN)产生循环死锁,根本不是SQL写得“不规范”,而是MySQL优化器决定的加锁顺序不可控——事务A可能先锁t2再锁t1,事务B反向操作,只要主键有交集,立刻成环。唯一可靠解法是绕开JOIN,不靠“提示”或“重试”,直接消灭跨表加锁。
为什么UPDATE t1 JOIN t2的加锁顺序不可信
优化器会根据统计信息、索引选择、数据分布动态决定驱动表和访问路径,UPDATE t1 JOIN t2 ON t1.id = t2.t1_id不等于“先锁t1”。实际执行中,t2可能被选为驱动表,先对t2扫描行加S锁,再回表t1加X锁;另一条相似语句却可能反着来。更麻烦的是:哪怕只更新t1,只要t2出现在ON条件里,InnoDB就会对t2中所有匹配行(甚至间隙)加S锁,且这些锁要等到整个事务结束才释放。
- 常见错误现象:
Deadlock found when trying to get lock; try restarting transaction - 使用场景:订单状态同步(
orders JOIN order_items)、用户积分批量修正(users JOIN user_logs) - 性能影响:
JOIN更新无法利用覆盖索引,常触发全表扫描+大量行锁,锁持有时间翻倍
拆成单表UPDATE并强制ID升序
放弃JOIN语法,应用层先查出要更新的主键列表,再分批生成IN语句。关键不是“查出来再更新”,而是ID必须升序排列——这能确保所有事务按同一物理顺序触发行,彻底消除加锁顺序分歧。
- 第一步:
SELECT t1.id FROM t2 JOIN t1 ON t2.t1_id = t1.id WHERE ... ORDER BY t1.id(务必加ORDER BY,保证结果升序) - 第二步:把ID列表按升序分组,每组不超过
max_allowed_packet限制(通常500个以内),拼成UPDATE t1 SET ... WHERE id IN (1,2,3,...) - 若
t2.t1_id无索引,这条SELECT本身就会全表扫描+锁大量间隙,必须先补索引 -
ORDER BY在IN子句中无效,但应用层生成时确保升序即可;示例:UPDATE accounts SET balance = balance - 10 WHERE id IN (101, 102, 105)
必须避开的三个隐形陷阱
很多死锁不是发生在SQL本身,而是在事务边界外“悄悄加锁”或“默默等锁”:
- 事务里夹着HTTP调用、Redis读写、日志落盘——一个本该50ms完成的更新,因等8秒API响应,锁持有时间拉长160倍,极易触发
Lock wait timeout exceeded - 外键约束会隐式对父表加S锁,若父表更新顺序与子表不一致(比如子表
order_items先于父表orders更新),立刻引入新锁序分支 -
INSERT ... ON DUPLICATE KEY UPDATE在唯一索引冲突时会加GAP锁+插入意向锁,两个事务同时命中同一UNIQUE字段,极易卡在锁升级阶段形成死锁
真正卡住系统的往往不是慢查询,而是那个在事务里没走索引的SELECT FOR UPDATE,或者没意识到外键触发了额外锁——这些细节不暴露在SQL里,却决定死锁是否发生。











