结论

  1. 外键不是两行之间的物理指针。 子表仍只保存普通列值。真正的“联系”是 pg_constraint 中的一条规则、父端唯一索引,以及 PostgreSQL 自动生成的 4 个内部约束触发器。DML 发生时,触发器拼出普通 SQL,经 SPI 执行查询、更新或删除。
  2. 子端写入会锁父行。 合法的子表 INSERT/UPDATE 会在父表执行 SELECT ... FOR KEY SHARE。这既验证父键存在,又把父键锁到事务结束,避免“刚检查完父行就被另一事务删掉”的 TOCTOU 漏洞。
  3. 这不意味着所有写都互相阻塞。 多个子事务引用同一父行时,KEY SHARE 彼此兼容;父表非键列更新采用较弱的 NO KEY UPDATE,也与之兼容。但父行删除、父键更新需要更强的行锁,会等待或冲突。
  4. CASCADE/SET NULL/SET DEFAULT 是真实的子表 DML。 它们会锁、更新或删除子行,会与业务对子行的并发写冲突,也会形成死锁。本文实测出一次由两个级联删除与相反子行持锁顺序构成的 40P01 deadlock detected。
  5. 外键不会自动给子端建索引。 父端必须已有非部分、非表达式、立即唯一的索引;子端索引需要手工创建。没有子端索引,删除/改父键可能为每个父行扫描整张子表。
  6. 外键通常降低写吞吐,不直接让普通查询“免 JOIN”。 本文 10 万行单语句插入从 87.815 ms 增至 328.902 ms。不过规划器会利用 FK 语义改善连接基数估计:复合连接的估计从 10 行修正为 10000 行(实际也是 10000),所以有时能间接选到更好的执行计划。PG17 仍执行了 Hash Join,没有因为 FK 消除内连接。

可以把它记成一句话:

外键是“目录规则 + 父端唯一索引 + 行级约束触发器 + MVCC 行锁”,不是“存储层指针”;它用额外查询、锁和可能的级联写,换取并发下也成立的引用完整性。

1. 分析基线与证据等级

下面示例统一使用:

CREATE TABLE parent (
    id   integer PRIMARY KEY,
    note text
);

CREATE TABLE child (
    id        integer PRIMARY KEY,
    parent_id integer,
    payload   text,
    CONSTRAINT child_parent_fk
        FOREIGN KEY (parent_id) REFERENCES parent(id)
);

2.1 创建外键时发生什么

创建阶段不是只在内存中登记一个名字,而是完成这些动作:

FOREIGN KEY DDL
  -> 解析子端列、父端列、MATCH 和 ON UPDATE/DELETE
  -> 在父表寻找可用的立即唯一索引
  -> 校验列数、数据类型和等号运算符
  -> 写 pg_constraint
  -> 父表:建 AFTER DELETE、AFTER UPDATE 动作触发器
  -> 子表:建 AFTER INSERT、AFTER UPDATE 检查触发器
  -> 默认扫描已有子行进行验证;NOT VALID 则暂时跳过旧数据

关键规则如下:

  • 省略父列清单时引用父表主键。
  • 显式引用的父列必须整体匹配一个唯一索引。源码要求键列数相同、indisunique、indisvalid,并拒绝部分索引和表达式索引,见 transformFkeyCheckAttrs。
  • 父端唯一约束不能是 DEFERRABLE。源码检查 indimmediate,否则报 cannot use a deferrable unique constraint,见 tablecmds.c:12137。FK 自己可以延迟,父端唯一性不可以延迟。
  • 当前可作为 FK 父键的唯一索引是 B-tree;等值运算符和必要的隐式类型转换会在创建时解析,见 ATAddForeignKeyConstraint。
  • 创建 FK 在被 ALTER 的表上取 ShareRowExclusiveLock,并在父表也取同级锁;该锁与普通 DML 的 RowExclusiveLock 冲突,因此直接 ADD FOREIGN KEY 会阻塞并发写,见 AlterTableGetLockLevel 和 ATAddForeignKeyConstraint。

实测:父端没有唯一索引时创建失败;补父端唯一索引后成功;子端仍没有索引。

ERROR: there is no unique constraint matching given keys for referenced table "prereq_parent"
CREATE UNIQUE INDEX prereq_parent_id_uq ON prereq_parent(id);
ALTER TABLE prereq_child ... FOREIGN KEY ...;  -- 成功

SELECT ... FROM pg_indexes WHERE tablename='prereq_child';
(0 rows)                                        -- 没有自动创建子端索引

这正是最常见的误区:父端索引是正确性前提,子端索引是通常必需的性能设计,但 PostgreSQL 不替你创建。

2.2 子表数据变化的效果

子端行为结果是否查父表主要锁/代价
插入非 NULL FK父键存在才成功是父表索引点查 + 父行 KEY SHARE
把 FK 更新为新值新父键存在才成功是同上
只更新非 FK 列正常成功通常可跳过不做 RI 点查;仍有普通 UPDATE 代价
删除子行直接删除否只解除一条引用
MATCH SIMPLE 下任一 FK 列为 NULL直接通过否默认行为
MATCH FULL 下全部为 NULL通过否表示“不引用”
MATCH FULL 下部分为 NULL报错否不允许半个复合引用

实测错误:

INSERT INTO child VALUES (11,999,'bad');
ERROR: insert or update on table "child" violates foreign key constraint "child_parent_fk"
DETAIL: Key (parent_id)=(999) is not present in table "parent".

只改 payload 成功。源码还会比较 FK 旧值和新值;如果键未变,不把 RI 事件加入 AFTER trigger 队列,见 RI_FKey_fk_upd_check_required 及调用点 AfterTriggerSaveEvent。

复合键与 NULL

实测 MATCH SIMPLE 接受以下四行:

(NULL, 999)
(999, NULL)
(NULL, NULL)
(1, 1)       -- 父表有 (1,1)

原因不是 PostgreSQL 去父表找到了 (NULL, 999),而是 MATCH SIMPLE 只要任一列为 NULL 就放弃整组匹配。MATCH FULL 只允许“全 NULL”或“全非 NULL”;(NULL,999) 实测报:

DETAIL: MATCH FULL does not allow mixing of null and nonnull key values.

PG17 语法层直接拒绝 MATCH PARTIAL,实测和 gram.y:4332-4342 均为 MATCH PARTIAL not yet implemented。

2.3 父表数据变化的效果

父端行为默认/动作结果
插入父行无动作触发器可使同事务内延迟检查的子行最终合法
更新非键列无引用动作父键没变,RI 事件可跳过
把键更新成相等值无引用动作源码比较旧、新键后跳过
更新被引用键ON UPDATE ...NO ACTION/RESTRICT 拒绝,或 CASCADE/SET 行为
删除被引用行ON DELETE ...NO ACTION/RESTRICT 拒绝,或 CASCADE/SET 行为
TRUNCATE不等价于逐行 DELETE不触发 ON DELETE CASCADE;需同时截断子表或 TRUNCATE ... CASCADE

默认 NO ACTION 的实测:父表 DELETE id=1 与 UPDATE id=9 WHERE id=1 都因子表仍有 parent_id=1 而失败,父、子数据均保持原样。

父表非键列更新为什么能跳过?RI_FKey_pk_upd_check_required 明确比较父键旧值和新值;相等就返回 false。更底层的 heap update 也会判断更新列是否碰到可作为键的唯一索引列,未碰到则选择 LockTupleNoKeyExclusive,见 heapam.c:3388-3420。

2.4 五种引用动作

动作父键 DELETE/UPDATE 时做什么可否延迟常见风险
NO ACTION检查是否仍有子行;默认动作FK 可声明 DEFERRABLE立即模式通常与 RESTRICT 观感相同
RESTRICT发现引用立即拒绝,并且不接受“同键替代父行”父端动作不可延迟数据调整顺序更严格
CASCADE对匹配子行执行真实 DELETE/UPDATE父端动作不可延迟写放大、长锁、递归级联、死锁
SET NULL对匹配子行执行 UPDATE SET … = NULL父端动作不可延迟子列 NOT NULL 或 MATCH FULL 可使动作失败
SET DEFAULT对匹配子行执行 UPDATE SET … = DEFAULT,再复查 FK父端动作不可延迟默认值没有父行时整个语句失败

源码创建动作触发器时,只有 NO ACTION 继承 FK 的 deferrable/initdeferred;其余四类均强制为非延迟,见 createForeignKeyActionTriggers。子端 INSERT/UPDATE 检查触发器则继承 FK 的延迟属性,见 CreateFKCheckTrigger。

CASCADE 实例
CREATE TABLE c_cas(
    id  integer PRIMARY KEY,
    pid integer REFERENCES p_cas(id)
                ON UPDATE CASCADE ON DELETE CASCADE
);

实测父键 1 -> 10 后,两条子行的 pid 都变为 10;删除父键 10 后,子表行数变成 0。整个过程不是改一个隐藏指针,而是触发器实际执行:

UPDATE ONLY child SET pid = $new WHERE $old = pid;
DELETE FROM ONLY child WHERE $old = pid;
SET NULL 的 PG17 列清单

PG17 允许 ON DELETE SET NULL(column_list) 只清复合 FK 的一部分。例如:

FOREIGN KEY (tenant, author) REFERENCES p_sn(tenant,id)
MATCH SIMPLE ON DELETE SET NULL(author)

删除父行 (7,1) 后,子行由 (tenant=7, author=1) 变为 (7,NULL)。它能通过是因为 MATCH SIMPLE 遇到任一 NULL 即不再引用;若搭配 MATCH FULL,这种部分 NULL 会失败。源码只对 DELETE 读取 confdelsetcols,UPDATE 时始终处理全部 FK 列,见 ri_set。

SET DEFAULT 仍然受 FK 约束

实测默认父键 0 存在时,删除父键 1 会把子行改成 pid=0。随后试图删除父键 0 失败,父行和子行都回滚保留。源码在 SET DEFAULT UPDATE 后主动再走一次 NO ACTION 检查,正是为了处理“默认值仍引用刚被删父键”的情况,见 ri_triggers.c:1192-1209。

2.5 DEFERRABLE:检查时点,而不是降低正确性

FOREIGN KEY(pid) REFERENCES p_def(id)
DEFERRABLE INITIALLY DEFERRED

以下顺序实测成功:

BEGIN;
INSERT INTO c_def VALUES (1,42); -- 此刻父行还没有
INSERT INTO p_def VALUES (42);
COMMIT;                          -- 提交前检查,最终状态合法

只插子行 (2,404) 而不补父行,错误出现在 COMMIT,整笔事务回滚。延迟事件在普通语句结束时被移入事务级队列,提交前由 CommitTransaction -> AfterTriggerFireDeferred 触发,见 xact.c:2200-2213 和 AfterTriggerFireDeferred。

NO ACTION 与 RESTRICT 的实质差异也在这里显现:

-- NO ACTION + DEFERRED:成功
BEGIN;
DELETE FROM p_na WHERE id=1;
INSERT INTO p_na VALUES(1);  -- 用同键的新父行替代旧行
COMMIT;

-- RESTRICT:DELETE 当场失败,来不及插入替代行

ri_restrict(..., is_no_action=true) 会先调用 ri_Check_Pk_Match,允许 NO ACTION 看最终是否已有同键父行;RESTRICT 则跳过这一步,见 ri_restrict。

延迟队列保存的是行事件,但不会把已经失效的旧 tuple 版本误判为违规。RI_FKey_check 用 SnapshotSelf 检查该版本是否仍存活:行后来被删掉就跳过;行后来被更新,则旧事件跳过并由最新版本的 UPDATE 事件负责。实测以下两笔都能提交:先插入非法 pid=999 后改成合法 pid=42,以及先插非法行后在提交前删除。对应源码见 ri_triggers.c:256-265。

2.6 NOT VALID:只宽限旧数据,不宽限新数据

实测流程:

  1. 子表先放入合法 (1,1) 和非法 (2,999)。
  2. ADD CONSTRAINT ... NOT VALID 成功,pg_constraint.convalidated=false。
  3. 新插入非法 (3,888) 仍立即失败,因为检查触发器已经安装。
  4. VALIDATE CONSTRAINT 因旧的 (2,999) 失败。
  5. 删除旧坏行后再次 VALIDATE 成功,convalidated=true。

默认初始验证优先生成一条 LEFT JOIN 找孤儿行,并临时使用 maintenance_work_mem;权限或 RLS 条件不允许时,退化为扫描每条子记录并模拟调用 RI_FKey_check_ins,见 RI_Initial_Check 和 validateForeignKeyConstraint。

运维上推荐大表采用:

先 CREATE INDEX CONCURRENTLY(子端)
-> ADD CONSTRAINT ... NOT VALID(安装触发器,避免长时间旧数据验证)
-> 清理孤儿数据
-> VALIDATE CONSTRAINT(PG17 在子表取 ShareUpdateExclusiveLock,可与普通 RowExclusive DML 并存)

注意:PG17 不支持在“引用端为分区表”时新增 NOT VALID 外键,源码直接报 feature not supported,见 tablecmds.c:9643-9657。

2.7 TRUNCATE、分区和超级用户绕过

TRUNCATE

TRUNCATE 为避免逐行扫描,不走 DELETE 行触发器。即使 FK 声明 ON DELETE CASCADE,单独 TRUNCATE parent 仍实测失败:

ERROR: cannot truncate a table referenced in a foreign key constraint
HINT: Truncate table "c_tr" at the same time, or use TRUNCATE ... CASCADE.

TRUNCATE parent, child 成功。源码会扫描 pg_constraint 检查是否有未包含在本次命令里的引用表,见 heap_truncate_check_FKs。

分区表

PG17 支持外键两端为分区表。创建时:

  • 被引用端的每个叶分区获得动作触发器和相应 pg_constraint 子项;
  • 引用端的每个叶分区获得 INSERT/UPDATE 检查触发器;
  • 跨分区 UPDATE 在触发器队列里有专门处理,避免把“移动分区”误当成 DELETE 后执行错误的 ON DELETE 动作。

入口和递归见 addFkRecurseReferenced、addFkRecurseReferencing 以及 AfterTriggerSaveEvent。本文只做了源码追踪,未做分区大表运行时压测。

超级用户可绕过,证明它不是存储层硬指针

内部 FK 触发器默认 tgenabled='O',即 origin 模式触发。超级用户把 session_replication_role=replica 后,origin 触发器不执行。实测在该模式插入了孤儿 (1,999);恢复 origin 后,新的孤儿 (2,888) 再次被拒绝。

源码判断见 TriggerEnabled;此 GUC 是 PGC_SUSET,见 guc_tables.c:4971。同理,超级用户 DISABLE TRIGGER ALL 也可能破坏完整性。它们是复制/维护工具,不应成为业务绕过方式;绕过后必须自行校验数据。

3. 原理:从目录到触发器,再到 SPI SQL

3.1 存的不是关系边,而是一条约束元数据

pg_constraint 的 FK 行保存:

conrelid        子表 OID
confrelid       父表 OID
conkey[]        子端列号
confkey[]       父端列号
conindid        父端支撑唯一索引 OID
conpfeqop[]     父值 = 子值 的运算符
conppeqop[]     父值 = 父值 的运算符
conffeqop[]     子值 = 子值 的运算符
confupdtype     ON UPDATE 动作
confdeltype     ON DELETE 动作
confmatchtype   MATCH 类型
condeferrable / condeferred / convalidated

字段定义见 pg_constraint.h:31-145,写入点见 addFkConstraint。子表 heap tuple 里只有 parent_id 数值,并没有父行 ctid、page id 或对象指针。

物理关系可画成:

child heap tuple                           parent heap tuple
┌────┬───────────┬─────────┐              ┌────┬──────┐
│ id │ parent_id │ payload │              │ id │ note │
└────┴───────────┴─────────┘              └────┴──────┘
          │                                    ▲
          │ 值相等规则,不是磁盘指针             │
          ├── pg_constraint:列号、运算符、动作  ┤
          ├── pg_trigger:4 个内部触发器         │
          └── SPI SQL + parent unique B-tree ───┘

因此 VACUUM 移动/清理版本、heap tuple 的 ctid 变化、Neon 按 LSN 重建页面,都不需要修改一个所谓“FK 指针”。只要值、索引和触发器语义保持一致即可。

3.2 为什么是四个触发器

实测一个默认 FK 生成:

on_table  function                 timing/event
parent    RI_FKey_noaction_del     AFTER DELETE
parent    RI_FKey_noaction_upd     AFTER UPDATE
child     RI_FKey_check_ins        AFTER INSERT
child     RI_FKey_check_upd        AFTER UPDATE

四者均为 FOR EACH ROW 的内部 constraint trigger。动作不同只会替换父端两个函数,例如 CASCADE 使用 RI_FKey_cascade_del/upd。

下面是创建父端动作触发器的原始逻辑摘录,中文注释为本文添加:

源码: tablecmds.c:12413-12451,createForeignKeyActionTriggers

fk_trigger = makeNode(CreateTrigStmt);
fk_trigger->isconstraint = true;       // 中文注释:这是约束触发器
fk_trigger->row = true;                // 中文注释:每个父行一个事件
fk_trigger->timing = TRIGGER_TYPE_AFTER;
fk_trigger->events = TRIGGER_TYPE_DELETE;

switch (fkconstraint->fk_del_action)
{
    case FKCONSTR_ACTION_NOACTION:
        fk_trigger->deferrable = fkconstraint->deferrable;
        fk_trigger->initdeferred = fkconstraint->initdeferred;
        fk_trigger->funcname = SystemFuncName("RI_FKey_noaction_del");
        break;
    case FKCONSTR_ACTION_RESTRICT:
        fk_trigger->deferrable = false; // 中文注释:RESTRICT 强制立即
        fk_trigger->initdeferred = false;
        fk_trigger->funcname = SystemFuncName("RI_FKey_restrict_del");
        break;
    case FKCONSTR_ACTION_CASCADE:
        fk_trigger->deferrable = false; // 中文注释:级联动作也不延迟
        fk_trigger->initdeferred = false;
        fk_trigger->funcname = SystemFuncName("RI_FKey_cascade_del");
        break;
}

省略项: SET NULL、SET DEFAULT、错误分支、UPDATE 触发器的同构代码、分区父触发器关系。

3.3 DML 到 RI 函数的真实调用链

子表 INSERT 的代表性调用链:

ModifyTable / ExecInsert
  -> ExecARInsertTriggers                       commands/trigger.c:2533
  -> AfterTriggerSaveEvent                      commands/trigger.c:6060
  -> AfterTriggerEndQuery                       commands/trigger.c:5037
  -> afterTriggerInvokeEvents                   commands/trigger.c:4626
  -> AfterTriggerExecute                        commands/trigger.c:4267
  -> ExecCallTriggerFunc
  -> RI_FKey_check_ins
  -> RI_FKey_check                              utils/adt/ri_triggers.c:239
  -> ri_FetchPreparedPlan / ri_PlanCheck
  -> SPI_execute_snapshot                       utils/adt/ri_triggers.c:2411
  -> parent unique index scan + FOR KEY SHARE
  -> heap_lock_tuple

INSERT 完成 heap/index 写入后,ExecARInsertTriggers 把事件交给 AFTER trigger 队列;普通立即事件在语句结束时由 AfterTriggerEndQuery 执行,延迟事件移入事务队列。触发器内部再用 SPI 执行普通计划,所以它会经过正常解析、规划、索引选择、执行器和 MVCC/锁层。

忠实简化后的伪代码如下:

on_child_insert_or_fk_update(new_row):
    if deferred_event_row_version_is_obsolete(new_row):
        return OK
    null_state = classify_fk_nulls(new_row)
    if null_state == ALL_NULL or (MATCH_SIMPLE and null_state == SOME_NULL):
        return OK
    if MATCH_FULL and null_state == SOME_NULL:
        raise FK_VIOLATION
    parent = index_lookup_parent(new_row.fk) FOR KEY SHARE
    if parent not found:
        raise FK_VIOLATION
    return OK

on_parent_delete_or_key_update(old_parent, action):
    if key_did_not_change:
        return OK
    if action in {NO_ACTION, RESTRICT}:
        if NO_ACTION and replacement_parent_with_same_key_exists:
            return OK
        if first_referencing_child_exists FOR KEY SHARE:
            raise FK_VIOLATION
    if action == CASCADE:
        DELETE/UPDATE all matching child rows
    if action in {SET_NULL, SET_DEFAULT}:
        UPDATE all matching child rows; recheck resulting constraints
  • 输入/输出:输入是触发器保存的 OLD/NEW tuple 版本和 FK 元数据;成功无返回数据,失败抛 SQL 错误并回滚语句/事务。
  • 不变量:立即检查在语句返回前成立;延迟检查在提交前最终成立。
  • 失败点:父键缺失、子引用仍存在、级联下游约束失败、锁超时、死锁或序列化冲突。
  • 复杂度:子写通常为一次父唯一索引点查;父写是否退化为子表全扫取决于子端索引。
  • 并发关系:存在性检查必须与 KEY SHARE 同时完成;否则“先查后锁”会留下竞态窗口。

3.4 子端检查为什么同时“查到”并“锁住”父行

源码: ri_triggers.c:359-407,RI_FKey_check

/* 构造的核心 SQL:
 * SELECT 1 FROM ONLY parent x
 * WHERE pk_col = $1 [AND ...]
 * FOR KEY SHARE OF x
 */
appendStringInfo(&querybuf, "SELECT 1 FROM %s%s x", pk_only, pkrelname);
for (int i = 0; i < riinfo->nkeys; i++)
{
    // 中文注释:使用创建 FK 时记录的父=子等号运算符
    ri_GenerateQual(&querybuf, querysep,
                    attname, pk_type,
                    riinfo->pf_eq_oprs[i],
                    paramname, fk_type);
}
appendStringInfoString(&querybuf, " FOR KEY SHARE OF x");

// 中文注释:执行计划;查不到时报告 FK violation
ri_PerformCheck(riinfo, &qkey, qplan,
                fk_rel, pk_rel, NULL, newslot,
                pk_rel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE,
                SPI_OK_SELECT);

实测用 auto_explain.log_nested_statements=on 捕获到内部真实计划:

Query Text: SELECT 1 FROM ONLY "fk_lab"."parent" x
            WHERE "id" OPERATOR(pg_catalog.=) $1 FOR KEY SHARE OF x
Query Parameters: $1 = '2'
LockRows  (actual time=0.013..0.014 rows=1)
  Buffers: shared hit=3
  -> Index Scan using parent_pkey on parent x
       Index Cond: (id = 2)

这里 FOR KEY SHARE 是正确性的核心:只做一个普通 MVCC SELECT 会存在竞态——事务 A 查到父行后,事务 B 可以在 A 插子行前删掉父行。KEY SHARE 把“存在性”保护到 A 事务结束;B 的删除/改键必须等待 A,然后重新检查。

3.5 NULL 快路径

源码: ri_triggers.c:276-340,RI_FKey_check

switch (ri_NullCheck(RelationGetDescr(fk_rel), newslot, riinfo, false))
{
    case RI_KEYS_ALL_NULL:
        return NULL;                    // 中文注释:全 NULL 对所有 MATCH 都通过

    case RI_KEYS_SOME_NULL:
        switch (riinfo->confmatchtype)
        {
            case FKCONSTR_MATCH_FULL:
                ereport(ERROR, ...);    // 中文注释:FULL 禁止部分 NULL
            case FKCONSTR_MATCH_SIMPLE:
                return NULL;            // 中文注释:SIMPLE 任一 NULL 即通过
        }

    case RI_KEYS_NONE_NULL:
        break;                          // 中文注释:完整键继续查父表
}

ri_NullCheck 本身只是逐列判断并返回 ALL/NONE/SOME,见 ri_triggers.c:2628-2664。因此 NULL 快路径是 O(k),不会访问父索引;k 为 FK 列数。

一个细节是,RI_FKey_check 在判 NULL 前已经用 RowShareLock 打开父关系,所以“不会访问父索引/父行”不等于“完全不取得父表级锁”;该表锁很弱,主要与强 DDL 锁冲突。

3.6 父端 NO ACTION / RESTRICT 查询

父行删除或改键后,触发器构造:

SELECT 1
FROM ONLY child x
WHERE $old_parent_key = x.parent_id
FOR KEY SHARE OF x
LIMIT 1;       -- SPI 层只需要知道是否存在一条

源码构造点见 ri_restrict,ri_PerformCheck 在 SELECT 型检查中把 limit=1,见 ri_triggers.c:2396-2402。这解释了两件事:

  • 有匹配子行时,找到第一条即可失败;
  • 没有子端索引且恰好没有匹配时,必须扫描完整子表,是最坏情况。

NO ACTION 还会先在父表查同键替代行;RESTRICT 不查,相关源码见 ri_Check_Pk_Match。

3.7 CASCADE / SET 是参数化 DML,不是特殊存储指令

源码: RI_FKey_cascade_del

// 中文注释:因为接下来真的 DELETE 子表,所以拿 RowExclusiveLock
fk_rel = table_open(riinfo->fk_relid, RowExclusiveLock);

/* 构造:DELETE FROM ONLY child WHERE $1 = fk_col [AND ...] */
appendStringInfo(&querybuf, "DELETE FROM %s%s", fk_only, fkrelname);
...

// 中文注释:limit=0,影响所有匹配子行;下游子表触发器也会正常执行
ri_PerformCheck(riinfo, &qkey, qplan,
                fk_rel, pk_rel, oldslot, NULL,
                true, SPI_OK_DELETE);

CASCADE UPDATE 构造 UPDATE child SET fk=$new WHERE fk=$old,见 RI_FKey_cascade_upd;SET 动作构造 UPDATE child SET fk=NULL|DEFAULT WHERE fk=$old,见 ri_set。这些 SPI DML 可以继续触发子表上的其他索引、CHECK、FK 和用户触发器,形成级联链。

3.8 计划缓存与权限

每个 backend 用 RI_QueryKey 缓存已准备的 SPI plan。第一次 SPI_prepare + SPI_keepplan,之后命中私有哈希表;表/列重命名等造成 plan invalid 时重新生成 SQL,见 ri_PlanCheck 和 ri_FetchPreparedPlan。这减少了重复解析/规划成本,但没有把 10 万行检查合并成一次批量查找——实测仍是 10 万次行触发器调用。

RI 查询切换成目标表 owner 执行,并设置 SECURITY_NOFORCE_RLS,见 ri_PlanCheck。因此普通用户即使没有直接 SELECT 父表权限,约束仍能正确检查;错误详情还会按列权限/RLS 决定是否显示具体键值,避免信息泄漏。

3.9 快照:如何防止并发新行从检查中漏掉

源码: ri_PerformCheck

if (IsolationUsesXactSnapshot() && detectNewRows)
{
    CommandCounterIncrement();
    test_snapshot = GetLatestSnapshot();       // 中文注释:用最新快照发现并发新行
    crosscheck_snapshot = GetTransactionSnapshot();
                                             // 中文注释:同时对照事务快照
}
else
{
    test_snapshot = InvalidSnapshot;          // 中文注释:READ COMMITTED 用常规最新行为
    crosscheck_snapshot = InvalidSnapshot;
}

spi_result = SPI_execute_snapshot(qplan, vals, nulls,
                                  test_snapshot, crosscheck_snapshot,
                                  false, false, limit);

在 READ COMMITTED 下,RI 查询使用足够新的常规快照;在 REPEATABLE READ/SERIALIZABLE 这类事务快照隔离中,若最新快照看到事务快照不该看到的新行,crosscheck 会让执行器报并发更新/序列化类错误,而不是悄悄漏过。再配合行锁,父、子两侧检查避免了“检查时没看到、提交时却已经冲突”的空窗。

4. 两端写入会不会冲突

4.1 锁关系先看这张表

操作另一端动作行锁/表锁核心典型关系
子 INSERT/改 FK查父键父表 RowShareLock + 父行 KEY SHARE多个引用者彼此兼容
父非键 UPDATE不改唯一键父行 NO KEY UPDATE与 KEY SHARE 兼容
父键 UPDATE改可引用唯一键父行 UPDATE/Exclusive与子端 KEY SHARE 冲突
父 DELETE删除父行父行 Exclusive与子端 KEY SHARE 冲突
NO ACTION/RESTRICT查子引用子表 RowShareLock + 子行 KEY SHARE找到一行即报错
CASCADE/SET写子行子表 RowExclusiveLock + 普通 UPDATE/DELETE 行锁与业务子行写冲突

FOR KEY SHARE 在执行器映射为 LockTupleKeyShare,见 nodeLockRows.c:159-174。更新不碰唯一键列时,heap update 选择 LockTupleNoKeyExclusive;碰键列时选择 LockTupleExclusive,见 heapam.c:3388-3420。底层把 KEY SHARE 映射为可形成 MultiXact 的 MultiXactStatusForKeyShare,见 heapam.c:148-169。

4.2 实测一:子 INSERT 挡住父 DELETE

会话 A:

BEGIN;
INSERT INTO lock_child VALUES(100,1,'uncommitted');
SELECT pg_sleep(5);
COMMIT;

会话 B 同时 DELETE parent id=1。第三会话观察:

application_name  state   wait_event_type  wait_event      blocked_by
fk_holder         active  Timeout          PgSleep         {}
fk_waiter         active  Lock             transactionid   {161}

A 提交后,B 醒来并重新完成 RI 判断,最终不是成功删除,而是报父键仍被子表引用。这证明它不是一次无锁的“先查再写”。

4.3 实测二:共享同一父行并不必然串行

A 持有一条尚未提交的 child(pid=2) 时,B 的结果:

INSERT another child(pid=2)                  0.783 ms,成功
UPDATE parent SET note=... WHERE id=2        0.253 ms,成功
UPDATE parent SET id=20 WHERE id=2          700.533 ms,lock timeout

所以“外键把两边联系起来”并不等于“所有写都打成一把互斥锁”。精确边界是:引用同键的 KEY SHARE 可共享,非键更新可并行,破坏键身份的更新/删除必须冲突。

一个合理推断是:极热父键虽然不把所有子插入串行化,但很多并发 KEY SHARE 会把父 tuple 的 xmax 组织成 MultiXact,产生 MultiXact 元数据和同一 heap page 的热点。因此“都不阻塞”不等于“没有伸缩性成本”。

4.4 实测三:CASCADE 会和子行写直接冲突

A 更新并持有 lock_cascade_child(id=1);B 删除其父行且 ON DELETE CASCADE。B 在 700 ms 后超时:

ERROR: canceling statement due to lock timeout
CONTEXT: while deleting tuple (0,1) in relation "lock_cascade_child"
SQL statement "DELETE FROM ONLY ...lock_cascade_child WHERE $1 = pid"

父表 DELETE 自身并没有在业务 SQL 里写子表,但 FK 动作触发器代它写了。这就是实际写冲突。

4.5 实测四:级联参与真实死锁

准备两个父行和两个各自引用的子行:

  • 事务 A 先锁子行 c1,再级联删除父行 p2,需要锁 c2;
  • 事务 B 先锁子行 c2,再级联删除父行 p1,需要锁 c1。

实测错误:

ERROR: deadlock detected
DETAIL: Process 234 waits for ShareLock on transaction 866; blocked by process 233.
        Process 233 waits for ShareLock on transaction 867; blocked by process 234.
CONTEXT: while deleting tuple (0,2) in relation "dead_child"
SQL statement "DELETE FROM ONLY ...dead_child WHERE $1 = pid"

数据库中最终只提交了被选为胜者的一笔,死锁牺牲者整笔回滚。这是 PostgreSQL 正常的死锁检测行为;应用必须捕获 SQLSTATE 40P01 并重试完整事务。

减少冲突和死锁的实际办法:

  • 两端事务遵守同一锁顺序,例如先锁父行,再按主键顺序处理子行;
  • 给每个 FK 的子端列建合适索引,缩短扫描和持锁窗口;
  • 避免一次级联几十万行,必要时按业务边界分批显式清理;
  • 事务短小,避免拿着外部网络调用、人工等待或长计算占用 FK 行锁;
  • 对 40P01、可重试序列化错误设计幂等重试。

5. 外键能不能提升性能

答案要拆成“写入”“父端维护”“查询规划”三部分。

5.1 子表写入:明确增加工作

同一容器、同一会话、相同三列和主键,两张空表分别有/无 FK,执行单条 INSERT ... SELECT generate_series(1,100000):

指标无 FK有 FK差值/倍率
Execution Time87.815 ms328.902 ms+241.087 ms,约 3.75 倍
FK trigger calls0100000每行一次
本次平均附加时间——约 2.41 μs/行

这是 tmpfs、热缓存、单会话的小型微基准,不能直接外推云端吞吐;但方向可靠:每个非 NULL FK 行都多一次触发器路径、父索引点查和 KEY SHARE。真实 Neon 的远程页面 miss 会放大延迟,预取、缓存、批次、并发和热点则会改变具体数值。

RI 的复杂度近似:

单行子写:O(k + log P)                 k=FK 列数,P=父索引项数
N 行子写:O(N * (k + log P))           当前是行触发器,不是批量半连接

SPI plan 缓存只消除重复 parse/plan,不能消除 N 次存在性检查。

5.2 FOR KEY SHARE 不是纯读:会改 tuple header 并写 WAL

逻辑上是 SELECT,但 heap 行锁会把当前 XID/MultiXactId 写进父 tuple 的 xmax,标记 buffer dirty,并遵守 WAL-before-data。Neon 源码的关键路径:

源码: heapam.c:5103-5196,heap_lock_tuple

compute_new_xmax_infomask(xmax, old_infomask, ...,
                          GetCurrentTransactionId(), mode, false,
                          &xid, &new_infomask, &new_infomask2);

HeapTupleHeaderSetXmax(tuple->t_data, xid); // 中文注释:把行锁身份写入 tuple header
MarkBufferDirty(*buffer);                  // 中文注释:父 heap page 被改脏

if (RelationNeedsWAL(relation))
{
    xl_neon_heap_lock xlrec;
    xlrec.xmax = xid;
    xlrec.t_cid = HeapTupleHeaderGetRawCommandId(tuple->t_data);
    ...
    recptr = XLogInsert(RM_NEON_ID, XLOG_NEON_HEAP_LOCK);
                                             // 中文注释:Neon 自定义行锁 WAL
    PageSetLSN(page, recptr);
}

在官方 PG17.5 容器中,用两张结构相同的子表插入 10 万行;FK 行引用 1000 个不同父行。预热后用 WAL LSN 差值测得:

无 FK:13,045,896 bytes
有 FK:13,102,072 bytes
差值:    56,176 bytes

这个差值会受 page image、事务边界和重复引用父键数量影响,不是固定“每行 0.56 字节”。本例一个事务内 10 万子行只涉及 1000 个不同父 tuple,同一事务重复锁同一父 tuple 可复用自身锁,因此额外 WAL 更接近“不同父 tuple 数”而不是“子行数”。Neon 的实际记录含额外 t_cid,不能把官方容器的精确字节数当作 Neon 云上字节数。

5.3 父端删除/改键:子端索引决定是否灾难性扫描

构造 100 万子行、1 万个被引用父键,再删除一个确实没有子引用的父键 999999:

场景RI 内部计划子表 bufferFK 触发器时间DELETE 总执行时间
子端无 pid 索引Seq Scan,移除 1,000,000 行shared hit=442525.580 ms25.653 ms
CREATE INDEX child(pid)Bitmap Index/Heap Scanshared read=30.292 ms0.353 ms

本次触发器时间约快 87.6x,总时间约快 72.7x。数据都在 tmpfs/缓存附近,生产磁盘或远端页面上的全表扫描通常更昂贵。

父端一次变更 K 行时,粗略最坏复杂度是:

无子端索引:O(K * C)                     C=子表行数
有子端索引:O(K * (log C + M))           M=每个父键匹配的子行数
CASCADE:再加 O(实际被更新/删除子行数) 的写入、索引和 WAL 代价

索引建议不是无脑“每个列各建一个”:复合 FK (tenant_id,parent_id) 通常建相同前导顺序的复合索引;若业务查询还需要其他列,可评估 INCLUDE,但要权衡子表写放大。用 EXPLAIN (ANALYZE, BUFFERS) 验证父端 DELETE/UPDATE 的触发器耗时,必要时用 auto_explain.log_nested_statements 看内部 SPI 计划。

5.4 查询规划:能改善估计,但不会自动免 JOIN

PG17 规划器读取 FK 元数据,把查询等值条件与 FK 每一列匹配。其源码目的写得很明确:复合 FK 的多个条件若按相互独立估计,会严重失真,见 match_foreign_keys_to_quals。

匹配后,get_foreign_key_join_selectivity 用“每个非 NULL 子行恰好匹配一个父行”的语义,内连接选择率取 1 / 父表原始行数,见 costsize.c:5689-5741。

实测 1000 个父复合键 (a,b)=(g,g)、10000 个合法子行:

查询:SELECT c.* FROM est_c c JOIN est_p p ON c.a=p.a AND c.b=p.b;

无 FK:                Hash Join rows=10     actual rows=10000
加 NOT VALID FK:      Hash Join rows=10000  actual rows=10000
VALIDATE 后:          Hash Join rows=10000  actual rows=10000

由此可得三个精确结论:

  1. FK 能显著改善连接基数估计,进而可能改变 join order、join algorithm 或内存预算;是否实际加速要看整个查询。
  2. PG17 此处没有消除内连接,三个计划都实际执行 Hash Join。外键不是“查询时直接沿指针跳转”。
  3. NOT VALID FK 也进入 PG17 的估计信息。源码 RelationGetFKeyList 读取所有 FK,并未过滤 convalidated。这只影响成本估算,不用于改变结果正确性的 JOIN 消除;若旧数据确有孤儿,估计可能过度乐观,计划可能反而变差。

所以“外键有没有性能提升”的完整回答是:

对写路径通常是成本;对子端索引完备的父端维护,可避免灾难性扫描但那是索引的功劳;对读查询,FK 可作为统计语义改善估计,偶尔间接提速,但 PG17 不因此把普通内连接变成免费操作。

6. Neon PG17 的特殊点

6.1 RI 上层行为不变

Neon PG17.5 的 ri_triggers.c、tablecmds.c、trigger.c 与上游 REL_17_5 完全一致。因此外键的 SQL 行为、NULL、动作、延迟、SPI 查询、触发器队列和锁模式选择都遵循 PostgreSQL 17.5。

在 Neon 架构中,检查发生在 compute 里的 PostgreSQL 执行器,不是 pageserver 在存储键值层理解“这两行有 FK”。pageserver/safekeeper 负责页面版本、WAL 和持久化边界;引用完整性仍由 compute 上的 PG 事务语义保证。

6.2 行锁 WAL 为什么被 Neon 改写

Neon 自定义 WAL resource manager ID 为 134,XLOG_NEON_HEAP_LOCK=0x40,结构额外携带 t_cid,见 neon_xlog.h:6-21 和 xl_neon_heap_lock。

Neon 文档解释:上游 heap WAL 不总是记录 tuple 的 command id,因为普通 PostgreSQL 恢复时原事务已经结束;Neon 却可能在原事务仍运行时靠 WAL 在 pageserver 重建页面,所以需要 t_cid。见 docs/core_changes.md:36-56。

自定义 rmgr 在 shared preload 阶段注册,收到 XLOG_NEON_HEAP_LOCK 后重放 xmax/infomask/t_cid 并设置 page LSN,见 neon_rmgr.c:45-75 和 redo_neon_heap_lock。Rust WAL decoder 也识别该记录并处理 visibility map,见 decoder.rs:566-572。

这对外键的含义是:即便只是子插入的父存在性检查,KEY SHARE 仍可能产生父页面修改和 Neon WAL。热点 FK 的性能不只包含父 B-tree 读,也可能包含 heap page、MultiXact、WAL 和远端存储链路成本。

6.3 原子性没有被拆开

触发器的 SPI UPDATE/DELETE 在发起 DML 的同一 PostgreSQL 事务中执行,没有独立提交边界。任一 RI 查询、级联、NOT NULL、用户触发器或下游 FK 报错,原始语句/事务一起回滚。SET DEFAULT 失败后父行和子行都保留的实测就是直接证据。

提交和持久化的专题链路可简化为:

业务 DML
  -> 当前事务写 heap/index
  -> AFTER RI trigger 在同事务做检查或级联 DML
  -> 延迟 RI trigger 在 pre-commit 全部执行
  -> 任一错误:事务 abort,所有变更不可见
  -> 全部成功:提交 WAL/事务状态
  -> compute 将包括 heap/index/row-lock 在内的 WAL 送入其持久化链路

更多推荐