外键约束里那一行 ON DELETE,可能是整个建表语句里最容易被忽视、却最能决定线上事故大小的部分。我见过不少项目,建表时随手写了 ON DELETE CASCADE,上线两年没出事,直到某次数据清理误删了一个主表 id,结果把关联的子表几百万行连带清空,连回滚都没法回滚。也见过因为没写任何动作,主表删除时被数据库报错拦住,开发一脸懵地问我为什么删不掉。这篇文章就把 PostgreSQL 里 ON DELETE 的几种策略彻底讲透,结合实际操作和踩坑经验,帮你在设计外键时做出更稳妥的选择。
如果你正在做表结构设计、写数据清理脚本,或者单纯看公司祖传建表语句里那一堆 ON DELETE 觉得头疼,这篇文章应该能给你一个完整的参考。我会从约束机制讲到具体策略,再做一轮实测验证,最后聊性能、锁和常见故障。内容偏实践,拿 postgres 命令行直接能跟着跑。
1. 外键约束的核心机制:为什么需要 ON DELETE
1.1 从引用完整性说起
PostgreSQL 里的外键约束,本质上是在维护两张表之间的引用关系:子表通过某一列引用主表的主键或唯一键。比如订单表里存了 user_id,引用用户表的 id,那么数据库就要保证这个 user_id 要么是 NULL,要么一定在用户表里存在。这个保证被称为引用完整性。
引用完整性不只是约束写入,还要考虑主表数据被删除时,子表那些“无处安放”的引用该何去何从。比如用户被删除后,这个用户的订单怎么办?是连同订单一起删掉,还是把订单里的 user_id 置空,或者直接拒绝删除用户?这些处理逻辑,就是 ON DELETE 子句要定义的策略。
很多开发同学容易把外键约束的检查理解为“新增/更新时才触发”,实际上删除操作同样会触发引用检查。每当你执行 DELETE FROM 主表,PostgreSQL 会检查所有引用了这张表的外键约束,并根据约束定义的策略,决定是报错、删除子表数据、还是更新子表数据。这个动作不是应用层能完全替代的,因为它发生在数据库内部,具有原子性,要么全部执行,要么全部回滚,这也是数据库设计上不可替代的一环。
1.2 五种策略速览:RESTRICT、NO ACTION、CASCADE、SET NULL、SET DEFAULT
在 PostgreSQL 中,外键约束的 ON DELETE 可以指定五种动作,每种动作的语义和默认行为如下:
| 策略 | 行为说明 | 是否默认 | 检查时机 |
|---|---|---|---|
NO ACTION |
删除主表记录时,如果存在子表引用则报错,不执行删除 | 默认(不写 ON DELETE 时) |
事务结束时检查(若约束不可延迟则为语句结束时) |
RESTRICT |
与 NO ACTION 类似,存在子表引用则报错 |
非默认 | 语句执行时立即检查 |
CASCADE |
删除主表记录时,自动删除所有引用它的子表记录 | 非默认 | 语句执行时级联删除 |
SET NULL |
删除主表记录时,将子表引用列的值置为 NULL | 非默认 | 语句执行时更新子表 |
SET DEFAULT |
删除主表记录时,将子表引用列的值设为该列的默认值 | 非默认 | 语句执行时更新子表 |
这里先提一个大家经常混淆的点:NO ACTION 和 RESTRICT 在大多数使用场景下表现完全一样,都会阻止删除,但它们在约束可延迟(DEFERRABLE)时存在本质区别。后面我会用实际操作演示这个差异。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 策略选型背后的业务逻辑
2.1 CASCADE:最顺手,也最容易出事故
ON DELETE CASCADE 的语义很直接:主表记录删除时,子表里那些引用它的记录也一并删除。这种策略在“子记录没有独立存在意义”的场景下非常合适,典型的例子是订单表和订单明细表。订单明细脱离了订单就没有业务价值,订单被删除,明细也应该删掉。如果不用 CASCADE,每次删订单都要手动先删明细,再删主单,很容易漏删,也会产生数据垃圾。
但 CASCADE 的风险同样巨大。首先是误操作放大:如果你在清理数据时误删了一个用户,而这个用户拥有几千条订单,每张订单又有几条明细,那么删除用户的同时,数据库会自动把这些订单和明细全部删除,删除量可能瞬间放大几十倍。更麻烦的是,CASCADE 会沿着外键链层层传递,A 表被删除级联到 B 表,B 表又级联到 C 表,一旦链条很长,删除范围就完全不可控。
我在实际项目里见过一个事故:运维同学为了清空一张临时配置表,执行了 DELETE FROM config WHERE ...,结果 config 表有个外键指向商品表,商品表又被 SKU 表引用,SKU 表又被库存表引用,一条错误的条件最终级联删掉了全平台商品、SKU 和库存数据。定位问题时发现,临时配置表之所以会级联,只是因为当初建表时为了图方便写了 ON DELETE CASCADE。从那以后,我对 CASCADE 的态度就变成了:只有业务上明确要求“同生共死”的强关联表才允许用,而且要经过 DBA 评审。
2.2 SET NULL 与 SET DEFAULT:为“保留历史数据”设计
ON DELETE SET NULL 适合子表记录不能删除、但需要解除关联的场景。举个例子:工单表里的 assignee_id 引用员工表,员工离职后,工单历史记录必须保留,但不能再关联到已删除的员工。此时将外键设为 SET NULL,员工被删除时,工单的 assignee_id 会被自动改为 NULL,工单数据完好无损,同时关联关系被安全解除。
使用 SET NULL 有个硬性条件:子表的外键列必须允许 NULL。如果建表时该列带有 NOT NULL 约束,删除主表记录时会直接报错,因为数据库无法把引用列置成 NULL。这一点非常容易踩坑。我见过有人在原有表上加外键时直接指定 ON DELETE SET NULL,结果 ALTER TABLE 成功了,但一删主表就报错,排查半天才发现外键列是 NOT NULL。
SET DEFAULT 与 SET NULL 类似,区别在于它将引用列设置为建表时指定的默认值,而不是 NULL。这个策略更适合“删除后归类到一个默认实体”的场景,比如删除“未知类目”后,所有商品归到一个“默认类目”下。但使用 SET DEFAULT 时要额外注意,默认值本身必须已经存在于被引用表的主键中,否则删除操作会违反外键约束,一样报错。
2.3 RESTRICT 与 NO ACTION:看起来一样,延迟检查时完全不同
先说结论:在默认情况下(约束不可延迟),RESTRICT 和 NO ACTION 的行为完全相同——存在子表引用就立刻报错,阻止删除。它们的区别只有在 DEFERRABLE 约束下才会显现。
RESTRICT 是“最严厉”的策略,它在语句执行时就立即检查,不管你后面事务里是否还打算删除子表记录。NO ACTION 则可以在事务范围内延迟检查,也就是说,你可以先删除主表记录,再删除子表记录,只要在事务提交时检查发现没有悬空引用,整个操作就合法。
这个特性有什么实际价值?想象一个场景:你需要批量清理一张主表的多行记录,但子表里也有大量关联记录需要一并清理。如果外键是 RESTRICT,你必须先删子表,再删主表,否则直接报错;如果外键是 NO ACTION 且被声明为 DEFERRABLE,你可以把两边的删除操作放在一个事务里,顺序无所谓,只要最后没有残留引用即可。这在复杂数据迁移脚本中非常有用,可以避免写一堆手动排序的删除逻辑。
3. 实操演示:把每种策略真实跑一遍
3.1 建表与基础数据准备
为了把五种策略看明白,我建议你在本地 PostgreSQL 环境里建一组测试表。下面这套建表语句,每种策略对应一张子表,主表共用一张。
sql复制-- 主表:用户
CREATE TABLE users (
id serial PRIMARY KEY,
name text NOT NULL
);
-- 子表:订单,外键使用 CASCADE
CREATE TABLE orders_cascade (
id serial PRIMARY KEY,
user_id int REFERENCES users(id) ON DELETE CASCADE,
amount numeric
);
-- 子表:订单,外键使用 SET NULL
CREATE TABLE orders_setnull (
id serial PRIMARY KEY,
user_id int REFERENCES users(id) ON DELETE SET NULL,
amount numeric
);
-- 子表:订单,外键使用 RESTRICT
CREATE TABLE orders_restrict (
id serial PRIMARY KEY,
user_id int NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
amount numeric
);
-- 子表:订单,外键使用 NO ACTION(可延迟)
CREATE TABLE orders_noaction (
id serial PRIMARY KEY,
user_id int REFERENCES users(id) ON DELETE NO ACTION,
amount numeric
);
-- 子表:订单,外键使用 SET DEFAULT(需要设置默认值)
CREATE TABLE test_default_user (
id serial PRIMARY KEY,
name text NOT NULL
);
INSERT INTO test_default_user (id, name) VALUES (999, '默认用户');
CREATE TABLE orders_setdefault (
id serial PRIMARY KEY,
user_id int NOT NULL DEFAULT 999 REFERENCES test_default_user(id) ON DELETE SET DEFAULT,
amount numeric
);
-- 插入测试数据
INSERT INTO users (id, name) VALUES (1, '张三'), (2, '李四');
INSERT INTO orders_cascade (user_id, amount) VALUES (1, 100), (1, 200);
INSERT INTO orders_setnull (user_id, amount) VALUES (1, 150);
INSERT INTO orders_restrict (user_id, amount) VALUES (2, 300);
INSERT INTO orders_noaction (user_id, amount) VALUES (2, 400);
INSERT INTO orders_setdefault (user_id, amount) VALUES (999, 50);
这里需要注意,orders_restrict 的 user_id 我刻意加上了 NOT NULL,因为它不涉及 SET NULL,列是否可空不影响 RESTRICT。而 orders_setdefault 必须有一个默认值,且默认值必须在 test_default_user 表里存在,否则删除时无法正确替换。
3.2 逐策略执行 DELETE 并观察行为
在数据准备完成后,我们分别对主表执行删除,观察结果。
CASCADE 行为
sql复制DELETE FROM users WHERE id = 1;
SELECT * FROM orders_cascade;
执行完后,orders_cascade 里 user_id = 1 的两条记录会自动消失。你只需要删主表,子表同步被清理。这就是 CASCADE 最直观的表现。
SET NULL 行为
sql复制DELETE FROM users WHERE id = 1; -- 假设 back data 里 id=1 还存在
SELECT * FROM orders_setnull;
注意,上一步如果已经删除过 id=1,需要重新插入数据。为方便测试,建议每测一个策略前都重新初始化数据。SET NULL 执行后,orders_setnull 里原来的 user_id 会变成 NULL,记录本身保留,金额、其他字段还在。
RESTRICT 行为
sql复制DELETE FROM users WHERE id = 2;
这条 SQL 会直接报错:ERROR: update or delete on table "users" violates foreign key constraint ... on table "orders_restrict"。数据库明确拒绝删除,users 表这一行原封不动,即使你在同一个事务里后续又把子表记录删掉,RESTRICT 也会在语句执行阶段就报错退出。
NO ACTION 行为(默认情况下)
sql复制DELETE FROM users WHERE id = 2;
同样会报错,报错信息与 RESTRICT 基本一致。这是因为默认创建的 NO ACTION 约束是不可延迟的,实际上它在语句结束时执行检查,效果等同于立即检查。后面我们会用 DEFERRABLE 改造它。
SET DEFAULT 行为
sql复制DELETE FROM test_default_user WHERE id = 999;
SELECT * FROM orders_setdefault;
这条执行后,orders_setdefault 中原来的 user_id = 999 会变成默认值 999?等一下,这里看起来有点绕。实际上,因为 test_default_user 里只有一条 id=999 的数据,删除它时,子表的外键列仍然被设为 DEFAULT,但默认值还是 999,然后数据库会发现 999 已经不存在于主表,于是报错。我建议你用这个例子来理解 SET DEFAULT 的隐藏问题:默认值必须存在,否则形同虚设。如果要在真实场景中体现 SET DEFAULT 的效果,你需要把默认值指向另一个主表里确实存在的 id,比如删除旧的默认用户之前,先把另一个用户指定为默认用户,再删旧的。
3.3 使用 DEFERRABLE 约束控制检查时机
刚才提到 NO ACTION 与 RESTRICT 在可延迟约束下会有差别,下面我们用实际操作验证。
sql复制-- 建一个可延迟的 NO ACTION 外键
CREATE TABLE orders_deferrable (
id serial PRIMARY KEY,
user_id int REFERENCES users(id) ON DELETE NO ACTION DEFERRABLE,
amount numeric
);
INSERT INTO users (id, name) VALUES (3, '王五');
INSERT INTO orders_deferrable (user_id, amount) VALUES (3, 500);
此时,如果我们开一个事务,先删除主表记录,再删除子表记录,看看效果:
sql复制BEGIN;
DELETE FROM users WHERE id = 3;
DELETE FROM orders_deferrable WHERE user_id = 3;
COMMIT;
这段事务可以正常提交,因为 DELETE FROM users 触发的检查被延迟到事务结束,而事务结束时,子表相关记录也已经删掉了,没有悬空引用。
对比一下,如果外键是 RESTRICT,同样的事务会直接失败:
sql复制-- 把 orders_deferrable 改成 RESTRICT 再试
ALTER TABLE orders_deferrable ALTER COLUMN user_id DROP NOT NULL;
实际生产中,合理使用 DEFERRABLE 可以减少删除时的排序依赖,尤其在做数据清洗时能省去很多麻烦。但注意,DEFERRABLE 也会带来风险:事务提交时如果仍然存在悬空引用,数据库会报错回滚,而这时你可能已经在这个事务里做了大量其他操作,回滚代价更高。所以它更适合在可控的脚本里使用,而不是默认约束方案。
4. 性能、锁与常见坑
4.1 外键检查对删除性能的影响
很多人以为 PostgreSQL 删除主表数据时,只是简单删掉一行,然后“顺便”检查子表。实际上,当外键约束存在且引用列没有索引时,数据库需要扫描整个子表来确认是否存在引用记录。这个扫描发生在 DELETE 语句执行过程中,子表数据量越大,扫描成本越高,甚至会让一次简单的删除变得极其缓慢。
我建议,凡是外键列,无论如何都要建索引。不仅是为了外键检查,也是为了让 JOIN 查询更快。PostgreSQL 不会自动为外键列创建索引,这是与 MySQL InnoDB 的一个显著差异。如果使用 ON DELETE CASCADE,没有索引时,每一个主表删除记录都会触发一次全表扫描,批量删除 1000 条主表记录,性能灾难可想而知。
sql复制CREATE INDEX idx_orders_cascade_user_id ON orders_cascade(user_id);
CREATE INDEX idx_orders_setnull_user_id ON orders_setnull(user_id);
-- 其他子表同样处理
建完索引后,外键检查会走索引查找,复杂度大幅降低。
4.2 批量删除时的锁与级联链问题
CASCADE 虽然写起来简单,但在批量删除时对锁的依赖非常明显。当你删除主表的一批记录时,数据库需要锁定所有受影响的子表行,如果外键层级很深,锁定的范围会呈指数级扩大。更麻烦的是,多个事务同时操作不同主表记录时,如果级联删除路径交叠,可能产生死锁。
例如:事务 A 删除用户 1,事务 B 删除用户 2,而用户 1 的订单里有一条记录被某种方式关联到了用户 2 的订单表,两个事务互相等待对方释放锁,最终死锁。数据库会自动回滚其中一个事务,但应用层收到死锁错误如果没有重试机制,就会导致用户看到失败请求。
因此,在执行涉及 CASCADE 的大规模数据清理时,我建议分批执行,每批删除几百条主表记录,然后提交,避免一次性锁定太多行。批次间留出间隔,降低锁竞争概率。
4.3 实战故障记录与排查思路实录
这里分享几个我在工作中实际遇到过的故障,以及排查方法,供你参考。
故障一:NOT NULL 列 + SET NULL 报错
现象:某表中 user_id 为 NOT NULL,外键设置为 ON DELETE SET NULL,删除主表记录时数据库报错,且错误信息并没有直接指出是外键问题,而是给出类似 “null value in column 'user_id' violates not-null constraint” 的提示。第一次遇到的同事很困惑,以为数据有问题。
排查思路:先看表结构,发现 user_id 有 NOT NULL 约束,再看外键定义,确认是 SET NULL。SET NULL 需要子列允许 NULL,二者冲突,数据库只能报错。解决办法是把该列改为可空,或者改用 RESTRICT 在应用层做软删除。
故障二:CASCADE 误删数据
现象:清理任务删了主表配置记录,结果发现多个维表数据被清空。一开始 DBA 以为是任务脚本写错了,翻看执行日志才发现,配置表的外键是 CASCADE,且被多个维表引用。
排查思路:审计建表语句中所有外键约束,找出非法使用 CASCADE 的表。可以用下面的 SQL 查询所有 CASCADE 外键:
sql复制SELECT
conrelid::regclass AS child_table,
confrelid::regclass AS parent_table,
conname,
pg_get_constraintdef(oid)
FROM pg_constraint
WHERE contype = 'f'
AND confdeltype = 'c';
通过这条语句,你可以快速定位所有可能级联删除的表,再做业务评审,决定是否保留 CASCADE。
故障三:批量删除超时
现象:对一张百万级子表做“删除父表记录让级联清理子表数据”的操作,结果 SQL 长时间执行不返回,最终超时或锁等待。
排查思路:查看子表外键列是否有索引,没有就立刻补上。再看是否一次删除太多主表记录,建议分批提交。此外,检查是否多个外键同时指向同一张主表,级联路径重叠会导致重复扫描。
5. 个人实操心得与建议
每次设计新表的外键时,我基本会强制自己先回答一个问题:主表记录被删除后,子表数据应该怎样才符合业务预期?如果子表是主表的从属实体、没有独立生命周期,比如订单明细、购物车项,我用 CASCADE。如果子表是历史记录,需要留痕但允许解绑,比如操作日志关联操作人、工单关联处理人,我优先用 SET NULL。如果业务上禁止删除还有历史引用记录的主表数据,比如财务凭证关联客户,我会用 RESTRICT 或 NO ACTION,让数据库做最后一道防线。
NO ACTION 和 RESTRICT 的选择上,我倾向于在可以接受事务内重排删除顺序的场景里用 NO ACTION DEFERRABLE,而在明确不允许任何悬空引用、也不希望通过事务顺序绕过的场景里用 RESTRICT。大多数传统业务系统其实用不上延迟检查,所以这个选项更多是因为我知道它的存在,而不是每条外键都去设置。
最后再分享一个小技巧:新项目上线前,用 pg_constraint 视图把所有外键策略导出来做一次评审,比在代码 review 里逐个看建表语句高效得多。你可以把 confdeltype 映射成可读文本,直接输出到一份文档里,作为数据库设计交付物的一部分。
sql复制SELECT
conrelid::regclass AS child_table,
confrelid::regclass AS parent_table,
CASE confdeltype
WHEN 'c' THEN 'CASCADE'
WHEN 'n' THEN 'SET NULL'
WHEN 'd' THEN 'SET DEFAULT'
WHEN 'r' THEN 'RESTRICT'
WHEN 'a' THEN 'NO ACTION'
END AS on_delete,
pg_get_constraintdef(oid) AS constraint_def
FROM pg_constraint
WHERE contype = 'f'
ORDER BY conrelid::regclass::text;
这套输出,配合业务流程图,基本能在设计阶段就把未来 80% 的删除风险堵死。希望这篇关于 PostgreSQL ON DELETE 策略的梳理,能让你少踩几个我踩过的坑。
