必须统一使用非空的deletedat(date型)字段实现软删除,null表示未删除;所有读操作需显式添加{deletedat: {$eq: null}}条件;须建稀疏索引,高频查询应设计含deletedat的复合索引;删除/恢复操作须原子执行。

软删除字段命名和类型必须统一且不可为空
软删除不是靠删文档,而是靠字段标记状态。最常见错误是用 isDeleted、deleted_at、status 混用,导致查询逻辑分散、ORM 映射混乱、索引失效。
推荐统一使用 deletedAt 字段(Date 类型),值为 null 表示未删除,非空表示软删除时间。这样既兼容 TTL 索引,也便于按时间范围归档,还能在聚合中直接用 $gt 或 $exists 判断。
-
deletedAt: null—— 有效数据(注意:不是false或0) - 避免布尔型
isDeleted: true/false:无法支持“按删除时间排序”或“7天内恢复”等业务需求 - 禁止字符串型如
status: "deleted":类型不一致易引发隐式转换,索引可能不命中
查询时必须显式过滤 deletedAt: { $eq: null }
Spring Data MongoDB 或原生驱动不会自动加软删除条件,漏写就等于把已删除数据暴露给业务。这不是框架特性,是设计契约。
常见错误现象:repository.findAll() 返回了“已删除”的用户;后台列表页显示失效订单;API 响应包含被逻辑删除的配置项。
- 所有读操作(
find、findOne、countDocuments)都需手动加{ deletedAt: { $eq: null } } - 不要依赖
$ne: null—— 它无法利用索引(MongoDB 对$ne不走索引) - 聚合管道中务必在
$match阶段前置该条件,否则后续$lookup或$group可能污染结果
必须为 deletedAt 创建稀疏索引
软删除字段大部分值是 null,如果建普通索引,会把所有文档都存进去,浪费空间且拖慢写入。稀疏索引只索引非空值,正适合这个场景。
执行命令:db.users.createIndex({ deletedAt: 1 }, { sparse: true })
- 稀疏索引对
deletedAt: { $exists: true }查询高效(例如“查所有已删除用户”) - 但对
deletedAt: { $eq: null }查询——也就是查有效数据——它不生效;此时需要复合索引兜底 - 若高频查询是“查未删除且按创建时间排序”,应建复合索引:
db.users.createIndex({ deletedAt: 1, createdAt: -1 }, { sparse: true }),并确保查询中同时带上两个条件
删除操作要原子更新,避免竞态和脏数据
软删除本质是 updateOne,但并发调用时容易出现“重复删”或“删后又被其他操作覆盖 deletedAt”。不能只靠应用层判断再更新。
正确做法是用 findOneAndUpdate + 条件更新,确保只对未删除文档生效:
db.users.findOneAndUpdate(
{ _id: ObjectId("..."), deletedAt: { $eq: null } },
{ $set: { deletedAt: new Date() } },
{ returnDocument: "after" }
)
- 条件
deletedAt: { $eq: null }是关键,防止二次软删除覆盖时间戳 - 避免先
find再update:中间可能被其他线程修改,产生竞态 - 恢复操作同理:必须用
{ deletedAt: { $ne: null } }作为前置条件,并设deletedAt: null
{ status: 1, createdAt: -1 },但查询是 { status: "active", deletedAt: { $eq: null } },那这个索引完全用不上。软删除字段必须参与索引设计,而不是当成事后补丁加上的过滤条件。











