加一个 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. 自测
- 软删除的本质用一句话怎么说?为什么它比"加一个字段"复杂得多?
- 唯一索引包含已删除行会导致什么具体问题?三种处理方式各自的代价是什么?
- 全局软删除过滤器为什么必须留一条绕过路径?不留会发生什么?
- 分区方案中,为什么唯一索引会成为限制?选分区要放弃什么?
- 软删除的语义会沿调用链扩散到哪五处?其中哪一处最容易造成口径争议?
- 为什么"软删除不等于可恢复"?如果确实需要恢复能力,应该怎么做?