推荐默认用 deleted_at 字段实现软删除,因其支持删除时间记录与按时间恢复,需配稀疏索引;若仅需二态且无审计需求,可用 is_deleted 布尔字段加普通索引。

软删除字段该用 is_deleted 还是 deleted_at
两者都能实现软删除,但语义和查询效率差异明显:is_deleted 是布尔值,索引小、查询快;deleted_at 是时间戳,能记录删除时间、支持按时间恢复或归档,但索引体积大、范围查询更重。
常见错误是只加 is_deleted 却没建索引,导致 find({is_deleted: false}) 全表扫描;或者用 deleted_at: null 表示未删除,结果 null 值不进 MongoDB 索引(除非用稀疏索引或显式存 ISODate("1970-01-01"))。
- 推荐默认用
deleted_at: {type: Date, default: null},删时设为new Date(),查时用{deleted_at: {$eq: null}} - 必须为
deleted_at建稀疏索引:db.collection.createIndex({deleted_at: 1}, {sparse: true}),否则null值不被索引覆盖 - 如果业务只要“删/不删”二态,且无审计需求,
is_deleted: {type: Boolean, default: false}+ 普通索引更轻量
MongoDB 查询里漏掉软删除条件的典型写法
开发者常在聚合、更新、删除操作中忘记过滤已删除文档,比如 updateOne({name: "foo"}, {...}) 直接改了已删除的旧记录,导致数据逻辑错乱。
最稳妥的做法是:所有读写操作都显式带上软删除条件,而不是靠中间件或全局钩子——后者容易被绕过或遗漏。
- 查列表:
find({deleted_at: {$eq: null}, status: "active"}),别写成find({status: "active"}) - 更新前先校验:
updateOne({ _id: id, deleted_at: {$eq: null} }, { $set: { ... } }),匹配数为 0 就说明文档已被软删除 - 聚合时在
$match阶段尽早过滤:{$match: {deleted_at: {$eq: null}}},避免后续阶段处理无效数据
用 TTL 索引自动清理软删除数据的风险
有人想用 deleted_at 字段配 TTL 索引,让 MongoDB 定期物理删除老的软删除文档。这看起来省事,但实际埋雷。
TTL 索引只支持 Date 类型字段,且删除动作由后台线程异步执行,无法控制时机;更重要的是,它会直接删文档,绕过所有应用层逻辑(如触发器、日志、关联清理)。
- 如果业务要求“软删除后保留 90 天再物理清除”,TTL 可以用,但必须确保
deleted_at字段在软删除时准确写入,且没有其他写操作覆盖它 - 禁止对
is_deleted: true这类布尔字段建 TTL 索引——MongoDB 不支持 - 生产环境用 TTL 前,务必在副本集上测试延迟:TTL 清理可能滞后数分钟,期间
countDocuments和实际磁盘占用会出现偏差
聚合管道里还原“已删除但需临时可见”的场景
管理后台常需要查看全部记录(含已删除),但普通用户只能看未删除的。这时候不能靠开关字段,得在聚合里动态控制可见性。
关键不是加不加条件,而是怎么加才不影响性能和可维护性——硬编码多个 $match 分支容易出错,也难复用。
- 用变量传参控制:
db.collection.aggregate([{$match: {deleted_at: {$eq: (showDeleted ? null : {$ne: null})}}}],注意括号位置,{$ne: null}才能命中非空时间戳 - 避免在
$or里混用:{$or: [{deleted_at: {$eq: null}}, {role: "admin"}]}会导致索引失效,应拆成两个独立查询或用$facet分流 - 如果要统计“总条数 vs 有效条数”,用
$facet一次聚合出两组结果,比两次countDocuments更省连接和网络开销
软删除真正难的不是字段设计,而是所有数据访问点是否一致地尊重这个状态。一个没加 deleted_at 条件的 findOneAndUpdate,就可能让软删除形同虚设。










