外键约束使意向锁成为死锁链中隐性环节,因其触发的隐式s锁需先获取is/ix锁,而外键列无索引时引发全表扫描与大量ix/x锁冲突,导致is/ix锁从协调者变为阻塞源。

INNODB 的意向锁本身不直接导致死锁,但外键约束会让意向锁成为死锁链中不可见的一环——真正的问题是外键触发的隐式加锁行为与事务顺序冲突叠加后,让 IS 和 IX 锁成了“锁等待放大器”。
为什么外键会让意向锁参与死锁?
外键检查不是凭空发生的:MySQL 在执行 UPDATE parent 或 INSERT INTO child 时,必须校验参照完整性,这会自动对关联记录加 S 锁(共享锁),而加 S 锁前,InnoDB 必须先获取父表或子表的 IS 锁。关键点在于:
-
IS锁之间兼容,但它和X锁不兼容——如果事务 A 正在等子表某行的X锁,而事务 B 持有该子表的IX锁(来自另一条更新),A 就会被卡在IS获取阶段 - 外键列没索引时,InnoDB 无法定位具体行,被迫全表扫描 → 触发大量
IX锁 + 行级X锁,把意向锁从“协调者”变成“阻塞源” -
SHOW ENGINE INNODB STATUS里看不到“ISlock wait”,只看到lock_mode X locks rec but not gap on table ...—— 意向锁的等待被折叠进实际行锁等待里,排查时容易漏掉根因
如何确认死锁链里有外键+意向锁参与?
别只盯着“Deadlock found”报错,重点看 SHOW ENGINE INNODB STATUS 输出中的三处信号:
- 等待链中出现两个事务分别持有
parent_table和child_table的X锁,且其中一个事务的前序语句是UPDATE parent—— 这大概率触发了外键隐式锁 - 日志里有类似
Trx has been waiting for 12 sec, waiting for lock on table `db`.`child_table`,但没提具体索引名 → 外键列缺失索引,InnoDB 正在等全表扫描完成 - 锁模式显示
lock_mode X locks index `PRIMARY`或lock_mode X locks index `fk_idx`,但fk_idx实际并不存在或不是独立索引 → 意向锁被迫升级为表级协调压力
必须立即执行的修复动作
光调隔离级别或重试事务没用,得切断外键隐式锁路径:
- 为每个外键列建**独立单列索引**:
ALTER TABLE child_table ADD INDEX idx_parent_id (parent_id);。联合索引如(parent_id, status)不算数,InnoDB 只认前缀匹配的“外键索引” - 禁用
ON UPDATE CASCADE和ON DELETE CASCADE:ALTER TABLE child_table DROP FOREIGN KEY fk_name;。残留外键即使不级联,仍会强制加S锁校验 - 把所有涉及外键变更的操作,拆成显式两步:先
SELECT id FROM child_table WHERE parent_id = ? FOR UPDATE,再UPDATE child_table SET parent_id = ? WHERE id IN (...)。整个过程必须包裹在同一个事务里 - 若业务允许,临时将该事务设为
READ COMMITTED:SET TRANSACTION ISOLATION LEVEL READ COMMITTED;。它能避免REPEATABLE READ下的间隙锁干扰,减少意向锁被拖住的概率
最容易被忽略的 ORM 坑
很多死锁根本不是手写 SQL 引起的,而是 ORM 自动生成的隐式外键操作:
- Django 的
on_update=models.CASCADE、Rails 的dependent: :update、Laravel 的->onDelete('cascade')都会在UPDATE parent时悄悄生成子表更新语句,绕过你的应用层控制 - 检查所有模型定义,把级联逻辑全部改为
PROTECT或RESTRICT,然后在 service 层手动实现先子后父的更新顺序 - ORM 的
bulk_update或update_or_create可能触发多次外键校验,比单条UPDATE更容易撞上意向锁瓶颈
UPDATE,InnoDB 背后却可能已锁住三张表的意向锁和数百行数据。真正要盯的不是锁类型,而是哪条 SQL 让 InnoDB “不得不”去查子表。











