软删除必须用 updateOne 设置 isDeleted: true,禁用 deleteOne;统一用布尔型 isDeleted 字段并配 partial 索引;查询需手动或钩子过滤,聚合管道和导出脚本易漏条件。

软删除不能靠 deleteOne() 或 deleteMany() 实现,它本质是字段标记 + 查询过滤的组合契约;自动过滤必须由应用层统一控制,MongoDB 本身不提供“默认排除已删文档”的机制。
为什么不能用 deleteOne() 做软删除
deleteOne() 是物理删除,执行后文档从磁盘彻底消失,isDeleted 字段根本没机会存在。常见错误是:先调用 deleteOne(),再试图给已不存在的文档加 isDeleted: true——这毫无意义。软删除的前提是文档还在,只是被逻辑标记。
- 所有“删除”操作必须改用
updateOne({ _id: id }, { $set: { isDeleted: true } }) - 禁止在业务代码里出现
deleteOne()对用户可见集合的直接调用(管理后台的“彻底删除”除外) - 如果 ORM 或 ODM 封装了
.remove()方法,需确认其底层是否真的走 update 而非 delete 命令
字段命名与类型必须严格统一
用 isDeleted(布尔型)是最稳妥的选择。别用 deleted、status、del_flag 等变体,否则聚合管道、索引、ODM 预设条件都会出问题。
- 字段值必须是布尔型
true/false,不是字符串"true"或"deleted" - 插入新文档时,必须设默认值
default: false;否则find({ isDeleted: false })查不到缺失该字段的老数据 - 更新时只用
$set,禁用replaceOne(),避免意外清空isDeleted字段
查询时如何真正“自动”过滤
没有银弹,“自动”依赖分层控制:ODM 层拦截最可靠,原生 driver 只能靠封装函数。但无论哪种方式,aggregate() 和 $lookup 都不会被自动覆盖,必须手动加条件。
- Mongoose 推荐用
schema.pre('find')+pre('findOne')+pre('countDocuments')多钩子,且每个都要检查this.options.skipSoftDelete开关 - 原生 driver 场景下,定义统一工具函数如
nonDeletedQuery(base),返回{ ...base, isDeleted: { $ne: true } } - 聚合管道里,
$match阶段必须显式写{ isDeleted: { $ne: true } };$lookup的pipeline内也要重复加,否则关联出已删文档 - 统计类操作必须用
countDocuments({ isDeleted: { $ne: true } }),别用estimatedDocumentCount()(它包含所有文档)
索引必须配合 partialFilterExpression
普通索引对 isDeleted 字段建索引没用,因为会把 true 和 false 文档全存进去,查活跃数据时仍要扫描大量无效项。真正起效的是 partial index。
- 创建索引时用:
db.collection.createIndex({ email: 1 }, { partialFilterExpression: { isDeleted: false } }) - 这个索引只包含
isDeleted: false的文档,体积小、查询快 - 但它不改变查询行为:你仍得在 find 条件里写
isDeleted: false,否则 MongoDB 不会命中该索引 - 已有集合加字段时,先建稀疏索引防阻塞,再用
updateMany()补全历史数据,最后重建为非稀疏索引
最容易被忽略的是聚合管道里的 $lookup 和管理员导出脚本——它们天然绕过 ODM 钩子,也最容易漏掉 isDeleted: false 条件,导致“幽灵数据”泄露。每次写 aggregate() 或导出逻辑,都得手动检查每一层 $match。











