Skip to content

软删除设计 ​

「删掉一条数据」在业务世界里很少真的是物理删除。用户注销后订单还要能查、邮件误删后要能进回收站找回、合同到期后还要留档审计——于是大多数业务系统都选择了软删除(soft delete):数据不真的从表里消失,而是打上一个「已删除」的标记,让普通查询把它过滤掉。

软删除看似只是「加一列」,但真要把它做对,牵扯的面比想象中宽:查询过滤要不要每次手写?唯一约束和软删除撞在一起怎么办(删除后想重建同名记录,却被告知「已存在」)?外键引用了软删除的行还算有效吗?恢复怎么实现?软删除的行越积越多,什么时候做物理清理与归档?这些问题每一个都有明确的工程答案,但每一个也都有大量做错的真实案例。

本页把这些答案一次讲全:deleted_at 与 is_deleted 两种建模方式的取舍、视图与默认过滤的工程化、部分唯一索引化解唯一约束冲突、外键与软删除的相互作用、恢复流程、定时物理清理,以及与触发器审计、Laravel SoftDeletes 的结合。

前置知识

建议先阅读:分页方案(OFFSET vs Keyset) 与 触发器概述。

基础概念 ​

概念英文 / 记法说明
软删除soft delete用标记位表示「已删除」,数据仍在表中
硬删除hard deleteDELETE 物理移除行,数据不可恢复
删除时间戳deleted_at记录删除发生时间,NULL 表示未删除
删除标记is_deleted布尔标记,true 表示已删除
部分唯一索引partial unique index只对部分行生效的唯一索引,化解软删除与唯一约束的冲突
唯一性漂移—软删除行继续占用唯一键,导致新行无法使用同一键值
恢复restore / undelete把 deleted_at 置回 NULL,让行重新可见
归档archive把软删除行搬到归档表或归档库,主表保持精简
TTL 清理time to live purge超过保留期的软删除行做物理删除

为什么要软删除 ​

软删除的价值集中在四点:

  1. 可恢复。误操作最频繁的场景就是删除。软删除让「回收站」「撤销删除」成为普通 UPDATE,而不是从备份里捞数据。
  2. 可审计。合规(财务、医疗、电子证据)往往要求「删除」在数据层面可追溯。软删除保留原行,配合触发器还能记录是谁在什么时候删的。
  3. 引用完整。订单引用了客户,客户「注销」了,订单的外键仍然指向一个真实存在的行,统计与查询不会因为悬空引用而破。物理删除则必须级联删除整条引用链,通常不可行。
  4. 删除是业务事件。「会员注销」「合同作废」本身带有业务语义(时间、操作人、原因),软删除字段正好承载这些信息。

软删除的代价 ​

代价同样明确:

  1. 每一条查询都要记得过滤。忘写 WHERE deleted_at IS NULL,已删除数据就泄漏到业务结果里。
  2. 表持续膨胀。删除的行都留在表里,索引也一起变大,扫描、VACUUM、备份的成本随之上升。
  3. 唯一约束冲突。这是软删除最经典的坑:用户名 alice 被软删除后,新用户想注册 alice,唯一索引却说「已存在」。
  4. 统计口径分裂。「活跃用户数」到底含不含已注销用户?每个聚合查询都要显式处理过滤条件,口径不一致会产生自相矛盾的报表。

一句话总结:软删除把「删除」从一次性操作变成了持续性成本。它适合「引用链长、需要恢复与审计」的核心业务数据;对日志、临时中间数据这类「删了就是删了」的数据,硬删除 + 分区反而更干净。

语法与用法 ​

删除时间戳 vs 布尔标记 ​

两种主流建模方式:

sql
-- 方式一:时间戳(推荐)
ALTER TABLE users ADD COLUMN deleted_at timestamptz;

-- 方式二:布尔标记
ALTER TABLE users ADD COLUMN is_deleted boolean NOT NULL DEFAULT false;

推荐方式一,理由:

维度deleted_atis_deleted
保留删除发生时间有无(除非再加列)
判断条件IS NULL= false
时间范围查询(如「上月注销的」)直接支持做不到
与恢复操作配合置回 NULL 即可需要额外字段
三态扩展(如 scheduled)可用特殊时间约定难扩展

deleted_at IS NULL 在语义上比 is_deleted = false 信息量更大:一个字段同时回答「删没删」和「什么时候删的」。布尔方案唯一的小优势是显式 NOT NULL DEFAULT false 时判断条件写起来更直白,但为此多维护一个字段并丢失时间信息并不划算。

建表示例 ​

sql
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 行)而不是覆盖第一次删除的时间与操作人——这个细节对审计很重要。

用视图封装过滤条件 ​

把「未删除」这个语义固化到视图里,业务查询从视图走,就不必每处手写过滤:

sql
-- 主视图:只含未删除行
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 回答「谁删的」,但「为什么删」往往也需要落库——尤其是「运营批量下架」「风控冻结」「合规要求撤回」这类有明确业务动机的删除:

sql
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 行」绑成一个批次:恢复接口可以按批次整体恢复,审计报表可以按批次统计影响面,误操作的定位从「逐行排查」变成「按批次回放」。
sql
-- 批量下架:一个批次号标记所有命中行
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)——唯一性只约束「未删除」的行:

sql
-- 删除默认的唯一约束
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;

效果验证:

sql
-- 未删除行的 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 视为互不相等,因此「未删除」的多行不会互相冲突的写法要小心——见下方的注意事项):

sql
-- 允许多个已删除版本共用同一 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 的语义,写用例验证,否则会出现「约束看起来存在却拦不住重复」的事故。

外键与软删除的相互作用 ​

软删除不影响数据库层面的外键完整性——行还在,外键照样成立。但业务层面要回答两个问题:

问题一:能不能软删除一个仍被引用的行? 数据库不会拦(行没删),要拦只能靠触发器或应用层校验:

sql
-- 触发器:有未完成订单的客户不允许软删除
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,而不要依赖触发器递归传播——触发器级联隐藏了删除范围,业务上难以审计:

sql
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,但要处理两件事:恢复条件校验(唯一键是否已被占用)与级联恢复:

sql
-- 恢复前校验: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;

校验与恢复要放在同一个事务里,并依赖部分唯一索引兜底——即使应用层校验有并发漏洞,唯一索引也会在恢复时拦住重复。

定时物理清理与归档 ​

软删除行无限堆积会让表越来越胖。常见策略是两段式:先归档(搬到归档表),再物理删除:

sql
-- 归档表结构与主表一致,并可加归档时间
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';

清理任务的运维要点:

  1. 分批执行。DELETE 大批量会持锁太久、产生大事务与 WAL 峰值。用 LIMIT(通过子查询)或按 id 范围分批,每批一个事务。
  2. 安排在低峰。清理与业务写入争抢 I/O 与锁资源。
  3. 用 pg_cron 调度。PostgreSQL 15+ 生态的 pg_cron 扩展可以让清理 SQL 按计划自动跑,无需外部 crontab。
  4. 保留期写入合规文档。财务数据往往要求保留数年,删除策略不能只看工程偏好。

软删除对统计的影响 ​

聚合查询必须统一过滤口径,否则报表自相矛盾:

sql
-- 反例:两个查询口径不一致,总和对不上
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(*)。

与触发器审计结合 ​

软删除行记录了「删除了什么、什么时候」,配合触发器可以补上「谁删的、删之前是什么」:

sql
-- 审计日志表
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
<?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 实现按时间批量的「硬删除」——按时间淘汰的场景里,「恢复」的诉求通常由备份承担,不需要软删除。两者的取舍见分区概述一页。

实战示例 ​

示例一:完整的软删除表与部分唯一索引 ​

sql
-- 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');
text
ERROR:  duplicate key value violates unique constraint "documents_doc_no_active_uniq"
DETAIL:  Key (doc_no)=(DOC-2026-001) already exists.

示例二:批量软删除 + 级联 + 审计的事务 ​

sql
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;

示例三:定时清理任务(分批) ​

sql
-- 每批最多 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 均摊,中途失败已跑的批次不回滚):

sql
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
<?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) 把整行快照存下来,将来追责、还原、对账都有据可依。

权限上区分「业务视图」与「数据管理」。 业务账号只给视图权限,基表(含已删除数据)只给管理账号——把「能看到已删除数据」变成显式授权而不是默认能力。

最佳实践 ​

  1. 统一使用 deleted_at timestamptz 建模,不用布尔标记。 一个字段同时承载「删没删」与「何时删」,时间范围查询、TTL 清理、审计都直接可用。
  2. 把「未删除」过滤固化到唯一入口。 视图、ORM 全局作用域或统一的 Repository 基类,选一种并全团队强制;评审中禁止绕过入口直查基表。
  3. 唯一性一律用部分唯一索引 CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL。 同时为这些索引写「删除后可重建」「恢复不冲突」两条用例,防止未来改动破坏语义。
  4. 删除语句永远带 AND deleted_at IS NULL 守卫。 让重复删除成为无操作,保护第一次删除的时间与操作人。
  5. 删除操作人(deleted_by)与删除原因要落列或落审计表。 「谁在什么时候删的」是绝大多数删除争议的第一问题,靠 WAL 或日志还原几乎不可能。
  6. 级联软删除用显式事务批量 UPDATE,不用触发器递归。 删除范围一目了然、可审计、可回滚;触发器级联的隐式传播在排障时是灾难。
  7. 恢复是业务操作:先校验(唯一键、父级状态)再执行,并用唯一索引兜底。 恢复入口与删除入口一样要写测试。
  8. 软删除表要有明确的 TTL 与归档策略。 归档表(LIKE ... INCLUDING ALL)+ 分批 DELETE + 低峰调度;保留期由合规而非工程直觉决定。
  9. 聚合一律用 FILTER 显式声明口径。 「活跃」与「全量」两个口径并列出现在一条 SQL 里,报表自相矛盾的问题从源头消失。
  10. 配合触发器审计记录删除前快照。 AFTER UPDATE OF deleted_at ... WHEN (OLD.deleted_at IS DISTINCT FROM NEW.deleted_at) 只在状态变化时写审计,无噪音。
  11. 框架的软删除能力(如 Laravel SoftDeletes)与数据库约束是互补关系。 框架保证应用行为一致,数据库保证数据语义一致;两者都要有,缺一不可。
  12. 流水型大数据不做软删除,用分区 DROP 替代。 按时间淘汰、无需恢复的数据,分区的整体淘汰比逐行打标 + 清理干净得多。

下一节

继续学习:任务队列(SKIP LOCKED)

参考链接 ​