2969 字
约 9 分钟
16
S08-A10 · 软删除的实现选择:标记、归档与视图

加一个 deleted_at 字段就能"删除"数据,这件事看起来太简单,于是几乎没有人认真设计过它。三年后表里有六千万行,其中四千二百万行是已删除的;每个查询都要带 deleted_at IS NULL,漏一处就查到脏数据;某个唯一索引因为包含了已删除行,导致同名记录无法重建。

软删除不是"打个标记",它是在同一个物理空间里保存两种语义的数据,而这两种数据的访问模式、约束、生命周期完全不同。


1. 三种实现与各自的代价

实现 做法 主要代价
标记删除 加 deleted_at / is_deleted 列 所有查询都要带条件;唯一索引冲突;表无限增长
归档分离 删除时搬到归档表,主表物理删 需要搬迁逻辑与归档表管理;跨表查询不便
视图隔离 保留所有行,对外只暴露过滤后的视图 视图的性能与可维护性随复杂度下降
分区/分表 删除的数据进独立分区 分区键选择受限;跨分区操作变复杂

还有一层容易被忽略的区别:谁发起了删除。用户主动删除、系统策略清理、合规要求删除,三者的语义不同——第一种应该可恢复,第二种通常不必,第三种必须彻底。把三者都塞进同一个 deleted_at,后果是恢复策略无法区分,只能一刀切。

可以用两列表达三种语义:deleted_at(何时)与 delete_reason(谁、为什么)。后者的取值集合应该小而稳定,例如 user、retention、compliance、merge。

四者可以组合,但组合会叠加复杂度。多数团队实际选的是第一种,因为它最省事——代价被推迟到数据量上来之后。

2. 标记删除的四个必然问题

这四个问题不是实现质量问题,而是标记删除这个方案本身带来的。

问题 机制 后果
查询条件遗漏 每处查询都要记得加条件 漏写处查到已删除数据(安全与统计错误)
唯一索引冲突 唯一索引包含已删除行 删了之后无法用同一名称重建
表无限增长 数据只增不减 索引膨胀、备份变大、扫描变慢
外键与关联语义 关联对象可能已被删 展示时出现悬空引用

第二项最容易被忽视,也最烦人。用户删除了一个名为 order-2026 的配置,想再建一个同名的,结果报唯一键冲突——因为那一行还在,只是 deleted_at 有值。

-- 唯一索引的三种处理方式,各有代价
-- 方式一:把 deleted_at 纳入唯一键(NULL 不参与唯一性)
ALTER TABLE config ADD UNIQUE KEY uk_name_del (name, deleted_at);
-- 问题:删除时间不同即可共存,同名可被删多次;且 NULL 语义依赖具体数据库

-- 方式二:删除时把唯一列改名,让出位置
UPDATE config SET name = CONCAT(name, '#del#', id), deleted_at = NOW() WHERE id = 7;
-- 优点:简单可靠;缺点:历史数据里的名称被改写,审计时语义变弱

-- 方式三:部分唯一索引(PostgreSQL 支持)
CREATE UNIQUE INDEX uk_name_alive ON config (name) WHERE deleted_at IS NULL;
-- 优点:语义最干净;限制:需要数据库支持部分索引

第三种是语义上最正确的,但只有部分数据库支持。MySQL 的变通做法是方式二,代价是历史名称被改写——这一点必须在审计要求里明确,否则删除记录会失去可读性。

3. 查询条件的强制方式

"记得加条件"靠人不可行,靠三种机制可以强制。

机制 强制程度 适用位置
ORM 全局过滤器 高(框架层注入) 应用内所有查询
数据库视图 高(对视图使用者透明) 报表、只读账号
代码评审检查 低(依赖人) 兜底
独立只读副本(仅存活数据) 中高 分析与导出

第一种是主流做法,但有一个前提:必须有办法绕过它。运维排查、数据修复、对账都需要看到已删除数据,如果全局过滤器无路可绕,这些场景就只能写裸 SQL,反而更危险。

// 全局软删除过滤器的配置要点:默认过滤 + 显式旁路 + 旁路留痕
soft_delete:
  enabled: true
  column: deleted_at
  scope: "com.example.**.repository.*"
  bypass:
    methods: ["findWithDeleted", "countAllIncludingDeleted"]
    require_reason: true          // 旁路必须传原因,写进慢查询日志
    audit: true                   // 旁路调用记录调用栈摘要
  view_for_readonly: "v_orders_alive"   // 只读账号只授权视图,不授权基表

第三种机制(评审检查)常被当作主要手段,但它其实是成本最高的一种:每次评审都要有人记得看,而且漏检不会立刻暴露。它可以作为兜底,不能作为主防线。

一个可执行的辅助手段是静态扫描:把所有直接查基表、又没带过滤条件的语句捞出来。

// 静态扫描:找出绕过全局过滤器的裸查询
grep -rn --include=*.xml --include=*.java \
  -E "(from|FROM)\s+(orders|users|config)\b" src/ \
| grep -v "deleted_at" | grep -v "v_orders_alive"
// 输出为空才说明没有绕过;有输出则逐条确认是否属于允许的旁路方法

view_for_readonly 这一条价值很高:给分析账号授权视图而不是基表,"忘记过滤"这个错误在那一侧就不可能发生。

4. 表增长与归档

标记删除的表会持续增长,处理方式取决于数据量与访问频率。

数据量级 做法 说明
千万行以下 只加索引,定期统计 增长尚可承受
千万到亿级 归档表 + 定期搬迁 搬迁要分批,避免长事务
亿级以上 分区,删除行进独立分区 分区可整体 DROP,回收最快
有合规保留期 归档到冷存,主表物理删 保留期由要求决定,不由成本决定

归档搬迁的执行细节比方案选择更容易出事故。要点只有三条:分批、可续、可核对。

-- 归档搬迁:分批 + 可续 + 可核对
INSERT INTO orders_archive SELECT * FROM orders
WHERE deleted_at IS NOT NULL AND deleted_at < '2026-01-01'
ORDER BY id LIMIT 5000;                      -- 分批,避免长事务

DELETE FROM orders WHERE id IN (SELECT id FROM orders_archive) LIMIT 5000;
-- 每批之后核对两侧行数,差异不为 0 立即停止

SELECT (SELECT COUNT(*) FROM orders WHERE deleted_at IS NOT NULL AND deleted_at < '2026-01-01')
     - (SELECT COUNT(*) FROM orders_archive) AS diff;
-- diff 必须为 0 才能进入下一批

最实用的是分区这一档:把删除行移到独立分区后,回收空间只需 DROP PARTITION,比 DELETE 快几个数量级,也不会产生大量 undo。

-- 分区方案:删除行进独立分区,定期整体回收
ALTER TABLE orders PARTITION BY RANGE COLUMNS (deleted_at) (
  PARTITION p_alive   VALUES LESS THAN ('9999-12-31'),
  PARTITION p_2026_10 VALUES LESS THAN ('2026-11-01'),
  PARTITION p_2026_11 VALUES LESS THAN ('2026-12-01')
);
-- 已删除行按删除月份落进 p_2026_xx;超过保留期整体 DROP
ALTER TABLE orders DROP PARTITION p_2026_10;
-- 注意:分区键必须进所有唯一索引,这会改变唯一性语义,需先评估

最后那条注释是关键限制:分区表的唯一索引必须包含分区键,这意味着 uk_name_alive 这类索引无法直接建立。选分区就要放弃部分唯一索引的语义,这是一次明确的交换。

5. 上下游的语义传播

软删除的影响不止在数据库,它会沿着调用链扩散。

位置 需要同步什么 遗漏后果
缓存 删除时失效对应键 已删除数据仍被返回
搜索结果 索引里也要标记 搜索能搜到已删内容
关联展示 悬空引用要处理 页面显示"未知对象"
统计口径 是否计入总数 报表与列表对不上
导出与对账 用哪一侧的数据 财务口径争议

第四项是最常见的口径争议:列表显示 20 条(存活),统计显示 25 条(含删除),两边都"对",但没人说得清哪个是业务想要的。做法是把口径写成命名——count_alive 与 count_total,两个数都给,不靠一个数承担两种含义。

6. 排错:五种"删了但还在"

现象 成因 处理方向
删了之后还能搜到 搜索索引未同步删除标记 删除走同一条事件,索引侧也置标记
删了之后页面仍显示 缓存未失效 删除操作同步清缓存,或按版本号失效
同名记录无法新建 唯一索引包含已删除行 改名或改部分唯一索引
报表数与列表数不一致 统计未区分存活/全量 两个口径分别命名
删除操作越来越慢 表增长导致索引扫描变慢 归档或分区

第一条在搜索场景特别常见,因为搜索索引通常是异步的:数据库删了,索引还没更新,中间的窗口足够让用户搜到。修法不是"等它同步",而是把删除事件也纳入索引更新的同一处理链,让两边有相同的传播路径。

7. 边界:什么情况不该做软删除

场景 为什么 做法
数据无保留要求,且可重建 软删除只增加复杂度 物理删除
删除即永久,业务上不可恢复 软删除制造"假可恢复" 物理删除 + 备份
表极小(万行内) 增长与索引问题都不存在 标记删除但不必归档
合规要求立即彻底删除 软删除违反要求 物理删除 + 审计记录

第二项值得强调:软删除给了"数据还在"的错觉,于是有人以为误删可以恢复——直到发现恢复流程根本不存在。软删除不等于可恢复,如果要做恢复,得单独设计和验证。


8. 自测

  1. 软删除的本质用一句话怎么说?为什么它比"加一个字段"复杂得多?
  2. 唯一索引包含已删除行会导致什么具体问题?三种处理方式各自的代价是什么?
  3. 全局软删除过滤器为什么必须留一条绕过路径?不留会发生什么?
  4. 分区方案中,为什么唯一索引会成为限制?选分区要放弃什么?
  5. 软删除的语义会沿调用链扩散到哪五处?其中哪一处最容易造成口径争议?
  6. 为什么"软删除不等于可恢复"?如果确实需要恢复能力,应该怎么做?
S08-A10 · 软删除的实现选择:标记、归档与视图
https://www.clxhxhhr.top/posts/6389/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。