28. MongoDB 聚合管道性能优化实战

聚合管道性能优化方法论:索引利用与阶段前置重排、$lookup 连接优化、100MB 内存限制与 allowDiskUse、explain 执行统计实战分析、常见性能陷阱与优化案例

聚合管道(Aggregation Pipeline)是 MongoDB 上复杂数据分析的核心工具,但"能跑出结果"与"跑得快"之间往往隔着十倍以上的性能差距。慢查询通常不是聚合逻辑本身复杂,而是阶段顺序、索引利用和内存策略出了问题:全表扫描的 $match、缺少索引支撑的 $lookup、触发 100MB 内存溢出的 $group、以及从未看过的 explain 输出。本文从索引利用、阶段重排、$lookup 优化、内存限制与执行统计五个角度,给出一套可落地的聚合性能优化方法论,并附一个完整的优化前后对比案例。

1. 聚合管道的索引利用

聚合框架不是"永远全表扫描":部分阶段可以直接使用索引,把数据量从"整个集合"缩小到"命中的文档"。索引能否被利用,取决于阶段类型与索引前缀的匹配程度。

阶段是否可用索引利用条件
$match是查询条件匹配索引前缀,等价于 find 的查询优化
$sort是排序字段与索引顺序一致,可避免 SORT 阶段
$group是_id 表达式与索引前缀一致,可用索引完成分组
$geoNear是必须依赖 2dsphere 地理索引
$lookup是被连接集合的 foreignField 上有索引
$unwind / $project否本身不触发索引,但可被优化器下推过滤条件
// 建立复合索引,同时支撑 $match 过滤与 $sort 排序
db.orders.createIndex({ status: 1, createdAt: -1 })

// 聚合中利用该索引:先 $match 裁剪,再按索引顺序排序
db.orders.aggregate([
  { $match: { status: "pending", createdAt: { $gte: ISODate("2026-09-01T00:00:00Z") } } },
  { $sort: { createdAt: -1 } },
  { $group: { _id: "$customerId", total: { $sum: "$amount" } } },
  { $sort: { total: -1 } },
  { $limit: 10 }
])

对于 $group,若 _id 表达式恰好是某个索引的前缀,MongoDB 可以利用索引的有序性直接在索引键上分组,跳过大部分文档读取。$match 在管道开头的优化价值最高,越靠前,后续阶段处理的数据就越少。

2. 阶段前置与重排

聚合性能的第一原则是"尽早减少数据量"。优化器会自动做部分重排(如把 $match 下推到 $project 之前),但多数场景仍依赖开发者手动安排阶段顺序。

2.1 过滤条件尽量前置

$match 必须尽可能放在管道最前面,尤其是在 $unwind、$group、$lookup 之前。展开数组之后再过滤,等于把被过滤掉的文档也放大了无数倍。

// 错误:先 $unwind 展开数组,再过滤,扫描与内存被放大
db.orders.aggregate([
  { $unwind: "$items" },
  { $match: { "items.sku": "A1001" } }          // 过滤太晚
])

// 正确:先 $match 裁剪文档,再展开数组
db.orders.aggregate([
  { $match: { "items.sku": "A1001" } },         // 有索引则命中索引
  { $unwind: "$items" }
])

2.2 先投影瘦身再分组

$group 的内存占用与参与分组的文档大小相关。先用 $project 只保留分组与统计需要的字段,能显著降低内存压力与磁盘溢写概率。

db.orders.aggregate([
  { $match: { createdAt: { $gte: ISODate("2026-09-01T00:00:00Z") } } },
  { $project: { customerId: 1, amount: 1 } },   // 丢弃 items 等大字段
  { $group: { _id: "$customerId", total: { $sum: "$amount" } } }
])

2.3 排序与取前 N

$sort 紧接 $limit 是最常见的 top-N 查询。若排序字段有索引支撑,MongoDB 会直接按索引顺序读取前 N 条,跳过完整的排序阶段。

db.orders.createIndex({ amount: -1 })

// 索引支撑下的 top-N:无 SORT 阶段,直接索引扫描
db.orders.aggregate([
  { $match: { status: "paid" } },
  { $sort: { amount: -1 } },
  { $limit: 5 }
])
重排动作收益适用前提
$match 前置减少后续阶段数据量过滤条件可下推
$project 前置降低 $group 内存只保留必要字段
$limit 紧跟 $sort索引 top-K排序字段有索引
$skip 后置避免拖慢索引扫描分页查询

决策铁律:写聚合时先问自己"哪个阶段能最大幅度减少数据量"。答案是过滤条件($match),其次是投影($project)。把这两个阶段尽可能前置,是性价比最高的优化。

3. $lookup 连接优化

$lookup 是聚合中代价最高的操作之一:它对每个来自本地集合的输入文档,去被连接集合中查找匹配项。查找的性能完全取决于被连接集合 foreignField 上的索引。

// 先为被连接集合建索引
db.customers.createIndex({ _id: 1 })

// 等值连接:orders.customerId -> customers._id
db.orders.aggregate([
  { $match: { status: "pending" } },
  { $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer"
    }
  },
  { $unwind: "$customer" }
])

没有索引支撑时,$lookup 会对被连接集合执行 COLLSCAN,对每个输入文档都扫一遍全表。10 万条订单连接 100 万客户,若 customers._id 无索引,代价接近 10 万次全表扫描。

// 用 explain 验证 $lookup 是否命中索引
db.orders.explain("executionStats").aggregate([
  { $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer"
    }
  }
])
// winningPlan 中应出现 IXSCAN,而不是 COLLSCAN
$lookup 形态索引要求典型代价
等值连接(localField = foreignField)foreignField 单字段索引每个输入文档一次索引查找
数组 localFieldforeignField 数组索引按数组元素多路查找
无索引连接无每个输入文档一次 COLLSCAN,禁止
大集合 join 大集合两侧索引内存放大,考虑预聚合

3.1 用预聚合替代高频连接

当连接的两侧都很大、且连接关系相对稳定时,与其每次实时 $lookup,不如把连接结果预聚合到缓存集合或直接冗余到文档中(数据建模中的去规范化)。高频读、低频写的场景收益尤其明显。

// 冗余 customerName 到订单,避免高频 $lookup
db.orders.updateMany({}, [
  { $set: { customerName: { $arrayElemAt: ["$customer.name", 0] } } }
])

3.2 连接后的过滤

$lookup 之后接 $match 过滤连接结果时,过滤发生在连接完成之后,无法利用被连接集合的索引。若过滤条件属于被连接方,尽量把它并入 $lookup 之前的 $match,或使用管道形式的 $lookup 在子管道中先行过滤。

4. 100MB 内存限制与 allowDiskUse

聚合的 $sort、$group 等需要内存的阶段,默认最多使用 100MB 内存。超出后聚合会直接报错,除非显式开启 allowDiskUse 把中间结果溢出到临时文件。

// 不加 allowDiskUse:大分组直接报错
db.orders.aggregate([
  { $group: { _id: "$customerId", total: { $sum: "$amount" } } }
])   // Exceeded memory limit for $group

// 开启磁盘溢出:可跑,但性能显著下降
db.orders.aggregate(
  [{ $group: { _id: "$customerId", total: { $sum: "$amount" } } }],
  { allowDiskUse: true }
)

磁盘溢写比内存处理慢一个数量级,allowDiskUse 是"能跑"的兜底,不是性能优化手段。更优的做法是先 $match 裁剪、再 $project 瘦身,把数据量压到 100MB 以内。

// mongosh 中允许聚合使用 500MB 磁盘缓冲
db.orders.aggregate(
  [
    { $match: { year: 2026 } },
    { $group: { _id: "$customerId", total: { $sum: "$amount" } } }
  ],
  { allowDiskUse: true, maxTimeMS: 60000 }
)
场景处理方式性能特征
数据量小(100MB 内)内存完成快
数据量超限且允许溢写allowDiskUse慢数倍,有临时文件
数据量超限且不允许溢写聚合报错失败
$out / $merge 写集合磁盘阶段结果落盘,释放内存

需要说明的是:100MB 限制按阶段计算,$facet 的每个子管道各自拥有独立的内存预算,但整体仍受可用内存约束。生产环境中,应优先通过阶段前置把 $group 的输入压小,把 allowDiskUse 留给确有需要的大规模分析任务。

重要:allowDiskUse 不是免费的通行证。磁盘溢写会拖垮共享同一台机器的其他查询。大数据量聚合应放在独立副本或离线分析节点执行,避免与线上业务共享资源。

5. explain 执行统计实战

explain("executionStats") 是聚合性能诊断的基石。它输出每个阶段的执行计划、扫描的文档数与键数、返回行数、耗时,以及被优化器淘汰的候选计划。

db.orders.explain("executionStats").aggregate([
  { $match: { status: "pending" } },
  { $group: { _id: "$customerId", total: { $sum: "$amount" } } },
  { $sort: { total: -1 } }
])

关注三个核心指标:

指标含义判断标准
totalDocsExamined实际读取的文档数应接近 nReturned,过大说明过滤无效
totalKeysExamined索引键扫描数远大于 nReturned 说明索引选择性差
executionTimeMillis阶段执行耗时定位最耗时的阶段

典型执行计划的解读要点:IXSCAN 代表走索引,COLLSCAN 代表全表扫描;FETCH 表示取回文档;SORT 表示内存排序(未利用索引顺序);GROUP 表示分组聚合。被淘汰计划(rejectedPlans)会列出优化器评估后放弃的候选,通常能揭示"另一种索引用法"。

// 输出片段(示意)
// winningPlan:
//   stage: "GROUP"
//   inputStage:
//     stage: "FETCH"
//     inputStage:
//       stage: "IXSCAN"
//       keyPattern: { status: 1 }
// executionStats:
//   nReturned: 2450
//   totalDocsExamined: 2450
//   totalKeysExamined: 2450
//   executionTimeMillis: 18

nReturned 与 totalDocsExamined 相等说明过滤精确、索引完整覆盖命中路径;二者差距大,则要检查是否缺少复合索引、是否在 $match 之后才做过滤。定期用 db.currentOp() 观察正在执行的聚合,配合 system.profile 慢查询日志,能把"偶发慢聚合"也纳入监控。

6. 大集合聚合优化案例

以电商订单集合为例,演示一套从"跑不动"到"毫秒级"的完整优化路径。集合有 1200 万文档,目标:统计近 30 天每个客户的订单总额,取前 10。

基线版本(未做任何优化):

db.orders.aggregate([
  { $group: { _id: "$customerId", total: { $sum: "$amount" } } },
  { $sort: { total: -1 } },
  { $limit: 10 }
])
// COLLSCAN 全表,$group 内存溢出,直接报错

第一步:前置时间过滤,建立复合索引:

db.orders.createIndex({ createdAt: -1, customerId: 1 })
db.orders.aggregate([
  { $match: { createdAt: { $gte: ISODate("2026-08-31T00:00:00Z") } } },
  { $group: { _id: "$customerId", total: { $sum: "$amount" } } },
  { $sort: { total: -1 } },
  { $limit: 10 }
])

第二步:投影瘦身,只保留分组字段:

db.orders.aggregate([
  { $match: { createdAt: { $gte: ISODate("2026-08-31T00:00:00Z") } } },
  { $project: { customerId: 1, amount: 1 } },
  { $group: { _id: "$customerId", total: { $sum: "$amount" } } },
  { $sort: { total: -1 } },
  { $limit: 10 }
])

第三步:若近 30 天数据量仍超 100MB,开启磁盘溢写并观察耗时:

优化阶段扫描方式内存耗时(示意)
基线COLLSCAN 全表溢出报错不可用
加索引 + $match索引范围扫描正常约 4.2s
投影瘦身索引范围扫描明显降低约 2.8s
最终 + 磁盘兜底索引范围扫描稳定约 2.5s

每一步都配合 explain 验证 totalDocsExamined 从千万级降到几十万级,executionTimeMillis 同步下降。生产优化不是一次做完,而是"优化一步、度量一步、再优化一步"的循环。

7. 常见性能陷阱

聚合性能问题大多来自几个反复出现的模式,提前识别能省去大量排查时间。

7.1 $unwind 数组爆炸

$unwind 会把一个文档按数组元素拆成多行,数组平均长度 10 的集合,展开后数据量放大 10 倍。展开前务必先用 $match 收敛文档数,必要时配合 $slice 限制参与展开的元素。

7.2 $regex 无法命中索引前缀

$regex 只有锚定开头的写法(如 ^A)才能利用前缀索引,中间或结尾匹配必然退化为扫描。高频模糊匹配应改用文本索引或 Atlas Search。

db.products.aggregate([
  { $match: { name: /^iPhone/ } },   // 可用前缀索引
  { $match: { name: /Phone/ } }      // 无法利用索引,谨慎使用
])

7.3 深层嵌套与递归

$graphLookup 按递归遍历图结构,深度与分支数乘积决定开销。大数据量图遍历务必加 maxDepth 与 depthField 限制,并评估是否值得。

7.4 超大 $in 数组

$in 数组元素过多时,索引查找的键数量线性增长。超过数百个元素的大 $in,应拆成多次查询或重新审视数据建模。

7.5 $facet 多重子管道

$facet 在同一输入上并行执行多个子管道,互不共享中间结果,内存峰值等于所有子管道之和。子管道都应从紧凑的 $match 开始。

8. 优化决策总结

聚合性能优化可以收敛成一张检查清单:先确认 $match 在最前且有索引支撑;$project 提前瘦身;$sort 紧跟 $limit 并匹配索引顺序;$lookup 的被连接字段建有索引;大数据量聚合评估 allowDiskUse;每次改动都用 explain("executionStats") 度量 totalDocsExamined 与 executionTimeMillis。

  • 索引先行:聚合只是把 find 的索引能力搬到了管道里,先建对索引再谈其他
  • 过滤最前:数据量越小,后续一切越便宜
  • 度量驱动:不要凭感觉优化,用 explain 数字说话
  • 磁盘兜底:allowDiskUse 解决"能不能跑",不解决"快不快"

决策铁律:当聚合查询变慢,先别急着加内存或开 allowDiskUse。回到 explain 输出,看 totalDocsExamined 与 nReturned 的差距。差距大,说明过滤阶段没吃住索引,这是性价比最高的优化点。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mongodb」更多文章

  1. 33. MongoDB 地理空间与全文检索
  2. 32. MongoDB 批量写入与吞吐优化
  3. 31. MongoDB 读写关注与一致性