PostgreSQL 外键加了 NOT VALID,删除父行仍会级联删子行
NOT VALID外键仍会执行ON DELETE CASCADE;旧孤儿可暂留,新引用继续检查。先读约束定义,确认删除规则和历史数据处理。
把外键改成 ON DELETE CASCADE NOT VALID 后,还没执行 VALIDATE CONSTRAINT,删除父表记录就已经删掉了关联子记录。同时,子表中原有的孤儿记录可能仍然存在。
这两种结果可以同时发生。NOT VALID 跳过添加约束时对旧数据的验证,不会暂停外键的删除动作,也不会关闭后续写入检查。若业务希望在验证完成前保留子记录,不能把它当成级联删除的开关。
本文限定 PostgreSQL 18 的普通非分区表、正常启用检查的外键。复制会话设置、被禁用的约束触发器或其他特殊维护方式需要另外核对。
先读实际外键定义
示例子表为 public.posts,父表为 public.blogs。下面只查询,不修改数据:
SELECT current_database(), current_user;
SELECT conname,
convalidated,
conrelid::regclass AS child_table,
confrelid::regclass AS parent_table,
pg_get_constraintdef(oid) AS definition
FROM pg_constraint
WHERE conrelid = to_regclass('public.posts')
AND contype = 'f';
将示例名称替换为实际对象。定义中应能看到引用列、父表和 ON DELETE CASCADE;convalidated 为假时,旧数据仍未完成验证。没有查到对应约束,先检查数据库、schema 与迁移是否真正提交,不要根据应用里的建表代码判断现有结构。
还要看删除究竟发生在父表哪条记录上。级联针对引用这条父记录的子行,不是把整张子表清空;其他外键或自定义触发器也可能产生额外动作,需要逐项核对。
旧孤儿保留,不代表外键没有生效
假设子表已有两行,分别引用父表编号 1 和 2,而父表只存在编号 1。添加未验证的级联外键后,旧的编号 2 引用可以暂时留在表里。随后删除父表编号 1,引用它的子行会被级联删除;编号 2 的孤儿行则不会因此得到修复。
后续新增一条缺少对应父记录的引用,仍应被外键拒绝。执行历史验证时,留下的孤儿记录也会导致验证失败。不能看到旧孤儿还在,就认定可以任意写入新的无效引用。
对于示例中的单列外键,可以先只读查找历史孤儿:
SELECT p.id, p.blog_id
FROM public.posts AS p
WHERE p.blog_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM public.blogs AS b
WHERE b.id = p.blog_id
)
LIMIT 100;
此处排除 SQL NULL,适用于允许空引用的普通单列外键;复合键和不同匹配规则要按真实约束改写。返回的最多 100 行只用于辨认问题,既不是违规总数,也不是待删除清单。确认应恢复父记录、修正引用还是保留数据后,再安排数据处理和验证。
迁移时单独确认删除规则
把 NO ACTION 改成 CASCADE 会改变业务行为。添加 NOT VALID 可以分开历史扫描,却不能延迟这项行为变化。发布迁移前,应确认父记录删除入口、后台清理任务和依赖子记录的业务都接受新规则。
约束变更也并非没有锁。单独添加外键和把删除、添加约束合在一次 ALTER 中,所需锁可能不同;组合操作使用其中最严格的锁。要根据实际脚本、表大小和并发安排窗口,不要把“跳过历史扫描”解释为“不会阻塞”。
确实需要暂停某类父记录删除时,在相应业务流程中控制这项操作。关闭触发器或临时移除外键会同时改变完整性保护,不能作为避免意外级联的常规处理。
这与 TRUNCATE 不执行 ON DELETE SET NULL 是不同问题。这里执行的是父行 DELETE,动作已经按现有外键生效。
先在隔离副本检查匹配子行、历史孤儿和新无效引用三种情况,再用实际约束名完成验证。正式变更前保留可恢复的数据与旧定义;恢复原外键定义只能影响后续操作,已经提交的级联删除需要从备份或其他恢复资料找回。


