聚合管道(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 单字段索引 | 每个输入文档一次索引查找 |
| 数组 localField | foreignField 数组索引 | 按数组元素多路查找 |
| 无索引连接 | 无 | 每个输入文档一次 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的差距。差距大,说明过滤阶段没吃住索引,这是性价比最高的优化点。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。