应使用deletedat字段(date类型,默认null),因其能区分“从未删除”和“已恢复”,避免布尔字段的语义歧义;mongodb原生支持null查询且索引友好,推荐mongoose中定义为{ type: date, default: null }。

软删除字段该用什么名字和类型?
别用 deleted 或 isDeleted 这种布尔值字段——它无法区分“从未删除”和“已恢复”。实际项目里更稳妥的是用 deletedAt,类型设为 Date(或 ISODate),默认值为 null。MongoDB 原生支持 null 查询,索引也友好;如果存字符串时间戳,反而容易出解析错误或时区混乱。
-
deletedAt: { type: Date, default: null }是 Mongoose Schema 的推荐写法 - 查询未删除数据时,用
{ deletedAt: null },不是{ deletedAt: { $eq: null } }(后者在某些驱动版本下行为不一致) - 硬删除前务必确认该字段非
null,避免误删活跃数据
如何让 find() 默认过滤已软删除文档?
Mongoose 的 pre('find') 钩子能统一拦截,但要注意:它不触发于 countDocuments、aggregate 等方法,且对 findOneAndUpdate 无效。更可靠的做法是封装一个自定义模型方法:
const User = mongoose.model('User', userSchema);
User.findActive = function(filter = {}, options = {}) {
return this.find({ ...filter, deletedAt: null }, options);
};
User.findByIdActive = function(id) {
return this.findOne({ _id: id, deletedAt: null });
};
- 所有业务代码调用
User.findActive()而非User.find() - 不要依赖中间件自动加条件——团队新人容易绕过,测试也难覆盖
- 聚合查询必须显式加
{ $match: { deletedAt: null } },否则统计结果会包含软删数据
恢复操作为什么不能只改 deletedAt 为 null?
直接 updateOne({ _id }, { $set: { deletedAt: null } }) 看似简单,但可能破坏数据一致性。比如:软删除时你顺手清空了 emailVerified 字段用于安全隔离,恢复时若不还原,用户登录会失败。
- 恢复逻辑应和软删除逻辑对称:软删时存快照(如
originalEmail)、恢复时回填 - 用事务包裹恢复操作,尤其涉及关联集合(如订单恢复需同步更新用户统计)
- 记录操作日志字段
restoredBy和restoredAt,审计时比单纯看deletedAt更可信
索引和性能陷阱在哪?
deletedAt: null 查询走不了普通单字段索引——MongoDB 认为 null 是“缺失值”,除非你显式建稀疏索引或复合索引。
- 建索引:
db.users.createIndex({ deletedAt: 1 }, { sparse: true })或{ deletedAt: 1, createdAt: -1 } - 避免在软删字段上做
$ne: null查询,它无法使用索引,全表扫描风险高 - 定期归档老的软删数据(如
deletedAt )到冷库存储,减少主库压力
软删除不是加个字段就完事,关键在所有读写路径都对齐语义——漏掉一个 aggregate 或一个后台任务,数据就错位了。










