Postgresql源码(159)外键深度分析
结论
- 外键不是两行之间的物理指针。 子表仍只保存普通列值。真正的“联系”是
pg_constraint中的一条规则、父端唯一索引,以及 PostgreSQL 自动生成的 4 个内部约束触发器。DML 发生时,触发器拼出普通 SQL,经 SPI 执行查询、更新或删除。 - 子端写入会锁父行。 合法的子表
INSERT/UPDATE会在父表执行SELECT ... FOR KEY SHARE。这既验证父键存在,又把父键锁到事务结束,避免“刚检查完父行就被另一事务删掉”的 TOCTOU 漏洞。 - 这不意味着所有写都互相阻塞。 多个子事务引用同一父行时,
KEY SHARE彼此兼容;父表非键列更新采用较弱的NO KEY UPDATE,也与之兼容。但父行删除、父键更新需要更强的行锁,会等待或冲突。 CASCADE/SET NULL/SET DEFAULT是真实的子表 DML。 它们会锁、更新或删除子行,会与业务对子行的并发写冲突,也会形成死锁。本文实测出一次由两个级联删除与相反子行持锁顺序构成的40P01 deadlock detected。- 外键不会自动给子端建索引。 父端必须已有非部分、非表达式、立即唯一的索引;子端索引需要手工创建。没有子端索引,删除/改父键可能为每个父行扫描整张子表。
- 外键通常降低写吞吐,不直接让普通查询“免 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)和非法(2,999)。 ADD CONSTRAINT ... NOT VALID成功,pg_constraint.convalidated=false。- 新插入非法
(3,888)仍立即失败,因为检查触发器已经安装。 VALIDATE CONSTRAINT因旧的(2,999)失败。- 删除旧坏行后再次 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,不是特殊存储指令
// 中文注释:因为接下来真的 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 Time | 87.815 ms | 328.902 ms | +241.087 ms,约 3.75 倍 |
| FK trigger calls | 0 | 100000 | 每行一次 |
| 本次平均附加时间 | — | — | 约 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 内部计划 | 子表 buffer | FK 触发器时间 | DELETE 总执行时间 |
|---|---|---|---|---|
子端无 pid 索引 | Seq Scan,移除 1,000,000 行 | shared hit=4425 | 25.580 ms | 25.653 ms |
CREATE INDEX child(pid) | Bitmap Index/Heap Scan | shared read=3 | 0.292 ms | 0.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
由此可得三个精确结论:
- FK 能显著改善连接基数估计,进而可能改变 join order、join algorithm 或内存预算;是否实际加速要看整个查询。
- PG17 此处没有消除内连接,三个计划都实际执行
Hash Join。外键不是“查询时直接沿指针跳转”。 NOT VALIDFK 也进入 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 送入其持久化链路
更多推荐


所有评论(0)