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

MongoDB pipeline 慢成狗。用 explain('executionStats')、加复合索引、`$match` 前置、`$facet` 拆并行分支。

新仪表盘在 dev 用 1 万文档跑得飞快。生产 500 万文档同一段聚合要 30 秒,把主节点 CPU 顶到天上。经典嫌疑:$lookup 的关联字段在外集合上没索引、$match 放在了 $group 后面、中间文档大得离谱把 stage 内存搞爆。修法:先用 explain('executionStats') 读管线计划、关联字段两边都加索引、$match 提到最前面、用 $facet 把独立分支并行化。

常见原因

按踩坑频率排序。

1. $lookup 外字段没索引

$lookup 默认对外集合做全表扫描,除非 localFieldforeignField 的关联被索引。

怎么判断explain('executionStats')$lookup 阶段是 COLLSCAN

2. $match 放在 $lookup$group 之后

管线先把整集合读出来、join 完、再过滤。应该反过来,先过滤。

怎么判断:管线第一段不是 $match$geoNear

3. 中间文档巨大

$group$push 把所有匹配文档拼成数组。某一组有 50 万条,单文档就超过 16 MB BSON 上限,或者撞上 100 MB stage 内存上限。

怎么判断:报 BSONObjectTooLargeExceeded memory limit for $groupallowDiskUse: true 能跑但慢。

4. 索引选择性不够

只索引了 status。查询过滤 status + tenant_id。还是 Postgres 那套——选择性最高的字段在前——复合 {tenant_id: 1, status: 1, created_at: -1} 才香。

怎么判断nReturned 远小于 totalDocsExamined

5. 排序走内存因为索引对不上

$match 后跟 $sort,前缀字段不一致,索引用不上。MongoDB 退化成内存排序、超过 100 MB 直接放弃。

怎么判断:explain 里 SORT 阶段 inMemory: trueusedDisk: true

最短修复路径

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
  • totalDocsExaminednReturned 的比(越接近 1 越好)
  • executionTimeMillisEstimate
  • $lookup 阶段嵌套的 lookup.queryPlanner

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" } }]);

Step 3: 把 $match 提到最前面

调顺序,选择性最高的过滤放第一阶段。

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+)能在外集合上只取要的字段。中间文档小很多。

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 } ],
  } },
]);

输入集合扫一遍,分支并行跑。注意 100 MB stage 上限,分支重的话开 allowDiskUse: true

Step 5: 避开巨大的 $push

别再 $push 后接 $slice,用 $topN/$bottomN(MongoDB 5.2+):

{ $group: {
    _id: "$user_id",
    recent: { $topN: { n: 5, sortBy: { created_at: -1 }, output: "$_id" } }
} }

天然被 n 截断,不会撞文档大小上限。

Step 6: 改一项验一项

db.orders.aggregate([...]).explain("executionStats")

目标:所有集合访问都是 IXSCANtotalKeysExamined / nReturned 小于 5,仪表盘查询 executionTimeMillis 1 秒内。

预防

  • 每个 $lookup 外字段都有索引。
  • 复合索引按 Equality - Sort - Range 排。
  • 管线第一阶段是 $match(或 $geoNear);早 $project 收窄中间结果。
  • 并行 rollup 用 $facet;不要无界 $push,用 $topN/$bottomN
  • db.setProfilingLevel(1, { slowms: 100 }) 抓慢查询,每周 review。

标签: #后端 #排查 #mongodb