DEBUG.WIKI
V.PS:让项目靠近用户,亚太、欧洲、北美 KVM VPS
IPPeak:从目标地区访问网页,住宅代理、SEO 监测、广告验证
ZgoCloud:多地区部署,按需选节点;日本、美国、香港、德国

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,动作已经按现有外键生效。

先在隔离副本检查匹配子行、历史孤儿和新无效引用三种情况,再用实际约束名完成验证。正式变更前保留可恢复的数据与旧定义;恢复原外键定义只能影响后续操作,已经提交的级联删除需要从备份或其他恢复资料找回。