软删除依赖isdeleted字段+应用层显式约定,而非索引;partial index仅加速查询,需配合统一字段名、默认值、$set更新及查询时必加isdeleted:false。

软删除不是靠索引实现的,Filter 索引(即 partial index)本身不参与“标记删除”逻辑,它只加速查询;真正起作用的是 isDeleted 字段 + 所有读写操作的显式约定。
为什么不能只靠 isDeleted 字段加普通索引?
因为普通索引会包含所有文档(包括 isDeleted: true 的),导致 find({ isDeleted: false }) 仍需扫描大量无效数据。尤其在历史数据多、软删比例高的集合中,性能会明显下滑。
- Partial index 可以把索引范围限制在活跃数据上,例如:
db.users.createIndex({ email: 1 }, { partialFilterExpression: { isDeleted: false } }) - 这个索引只包含
isDeleted: false的文档,find({ email: "a@b.c", isDeleted: false })能命中,但find({ email: "a@b.c" })不会自动加上isDeleted: false条件 —— 这个逻辑必须由应用层保证 - 如果你漏写了
isDeleted: false,查询就会穿透到全量数据,partial index 形同虚设
isDeleted 字段建模的三个硬性要求
字段名、类型、默认值、更新方式,任何一个出错都会让软删除失效或难以维护。
- 字段名统一用
isDeleted(布尔型),避免混用deleted、soft_deleted、_deleted等,否则 ORM 或聚合管道容易漏判 - 必须设默认值:
{ type: Boolean, default: false };否则新插入文档没有该字段,find({ isDeleted: false })查不到它们(MongoDB 中field: null≠field: false) - 更新必须用
$set:软删时执行updateOne({ _id: id }, { $set: { isDeleted: true } }),而不是replaceOne,否则可能意外清空其他字段 - 慎用
upsert: true:如果误配了 upsert,可能把已软删的文档“复活”成未删状态
查询时漏加 isDeleted: false 是最高频错误
几乎所有线上事故都源于某次查询忘了过滤,比如分页接口、统计聚合、后台导出脚本,甚至 countDocuments({}) 都可能把软删数据算进去。
- 推荐在 Model 层封装默认查询条件,例如 Mongoose 中定义 static 方法:
User.getActive = function() { return this.find({ isDeleted: false }); } - 聚合管道里尤其危险:
$lookup后的子集合如果不加isDeleted: false,关联结果会包含已软删数据 - 使用
countDocuments({ isDeleted: false })替代estimatedDocumentCount(),后者返回总文档数(含软删) - 测试阶段用 mongosh 快速验证:
db.users.countDocuments({ isDeleted: true })和db.users.countDocuments({})对比,确认软删比例合理
Partial index 不解决数据一致性问题
索引只是加速手段,它无法防止你误删、误查、或忘记恢复。真正要守住的边界在代码层。
- 软删恢复必须是
$set: { isDeleted: false },不能用$unset,否则字段丢失,后续查询失效 - 不要依赖 TTL 索引做软删除主逻辑 —— 它是异步的、不可控的,且一旦触发就是物理删除
- 如果业务需要审计(谁删的、何时删的),得额外加
deletedAt和deletedBy字段,isDeleted仅作开关用 - 备份与导出脚本最容易忽略软删状态,导出前务必确认是否要包含
isDeleted: true文档
最易被忽略的一点:partial index 创建后,对 isDeleted 字段的写入不会自动触发索引重建,但如果你批量更新了大量文档的 isDeleted 值,建议手动运行 db.runCommand({ compact: "users" })(仅 WiredTiger 引擎支持),回收索引碎片 —— 否则查询性能可能随时间缓慢退化。











