软删除不能仅用 { deletedAt: null } 判断,因会漏掉字段不存在、值为 undefined 或空字符串的情况;须用 { deletedAt: { $exists: true, $eq: null } } 双重校验,并通过 pre('find') 等钩子统一注入条件,配合索引优化与原子 restore 操作。

软删除为什么不能只靠 deletedAt 字段判断
直接在查询里加 { deletedAt: null } 看似简单,但会漏掉两种关键情况:一是 deletedAt 字段根本不存在(老数据没补字段),二是值为 undefined 或空字符串(代码写错或迁移不彻底)。Mongoose 的 find() 默认不查缺失字段,所以必须统一用 $exists 和 $eq 双重校验。
推荐写法是:{ deletedAt: { $exists: true, $eq: null } } —— 这能确保只匹配「字段存在且值为 null」的文档。如果历史数据允许缺失该字段,就得先跑一次迁移脚本补全默认值。
用 pre('find') 中间件自动注入软删除条件
手动每个 find() 都加过滤条件容易遗漏,也违背 DRY 原则。Mongoose 提供了 pre('find') 钩子,可以在所有读取操作前统一拦截并修改 query。
注意点:
- 必须对
findOne、findOneAndUpdate、countDocuments等也单独注册钩子,pre('find')不覆盖它们 - 要排除管理员或恢复场景——加个临时标志位,比如
req.skipSoftDelete = true,中间件里检查this.options.skipSoftDelete - 聚合管道(
aggregate())不走这个钩子,得单独处理
示例中间件:
schema.pre('find', function() {
if (this.options.skipSoftDelete) return;
this.find({ deletedAt: { $exists: true, $eq: null } });
});
restore() 方法怎么安全地还原被删文档
软删除后还原不是简单地 save() 清空 deletedAt,因为并发场景下可能有其他操作正在读取或更新同一文档。
根据 package.json 中的 engines 字段自动检测 Node.js 版本需求,未安装 nvm 时自动安装并切换至对应版本。支持 Windows、macOS、Linux。适用于 Node.js 项目开发、版本切换和环境配置。
推荐用 findOneAndUpdate() 带原子条件:
- 只还原当前确实处于「已软删」状态的文档:
{ deletedAt: { $ne: null } } - 避免重复还原:用
{ new: true }拿回更新后的文档,再检查deletedAt是否真变回null - 不要用
save()+doc.deletedAt = null,这中间可能被其他请求覆盖
示例:
User.findOneAndUpdate(
{ _id: userId, deletedAt: { $ne: null } },
{ $set: { deletedAt: null } },
{ new: true }
);
索引和性能隐患必须提前加
deletedAt 字段如果没建索引,带软删除条件的查询会全表扫描,尤其在百万级集合里响应直接卡死。
建复合索引时注意顺序:
- 高频查询字段放前面(比如
status、createdAt) -
deletedAt放最后,方便覆盖索引生效 - 明确加上
sparse: true,跳过null和缺失值,减小索引体积
例如:userSchema.index({ status: 1, deletedAt: 1 }, { sparse: true })。别忘了在生产环境运行 db.collection.getIndexes() 确认索引已生效。
真正麻烦的是聚合查询里用 $match 过滤 deletedAt —— 它不一定走索引,得看整个 pipeline 顺序和是否触发索引覆盖。上线前务必用 explain("executionStats") 验证。

















