你写了个清理脚本,把过期文章批量删掉。跑完去看站点,发现一堆讨论帖还挂在那里,点进去指向的页面已经 404 了。
现象
删除文章之后,站内还残留着指向已不存在页面的"空壳"讨论帖——用户能点进来,但页面主体是空的或者直接 404。看起来像删除没删干净。
如果你再去看看数据库,会发现这些讨论帖还在,只是它们指向文章的那个字段变成了 NULL。
值得注意的一个信号是:站点的"讨论数"统计可能还是对的(因为帖子确实还在),但页面上"关联文章"的标题全部是空的。这种"数据在、语义没了"的状态,正是悬空引用的典型表现。
根因
关联表的外键定义是 ON DELETE SET NULL,不是 ON DELETE CASCADE。
外键的删除行为决定了"删主记录时关联记录会怎样",常见的有几种:
| 行为 | 删除主记录时,关联记录会怎样 |
|---|---|
CASCADE | 关联记录一起删除 |
SET NULL | 关联记录保留,但其外键列被置为 NULL |
RESTRICT / NO ACTION | 如果有关联记录,禁止删除主记录 |
你的表用的是 SET NULL,所以删掉文章后,讨论帖还在,只是它的"所属文章"字段变成了 NULL——看起来就像悬空的孤儿记录。
这个默认值往往是建表时随手选的(或者 ORM 默认给的),并没有认真考虑过"删文章时帖子该怎么办"这个业务问题。直到真的删了一次才发现。
这里有个容易混淆的点:SET NULL 在数据库层面不是错误,它完全按定义执行了。数据库没坏、约束没失效——是"当初选的语义"和"现在想要的语义"不一致。所以去查日志、查约束、查报错,都找不到"问题",因为它压根没报错。
解决
第一步,删主记录之前,先显式删掉它的关联记录。 把一次删除拆成两步:
begin;
-- 先删关联记录(明确按外键值定位)
delete from discussion where "articleId" = $1;
-- 再删主记录
delete from "Article" where id = $1;
commit;
放在一个事务里,保证要么都成功要么都回滚——否则中途失败会留下更乱的状态。
第二步,清理已经产生的历史孤儿记录。
⚠️ 这里有一条最容易犯的致命错误:不能用"外键为空"来筛选待清理记录。
因为用户自发的帖子(不挂在任何文章下的那种)外键本来就是空的。按这个条件去删,会把大量用户原创内容一起删掉,而且是不可逆的灾难。
正确做法是加一个可靠条件来界定:
-- 错误示范:会误删用户自发内容
-- delete from discussion where "articleId" is null;
-- 正确思路:加时间窗 + 内容特征等可靠条件
select *
from discussion d
where d."articleId" is null
and d."createdAt" < '2026-01-01'
and d.content like '%占位%'
-- 先 select 出来让业务确认,再改成 delete
;
更稳妥的做法是:先 select 出来导出,让业务方确认这批是垃圾记录,再执行删除。
延伸与预防
一、动手写清理脚本前,先看清外键的删除行为。
select
conname,
confdeltype, -- 'c'=CASCADE, 'n'=SET NULL, 'r'=RESTRICT, 'a'=NO ACTION
conrelid::regclass as child_table,
confrelid::regclass as parent_table
from pg_constraint
where contype = 'f'
and confrelid = '"Article"'::regclass;
(注意上面表名加了引号,这是上一个坑的教训。)
二、任何批量删除脚本,先用 select 数一遍。
把"会被删的行"和"会被影响的行"都列出来,确认数量符合预期,再改成 delete。这一步能挡住绝大多数误删。养成习惯之后,写 delete 之前的那次 select 会变成肌肉记忆。
三、长期上,如果业务语义上讨论帖确实该随文章一起消失,那就把外键改成 ON DELETE CASCADE,从模型层面表达这个意图。让数据库的约束表达业务规则,而不是靠每次删除时都记得"我还要手动删关联表"——人总会忘。
最后一条判断方法:遇到"删了主记录、关联数据变奇怪",先别急着写补丁脚本,先查一遍这个外键的 confdeltype。搞清楚数据库本来打算怎么处理,再决定是"改外键语义"还是"在应用层显式删"——两者的适用场景不同,但都比"事后手工清理"要稳。