外键检查是每行变更前的原子加锁动作,非事后校验:insert/update/delete时先对父表对应行加s锁验证存在性,再操作子表,锁与校验不可分割;父子表强制“父→子”加锁顺序,但应用层乱序或子表无索引将引发死锁或全表扫描。

外键检查不是事后校验,而是每行变更前的原子加锁动作
MySQL 的外键约束检查从不等到语句执行完再统一验证。它嵌在 DML 每一行的写入路径中,且与加锁强绑定——查父表、加锁、插入/更新/删除,三者不可分割。
典型表现:INSERT INTO child (parent_id) VALUES (123) 不是先去父表 SELECT,再决定插不插;而是直接对父表中 id = 123 的行尝试加 S 锁。如果该行不存在、被其他事务 X 锁住、或锁等待超时,就立刻报错 Cannot add or update a child row: a foreign key constraint fails。
关键点在于:这个 S 锁不是“为了读而加”,而是“为了证明合法性而加”,且必须成功持有才能继续。所以哪怕父表只是被另一个事务 SELECT ... FOR UPDATE 锁住,当前 INSERT 也会阻塞。
父子表加锁顺序固定为「父 → 子」,但应用层乱序是死锁主因
InnoDB 强制按父表先行加锁,再操作子表。但这个顺序只在单条 DML 内部成立;跨语句、跨事务时,应用逻辑若没对齐,就会撞上死锁。
- 事务 A 执行
DELETE FROM parent WHERE id = 100:先对父行加 X 锁,再扫描子表找引用,对匹配的子行加 LOCK_S(用于检查)或 LOCK_X(如启用 CASCADE) - 事务 B 同时执行
INSERT INTO child (parent_id) VALUES (100):先对父表id = 100行加 S 锁,再插入子行 - 若 A 已持子表范围锁、B 已持父表 S 锁,双方等待对方释放,死锁立即触发
这不是概率问题,是确定性风险。ORM 自动生成的级联操作(如 Django 的 on_delete=models.CASCADE)会让这种交叉更隐蔽。
子表外键列没索引 → 全表扫描 + 行锁升级 ≈ 实际锁表
外键检查需要快速定位子表中所有 parent_id = ? 的行。如果子表没有以 parent_id 为最左前缀的索引,InnoDB 就只能全表扫描——逐行加锁、判断、再释放。过程中可能升级为表级意向锁(IX),导致其他事务连 SELECT 都被卡住。
常见误判:“父表有主键索引,子表应该没问题”。错。InnoDB 不复用父表索引,子表外键列必须自己建索引:
- ✅ 有效:
ALTER TABLE child ADD INDEX idx_fk_parent_id (parent_id) - ✅ 也有效:
ALTER TABLE child ADD INDEX idx_composite (parent_id, status)(parent_id在最左) - ❌ 无效:
ALTER TABLE child ADD INDEX idx_wrong (status, parent_id)(parent_id不是最左,InnoDB 视为不可用)
字符型外键还要额外核对字符集和 collation 是否完全一致,否则索引失效,等效于没建。
ON DELETE CASCADE 是隐式批量加锁,比手动删更危险
ON DELETE CASCADE 不是“删完父再删子”的两阶段操作,而是在父行 DELETE 过程中同步触发子行删除:父行加 X 锁后,InnoDB 立即用外键索引定位所有子行,逐条加 X 锁并删除。整个过程不可中断、不可分批。
这意味着:
- 子表数据量大时,父行 X 锁会长时间持有,阻塞所有访问该父记录的并发事务
- 没有批量优化,是逐行处理,容易触发
lock wait timeout exceeded - 子表外键列若无索引,直接全表扫描加 X 锁,性能雪崩
- 级联失败时不会部分回滚:父删了一半出错,已删的子行不会自动恢复(靠事务整体回滚兜底)
真正可控的做法是业务层分步:先 SELECT id FROM child WHERE parent_id = ? FOR UPDATE 拿到子 ID 列表,再批量删子表,最后删父表——锁粒度小、可监控、可重试。
外键加锁最易被忽略的一点:即使你禁用了 FOREIGN_KEY_CHECKS = 0,只要外键约束还存在,InnoDB 在 DML 中仍会走完整性校验路径,只是跳过报错。要彻底消除隐式锁,必须 DROP FOREIGN KEY。但删之前,得确保应用层已接管所有一致性逻辑。











