必须禁用ON UPDATE CASCADE和ON DELETE CASCADE,因其隐式加锁不可控:InnoDB会扫描子表并加X锁,无索引时触发全表扫描与表级IX锁,导致阻塞;须同步完成建索引、拆级联至应用层、删除外键三步。

必须禁用 ON UPDATE CASCADE 和 ON DELETE CASCADE,否则锁顺序失控是必然的,不是概率问题。
为什么外键级联会隐式加锁且无法控制
你执行 UPDATE parent SET id = 100 WHERE id = 1,InnoDB 不仅锁父表该行,还会扫描子表所有 parent_id = 1 的行并加 X 锁——这个过程不走你的 SQL 路径,也不受事务内语句顺序影响。更危险的是:如果子表 parent_id 列没索引,InnoDB 直接全表扫描 + 表级意向锁(IX),多个事务一碰就卡死,连死锁都不是,是阻塞。
常见现象包括:Deadlock found when trying to get lock、lock_mode X locks index `fk_idx` 出现在 SHOW ENGINE INNODB STATUS 中,但你的代码里根本没写那条子表语句。
必须立即做的三件事:索引、拆步、删外键
这三步缺一不可,只做其中一两个仍大概率复现问题:
- 为每个外键列单独建索引:
ALTER TABLE child_table ADD INDEX idx_parent_id (parent_id);。不能依赖联合索引前缀,InnoDB 不认它为“外键索引” - 把级联逻辑从数据库层移到应用层:先
SELECT id FROM child_table WHERE parent_id = ? FOR UPDATE查出 ID 列表,再UPDATE child_table SET parent_id = ? WHERE id IN (...)或DELETE FROM child_table WHERE id IN (...) - 删除外键约束:
ALTER TABLE child_table DROP FOREIGN KEY fk_name;。残留外键仍会触发完整性校验 S 锁,哪怕你禁用了级联
应用层实现时最容易踩的坑
显式两步看似简单,但漏掉以下任一点都会让死锁照常发生:
-
SELECT ... FOR UPDATE必须加,否则可能读到旧数据,导致子表更新遗漏;但不能用SELECT ... JOIN parent_table,会额外锁父表 - 整个操作必须包裹在同一个事务中:
BEGIN→SELECT ... FOR UPDATE→UPDATE/DELETE child→UPDATE/DELETE parent→COMMIT - 所有业务路径严格按「先子后父」顺序执行。推荐这个顺序,因为子表数据量通常更大,早释放更安全
- 检查 ORM 配置:Django 的
on_update=models.CASCADE、Rails 的dependent: :update会自动生成隐式级联 SQL,必须清理
隔离级别陷阱:RR 下 Next-Key Lock 会放大风险
MySQL 默认的 REPEATABLE READ 隔离级别会让问题更隐蔽:SELECT ... FOR UPDATE 在 RR 下可能加 Next-Key Lock(记录锁 + 间隙锁),导致并发插入时意外锁住空隙。若业务允许,可对该事务临时设为 READ COMMITTED:SET TRANSACTION ISOLATION LEVEL READ COMMITTED;。
真正难处理的不是锁本身,而是外键让锁变成“隐身”的——你查不到它在哪加的、什么时候加的、加了多久。只要外键还挂着,你就永远在和 InnoDB 的内部调度赛跑。


















