外键让关系在普通数据库写入中得到强制校验。任务可以引用已有项目,即使导入脚本或新应用路径忘了提前检查,数据库也能拒绝不存在的项目标识。这条约束定义的是引用完整性,并不决定某个业务操作选中的项目是否正确。明确这个范围,才能既利用数据库保证,也不把业务判断错误地交给外键。
把关系写准确
引用键指向父表中适用的唯一列,通常是主键。多条子记录可以引用同一父行,因此,只有外键并不代表一对一关系,也不要求每个父行必须拥有子记录。如果业务还需要唯一性或其他规则,应单独声明,不能从“存在引用”推导出实际上没有约束的基数关系。
是否允许空值,是另一项决定。单列外键通常允许 SQL NULL,表示没有引用;每个子记录都必须有父记录时,应另外声明 NOT NULL。对于复合键,PostgreSQL 默认的 MATCH SIMPLE 在任意引用列为空时,就允许该行不执行匹配;MATCH FULL 则要求全部为空或全部匹配。因此,只填写一部分的复合引用,在默认模式下不一定无效。
键的作用域也必须准确。如果标识只在某个租户内部唯一,就应引用适当的“租户与标识”组合键,不能默认其他租户里存在相同编号就算正确。即使引用范围定义正确,外键仍不是授权检查:某个项目确实存在,也可能不属于当前用户或当前工作流应操作的对象。
父行删除需要生命周期规则
NO ACTION 是 PostgreSQL 默认的删除动作。在通常的立即检查模式下,删除仍被引用的父行会失败,除非语句结束时约束依然满足。如果约束声明为可延迟,NO ACTION 可以推迟检查;RESTRICT 则不允许延迟这项删除限制。两者不能在任何场景下都当成同义词,迁移或批量操作尤其需要检查其差别。
CASCADE 会在父行删除时自动删除引用它的子行,适合没有独立生命周期的组成部分,但删除范围可能沿多层关系扩张。SET NULL 保留子行并清空引用,前提是空值及其他约束允许这样做。SET DEFAULT 也必须留下有效引用,不能因为设置了默认值就绕开完整性规则。
ON UPDATE 则单独规定被引用键变化时的动作。更新级联会把新键复制到引用行,并不代表覆盖子记录其他无关字段。应根据所有权与保留要求选择动作。为了让报错消失,就把限制删除改为级联,可能把本来有价值的完整性失败变成意外数据删除;约束动作不是单纯的语法偏好。
坏掉的样子
应用先查父行是否存在,仍可能与另一个事务删除父行发生竞态。数据库约束会协调真正写入,因此,应把它作为关系的权威校验,而不是只依赖更早的读取。高权限操作禁用执行,或迁移尚未验证旧数据,是不同状态,不能默认它们拥有已经完整启用并验证的外键保证。
写入成本也取决于负载。删除父行可能需要查找大量子行、获取锁并执行级联。被引用键必须已经具有适用的主键、唯一约束或合适的唯一索引;添加外键不会自动给子表引用列建立索引。需要检查这些访问路径和删除关系图的规模。关系有声明,也不保证优化器一定消除关联,更不代表所有 JOIN 都变成低成本操作。
快速验证
把下面内容保存为 foreign-key-actions.sql,在可丢弃的 PostgreSQL 18 数据库中执行 psql -X -v ON_ERROR_STOP=1 -f foreign-key-actions.sql。实验用临时表分别表示任务、随项目存在的文件和需要保留的历史,并选择不同删除动作。末尾回滚会清除全部对象和修改。
\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE projects (id integer PRIMARY KEY);
CREATE TEMP TABLE tasks (id integer PRIMARY KEY,project_id integer REFERENCES projects ON DELETE NO ACTION);
CREATE TEMP TABLE files (id integer PRIMARY KEY,project_id integer REFERENCES projects ON DELETE CASCADE);
CREATE TEMP TABLE audit_history (id integer PRIMARY KEY,project_id integer REFERENCES projects ON DELETE SET NULL);
INSERT INTO projects VALUES (1),(2);
INSERT INTO tasks VALUES (10,1),(11,NULL);
INSERT INTO files VALUES (20,1);
INSERT INTO audit_history VALUES (30,1);
DO $$ BEGIN
INSERT INTO tasks VALUES (12,999);
RAISE EXCEPTION 'dangling reference accepted';
EXCEPTION WHEN foreign_key_violation THEN RAISE NOTICE 'missing project rejected';
END $$;
DO $$ BEGIN
DELETE FROM projects WHERE id=1;
RAISE EXCEPTION 'referenced project deleted';
EXCEPTION WHEN foreign_key_violation THEN RAISE NOTICE 'NO ACTION blocked parent deletion';
END $$;
DO $$ BEGIN
IF (SELECT count(*) FROM files) <> 1 OR
(SELECT project_id FROM audit_history WHERE id=30) IS DISTINCT FROM 1 THEN
RAISE EXCEPTION 'failed deletion left partial side effects';
END IF;
END $$;
DELETE FROM tasks WHERE id=10;
DELETE FROM projects WHERE id=1;
SELECT * FROM tasks;
SELECT * FROM audit_history;
SELECT count(*) AS remaining_files FROM files;
DO $$ BEGIN
IF (SELECT array_agg(id ORDER BY id) FROM projects) IS DISTINCT FROM ARRAY[2] THEN
RAISE EXCEPTION 'wrong surviving project';
END IF;
IF (SELECT count(*) FROM files) <> 0 OR
(SELECT count(*) FROM audit_history WHERE id=30 AND project_id IS NULL) <> 1 OR
(SELECT count(*) FROM tasks WHERE id=11 AND project_id IS NULL) <> 1 THEN
RAISE EXCEPTION 'wrong delete action or null handling';
END IF;
END $$;
ROLLBACK;
PostgreSQL 18.6 拒绝了引用项目九百九十九的任务,却接受了引用为空的任务十一。第一次删除项目一失败,因为任务十仍然引用它。失败的语句不会只删掉其他依赖数据而留下半套结果,这一点也需要与成功删除后的级联行为区分。
显式删除任务十之后,再删父行就成功了。文件通过 CASCADE 消失;历史记录三十仍然存在,但项目列变为 NULL;原来就没有项目引用的任务十一也保留下来。项目二没有子记录,照样合法存在。断言检查这些不同结果,说明“引用必须有效”与“子记录必须跟着删除”是不同契约。审阅 schema 变更时,可以用同样的生命周期案例与应用负责人核对,而不是只确认建表语句能执行。
来源
- PostgreSQL 约束,引用完整性、空值、删除动作和索引,已核验 2026-09-10。
- PostgreSQL CREATE TABLE,外键匹配与延迟检查语法,已核验 2026-09-10。