删了又没完全删:两段式软删除的设计

SailTrack
2026-06-16
点 赞
0
热 度
5
评 论
0
  1. 首页
  2. 后端开发
  3. 删了又没完全删:两段式软删除的设计

写在前面

"删除"大概是业务系统里被低估得最狠的功能。它听起来就是一条 DELETE FROM ... WHERE id = ?,但真实世界里的删除是:“用户手滑删了能找回吗?”、“误删影响了别人怎么办?”、“彻底删除之后磁盘上的文件什么时候清?”

我的文件模块一开始就用了最常见的软删除:加个 is_deleted 字段,查询时过滤。上线前自己扮演了一次手滑用户——删掉文件,然后发现"我想要它回来"这个动作没有任何接口支持。这篇记录我后来怎么把软删除做成完整的两段式:先进回收站(可逆),再彻底删除(不可逆)。

一、一个字段不够:deleted_at 和 is_deleted 的分工

最常见的软删除就一个布尔字段。但仔细拆解"删除"这个动作,它其实包含两个完全不同的阶段:

第一阶段(trash):用户说"我不要了" —— 可逆,有时限
第二阶段(purge):系统说"真的清掉了" —— 不可逆

对应到表结构,就是两个字段各管一段:

-- 第一阶段:进回收站
UPDATE attachment_ref
   SET deleted_at = ?, deleted_by = ?, purge_after = ?, updated_by = ?
 WHERE id = ? AND is_deleted = 0 AND deleted_at IS NULL

-- 第二阶段:彻底删除
UPDATE attachment_ref
   SET is_deleted = 1, updated_by = ?
 WHERE id = ? AND is_deleted = 0 AND deleted_at IS NOT NULL

语义划分得很清楚:

deleted_at 是"用户视角的删除"。 非空就说明在回收站里,列表接口按它过滤;purge_after = now + 30 天 是这张"回收站门票"的过期时间。

is_deleted 是"系统视角的删除"。 只有 purge 阶段才置 1,此后这条记录对业务彻底不可见,排队等待物理清理(文件块删除、引用计数扣减都是这一步触发的事)。

两个 WHERE 条件也别看不眼——is_deleted = 0 AND deleted_at IS NULL 保证了"已彻底删除的记录不能再次进回收站",is_deleted = 0 AND deleted_at IS NOT NULL 保证了"没进过回收站的记录不能被 purge"。状态机的每一条边,都体现在 UPDATE 的前置条件里。 数据库行锁替你挡住了并发的乱序操作。

二、恢复:把时间窗口做进约束

有了回收站就得有恢复。第一版恢复接口就是简单地把 deleted_at 清空,评审时同事问了一句:“回收站过期的文件还能恢复吗?”

好问题。回收站的语义是"30 天内可恢复"——如果过期了还能恢复,那 purge_after 就是个摆设;如果直接报错,用户体验又很突兀。我的实现:

public FileRestoreResponse restoreEmployeeFile(Long attachmentRefId, FileTrashRequest request) {
    AttachmentRow attachment = ...;
    if (attachment.deletedAt() == null) {
        return buildRestoreResponse(attachmentRefId);   // 没删过?幂等成功
    }
    if (attachment.purgeAfter() != null && attachment.purgeAfter().isBefore(LocalDateTime.now())) {
        throw new BizException(410, "文件已过恢复期");   // 过期?明确拒绝
    }
    // 清空回收站字段
}

三个分支三种语义:没删过的恢复请求返回成功(幂等,防止前端重复点击报错)、窗口内的正常恢复、窗口外的 410 Gone——这个状态码几乎就是为"资源曾经存在、现在永远没有了"发明的,不用它用谁?

时间窗口要在入口检查,而不是依赖定时任务准时跑。 定时任务是"30 天后批量清理过期项",但它可能延迟、可能挂掉;如果恢复逻辑依赖"定时任务已经把它清了"来拦截过期恢复,任务一延迟,过期的文件就都被恢复了。把"现在是否还在窗口内"做成业务规则,系统的正确性就不依赖任何后台任务的时效。

三、查询纪律:过滤器会传染

软删除最臭名昭著的坑是查询遗漏。硬删除的世界里,“查不到"就是"不存在”;软删除的世界里,"查不到"有三种可能:不存在、被删了、忘加过滤条件了。

我的会话模块仓库里,is_deleted = 0 出现了二十多次。为什么这么多?因为会话查询几乎全是多表 JOIN——会话表、成员表、头像引用表、头像文件表,每一张表都要自己的过滤条件:

SELECT ...
FROM im_conversation c
JOIN im_conversation_member m ON ...
LEFT JOIN attachment_ref private_avatar_ref ON ... AND private_avatar_ref.is_deleted = 0
LEFT JOIN file_object    private_avatar_file ON ... AND private_avatar_file.is_deleted = 0
WHERE c.is_deleted = 0
  AND m.status = 'ACTIVE'

漏掉一个 LEFT JOIN 里的条件,就会出现"已删除的头像在某个角落里复活"的灵异现象。这种 bug 不会在测试环境暴露(测试数据少、删除少),会在上线几个月后由某个真实用户触发。

我的应对办法笨但有效:每个仓库类里写查询时,先抄一遍"哪些表有 is_deleted"的清单,写完 SQL 逐表核对。更好的解法是 MySQL 8 的视图或者 MyBatis 拦截器自动注入条件——但项目用的是手写 JdbcTemplate SQL,纪律就是当前的自动化。

四、不是所有"删"都该用软删除

做了两段式软删除之后,我反而变得更挑剔:"删除"到底意味着什么?

用户删掉的,才用回收站。 文件删除、会话删除——这是用户的资产操作,有"误删"的可能,需要反悔通道。

业务状态变化,用状态机,别冒充删除。 员工离职不是"删除员工"——他的考勤记录、审批单、消息都还在被引用。我的系统里离职是 OrgUserStatusChangedEvent,账号状态置为停用,数据一条不删。把"离职"做成删除,是软删除最经典的误用:你以为删的是一行,其实删掉的是几十张表里的外键关系。

纯技术数据,硬删除。 过期的登录失败计数(Redis key 自动过期)、一次性票据记录——这类数据没有"找回"的业务含义,软删除只是给表增加噪音。判断标准回到那个问题:用户会为它的消失来找你吗? 不会,就删干净。

五、purge 之后:物理清理是一条流水线

is_deleted = 1 不是终点。真正的存储空间释放发生在后续的清理阶段:数据库里置位,然后调度清理任务去删物理文件、扣引用计数。这两步之间隔着一个分布式系统最不喜欢的承诺——“数据库说删了,磁盘不一定真删了”。

所以清理是幂等的、可重试的、有流水的:任务挂了下次接着扫,文件已经删过的跳过,每次清理留记录。软删除给了用户反悔的余地,也给了系统"延迟释放"的余地——磁盘空间不需要业务事务的原子性,它需要的是最终一致的清理管道。

六、小结

三点体会:

  1. 删除是状态机,不是一个动作。 活跃 → 回收站(deleted_at)→ 彻底删除(is_deleted)→ 物理清理,每条边有前置条件,每个状态有出口。把删除当一条 UPDATE 来设计,迟早被"手滑用户"逼着重做。
  2. 正确性别依赖后台任务的时效。 恢复窗口在入口判断,过期清理只负责释放,职责分清楚,定时任务挂了系统也不会出错。
  3. 查询遗漏是软删除的终身税。 每张有 is_deleted 的表,它的每一个 JOIN 都要交过滤条件。要么用纪律交,要么用框架交,但账单永远存在。

现在回收站里静静躺着测试期删掉的十几个文件,30 天后它们会自己消失。用户看到的是"删错了还能找回",系统记住的是"每一层删除都有边界"——这个功能用户永远不会夸,但缺了它,第一个手滑的人会记你很久。


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

不具版权性
不具时效性

文章内容不具时效性。若文章内容有错误之处,请您批评指正。

目录

欢迎来到SailTrack的站点,为您导航全站动态

65 文章数
11 分类数
5 评论数
60标签数