新仪表盘在 dev 用 1 万文档跑得飞快。生产 500 万文档同一段聚合要 30 秒,把主节点 CPU 顶到天上。最快的修法、按优先级排:先跑 explain("executionStats"),给 $lookup 的外字段加索引,把 $match 提到管线第一阶段,再在 join 之前 $project 掉用不到的字段。在 MongoDB 8.x(截至 2026 年 6 月最新是 8.3.4)上,slot-based 引擎会把 $lookup 显示为一个带 strategy 字段的 EQ_LOOKUP 节点——你想看到的是 IndexedLoopJoin;如果是 NestedLoopJoin,说明它在对每条输入文档扫一遍外集合。
这四步通常能把 30 秒的仪表盘查询压到 1 秒以内。下面先教你怎么判断自己踩的是哪个坑。
你踩的是哪个坑
先跑下面的诊断,再跳到对应原因。
| explain 里的症状 | 最可能的原因 | 修法 |
|---|---|---|
EQ_LOOKUP 显示 strategy: "NestedLoopJoin" | $lookup 外字段没索引 | 原因 1 |
管线第一阶段不是 $match / $geoNear | 过滤发生在 join 之后 | 原因 2 |
某个 $group 上 usedDisk: true,或报 BSONObjectTooLarge | $push 产生的中间文档巨大 | 原因 3 |
nReturned 远小于 totalDocsExamined | 索引选择性不够 | 原因 4 |
SORT 阶段 usedDisk: true | 排序用不上索引 | 原因 5 |
常见原因
按踩坑频率排序。
1. $lookup 外字段没索引
外集合的 foreignField 没索引时,MongoDB 对每条输入文档都扫一遍整个外集合——一次 O(n) 扫描变成 O(n x m)。如果 foreignField 是 _id,默认 _id 索引能覆盖;别的字段就得显式建索引。
怎么判断(MongoDB 8.x):explain("executionStats") 里 $lookup 阶段是 EQ_LOOKUP 且 "strategy": "NestedLoopJoin"。健康的 join 会显示 "strategy": "IndexedLoopJoin" 并带一个 indexName。EQ_LOOKUP 节点只在 slot-based 执行引擎(SBE)跑这个 join 时才会出现。如果管线退回到 classic 引擎——比如某个 $lookup 在外集合上跑了子 pipeline:,或者关联字段用了数字路径分量——你根本看不到 EQ_LOOKUP;这时去 $lookup.queryPlanner 里找嵌套的 COLLSCAN。根因一样:外字段没索引。
关于 hash join:从 MongoDB 8.0 起,当没有可用索引、开了 allowDiskUse: true、且外集合在三个上限内都足够小时,SBE planner 可能选 "strategy": "HashJoin" 而不是 NestedLoopJoin——这三个上限是 internalQueryCollectionMaxNoOfDocumentsToChooseHashJoin(默认 10000 条)、internalQueryCollectionMaxDataSizeBytesToChooseHashJoin(默认 100 MB)和 internalQueryCollectionMaxStorageSizeBytesToChooseHashJoin(默认 100 MB)。小关联表用 hash join 没问题,但外集合一大,还是得建真索引,让它走 IndexedLoopJoin。
2. $match 放在 $lookup 或 $group 之后
管线先把整集合读出来、join 完、再过滤。应该反过来,先过滤。聚合优化器会自动把部分 $match 前移,但它没法移动一个引用了 $lookup/$group 产出字段的 $match,所以这种顺序得你自己写对。
怎么判断:管线实际执行的第一段不是 $match 或 $geoNear。把你写的管线和 explain() 里的 stages 数组对一下,看优化器真正跑的是什么。
3. 中间文档巨大
$group 配 $push 把所有匹配文档拼成数组。某一组有 50 万条,单文档就超过 16 MiB BSON 上限,或者 $group 阶段撞上 100 MB 内存上限开始往磁盘溢写。
怎么判断:报 BSONObjectTooLarge,或 explain 里 $group/$sort 阶段 usedDisk: true。注意:从 MongoDB 6.0 起,服务端参数 allowDiskUseByDefault 默认就是 true,所以重的阶段通常是溢写到磁盘后变慢,而不是直接抛 QueryExceededMemoryLimitNoDiskUseAllowed。只有运维把它设成 setParameter allowDiskUseByDefault false、或你传了 { allowDiskUse: false } 时,才会看到那个硬报错。不管哪种,溢写都是症状不是解药——该做的是把中间结果改小(见 Step 5)。
4. 索引选择性不够
只索引了 status,但查询过滤 status + tenant_id。“选择性最高的字段在前”这套直觉仍然成立:复合 { tenant_id: 1, status: 1, created_at: -1 } 才能覆盖整个谓词。
怎么判断:nReturned 远小于 totalDocsExamined(索引放进来太多文档,剩下的靠服务器在内存里过滤)。
5. 排序走内存因为索引对不上
$match 后跟 $sort,前缀字段不一致,索引用不上。MongoDB 退化成内存排序、超过 100 MB 就溢写磁盘。
怎么判断:explain 里有 SORT 阶段(而不是排序走 IXSCAN)且 usedDisk: true。阻塞式内存排序还有个特征:totalKeysExamined 远小于 totalDocsExamined。
最短修复路径
Step 1: 读计划
db.orders.aggregate([
{ $match: { tenant_id: "acme", status: "paid", created_at: { $gte: ISODate("2026-05-01") } } },
{ $lookup: { from: "users", localField: "user_id", foreignField: "_id", as: "user" } },
{ $unwind: "$user" },
{ $group: { _id: "$user.country", revenue: { $sum: "$amount" } } },
{ $sort: { revenue: -1 } },
], { allowDiskUse: true }).explain("executionStats");
每一阶段都看:
- 基集合上是
IXSCAN还是COLLSCAN。 $lookup节点:MongoDB 8.x 上看EQ_LOOKUP及其strategy(IndexedLoopJoin最好);classic 引擎上看lookup.queryPlanner。totalDocsExamined跟nReturned的比(越接近 1 越好)。totalKeysExamined跟totalDocsExamined(有差距说明索引没覆盖过滤条件)。- 任何
$group/$sort阶段的usedDisk(true表示溢写了)。 - 每阶段的
executionTimeMillisEstimate,找出最慢那一环。
Step 2: 加对的复合索引
经验法则(Equality、Sort、Range):
// orders:tenant_id、status 等值,created_at 范围
db.orders.createIndex({ tenant_id: 1, status: 1, created_at: -1 });
// users:_id 默认有索引。如果 localField/foreignField 是别的字段,记得索引外字段
db.users.createIndex({ _id: 1 });
非 _id 字段的 lookup,索引要建在外集合的关联字段上:
db.events.createIndex({ user_id: 1 });
db.users.aggregate([{ $lookup: { from: "events", localField: "_id", foreignField: "user_id", as: "events" } }]);
再跑一遍 explain:$lookup 现在应该报 "strategy": "IndexedLoopJoin",并带上你刚建的 indexName。
Step 3: 把 $match 提到最前面,并尽早 $project
调顺序,选择性最高的过滤放第一阶段,并在 join 之前砍掉用不到的字段,让中间文档变小。
db.orders.aggregate([
// 1. 先狠狠过滤
{ $match: {
tenant_id: "acme",
status: "paid",
created_at: { $gte: ISODate("2026-05-01"), $lt: ISODate("2026-06-01") }
} },
// 2. 只 project 用得到的字段(中间小一点)
{ $project: { user_id: 1, amount: 1 } },
// 3. lookup 走索引外字段,并只取外集合需要的字段
{ $lookup: { from: "users", localField: "user_id", foreignField: "_id", as: "user", pipeline: [{ $project: { country: 1 } }] } },
{ $unwind: "$user" },
{ $group: { _id: "$user.country", revenue: { $sum: "$amount" } } },
{ $sort: { revenue: -1 } },
]);
$lookup 里嵌套 pipeline:(MongoDB 5.0+)能在外集合上 $match 和 $project,只带回你真正用到的外字段。中间文档小很多。有一点要注意:带子 pipeline: 的 $lookup 由 classic 引擎执行,所以即使 foreignField 上的索引照样用得到,explain 里也不会出现 EQ_LOOKUP 节点。这种情况别去找 strategy 字段,改用 totalKeysExamined > 0 加上 $lookup.queryPlanner 里的 IXSCAN 来确认 join 走了索引。
Step 4: 用 $facet 并行分支
仪表盘要三个独立 rollup?放一个聚合里用 $facet,输入集合只扫一遍。
db.orders.aggregate([
{ $match: { tenant_id: "acme", created_at: { $gte: ISODate("2026-05-01") } } },
{ $facet: {
byCountry: [ { $group: { _id: "$country", n: { $sum: 1 } } } ],
byStatus: [ { $group: { _id: "$status", n: { $sum: 1 } } } ],
topUsers: [ { $group: { _id: "$user_id", n: { $sum: 1 } } }, { $sort: { n: -1 } }, { $limit: 10 } ],
} },
]);
输入集合扫一遍。注意:每个 $facet 子管线各有自己的 100 MB 上限,而且 $facet 的子阶段用不上索引,所以一定要靠前置 $match 把输入集合压小。哪个分支重,就会溢写到磁盘。
Step 5: 避开巨大的 $push
别再 $push 后接 $slice,用 $topN/$bottomN(MongoDB 5.2+):
{ $group: {
_id: "$user_id",
recent: { $topN: { n: 5, sortBy: { created_at: -1 }, output: { id: "$_id", amount: "$amount" } } }
} }
output 接受任意表达式,所以每个元素只取你需要的字段。天然被 n 截断,分组保持很小,不会撞 16 MiB 文档大小上限。
Step 6: 验证是否修好
每改一项就再跑一遍 explain:
db.orders.aggregate([ /* 你的各阶段 */ ]).explain("executionStats")
下面这些全满足才算修好:
- 所有基集合访问都是
IXSCAN,不是COLLSCAN。 $lookup报"strategy": "IndexedLoopJoin"(小外表报HashJoin也行)——绝不能是NestedLoopJoin。totalKeysExamined / nReturned小于 5。- 没有任何
$group/$sort阶段显示usedDisk: true。 - 仪表盘查询
executionTimeMillis在 1 秒以内。
线上要真实压测时,用数据库 profiler 或 db.currentOp() 在负载下观察,别只信一次性的 explain。
预防
- 每个
$lookup外字段都有索引;用 explain 确认 join 报IndexedLoopJoin。 - 复合索引按 Equality - Sort - Range 排。
- 管线第一阶段是
$match(或$geoNear);早$project收窄中间结果。 - 并行 rollup 用
$facet;不要无界$push,用$topN/$bottomN。 - 用
db.setProfilingLevel(1, { slowms: 100 })抓慢查询,再每周 reviewsystem.profile(或 Atlas Performance Advisor)。
参考:MongoDB 官方 $lookup 文档 明说,外集合 foreignField 没索引时,等值匹配的 $lookup”will likely have poor performance”;聚合管线限制 页面则记录了 100 MB 阻塞阶段上限和 16 MiB BSON 文档上限。
常见问题
为什么 dev 上飞快,生产上慢?
Dev 只有 1 万文档,所以哪怕走 COLLSCAN 或 NestedLoopJoin 也是秒回,把缺索引的问题盖住了。到了 500 万文档,同样的计划就是 O(n x m)。要在生产规模的数据上跑 explain("executionStats"),别只在 dev 数据集上验。
explain 显示 HashJoin 而不是 IndexedLoopJoin,有问题吗?
不一定。从 MongoDB 8.0 起,没有可用索引、开了 allowDiskUse: true、外集合在三个上限内都足够小时,SBE 会选 HashJoin(这三个上限是 internalQueryCollectionMaxNoOfDocumentsToChooseHashJoin,默认 10000 条;外加 100 MB 数据大小和 100 MB 存储大小两个上限)。小关联表用它很快。但外集合大的话,加索引让它走 IndexedLoopJoin。
开 allowDiskUse: true 能解决慢吗?
不能。它只是靠把中间结果溢写到磁盘来避免 QueryExceededMemoryLimitNoDiskUseAllowed 报错,而磁盘比 RAM 慢得多。而且从 MongoDB 6.0 起它默认就是开的(allowDiskUseByDefault: true)。把 usedDisk: true 当成警告信号,靠前置 $match/$project 或 $topN 把这个阶段改小。
复合索引里字段顺序怎么排?
Equality、Sort、Range(ESR)。用 $eq 匹配的字段放最前,然后是排序字段,最后才是 $gte/$lt 这类范围过滤。对 { tenant_id: "acme", status: "paid", created_at: { $gte: ... } } 且按 created_at 排序,用 { tenant_id: 1, status: 1, created_at: -1 }。
我加了索引,$lookup 还是显示 NestedLoopJoin,为什么?
几个常见原因。索引必须建在外集合的 foreignField 上,不是本地字段。查询/集合的 collation 和索引对不上,索引就会被判定不可用。localField/foreignField 里有数字路径分量(比如按 tags.0 关联)会强制走 classic 引擎、完全跳过 EQ_LOOKUP。还有,如果 from 指向视图或分片集合,SBE 不会跑这个 join,你也看不到 IndexedLoopJoin。用 db.users.getIndexes() 确认索引确实存在,并把 foreignField 的拼写和它核对一遍。
怎么知道优化器重排了我的管线?
把你写的各阶段和 explain() 里的 stages(或 queryPlanner.winningPlan)数组对一下。聚合优化器会自动把部分 $match/$sort 前移,但它不会移动依赖 $lookup/$group 产出字段的 $match,所以这类顺序得你自己写到最优。