外键约束本身不直接导致死锁,但其校验逻辑强制先对父表加s锁、再对子表加x锁,两阶段非原子加锁且顺序固定,易引发abba循环等待死锁;子表外键列无索引会触发全表扫描并锁所有行,大幅增加死锁概率;on update cascade会逐行处理并延长父表锁持有时间,进一步放大冲突。

外键约束本身不直接死锁,但它的校验逻辑会强制跨表加锁,且顺序固定、不可原子化——这才是死锁高发的根源。
外键 UPDATE 为什么会触发父表 S 锁 + 子表 X 锁
InnoDB 在执行 UPDATE child_table SET parent_id = ? WHERE id = ? 时,并不是只锁子表。它必须验证新 parent_id 在父表中真实存在,于是先对父表那行加 LOCK_S(共享锁),再对子表当前行加 LOCK_X(排他锁)。这两个动作有时间差,且顺序不可逆。
常见死锁链:
- 事务 A:
UPDATE child SET parent_id = 100→ 持有父表id=100的 S 锁,等待子表某行 X 锁 - 事务 B:
DELETE FROM parent WHERE id = 100→ 需要父表id=100的 X 锁(被 A 的 S 锁阻塞),同时扫描子表并尝试加 X 锁 → 等待 A 释放子表行
结果就是 A 等 B 释放父表锁,B 等 A 释放子表行 —— 典型 ABBA 循环等待。
子表外键列没索引 = 实际上的全表锁
如果子表的外键列(如 orders.user_id)没有索引,InnoDB 就无法快速定位哪些子行引用了父表某条记录。它只能全表扫描,并对每一行加隐式 S 锁(用于检查 DELETE 或 UPDATE 父表时是否还有子记录)。这等效于“锁住所有行”,极大增加与其他事务冲突概率。
验证方式:
- 执行
SHOW CREATE TABLE child_table\G,看外键列是否出现在任何KEY或INDEX中 - 查
information_schema.KEY_COLUMN_USAGE确认外键定义,再比对SHOW INDEX FROM child_table - 死锁日志里若出现大量
GEN_CLUST_INDEX锁且 page no 范围极大,基本可断定是全表扫描锁
建索引必须满足:外键列是索引的最左前缀。例如 CREATE INDEX idx_user_id ON orders(user_id) 有效;CREATE INDEX idx_status_user ON orders(status, user_id) 无效。
ON UPDATE CASCADE 是隐藏的死锁放大器
启用 ON UPDATE CASCADE 后,一次 UPDATE parent SET id = ? 会被 InnoDB 自动拆成多个子操作:先锁父表行,再逐行扫描子表、加锁、更新。这个过程无法批量优化,也无法被 EXPLAIN 观察,但锁行为完全暴露在外。
更危险的是,它让事务持有父表锁的时间显著拉长,期间任何想访问该父记录的事务(包括 SELECT ... FOR UPDATE)都会被阻塞。
替代做法:
- 禁用级联,改用应用层两步更新:先
SELECT id FROM child_table WHERE parent_id = ? FOR UPDATE,再UPDATE child_table SET parent_id = ? WHERE id IN (...) - 确保整个操作在同一个事务内完成,避免快照读漏数据
-
IN列表长度建议 ≤ 1000,否则优化器可能放弃索引
拆除外键前必须做的三件事
线上直接 ALTER TABLE DROP FOREIGN KEY 是高危操作:不校验现有数据,可能留下孤儿记录;MySQL 5.7 及之前还会锁表。
安全拆除路径:
- 确认业务是否真依赖该外键:搜代码库里的 ORM 配置、存储过程、迁移脚本,很多外键只是历史残留
- 手动校验参照完整性:
SELECT COUNT(*) FROM child_table c LEFT JOIN parent_table p ON c.parent_id = p.id WHERE p.id IS NULL,结果必须为 0 - 用在线 DDL 执行:
ALTER TABLE child_table DROP FOREIGN KEY fk_name, ALGORITHM=INPLACE, LOCK=NONE;若失败,降级为LOCK=SHARED(允许读,阻塞写)
拆除后,一致性不能靠“自觉”——必须由应用层显式校验(如写入前 SELECT ... FOR UPDATE),或引入异步稽核任务定时扫孤儿数据。这点最容易被忽略,也最常出问题。











