
最近折腾了 MongoDB 的聚合管道,在生产环境里踩了几个大坑,这篇把问题说清楚。集合数据量到了几亿甚至几十亿级别的时候,管道写得不讲究,分分钟把整个集群拖垮。这篇文章从聚合阶段的底层执行机制讲起,梳理常见的性能瓶颈,然后给出实打实的优化方案——管道重排、索引利用、分片策略、内存管理,都是我们在线上验证过的经验。
每个聚合管道都会被编译成内部的执行计划,有点像关系型数据库的查询优化器。MongoDB 的聚合引擎逐阶段处理文档,每个阶段的输出作为下一个阶段的输入。每个阶段以流式方式运作:文档流经管道、被转换、过滤或分组,然后向下传递。理解这个流式模型非常关键,因为它决定了某个阶段能否利用索引,还是必须进行全集合扫描或者内存排序。
执行引擎将阶段分为两类:
$match、$sort、$skip、$limit、$project。$group、$sort(没有可用索引时)、$facet、$bucket。比如这个管道 db.orders.aggregate([{$match: {...}}, {$sort: {...}}, {$group: {...}}]),MongoDB 会先扫描、对所有数据进行内存排序,然后再分组。如果 $sort 能利用索引,它就变成了流式操作,内存压力会骤降。
动手优化之前,先认清楚哪些写法会拖慢管道。
如果把 $match 放在 $project、$group 或 $lookup 后面,意味着管道在过滤之前已经处理了集合里的每一条文档。解决办法很简单:把 $match 尽量往前面放。
按某个字段分组但没有对应索引,MongoDB 就得在内存里为所有分组键建一个哈希表。集合基数(cardinality)高的时候,很容易就把每个阶段 100MB 的内存上限撑爆了。
$lookup 默认是嵌套循环关联。如果被关联集合的关联字段没有索引,每次 lookup 都会退化成一趟全集合扫描。就算有索引,lookup 放在 $unwind 或 $group 里面也会把代价放大好几倍。
$sort 阶段没有匹配的索引,就必须把所有文档加载到内存里做排序。如果结果集超过 100MB,管道就会报错,除非启用了 allowDiskUse。
包含下游根本用不到的字段会增加文档体积、内存消耗和网络 I/O。早点裁剪字段能有效缩减工作集。
最立竿见影的优化就是把过滤操作尽量往前挪。MongoDB 的查询规划器只有在 $match 出现在任何阻塞阶段之前时,才能用索引来加速。
看这个反面典型:
// ❌ 低效:先处理全部文档再过滤
db.orders.aggregate([ { $project: { total: { $sum: "$items.price" } } }, { $match: { total: { $gt: 1000 } } }, { $group: { _id: "$customerId", count: { $sum: 1 } } }
])
$project 和 $sum 对每一条文档都执行了一遍。更优的写法把 $match 放到最前面:
// ✅ 高效:先过滤,缩小工作集
db.orders.aggregate([ { $match: { status: "completed" } }, { $project: { total: { $sum: "$items.price" }, customerId: 1 } }, { $match: { total: { $gt: 1000 } } }, { $group: { _id: "$customerId", count: { $sum: 1 } } }
])
有些情况下,可以把引用计算字段的 $match 改写成直接匹配集合上有索引的字段。比如你经常按 total > 1000 过滤订单,可以考虑在每条订单文档里维护一个 total 字段并加索引,然后直接在这个字段上做匹配。
索引是加速聚合管道的主要杠杆。下面讲几个关键策略:
如果管道开头是 { $match: { status: "shipped", region: "us-east" } },后面跟着 { $sort: { createdAt: -1 } },建一个复合索引 { status: 1, region: 1, createdAt: -1 } 就能让 MongoDB 同时利用索引做过滤和排序,完全省掉内存排序。
db.orders.createIndex({ status: 1, region: 1, createdAt: -1 })
每做一次 $lookup 把 orders 关联到 customers(用 customerId),就需要在 customers.customerId 上建索引。没有的话,每次 lookup 都会触发一次 customers 集合的全表扫描。
// 关联集合上的索引
db.customers.createIndex({ _id: 1 }) // 利用索引的 lookup 管道
db.orders.aggregate([ { $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } }, { $match: { "customer.tier": "premium" } }
])
如果 80% 的查询都是过滤 status: "active" 的文档,可以建一个只覆盖活跃文档的部分索引:
db.orders.createIndex( { customerId: 1, createdAt: -1 }, { partialFilterExpression: { status: "active" } }
)
这样索引体积更小、查询更快,同时仍然覆盖最常用的查询模式。
聚合时按的字段并非每个文档都有(灵活 Schema 里很常见),可以用通配符索引来覆盖这些稀疏字段:
db.orders.createIndex({ "$**": 1 })
但要慎用,通配符索引的体积通常比针对性建的索引大很多。
MongoDB 对每个管道阶段施加 100MB 内存限制。如果 $group、$sort 或 $lookup 超了这个限制,管道就会报错,除非启用了 allowDiskUse:
db.orders.aggregate( [/* pipeline stages */], { allowDiskUse: true }
)
allowDiskUse 确实能防止报错,但代价是性能:磁盘 I/O 比内存操作慢好几个数量级。我们的目标应该是尽量在内存限制内完成,而不是依赖磁盘溢写。
降低内存占用的方法:
$match 阶段推到管道最前面,减少处理的文档数量。$group 操作,可以先按分组键排序,让 MongoDB 使用流式分组算法而不是基于哈希的算法。$limit 或 $sample 限制流经昂贵阶段的文档数量。$match 或 $limit 来减少文档数量。比如下面这个管道就很省内存,因为 $sort 用了索引,$group 是在预排序好的流上做的:
db.orders.aggregate([ { $match: { status: "completed" } }, { $sort: { customerId: 1, createdAt: 1 } }, { $group: { _id: "$customerId", totalSpent: { $sum: "$amount" } } }
])
单个集合的数据量超过单台服务器的处理能力时,就需要分片了。但分片会给聚合性能带来新的约束。
分片键决定了数据的分布方式。一个好的分片键要保证数据均匀分布,同时让查询能定位到特定分片。如果聚合管道按分片键过滤,MongoDB 就能把查询路由到部分分片(定向路由)。如果过滤条件里没有分片键,就必须对所有分片做 scatter-gather,性能会差很多。
$merge 和 $out 这类阶段会把聚合结果写回集合。在分片集群里,这些阶段需要把所有数据合并到单个分片上,容易成为瓶颈。可以考虑把结果写到分片的集合里,而不是不分片的集合。
当 $group 阶段跨越多个分片时,MongoDB 先在每个分片上做本地聚合,然后把各分片的局部结果合并到一个 merge 分片上。这种两阶段聚合在分组键基数高的时候会很慢。缓解办法:
$bucket 或 $facet 分拆工作负载。举个例子,如果 orders 按 customerId 分片,按 customerId 分组的查询就能享受定向路由的好处:
db.orders.aggregate([ { $match: { customerId: { $in: ["c1", "c2", "c3"] } } }, { $group: { _id: "$customerId", totalSpent: { $sum: "$amount" } } }
])
MongoDB 只会把这条查询路由到持有 c1、c2、c3 数据的那几个分片,然后在每个分片上做本地聚合,最后合并结果。
在分片集群里,如果被 $lookup 关联的集合也是分片的,本地字段和外部字段必须是兼容的分片键。否则 MongoDB 会做一个广播 lookup 到所有分片,代价极其高昂。
问题:某电商平台每天跑一个聚合任务,按商品类目计算营收。orders 集合有 5 亿条文档,管道跑一次要 45 分钟,还经常超时。
原始管道:
db.orders.aggregate([ { $project: { categoryId: 1, amount: { $sum: "$items.price" }, status: 1 } }, { $match: { status: "completed" } }, { $group: { _id: "$categoryId", totalRevenue: { $sum: "$amount" } } }, { $sort: { totalRevenue: -1 } }
])
采取的优化措施:
$match 移到 $project 前面,缩小工作集。{ status: 1, categoryId: 1 } 上建索引,同时支持过滤和分组。total 字段并加索引,省掉 $project 里那个 $sum。优化后的管道:
db.orders.createIndex({ status: 1, categoryId: 1, total: 1 }) db.orders.aggregate([ { $match: { status: "completed" } }, { $group: { _id: "$categoryId", totalRevenue: { $sum: "$total" } } }, { $sort: { totalRevenue: -1 } }
], { allowDiskUse: true })
结果:运行时间从 45 分钟降到 3 分钟。索引让 MongoDB 只扫描已完成的订单,分组也直接利用索引顺序完成,省掉了内存排序。
优化不是一劳永逸的事。用 MongoDB 自带的工具持续揪出慢管道。
开启分析器抓取慢操作:
db.setProfilingLevel(1, { slowms: 100 })
出现在 system.profile 里、millis 或 keysExamined 值很高的查询就是优化的目标。
对可疑管道始终跑一下 explain():
db.orders