应使用$expr配合$dateSubtract(5.0+)或$$NOW+$subtract动态计算时间条件,建status升序+createdAt降序复合索引,分批limit删除并count验证。

用 $expr 配合日期运算符写动态时间条件
直接在 deleteMany() 里写死时间戳不现实,比如“删掉 90 天前且状态为 "archived" 的文档”,得让 MongoDB 自己算当前时间减去偏移量。这时候不能用字符串形式的 ISODate,必须用 $expr 进入表达式上下文。
常见错误是写成:{ createdAt: { $lt: new Date(Date.now() - 90 * 24 * 60 * 1000) } } —— 这在 mongosh 里能跑,但一旦部署到应用层(比如 Node.js 驱动),时间就变成客户端本地时间,和副本集主节点时区不一致会导致漏删或误删。
- 正确做法是用
$dateSubtract(MongoDB 5.0+)或$subtract+$$NOW(兼容旧版) - 例如删 90 天前的归档数据:
{ $expr: { $and: [ { $eq: ["$status", "archived"] }, { $lt: ["$createdAt", { $dateSubtract: { startDate: "$$NOW", unit: "day", amount: 90 } }] } ] } } - 注意
$dateSubtract返回的是日期类型,可直接和$createdAt比较;若用$subtract,得手动除以 1000 转成秒级时间戳再比,容易出错
避免全表扫描:确保时间字段有支持范围查询的索引
即使条件写对了,没索引也会触发 collection scan,删几百万条可能卡住整个实例。MongoDB 不会自动用 _id 或其他单字段索引优化复合时间条件。
典型坑是只建了 { status: 1 } 索引,以为能加速 status + createdAt 查询——实际效果极差,因为 status 基数低(比如只有 3 个值),索引选择性差,引擎大概率放弃使用。
- 必须建复合索引,把时间字段放后面:
db.collection.createIndex({ status: 1, createdAt: -1 }) - 顺序很重要:等值匹配字段(
status)在前,范围字段(createdAt)在后;降序(-1)便于从最新往最旧删,减少锁持有时间 - 删完记得用
explain("executionStats")验证是否命中索引,关注nReturned和totalDocsExamined是否接近
分批删除防止长事务阻塞写入
一次 deleteMany() 删除几十万文档,会持有集合级别的写锁(WiredTiger 引擎下是更细粒度,但仍可能 block 其他写操作),同时产生大量 oplog,拖慢复制延迟。
别信“删完再优化”的想法——线上服务等不了你 20 分钟。
- 用
limit控制每批数量,比如每次删 1000 条:db.collection.deleteMany({ /* 条件 */ }, { limit: 1000 }) - 配合
while循环(mongosh)或定时任务(应用层),检查上一批返回的deletedCount,为 0 则退出 - 两次删除之间加毫秒级 delay(如
sleep(100)),给其他请求腾出资源 - 注意:不要用
skip分页式删除(如skip(1000).limit(1000)),越往后性能越差
删之前先 count,删之后查剩余,别靠感觉判断
线上删数据没有“差不多就行”。你以为删了 3 天前的数据,结果条件里写成了 $gt 而不是 $lt,或者时间字段名拼错成 createAt,就真删错了。
真实案例:某次误把 updatedAt 当 createdAt 用,导致删掉的是最近更新过的活跃数据,而不是历史归档数据。
- 执行前先跑 count:
db.collection.countDocuments({ $expr: { $lt: ["$createdAt", { $dateSubtract: { ... } }] } }) - 删完立刻查同条件的剩余数,确认趋近于 0;再抽样看几条残留文档的
createdAt值,验证是否真在阈值之外 - 如果业务允许,建议先导出待删数据 ID 到文件(用
find().map(x => x._id)),再按 ID 批量删,可审计、可回滚
时间条件批量删除最难的不是语法,是确认“删的真是你想删的那批”——所有计算、索引、分批操作,都是为这个确认服务的。少一次 count,多十分风险。


















