PostgreSQL软删除需在BEFORE DELETE触发器中显式RETURN NULL,否则仍执行物理删除;外键须禁用CASCADE,改用SET NULL或RESTRICT;须配合视图、RLS及条件索引确保逻辑一致性。

BEFORE DELETE触发器必须返回NULL才能跳过物理删除
PostgreSQL 的 BEFORE DELETE 触发器不是“拦截器”,它不会自动取消删除——你得显式返回 NULL,否则数据库照常执行物理删除。这是软删除能成立的前提,也是最容易漏掉的一行代码。
常见错误现象是:触发器函数写了 UPDATE 设置 deleted_at,但没写 RETURN NULL,结果数据既被标记又真删了。
- 函数末尾必须加
RETURN NULL(对行级触发器) - 不能在触发器里写
ROLLBACK—— 这会中止整个事务,不是仅跳过当前行 - 若用语句级触发器(
FOR EACH STATEMENT),OLD 不可用,得靠tg_argv或临时表传参,不推荐用于软删除
关联表的软删除必须手动更新,不能依赖ON DELETE CASCADE
外键设了 ON DELETE CASCADE 会直接物理删除子表记录,完全绕过你的软删除逻辑。比如用户表 users 软删时,订单表 orders 若有 CASCADE,就会把订单真删掉,而不是设 deleted_at。
实操建议:
- 所有外键约束必须改成
ON DELETE SET NULL或RESTRICT,确保级联行为可控 - 在主表的
BEFORE DELETE触发器里,用UPDATE手动设置子表的deleted_at字段(如UPDATE orders SET deleted_at = NOW() WHERE user_id = OLD.id) - 多层关联(如
users → orders → order_items)不要在触发器里递归调用,应拆成应用层事务或分步批量UPDATE
WHERE deleted_at IS NULL 必须进索引和视图,不能靠应用层拼条件
漏加 WHERE deleted_at IS NULL 是软删除系统最常出问题的地方——尤其是 ORM 自动生成的 JOIN 查询、聚合统计、或管理员直连查原表时,会意外读到已软删的数据。
真正防得住的方式只有两种:
- 建视图封装活跃数据:
CREATE VIEW users_active AS SELECT * FROM users WHERE deleted_at IS NULL,让所有业务查询只走这个视图 - 启用 RLS(行级安全策略):
CREATE POLICY select_not_deleted ON users FOR SELECT USING (deleted_at IS NULL),这样即使查原表也自动过滤 - 关键字段(如邮箱唯一性)的约束必须加条件:
CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE deleted_at IS NULL,否则软删后无法重用邮箱
硬删除需求必须独立通道,不能和软删除混用
运维清理归档、GDPR 彻底擦除、修复迁移脏数据等场景,需要真实物理删除。如果所有 DELETE 都被同一个触发器劫持,这些操作就卡死。
解决方案不是“给触发器加开关参数”,而是提供隔离路径:
- 建专用函数,如
hard_delete_user(id INTEGER),内部用DELETE FROM users WHERE id = $1并关闭 RLS(用SET LOCAL row_security TO OFF) - 给 DBA 单独分配 bypass 角色,该角色在查询时不受 RLS 限制
- 绝对不要在触发器里判断
CURRENT_USER = 'admin'来绕过逻辑——权限应由角色体系控制,而非硬编码在触发器里
最易被忽略的是:RLS 对超级用户默认不生效,测试时用 postgres 用户连,永远看不出策略是否起作用。必须用普通角色登录验证。

















