上周处理了一个让我印象深刻的线上问题:运营在后台删除一个长期未合作的企业账号,页面卡了四十多秒,最后弹出一句外键冲突。查下来,这个企业表被十来个业务表引用,其中大部分外键都用的默认策略——NO ACTION,删除时数据库要逐张引用表做引用检查,一遇到历史残留数据,整笔删除直接失败回滚。当时团队里有人第一反应是“改成 CASCADE 不就行了”,我没有立刻点头。PostgreSQL 的 ON DELETE 策略表面上看是五个选项,实际选起来牵扯数据建模、删除性能、运营误操作、数据恢复成本,每个维度都能写出一堆故事。
这篇把我这些年和 PostgreSQL ON DELETE 打交道攒下来的经验摊开讲。适合正在做 PostgreSQL 建表方案、处理过外键冲突、或者准备把删除逻辑从应用层下放到数据库层的朋友。
1. ON DELETE 到底管住了什么:从一次五十秒删除事故说起
1.1 默认策略其实就是“不许删”
PostgreSQL 里如果不指定任何 ON DELETE,外键默认就是 NO ACTION。这个策略的语义很直白:子表里只要存在引用这条父数据的记录,删除父数据就会报外键冲突,整条语句被拒绝。
很多人建表时没意识到这个默认行为存在,直到第一次被外键报错打断。我见过不少项目,建表时全部外键都不写策略,等到上线运营要删数据了,才发现数据库像一把铁锁,把删除路径全堵死了。
SQL 标准为什么把默认设成这么保守?原因是引用完整性这条底线不能轻易突破。数据库无法判断你删掉父数据之后,子表里那些孤儿记录是垃圾还是核心资产,所以它宁可拒绝操作,把选择权留给开发者。这个设计思路是对的,但带来的副作用是:如果应用层不配合,运营后台的“删除按钮”就形同虚设。
用生活里的例子类比:你有一套房子,孩子住在里面,默认规则是“你不许卖房,直到孩子搬出去”。如果孩子不在里面,房子可以正常交易。NO ACTION 就是数据库在替你检查“孩子到底还在不在住”,一旦还在就直接拉闸。
1.2 应用层手动维护引用关系,到底有多痛
在没有外键约束、或者全部用 NO ACTION 的老系统里,删除一条父数据往往意味着应用层要手动按“从子到父”的顺序,在同一个事务里先删子表、再删父表。
这个流程听起来不复杂,实际跑起来非常折磨人。首先是顺序容易搞反,尤其是在多层引用关系里,今天新增了一张关联表,开发不知道,删除逻辑就漏删了一环,留下孤儿数据;其次是并发问题,两个请求同时删除同一父数据,中间穿插着新的子表插入,就可能产生竞态,数据库这边还没反应过来,应用层已经提交了脏数据。
ON DELETE 策略的意义,是把“删父数据时子表怎么办”这个决策从应用代码里抽出来,下沉到数据库约束层。一旦定义清楚,不管多少条调用链走删除逻辑,数据库都会强制执行同一套规则,不会再出现某个接口忘了删子表导致的数据残留。
1.3 这个设计点为什么值得单独拎出来聊
外键删除策略看起来只是一行 DDL 的事情,但它本质上是数据生命周期设计的一部分。不同的业务表关系,对“父数据消失后子数据怎么办”的答案完全不同。
订单和订单明细,父订单没了明细也没有业务意义;用户和财务流水,用户删了流水却是必须长期保留的审计材料;文章和作者,如果作者被删了,文章可能仍要保留但作者信息可以置空。这三种关系,对应完全不同的删除策略。所以不要再用“统一 CASCADE”或者“统一 NO ACTION”去应付所有表,一定要按业务关系逐张分析。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 五种策略的真实差异:CASCADE、RESTRICT、NO ACTION、SET NULL、SET DEFAULT
2.1 NO ACTION 和 RESTRICT:表面都是拦截,机制完全不同
绝大多数人分不清 NO ACTION 和 RESTRICT,因为在默认配置下,两者表现几乎一样:子表有引用就禁止删除父表行。
真正的区别在“可延迟性”。RESTRICT 在 PostgreSQL 里是不可延迟的,一旦删除语句试图破坏引用完整性,数据库立刻报错,没有任何商量余地。NO ACTION 则特殊在:如果约束声明为 DEFERRABLE,可以把检查从语句执行完推迟到整个事务提交时再执行。
这个差异在批量清理场景里非常有用。举个例子,你有两张表要清理:先删子表数据再删父表数据,按默认语句级检查,顺序必须严格遵守;但如果把外键约束设置成 DEFERRABLE INITIALLY DEFERRED,就可以在一个事务里先删父表、再删子表,提交时数据库统一检查,只要最终没有违反引用关系就放行。
sql复制CREATE TABLE parent (
id bigint PRIMARY KEY
);
CREATE TABLE child (
id bigint PRIMARY KEY,
parent_id bigint NOT NULL,
CONSTRAINT fk_child_parent
FOREIGN KEY (parent_id) REFERENCES parent(id)
ON DELETE NO ACTION
DEFERRABLE INITIALLY DEFERRED
);
这里要注意,INITIALLY DEFERRED 意味着这个约束在该事务里默认延迟到底,如果业务上有删除顺序强一致的诉求,别乱设。
2.2 CASCADE:好用的背后是递归、索引扫描和锁
CASCADE 是大家最眼熟的策略:删除父表数据时,数据库自动删除引用它的子表数据。它像一个递归的删除机器,子表如果还有孙表外键指向它,会继续往下删。
sql复制CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no text NOT NULL
);
CREATE TABLE order_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL,
product_name text NOT NULL,
quantity integer NOT NULL,
CONSTRAINT fk_order_items_order
FOREIGN KEY (order_id) REFERENCES orders(id)
ON DELETE CASCADE
);
这段建表逻辑很典型:订单和订单明细是强父子关系,订单都不存在了,明细留着毫无意义,CASCADE 是合理的。
但 CASCADE 的代价容易被低估。数据库执行级联删除时,内部要去子表的外键列上找匹配行,如果子表外键列没有索引,就会退化成全表扫描。父表一次性删掉一万行,子表可能要被反复扫一万次。即使 PostgreSQL 14 以后对批量引用检查做了大量优化,深层级联的每行星火成本依然存在。
另外还要提醒一点:CASCADE 是真正物理删除,不经过任何应用层逻辑。一旦误删父数据,被级联清掉的子数据没有任何还原机制,只能从备份恢复。这个风险要在业务评估时放在最前面。
2.3 SET NULL 和 SET DEFAULT:保留子行,但断开联系
SET NULL 的意思是:删除父表行时,子表里对应的外键列自动置为 NULL。它不删除子行,只是把引用关系断开。
sql复制CREATE TABLE articles (
id bigint PRIMARY KEY,
title text NOT NULL,
author_id bigint,
CONSTRAINT fk_articles_author
FOREIGN KEY (author_id) REFERENCES users(id)
ON DELETE SET NULL
);
这里 author_id 就故意设计成允许 NULL,因为作者可能注销,文章还是要保留显示。
SET NULL 有一个前置条件很关键:子表外键列不能有 NOT NULL 约束,否则删除父表行时数据库试图写入 NULL,会直接报错,整条删除失败。这个错误比 NO ACTION 的报错更隐蔽,因为它不是发生在 DDL 阶段,而是发生在运行时。
SET DEFAULT 同理,要求子表列有一个默认值,并且这个默认值必须是父表里真实存在的某条记录的主键。实际业务里用的很少,一般适合把外键指向“未知用户”或“默认分类”这种字典行。
这两个策略的代价是会产生 NULL 外键。查询时如果没做好空值处理,WHERE author_id = 1 会漏掉删掉作者的历史文章,报表统计也可能出现“作者为空”的脏数据。所以选择 SET NULL 之前,先确认下游所有查询都考虑了 NULL。
2.4 ON UPDATE 策略:一半人不设,一半人设错
ON DELETE 讨论得多,ON UPDATE 经常被忽略。其实机制完全对称:父表主键或外键列的值被更新时,子表引用怎么办。
主键为稳定列时(自增 ID、随机 UUID),ON UPDATE 几乎不会被触发,所以不少人从不写 ON UPDATE 子句。但一旦出现自然键更新,比如把企业账号的编号从旧编码改成新编码,而且这个编号恰好又是被引用的键,ON UPDATE 策略就会决定子表数据是跟随更新、置空,还是拒绝更新。
我的建议是:创建外键时把 ON UPDATE 显式写出来,就算用默认的 NO ACTION 也写清楚,不要留白。这样后来维护 SQL 的人能一眼看到设计意图,而不是靠猜。
3. 业务建模视角:这张表到底该用哪种删除策略
3.1 一张决策表,把常见关系对号入座
我把这些年见过的表关系做了个归类,可以根据自己项目对号入座:
| 关系形态 | 建议策略 | 理由 |
|---|---|---|
| 主文档与明细行(订单-订单明细) | CASCADE | 明细没有独立业务含义,父没了子就没必要留 |
| 用户与财务流水、合同、发票 | RESTRICT / NO ACTION | 财务数据必须长期保存,严禁连带删除 |
| 内容表与作者/创建人(作者可能注销) | SET NULL | 内容保留,作者信息可空 |
| 多对多关联表(文章-标签) | CASCADE | 关联关系随任意一侧消失自动清理,避免残留 |
| 字典表与业务数据 | RESTRICT / NO ACTION | 字典码被引用时不删,保证业务数据不会指向不存在的字典 |
| 归档表/历史快照表 | NO ACTION | 数据只进不出,删除操作本身就应该被约束 |
这个表不是绝对标准,但它覆盖了大多数业务系统的核心场景。核心判断逻辑只有一句话:父数据消失之后,子数据到底还有没有独立存在的价值。
3.2 从关系生命周期判断,而不是从表名判断
很多人的误区是只看表名猜关系。用户表和订单表,一听就是“用户删除订单应该怎么办”,但实际要分情况:用户注销后,他名下的有效订单要不要保留?售后记录要不要留?历史违约记录要不要留?
真正可靠的做法是画出实体关系图后,在每条外键连线上标注“父数据生命周期结束,子数据命运如何”。这个动作应该在数据库设计评审阶段完成,而不是等业务上线后靠告警来替你做决定。
比如一个电商系统里,用户与购物车项的关系适合 CASCADE,因为购物车项本来就是临时数据;用户与订单详情的关系适合 RESTRICT 或软删除,因为订单涉及仓储、物流、售后、对账,牵一发动全身;用户与消息通知的关系适合 SET NULL 或 NO ACTION,具体看通知页面如何处理已注销用户。
3.3 软删除能不能替代 ON DELETE 策略
总有人问,我能不能不做物理删除,全部用删除标记。可以,但软删除替代不了外键删除策略,它们是两个维度的问题。
软删除是给父表加一个 deleted_at 字段,逻辑上把数据隐藏,但表里物理行还在,子表外键依然能正常引用。它的优点是恢复容易、误删可救;缺点是所有查询都要带 deleted_at IS NULL 过滤条件,一旦漏写,业务数据就会流出已删除记录,统计也容易算重。
实际上,软删除和外键策略可以组合使用。父表被“删除”时只是标记隐藏,子表数据不受影响;等到真正要做数据清理、把物理行删掉时,再用 ON DELETE 策略控制子表结果。这种做法在合规审计要求严格的系统里很常见。
3.4 选策略之前,先想清楚“删除”是哪种业务动作
我见过太多把“删除”当“清理”来建模的项目。同样是删除用户,可能是用户自助注销、可能是管理员封禁、可能是测试数据清理,三种场景对子表数据的要求完全不一样。
用户自助注销,通常希望匿名化处理,子表数据可能保留但去掉个人身份字段;管理员封禁,往往要求保留所有关联数据用于风控和审计,那就不该物理删除;测试数据清理,最好是一分钟之内把整套关联数据全部清干净,CASCADE 反而成了最好的朋友。
所以不要急着写 DDL,先走到产品经理和运营面前问清楚:这个删除动作发生之后,用户能不能恢复?子表数据要留多久?有没有合规审阅需求?这些问题有了答案,ON DELETE 策略自然就浮出水面了。
4. 上线前必须想清楚的性能与运维细节
4.1 大表级联删除为什么慢:索引缺失和 WAL 膨胀
外键列上建索引,这件事说了无数次,但很多表还是漏了。在 PostgreSQL 里,如果子表外键列没有索引,父表删除一行时,数据库要扫描整张子表来确认有没有引用行。如果这个删除还带上了 CASCADE,扫描整表找到所有匹配行,再逐行删除,性能会非常难看。
我在生产环境见过一张一亿行的流水表,外键列没建索引,因为历史原因还有五百万历史记录被引用。业务要清理一批旧用户,删除只跑了两百条,数据库 CPU 直接打满,原本几毫秒的删除语句变成秒级。给外键列补上索引后,同样的删除降到几十毫秒。
sql复制CREATE INDEX idx_order_items_order_id ON order_items(order_id);
外键索引缺失还会连带影响父表更新操作。因为 ON DELETE 和 ON UPDATE 都要根据外键列定位子表行,索引缺失会让更新父键也变成全表扫描。
大表级联删除还会造成 WAL 日志膨胀。一次删除几百万行,WAL 文件会快速增长,如果开启了归档,归档备份也可能跟不上。所以大数据量删除尽量不要放在业务高峰期,宁可选择凌晨维护窗口执行,删完后立刻手动执行一次 VACUUM 回收膨胀空间。
分批删除是比较可靠的操作方式:
sql复制DO $$
DECLARE
v_batch_size integer := 1000;
v_deleted integer;
BEGIN
LOOP
DELETE FROM parent_table
WHERE id IN (
SELECT id
FROM parent_table
WHERE created_at < '2023-01-01'
ORDER BY id
LIMIT v_batch_size
)
RETURNING count(*) INTO v_deleted;
EXIT WHEN v_deleted = 0;
COMMIT;
PERFORM pg_sleep(0.1);
END LOOP;
END $$;
分批删除有一点要注意:每一批是独立事务,如果中途报错,前面已删的数据不会自动回滚。执行前务必备份,或者先查一遍还剩多少数据量,做好心理建设。
4.2 关联删除的死锁链路与规避
外键约束和 DELETE 语句一起跑,另一个经典问题就是死锁。死锁的高发场景是两个会话同时删除不同父子表,但删除顺序不一致。
举一个常见例子。会话 A 先删除订单表数据,触发了外键检查,需要到子表订单明细上定位引用行;会话 B 同时删除了订单明细表的数据,又试图回过去更新或删除订单表。两边互相持有了对方下一步要操作的锁,数据库就只能牺牲一个事务。
规避办法有三个层面。最根本的是让所有删除操作固定表处理顺序,比如都统一先删子表、再删父表,从应用入口上杜绝交叉;其次是能合并的事务尽量合并,减少多个会话同时操作同一组表;第三是对于可延迟约束,在事务开头执行 SET CONSTRAINTS ALL DEFERRED,把检查推到提交阶段,给数据库更多余地重新排序。
sql复制BEGIN;
SET CONSTRAINTS ALL DEFERRED;
DELETE FROM parent_table WHERE id = 100;
DELETE FROM child_table WHERE parent_id = 100;
COMMIT;
不过延迟约束也有风险,执行复杂事务时提交阶段才报外键冲突,事务整体回滚后可能造成连接占用时间变长,外部接口容易超时。用之前要权衡。
4.3 触发器和外键检查的执行顺序
PostgreSQL 里外键约束在内部就是约束触发器。用户自定义的 AFTER DELETE 触发器和外键检查的执行顺序,并不是完全由我们直观控制,尤其在同一张表上挂多个触发器时,顺序按名称字母序之类的规则决定。
在实际项目里,我踩过一次坑:在父表上写了一个 AFTER DELETE 触发器,逻辑是删父数据后顺手去清理一张独立日志表的旧记录。这个触发器运行时,子表外键检查已经先跑完了,日志表清理动作做得没问题,但后来发现日志表里还残留着一些理论上不该存在的记录,原因是另一条调用链没有走这张父表的删除逻辑,直接从子表入口删了数据。
这样的问题很难从代码层面直接定位。我的建议是:不要依赖外键触发器和用户自定义触发器的相对顺序来保证业务一致性。凡是跨表的状态变更,最好在同一个存储过程或同一个应用事务里显式完成,能不用触发器就不用。
4.4 分区表、逻辑复制和备份下的额外注意事项
分区表上的外键策略限制比较多。老版本 PostgreSQL 要求外键必须包含分区键,否则连约束都建不上;虽然新版放宽了很多,但涉及到 ON DELETE CASCADE 跨分区删除时,仍然要注意删除性能可能更差,因为每个分区里的索引都是独立的。
逻辑复制场景下,父表上执行级联删除,物理删除操作发生在源库,会产生删除日志并复制到订阅端,订阅端通常不会重新执行外键检查。这是很合理的默认行为,但意味着订阅端如果额外建立了一些源库没有的约束或触发器,业务数据流过去后行为可能不同。遇到这类情况,先把两端约束对齐,再做迁移。
备份和恢复策略也要把 ON DELETE 考虑进去。一个常用操作是删除大量数据前做一次 pg_dump 或快照备份。即便是多级 CASCADE 把子表删空了,只要备份在线,就还能恢复回来。我见过最惨的案例是生产环境没有开启归档,误删父表后所有子表数据被级联清空,最后只能从一天前的备份恢复,丢失了大量当天数据。备份永远是你最后的退路,删数据之前一定检查备份是否完好。
5. 线上排查与实验方法:把约束查出来、删一遍再改
5.1 一条 SQL 把所有外键和删除规则翻出来
项目迭代久了,DBA 换了几批,外键策略很容易失控。此时最实用的动作是把线上所有外键约束连同删除规则一起导出来。
sql复制SELECT
c.conname AS constraint_name,
c.conrelid::regclass AS child_table,
a.attname AS child_column,
c.confrelid::regclass AS parent_table,
af.attname AS parent_column,
CASE c.confdeltype
WHEN 'a' THEN 'NO ACTION'
WHEN 'r' THEN 'RESTRICT'
WHEN 'c' THEN 'CASCADE'
WHEN 'n' THEN 'SET NULL'
WHEN 'd' THEN 'SET DEFAULT'
END AS delete_rule,
CASE c.confupdtype
WHEN 'a' THEN 'NO ACTION'
WHEN 'r' THEN 'RESTRICT'
WHEN 'c' THEN 'CASCADE'
WHEN 'n' THEN 'SET NULL'
WHEN 'd' THEN 'SET DEFAULT'
END AS update_rule
FROM pg_constraint c
JOIN pg_attribute a
ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey)
JOIN pg_attribute af
ON af.attrelid = c.confrelid AND af.attnum = ANY (c.confkey)
WHERE c.contype = 'f'
ORDER BY c.conrelid::regclass::text;
这条查询输出结果后,可以直接对照设计文档找出哪些外键策略不符合预期。我在很多项目里跑完这个 SQL,都能发现当初建表时没写 ON DELETE 而默认成 NO ACTION 的约束,这些表往往就是业务删除慢的隐患。
5.2 用事务把删除策略“演习”一遍
在测试环境验证策略,最直接的方法是把删除语句抛在一个事务里,执行后不回滚,先观察行为,再手动回滚。
sql复制BEGIN;
-- 先看这条删除会不会触发预期策略
DELETE FROM parent_table WHERE id = 100;
-- 检查子表数据状态
SELECT * FROM child_table WHERE parent_id = 100;
ROLLBACK;
如果约束是延迟的,还可以在事务里调整删除顺序,体会 SET CONSTRAINTS 对执行计划的影响。线上环境如果实在要验证,也务必在事务里做,别拿生产数据直接试手。
更进一步的验证是配合 EXPLAIN ANALYZE,看删除语句实际扫描了哪些表、用了哪些索引、耗时集中在哪一步。虽然 EXPLAIN ANALYZE 会真实执行删除,但外面包一层事务回滚就能避免落库。
5.3 我看过的三个经典翻车案例和复盘
案例一是 CASCADE 误删财务数据。业务方要求“用户注销后清理全部关联数据”,开发把所有外键全改成 CASCADE。结果运营误点了某个测试账号的注销,该账号关联的合同、发票、付款记录全部被物理删除。幸好备份及时,才没造成不可挽回的损失。复盘结论是:CASCADE 只应该用在“子表数据完全没有独立价值”的关系上,财务、合同这类审计数据一律禁止 CASCADE。
案例二是 SET NULL 撞上 NOT NULL 约束。表结构里外键列设置了 NOT NULL,同时又定了 ON DELETE SET NULL,DDL 创建时 PostgreSQL 并不会提前报错,直到实际删除父表行才报错。这属于建表时就被忽略的隐性冲突,排查起来有一定迷惑性,因为 SQL 层面看着完全正常。
案例三是延迟约束导致提交超时。项目为了批量清理数据,把多个外键都改成 DEFERRABLE INITIALLY DEFERRED,然后在一次大事务里删除几百万行。执行到提交阶段时检查外键发现还有残留引用,整个事务回滚,数据库连接长时间被占用,业务请求排队超时。复盘结论是:延迟约束只适合小事务内的顺序调整,大批量清理前必须先做数据预检。
最后的一点实战体会
现在的习惯是,每个新库的表关系评审,我把 ON DELETE 策略当成第一项必查项。每张表的外键必须明确写策略,不允许用隐式默认值。
同时在所有外键列上建索引,这是性价比最高的删改优化手段。之前调过的几个慢删除系统,八成问题都出在外键列没有索引,补上之后删父表速度肉眼可见提升。
还有一个小技巧:对每类删除策略写一条回归测试用例。比如“删除父数据后子表数量符合预期”“删除后外键不会残留空值垃圾”。这样后续如果有人改了 DDL 或迁移脚本,测试能第一时间暴露策略被改坏的问题。
ON DELETE 策略不是一行 DDL 的事,它决定的是数据整个生命周期的出口怎么走。花半天时间把现有表结构过一遍,把每张表的删除策略对齐到真实业务关系上,比后面被外键报错和慢删除折磨一整周要划算得多。
