应始终用 explain("executionStats") 获取真实执行快照,重点关注 stages 数组中各 stage 的 executionTimeMillisEstimate、totalKeysExamined 与 totalDocsExamined;加 $unwind/$lookup 或调换 $match 位置后必须重跑;务必配 allowDiskUse: true 防失真,但 $graphLookup 除外。

直接用 explain("executionStats") 看各阶段真实耗时
别信“大概慢”“应该卡在 $group”,explain("executionStats") 是唯一能告诉你每个阶段到底干了什么、花了多久、扫了多少数据的手段。它返回的不是估算,是实打实的执行快照。
关键不是跑一次,而是每次改完管道就跑——尤其加了 $unwind、$lookup 或调整 $match 位置后。
-
executionStats.executionTimeMillis是整条管道总耗时,但没意义,得往下挖 - 真正要看的是
stages数组里每个 stage 的executionTimeMillisEstimate(注意:这是估计值,但和实际偏差通常 - 如果某个 stage 的
memUsage接近 100MB(比如"memUsage": 98765432),说明它快爆内存了,后续可能静默降级或报错 -
stage: "COLLSCAN"出现在$lookup的from集合里?那不是主集合的问题,是那个被关联的集合缺索引
$match 放哪儿、怎么写,直接影响 explain 能不能看出问题
explain 只对「第一个能走索引的 $match」生效。后面再写十个 $match,explain 里也看不到索引扫描,因为数据早被前面的 $unwind 或 $addFields 搞变形了。
- 复合索引字段顺序必须和
$match查询字段顺序一致:db.logs.createIndex({userId: 1, createdAt: 1})能加速{$match: {userId: "u1", createdAt: {$gte: ISODate("...")}}},但反过来就几乎无效 - 类型不匹配等于没索引:索引建在
ObjectId字段上,查询却传字符串 ID(如"507f1f77bcf86cd799439011"),explain 里会直接显示totalKeysExamined: 0 -
$lookup内部 pipeline 里的$match,索引必须建在from集合上,且字段也要对齐——explain 里会单独标出那个 from 集合的扫描行为
看懂 totalDocsExamined 和 nReturned 的比值,比看耗时更准
耗时受机器负载、网络抖动影响大;但 totalDocsExamined / nReturned 这个比值,直接暴露“你让数据库干了多少无用功”。
- 比值 ≈ 1(比如 217 vs 215)→ 过滤早、索引有效、管道干净
- 比值 > 10(比如 50万 vs 200)→ 大概率
$match放太晚,或者根本没命中索引,explain 里会看到totalKeysExamined很低甚至为 0 -
totalDocsExamined高但totalKeysExamined接近它 → 索引建了也用了,但阶段本身吃资源,比如$unwind后文档数爆炸,$group前没$project精简字段
allowDiskUse: true 不是性能优化,是避免 explain 失真
默认 100MB 内存限制下,一旦某个 stage 超限,MongoDB 会自动降级:要么中止、要么切到磁盘临时文件、要么静默跳过部分逻辑——这时 explain("executionStats") 返回的耗时、扫描量、内存占用全不可信。
所以,只要管道里有 $group、$sort、$unwind 或复杂 $lookup,运行 explain 时务必带上:
db.orders.aggregate([
{$match: {status: "completed"}},
{$group: {_id: "$customerId", total: {$sum: "$amount"}}}
], {
explain: "executionStats",
allowDiskUse: true
})
否则你看到的 “执行快”,只是它偷偷把活儿干了一半就停了。
真正容易被忽略的点是:$graphLookup 完全无视 allowDiskUse,硬性 100MB 顶格,explain 里一旦出现它的 memUsage 接近上限,就得重构逻辑——没法靠参数绕过去。


















