给 PostgreSQL 现有表添加外键,既是 schema 变更,也是对历史数据作出声明。新写入可以立即受到检查,而旧行仍可能保留失效引用。规划迁移或排查完整性错误时,应分别考虑约束是否执行、历史数据是否验证,以及检查发生在什么时候,不能把这三个状态混成“外键已经存在”。

执行约束不等于验证历史数据

普通启用的外键要求适用的非空引用能匹配父键。被引用列需要符合条件的唯一性,例如适当的非延迟主键、唯一约束或非部分唯一索引。子列如果没有另外声明 NOT NULL,仍然允许空值;外键本身不会把所有关系变成必填。复合引用默认使用 MATCH SIMPLE,只要一个引用列为空就不要求匹配;MATCH FULL 允许全部为空,却拒绝部分为空、部分非空的组合。

ADD CONSTRAINT … NOT VALID 跳过对现有行的初次扫描,但仍会检查新建或改变的引用,因此可以在调查旧数据时阻止新增孤儿引用。NOT VALID 并不表示规则被禁用。与此同时,数据库还不能假定所有历史行都满足该约束,迁移也不能仅凭添加语句成功就宣告数据已经完整。

VALIDATE CONSTRAINT 扫描现有数据,检查成功后才把约束标为已验证。如果仍有历史孤儿行,验证会失败,约束继续处于未验证状态。应依据权威业务关系修复数据,再次验证。为了让检查变绿而编造父记录或删除子记录,即使得到结构一致的数据库,也可能破坏真实业务事实;修复依据不能只来自外键错误本身。

把部署阶段分开处理

添加外键即使使用 NOT VALID,也会锁定引用表和被引用表,不能把它描述成完全无锁的部署方式。其价值是在添加阶段持有锁时,避免执行可能很长的历史数据扫描。VALIDATE 使用允许普通数据修改的较弱锁,但仍消耗资源,并与其他 schema 操作进行协调,所以也需要观察执行耗时和实际争用。

如果迁移目标是缩短第一个锁窗口,应先提交短暂的添加阶段,再把验证作为后续操作执行。把两步包在同一个长事务中,会让早先取得的锁一直保留到事务结束,可能抵消分阶段的收益。下面示例故意把步骤放在一个事务里,只是为了最后回滚清理整个测试环境,并不是生产锁窗口的部署模板。

PostgreSQL 不会自动给子表引用列建立索引。父键的唯一性已有索引,但更新或删除父行时,仍可能需要查找子表引用。应先检查子表已有索引,再在实际负载需要时补上适合的访问路径。声明了外键,不代表优化器一定消除关联,也不保证大范围级联删除会很快。

什么时候延迟检查

DEFERRABLE 外键可以把检查推迟到事务结束。INITIALLY DEFERRED 表示初始就采用延迟模式;SET CONSTRAINTS 可以把适用的约束切回立即检查,并检查此前尚未确认的工作。这使同一事务中先插子行、再补父行的顺序成为可能,适合需要在最终提交点保证完整性的批量操作。

延迟并不允许提交未解决的失效引用,它改变的是检查边界,而不是最终不变量。NO ACTION 可以配合延迟检查,RESTRICT 的删除限制不能延迟。级联动作也不会仅因为约束可延迟就一并推后。应一起选择动作与检查模式,不能把 DEFERRABLE 理解为临时忽略完整性而无需满足结束条件的许可。

坏掉的样子

添加语句成功后,迁移看似完成,但 convalidated 可能仍为假。因此,成功验证必须是独立的完成条件。反过来,如果高权限操作绕过执行,目录中的标记本身也不能证明数据完整。历史导入和复制流程应按实际执行的检查评估,不能只看最后留下了什么约束名字。

约束报错也可能说明正确的策略正在生效。应先定位缺少的父键和事务操作顺序,再决定是否真的需要修改规则。父子操作可能等待其他事务;为了避免等待而绕过检查,会把协调问题变成数据损坏风险,无法从根本上修复原来的调用流程。

快速验证

把下面内容保存为 foreign-key-validation.sql,在可丢弃的 PostgreSQL 18 数据库中执行 psql -X -v ON_ERROR_STOP=1 -f foreign-key-validation.sql。所有表都是临时表,末尾回滚撤销全部变更。

\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE customers (id integer PRIMARY KEY);
CREATE TEMP TABLE invoices (id integer PRIMARY KEY,customer_id integer);
INSERT INTO customers VALUES (1);
INSERT INTO invoices VALUES (10,1),(11,99);
ALTER TABLE invoices ADD CONSTRAINT invoices_customer_fk
FOREIGN KEY (customer_id) REFERENCES customers NOT VALID;
SELECT convalidated FROM pg_constraint
WHERE conrelid='invoices'::regclass AND conname='invoices_customer_fk';
DO $$ BEGIN
  INSERT INTO invoices VALUES (12,99);
  RAISE EXCEPTION 'new orphan accepted';
EXCEPTION WHEN foreign_key_violation THEN RAISE NOTICE 'NOT VALID still checks new references';
END $$;
DO $$ BEGIN
  ALTER TABLE invoices VALIDATE CONSTRAINT invoices_customer_fk;
  RAISE EXCEPTION 'old orphan was not detected';
EXCEPTION WHEN foreign_key_violation THEN RAISE NOTICE 'validation found the old orphan';
END $$;
INSERT INTO customers VALUES (99);
ALTER TABLE invoices VALIDATE CONSTRAINT invoices_customer_fk;
SELECT convalidated FROM pg_constraint
WHERE conrelid='invoices'::regclass AND conname='invoices_customer_fk';
SELECT indexrelid::regclass AS child_indexes FROM pg_index WHERE indrelid='invoices'::regclass;
CREATE INDEX invoices_customer_lookup ON invoices (customer_id);
CREATE TEMP TABLE deferred_child (
  id integer PRIMARY KEY,
  customer_id integer REFERENCES customers DEFERRABLE INITIALLY DEFERRED
);
INSERT INTO deferred_child VALUES (20,7);
INSERT INTO customers VALUES (7);
SET CONSTRAINTS ALL IMMEDIATE;
DO $$ BEGIN
  IF (SELECT convalidated FROM pg_constraint
      WHERE conrelid='invoices'::regclass AND conname='invoices_customer_fk') IS DISTINCT FROM true THEN
    RAISE EXCEPTION 'constraint not validated';
  END IF;
  IF EXISTS (SELECT FROM invoices i LEFT JOIN customers c ON c.id=i.customer_id
             WHERE i.customer_id IS NOT NULL AND c.id IS NULL) THEN
    RAISE EXCEPTION 'orphan remains';
  END IF;
  IF (SELECT customer_id FROM deferred_child WHERE id=20) IS DISTINCT FROM 7 THEN
    RAISE EXCEPTION 'deferred insertion failed';
  END IF;
END $$;
ROLLBACK;

PostgreSQL 18.6 在添加后显示 convalidated 为假。新建一个指向缺失客户九十九的引用会失败,随后验证也独立发现原有孤儿行。恢复实验里已知缺失的父记录后,验证才能把标记变成真。索引列表最初只有 invoices_pkey,引用列的索引需要显式创建,不能从外键声明推断已经自动生成。

延迟检查示例先插入子行二十,再创建客户七,然后成功切换成立即检查。最终断言确认约束已验证、发票没有残留孤儿引用,并且延迟建立的关系正确。这些检查覆盖数据修复与验证的完整路径,而不只是证明一条约束声明可以成功执行。

来源