MongoDB `$lookup` + `$group` 聚合管线跑 30 秒

MongoDB 聚合管线在生产环境慢成狗。读 explain('executionStats')、给关联字段加索引、把 $match 提到最前、看懂新的 EQ_LOOKUP strategy。

新仪表盘在 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
某个 $groupusedDisk: 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" 并带一个 indexNameEQ_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 及其 strategyIndexedLoopJoin 最好);classic 引擎上看 lookup.queryPlanner
  • totalDocsExaminednReturned 的比(越接近 1 越好)。
  • totalKeysExaminedtotalDocsExamined(有差距说明索引没覆盖过滤条件)。
  • 任何 $group/$sort 阶段的 usedDisktrue 表示溢写了)。
  • 每阶段的 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 }) 抓慢查询,再每周 review system.profile(或 Atlas Performance Advisor)。

参考:MongoDB 官方 $lookup 文档 明说,外集合 foreignField 没索引时,等值匹配的 $lookup”will likely have poor performance”;聚合管线限制 页面则记录了 100 MB 阻塞阶段上限和 16 MiB BSON 文档上限。

常见问题

为什么 dev 上飞快,生产上慢?

Dev 只有 1 万文档,所以哪怕走 COLLSCANNestedLoopJoin 也是秒回,把缺索引的问题盖住了。到了 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,所以这类顺序得你自己写到最优。

标签: #后端 #排查 #mongodb