软删除设计
「删掉一条数据」在业务世界里很少真的是物理删除。用户注销后订单还要能查、邮件误删后要能进回收站找回、合同到期后还要留档审计——于是大多数业务系统都选择了软删除(soft delete):数据不真的从表里消失,而是打上一个「已删除」的标记,让普通查询把它过滤掉。
软删除看似只是「加一列」,但真要把它做对,牵扯的面比想象中宽:查询过滤要不要每次手写?唯一约束和软删除撞在一起怎么办(删除后想重建同名记录,却被告知「已存在」)?外键引用了软删除的行还算有效吗?恢复怎么实现?软删除的行越积越多,什么时候做物理清理与归档?这些问题每一个都有明确的工程答案,但每一个也都有大量做错的真实案例。
本页把这些答案一次讲全:deleted_at 与 is_deleted 两种建模方式的取舍、视图与默认过滤的工程化、部分唯一索引化解唯一约束冲突、外键与软删除的相互作用、恢复流程、定时物理清理,以及与触发器审计、Laravel SoftDeletes 的结合。
前置知识
建议先阅读:分页方案(OFFSET vs Keyset) 与 触发器概述。
基础概念
| 概念 | 英文 / 记法 | 说明 |
|---|---|---|
| 软删除 | soft delete | 用标记位表示「已删除」,数据仍在表中 |
| 硬删除 | hard delete | DELETE 物理移除行,数据不可恢复 |
| 删除时间戳 | deleted_at | 记录删除发生时间,NULL 表示未删除 |
| 删除标记 | is_deleted | 布尔标记,true 表示已删除 |
| 部分唯一索引 | partial unique index | 只对部分行生效的唯一索引,化解软删除与唯一约束的冲突 |
| 唯一性漂移 | — | 软删除行继续占用唯一键,导致新行无法使用同一键值 |
| 恢复 | restore / undelete | 把 deleted_at 置回 NULL,让行重新可见 |
| 归档 | archive | 把软删除行搬到归档表或归档库,主表保持精简 |
| TTL 清理 | time to live purge | 超过保留期的软删除行做物理删除 |
为什么要软删除
软删除的价值集中在四点:
- 可恢复。误操作最频繁的场景就是删除。软删除让「回收站」「撤销删除」成为普通 UPDATE,而不是从备份里捞数据。
- 可审计。合规(财务、医疗、电子证据)往往要求「删除」在数据层面可追溯。软删除保留原行,配合触发器还能记录是谁在什么时候删的。
- 引用完整。订单引用了客户,客户「注销」了,订单的外键仍然指向一个真实存在的行,统计与查询不会因为悬空引用而破。物理删除则必须级联删除整条引用链,通常不可行。
- 删除是业务事件。「会员注销」「合同作废」本身带有业务语义(时间、操作人、原因),软删除字段正好承载这些信息。
软删除的代价
代价同样明确:
- 每一条查询都要记得过滤。忘写
WHERE deleted_at IS NULL,已删除数据就泄漏到业务结果里。 - 表持续膨胀。删除的行都留在表里,索引也一起变大,扫描、VACUUM、备份的成本随之上升。
- 唯一约束冲突。这是软删除最经典的坑:用户名
alice被软删除后,新用户想注册alice,唯一索引却说「已存在」。 - 统计口径分裂。「活跃用户数」到底含不含已注销用户?每个聚合查询都要显式处理过滤条件,口径不一致会产生自相矛盾的报表。
一句话总结:软删除把「删除」从一次性操作变成了持续性成本。它适合「引用链长、需要恢复与审计」的核心业务数据;对日志、临时中间数据这类「删了就是删了」的数据,硬删除 + 分区反而更干净。
语法与用法
删除时间戳 vs 布尔标记
两种主流建模方式:
-- 方式一:时间戳(推荐)
ALTER TABLE users ADD COLUMN deleted_at timestamptz;
-- 方式二:布尔标记
ALTER TABLE users ADD COLUMN is_deleted boolean NOT NULL DEFAULT false;推荐方式一,理由:
| 维度 | deleted_at | is_deleted |
|---|---|---|
| 保留删除发生时间 | 有 | 无(除非再加列) |
| 判断条件 | IS NULL | = false |
| 时间范围查询(如「上月注销的」) | 直接支持 | 做不到 |
| 与恢复操作配合 | 置回 NULL 即可 | 需要额外字段 |
| 三态扩展(如 scheduled) | 可用特殊时间约定 | 难扩展 |
deleted_at IS NULL 在语义上比 is_deleted = false 信息量更大:一个字段同时回答「删没删」和「什么时候删的」。布尔方案唯一的小优势是显式 NOT NULL DEFAULT false 时判断条件写起来更直白,但为此多维护一个字段并丢失时间信息并不划算。
建表示例
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username text NOT NULL,
email text NOT NULL,
deleted_at timestamptz,
deleted_by bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- 普通唯一约束:username 全局唯一(连软删除的行一起算)——后面会改造成部分唯一索引
CONSTRAINT users_username_key UNIQUE (username)
);
-- 基础过滤索引:只索引「未删除」的行,既小又快
CREATE INDEX idx_users_active ON users (username) WHERE deleted_at IS NULL;
-- 删除操作:永远用 UPDATE 而不是 DELETE
UPDATE users
SET deleted_at = now(), deleted_by = 1001
WHERE id = 42 AND deleted_at IS NULL;注意 UPDATE ... WHERE id = 42 AND deleted_at IS NULL:加上 deleted_at IS NULL 让「重复删除」变成无操作(影响 0 行)而不是覆盖第一次删除的时间与操作人——这个细节对审计很重要。
用视图封装过滤条件
把「未删除」这个语义固化到视图里,业务查询从视图走,就不必每处手写过滤:
-- 主视图:只含未删除行
CREATE VIEW v_users AS
SELECT id, username, email, created_at, updated_at
FROM users
WHERE deleted_at IS NULL;
-- 业务查询从视图走,天然过滤已删除行
SELECT * FROM v_users WHERE email LIKE '%@example.com';
-- 需要看已删除数据(回收站)时,直接查基表
SELECT * FROM users WHERE deleted_at IS NOT NULL;视图方案的另一个好处是权限控制:可以只授予业务账号 v_users 的 SELECT 权限而不授予基表,从机制上杜绝「忘了过滤」这类事故。
删除原因与批量删除标记
deleted_at 回答「什么时候删的」,deleted_by 回答「谁删的」,但「为什么删」往往也需要落库——尤其是「运营批量下架」「风控冻结」「合规要求撤回」这类有明确业务动机的删除:
ALTER TABLE documents
ADD COLUMN delete_reason text, -- 约定枚举:user_request / compliance / admin_cleanup / risk_freeze
ADD COLUMN delete_batch uuid; -- 批量删除的批次号,单条删除留 NULL两个字段的使用纪律:
delete_reason用应用层维护的受控枚举(CHECK 约束或字典表兜底),自由文本的原因列很快会变成没人看得懂的垃圾场;delete_batch把「一次运营操作删掉的 8000 行」绑成一个批次:恢复接口可以按批次整体恢复,审计报表可以按批次统计影响面,误操作的定位从「逐行排查」变成「按批次回放」。
-- 批量下架:一个批次号标记所有命中行
UPDATE documents
SET deleted_at = now(),
deleted_by = 7,
delete_reason = 'compliance',
delete_batch = 'b3f1c2a9-1111-2222-3333-444455556666'
WHERE category = 'expired_promo'
AND deleted_at IS NULL
RETURNING id;
-- 批次影响面(审计/复盘报表)
SELECT count(*) AS affected, min(deleted_at) AS started_at
FROM documents WHERE delete_batch = 'b3f1c2a9-1111-2222-3333-444455556666';
-- 按批次整体恢复(误操作的后悔药)
UPDATE documents
SET deleted_at = NULL, delete_batch = NULL
WHERE delete_batch = 'b3f1c2a9-1111-2222-3333-444455556666'
AND deleted_at IS NOT NULL;批次列与部分唯一索引不冲突:它只做分组标识,不参与唯一性。两点纪律必须遵守:批量 UPDATE 的 WHERE 条件同样要带 deleted_at IS NULL 守卫(避免重复执行时覆盖首次删除的原因与批次);执行前先用同样条件的 SELECT count(*) 预估影响面——「先数再删」是批量操作的铁律。
详细说明
部分唯一索引:化解软删除与唯一约束的冲突
这是软删除设计中最关键的一步。默认的唯一约束作用于所有行(包括已删除行),于是出现「唯一性漂移」:alice 注销后,新 alice 永远无法注册。
解决办法是部分唯一索引(partial unique index)——唯一性只约束「未删除」的行:
-- 删除默认的唯一约束
ALTER TABLE users DROP CONSTRAINT users_username_key;
-- 改为部分唯一索引:只对未删除行保证 username 唯一
CREATE UNIQUE INDEX users_username_active_uniq
ON users (username)
WHERE deleted_at IS NULL;
-- 同样处理 email
CREATE UNIQUE INDEX users_email_active_uniq
ON users (email)
WHERE deleted_at IS NULL;效果验证:
-- 未删除行的 username 冲突依然会被拒绝
INSERT INTO users (username, email) VALUES ('alice', 'alice@x.com');
-- ERROR: duplicate key value violates unique constraint "users_username_active_uniq"
-- 软删除 alice 后可以再次插入 alice
UPDATE users SET deleted_at = now() WHERE username = 'alice';
INSERT INTO users (username, email) VALUES ('alice', 'alice2@x.com');
-- INSERT 0 1一个更强的变体是「同一键值只能有一个未删除版本」不够用、还需要「同一键值可以多次历史删除」时,把 deleted_at 也纳入键(PostgreSQL 的唯一索引把 NULL 视为互不相等,因此「未删除」的多行不会互相冲突的写法要小心——见下方的注意事项):
-- 允许多个已删除版本共用同一 username(NULL 不参与唯一比较)
CREATE UNIQUE INDEX users_username_active_uniq
ON users (username)
WHERE deleted_at IS NULL;NULL 的唯一索引行为要牢记
PostgreSQL 默认索引中 NULL 互不相等(即两个 NULL 不冲突),PG15 起可用 NULLS NOT DISTINCT 改变这一点。做「含软删除列的唯一索引」时,一定先想清楚索引列里出现 NULL 的语义,写用例验证,否则会出现「约束看起来存在却拦不住重复」的事故。
外键与软删除的相互作用
软删除不影响数据库层面的外键完整性——行还在,外键照样成立。但业务层面要回答两个问题:
问题一:能不能软删除一个仍被引用的行? 数据库不会拦(行没删),要拦只能靠触发器或应用层校验:
-- 触发器:有未完成订单的客户不允许软删除
CREATE FUNCTION prevent_deleting_customer_with_orders() RETURNS trigger AS $$
BEGIN
IF EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = NEW.id AND o.deleted_at IS NULL
) THEN
RAISE EXCEPTION 'customer % has active orders', NEW.id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_users_soft_delete_guard
BEFORE UPDATE OF deleted_at ON users
FOR EACH ROW
WHEN (NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL)
EXECUTE FUNCTION prevent_deleting_customer_with_orders();问题二:软删除要不要级联传播? 客户软删除了,他的订单、地址要不要一起标记删除?推荐做法是级联软删除用显式事务批量 UPDATE,而不要依赖触发器递归传播——触发器级联隐藏了删除范围,业务上难以审计:
BEGIN;
UPDATE users SET deleted_at = now() WHERE id = 42;
UPDATE addresses SET deleted_at = now() WHERE user_id = 42;
UPDATE orders SET deleted_at = now() WHERE user_id = 42;
COMMIT;级联范围在事务里一目了然,回滚也只回滚一次操作。
恢复流程
恢复就是反向 UPDATE,但要处理两件事:恢复条件校验(唯一键是否已被占用)与级联恢复:
-- 恢复前校验:username 是否已被其他未删除行占用
SELECT 1 FROM users
WHERE username = (SELECT username FROM users WHERE id = 42)
AND id <> 42
AND deleted_at IS NULL;
-- 校验通过后执行恢复
UPDATE users
SET deleted_at = NULL, deleted_by = NULL, updated_at = now()
WHERE id = 42 AND deleted_at IS NOT NULL;校验与恢复要放在同一个事务里,并依赖部分唯一索引兜底——即使应用层校验有并发漏洞,唯一索引也会在恢复时拦住重复。
定时物理清理与归档
软删除行无限堆积会让表越来越胖。常见策略是两段式:先归档(搬到归档表),再物理删除:
-- 归档表结构与主表一致,并可加归档时间
CREATE TABLE users_archived (LIKE users INCLUDING ALL);
ALTER TABLE users_archived ADD COLUMN archived_at timestamptz NOT NULL DEFAULT now();
-- 第一步:把注销超过 180 天的行搬去归档表
WITH moved AS (
DELETE FROM users
WHERE deleted_at < now() - interval '180 days'
RETURNING *
)
INSERT INTO users_archived
SELECT *, now() FROM moved;
-- 第二步:清理归档表里超过保留上限的数据(按合规要求决定是否保留)
DELETE FROM users_archived WHERE archived_at < now() - interval '3 years';清理任务的运维要点:
- 分批执行。
DELETE大批量会持锁太久、产生大事务与 WAL 峰值。用LIMIT(通过子查询)或按 id 范围分批,每批一个事务。 - 安排在低峰。清理与业务写入争抢 I/O 与锁资源。
- 用
pg_cron调度。PostgreSQL 15+ 生态的pg_cron扩展可以让清理 SQL 按计划自动跑,无需外部 crontab。 - 保留期写入合规文档。财务数据往往要求保留数年,删除策略不能只看工程偏好。
软删除对统计的影响
聚合查询必须统一过滤口径,否则报表自相矛盾:
-- 反例:两个查询口径不一致,总和对不上
SELECT count(*) FROM orders; -- 含已删除订单
SELECT sum(amount) FROM orders WHERE deleted_at IS NULL; -- 只算有效订单
-- 正例:所有聚合显式声明口径
SELECT
count(*) FILTER (WHERE deleted_at IS NULL) AS active_orders,
count(*) FILTER (WHERE deleted_at IS NOT NULL) AS deleted_orders,
sum(amount) FILTER (WHERE deleted_at IS NULL) AS active_amount
FROM orders;FILTER 子句让多个口径的聚合在一条 SQL 里并列出现,口径一目了然。团队规范里应明确:任何对软删除表的聚合都必须显式写出口径,禁止裸 count(*)。
与触发器审计结合
软删除行记录了「删除了什么、什么时候」,配合触发器可以补上「谁删的、删之前是什么」:
-- 审计日志表
CREATE TABLE audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
table_name text NOT NULL,
row_id bigint NOT NULL,
action text NOT NULL, -- 'soft_delete' / 'restore'
old_data jsonb,
actor_id bigint,
occurred_at timestamptz NOT NULL DEFAULT now()
);
CREATE FUNCTION log_soft_delete() RETURNS trigger AS $$
BEGIN
INSERT INTO audit_log (table_name, row_id, action, old_data, actor_id)
VALUES (
TG_TABLE_NAME,
OLD.id,
CASE WHEN NEW.deleted_at IS NOT NULL THEN 'soft_delete' ELSE 'restore' END,
to_jsonb(OLD),
NEW.deleted_by
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_users_audit
AFTER UPDATE OF deleted_at ON users
FOR EACH ROW
WHEN (OLD.deleted_at IS DISTINCT FROM NEW.deleted_at)
EXECUTE FUNCTION log_soft_delete();WHEN (OLD.deleted_at IS DISTINCT FROM NEW.deleted_at) 保证只有删除状态真正变化时才写审计——普通的 UPDATE users SET email = ... 不会产生噪音记录。触发器的完整机制见触发器概述。
Laravel SoftDeletes 概述
Laravel 的 SoftDeletes trait 是 PHP 生态里软删除的标准化实现,核心就是 deleted_at 时间戳:
<?php
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class User extends Model
{
use SoftDeletes; // 约定使用 deleted_at 列
}
// 查询默认过滤已删除行
User::where('active', true)->get();
// select * from users where active = true and deleted_at is null
// 包含已删除行 / 仅已删除行
User::withTrashed()->get();
User::onlyTrashed()->get();
// 软删除与恢复(都是普通 UPDATE,不触碰 DELETE)
$user->delete(); // UPDATE users SET deleted_at = now() WHERE id = ?
$user->restore(); // UPDATE users SET deleted_at = null WHERE id = ?
$user->forceDelete(); // 真正的 DELETE
// 判断与键名
$user->trashed(); // 是否已软删除
User::withTrashed()->find(5)->id;要点:
- 所有查询(关联查询、预加载)都会自动带上
deleted_at IS NULL,这正是「把过滤固化到框架层」的思路,与视图方案殊途同归。 forceDelete()是物理删除,要谨慎暴露给业务代码。- 注意:Eloquent 的关联默认跳过已软删除的关联对象,如果业务需要「注销用户的历史订单」,必须显式
withTrashed()。 - 无论用什么框架,数据库层的部分唯一索引仍然是必需的——框架能保证「应用的删除是软删除」,保证不了「绕过应用的数据操作」也遵守唯一性语义。
分区表与软删除的组合
对超大表(日志、事件流水),分区 + 软删除的组合要谨慎:软删除行分布在所有分区,物理清理时要逐分区扫。更干净的做法是这类数据不做软删除,直接用分区整体 DROP 实现按时间批量的「硬删除」——按时间淘汰的场景里,「恢复」的诉求通常由备份承担,不需要软删除。两者的取舍见分区概述一页。
实战示例
示例一:完整的软删除表与部分唯一索引
-- 1. 建表:含软删除字段与操作人
CREATE TABLE documents (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
owner_id bigint NOT NULL,
doc_no text NOT NULL,
title text NOT NULL,
deleted_at timestamptz,
deleted_by bigint,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 2. 部分唯一索引:未删除的 doc_no 唯一
CREATE UNIQUE INDEX documents_doc_no_active_uniq
ON documents (doc_no) WHERE deleted_at IS NULL;
-- 3. 列表查询索引:owner + 未删除
CREATE INDEX documents_owner_active_idx
ON documents (owner_id) WHERE deleted_at IS NULL;
-- 4. 软删除一条
UPDATE documents
SET deleted_at = now(), deleted_by = 7
WHERE id = 100 AND deleted_at IS NULL
RETURNING id, doc_no;
-- 5. 用同一 doc_no 重建一条(唯一索引不拦,因为旧行已删除)
INSERT INTO documents (owner_id, doc_no, title)
VALUES (7, 'DOC-2026-001', 'recreated doc');
-- 6. 验证:同一 doc_no 只能有一个未删除版本
INSERT INTO documents (owner_id, doc_no, title)
VALUES (7, 'DOC-2026-001', 'duplicate doc');ERROR: duplicate key value violates unique constraint "documents_doc_no_active_uniq"
DETAIL: Key (doc_no)=(DOC-2026-001) already exists.示例二:批量软删除 + 级联 + 审计的事务
BEGIN;
-- 删除用户
UPDATE users SET deleted_at = now(), deleted_by = 7
WHERE id = 42 AND deleted_at IS NULL;
-- 级联删除其地址与订单(显式列出,可审计)
UPDATE addresses SET deleted_at = now(), deleted_by = 7
WHERE user_id = 42 AND deleted_at IS NULL;
UPDATE orders SET deleted_at = now(), deleted_by = 7
WHERE user_id = 42 AND deleted_at IS NULL;
COMMIT;示例三:定时清理任务(分批)
-- 每批最多 10000 行,循环直到清完(可在 pg_cron 或应用调度器中执行)
DO $$
DECLARE
affected int;
BEGIN
LOOP
WITH batch AS (
SELECT id FROM documents
WHERE deleted_at < now() - interval '180 days'
LIMIT 10000
FOR UPDATE SKIP LOCKED
)
DELETE FROM documents d
USING batch
WHERE d.id = batch.id;
GET DIAGNOSTICS affected = ROW_COUNT;
EXIT WHEN affected = 0;
COMMIT; -- 需要 CALL 的存储过程版本;DO 内不能 COMMIT 时改为应用层循环
END LOOP;
END;
$$;说明
DO 块内部不允许事务控制;生产中通常把上面的循环改写为 CREATE PROCEDURE ... LANGUAGE plpgsql 并用 CALL 执行(过程体支持 COMMIT),或在应用层循环执行单批 DELETE。这里保留 DO 版本仅作骨架示意。
生产版存储过程(每批独立提交,锁与 WAL 均摊,中途失败已跑的批次不回滚):
CREATE PROCEDURE purge_deleted_documents(batch_size int DEFAULT 10000)
LANGUAGE plpgsql AS $$
DECLARE
affected int;
BEGIN
LOOP
WITH batch AS (
SELECT id FROM documents
WHERE deleted_at < now() - interval '180 days'
LIMIT batch_size
FOR UPDATE SKIP LOCKED
)
DELETE FROM documents d USING batch WHERE d.id = batch.id;
GET DIAGNOSTICS affected = ROW_COUNT;
EXIT WHEN affected = 0;
COMMIT; -- 过程体内允许事务控制:每批一个独立事务
END LOOP;
END;
$$;
CALL purge_deleted_documents(5000);分批 + SKIP LOCKED 的组合让清理任务与在线业务互不阻塞,重试与退避的机制细节与任务队列一页相通,见下一节。
示例四:回收站接口(PHP)
<?php
// 回收站:列出已删除文档
function listTrash(PDO $pdo, int $ownerId, int $limit = 50): array
{
$stmt = $pdo->prepare(<<<'SQL'
SELECT id, doc_no, title, deleted_at
FROM documents
WHERE owner_id = :owner AND deleted_at IS NOT NULL
ORDER BY deleted_at DESC
LIMIT :limit
SQL);
$stmt->execute([':owner' => $ownerId, ':limit' => $limit]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
// 恢复:校验唯一性后恢复(依赖事务 + 部分唯一索引双保险)
function restoreDocument(PDO $pdo, int $id, int $actorId): void
{
$pdo->beginTransaction();
try {
$check = $pdo->prepare(<<<'SQL'
SELECT 1 FROM documents d1
WHERE d1.id = :id
AND EXISTS (
SELECT 1 FROM documents d2
WHERE d2.doc_no = d1.doc_no
AND d2.id <> d1.id
AND d2.deleted_at IS NULL
)
SQL);
$check->execute([':id' => $id]);
if ($check->fetchColumn()) {
throw new DomainException('doc_no is occupied by another active document');
}
$restore = $pdo->prepare(<<<'SQL'
UPDATE documents
SET deleted_at = NULL, deleted_by = NULL
WHERE id = :id AND deleted_at IS NOT NULL
SQL);
$restore->execute([':id' => $id]);
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}注意事项
所有查询都要过滤,忘了就漏数据。 这是软删除的第一事故源。用视图、ORM 全局作用域或查询构建器默认条件把过滤固化到「唯一入口」,代码评审时见到裸查基表的 SELECT 直接打回。
唯一约束必须改造成部分唯一索引。 不改的话,「删除后重建同名记录」这个最常见的业务动作会被数据库拦死;改的时候要先用用例验证 NULL 的唯一索引行为。
部分唯一索引只保证「一个未删除版本」,不保证「恢复不撞车」。 并发恢复同一键值的两个已删除行,最终由唯一索引裁决,应用要把唯一冲突翻译成业务错误提示,而不是 500。
软删除行持续膨胀,必须有清理策略。 上线时就要定好保留期与清理任务,而不是等表膨胀到影响查询再补救。清理用分批 + 低峰执行,避免长事务与大 WAL 峰值。
统计口径必须显式。 任何聚合都要写清楚含不含已删除行;报表口径不一致造成的「数字对不上」排查成本远高于每条 SQL 多写一个条件。
外键约束依然生效,级联语义要重新审视。 数据库的 ON DELETE CASCADE 只对物理删除生效;软删除的级联要在应用层或显式事务里实现,两边不能混着用。
恢复要校验业务条件,不只是置回 NULL。 唯一键被占用、父级仍然删除态、合同已过期……恢复操作和删除一样是业务操作,要过同样的校验。
审计要记「删除前的完整状态」。 只记 id 与时间不够,to_jsonb(OLD) 把整行快照存下来,将来追责、还原、对账都有据可依。
权限上区分「业务视图」与「数据管理」。 业务账号只给视图权限,基表(含已删除数据)只给管理账号——把「能看到已删除数据」变成显式授权而不是默认能力。
最佳实践
- 统一使用
deleted_at timestamptz建模,不用布尔标记。 一个字段同时承载「删没删」与「何时删」,时间范围查询、TTL 清理、审计都直接可用。 - 把「未删除」过滤固化到唯一入口。 视图、ORM 全局作用域或统一的 Repository 基类,选一种并全团队强制;评审中禁止绕过入口直查基表。
- 唯一性一律用部分唯一索引
CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL。 同时为这些索引写「删除后可重建」「恢复不冲突」两条用例,防止未来改动破坏语义。 - 删除语句永远带
AND deleted_at IS NULL守卫。 让重复删除成为无操作,保护第一次删除的时间与操作人。 - 删除操作人(
deleted_by)与删除原因要落列或落审计表。 「谁在什么时候删的」是绝大多数删除争议的第一问题,靠 WAL 或日志还原几乎不可能。 - 级联软删除用显式事务批量 UPDATE,不用触发器递归。 删除范围一目了然、可审计、可回滚;触发器级联的隐式传播在排障时是灾难。
- 恢复是业务操作:先校验(唯一键、父级状态)再执行,并用唯一索引兜底。 恢复入口与删除入口一样要写测试。
- 软删除表要有明确的 TTL 与归档策略。 归档表(
LIKE ... INCLUDING ALL)+ 分批 DELETE + 低峰调度;保留期由合规而非工程直觉决定。 - 聚合一律用
FILTER显式声明口径。 「活跃」与「全量」两个口径并列出现在一条 SQL 里,报表自相矛盾的问题从源头消失。 - 配合触发器审计记录删除前快照。
AFTER UPDATE OF deleted_at ... WHEN (OLD.deleted_at IS DISTINCT FROM NEW.deleted_at)只在状态变化时写审计,无噪音。 - 框架的软删除能力(如 Laravel SoftDeletes)与数据库约束是互补关系。 框架保证应用行为一致,数据库保证数据语义一致;两者都要有,缺一不可。
- 流水型大数据不做软删除,用分区 DROP 替代。 按时间淘汰、无需恢复的数据,分区的整体淘汰比逐行打标 + 清理干净得多。
下一节
继续学习:任务队列(SKIP LOCKED)
参考链接
- PostgreSQL 官方文档:Partial Indexes(部分索引)
- PostgreSQL 官方文档:Unique Indexes 与 NULL 处理
- PostgreSQL 官方文档:CREATE TRIGGER
- PostgreSQL 官方文档:CREATE VIEW
- PostgreSQL 官方文档:DELETE
- PostgreSQL 官方文档:Aggregate Expressions(FILTER 子句)
- Laravel 官方文档:Eloquent Soft Deletes
- PostgreSQL 官方文档:pg_cron(定时任务扩展)
- PostgreSQL 官方文档:Table and Index Maintenance(清理膨胀)