MongoDB的聚合管道功能强大,但管道一旦复杂起来,性能问题就会接踵而至。一条聚合语句可能串联五六个阶段,任何一个阶段处理不当都会让整个查询变得极慢。要定位瓶颈,靠猜是不行的,必须借助MongoDB自带的性能分析工具。本文围绕数据库分析器和执行计划展开,从开启、读取到分析、优化,完整梳理一套可落地的排查流程。

一、开启并使用数据库分析器
MongoDB的profiler分为三个级别:0表示关闭,1表示只记录慢操作,2表示记录所有操作。默认级别是0,也就是什么都不记录,所以很多时候你感觉无从下手,是因为分析器根本没开。开启方式很简单,使用db.setProfilingLevel()命令即可。
// 级别1,慢操作阈值设为100毫秒
db.setProfilingLevel(1, { slowms: 100 })
// 级别2,记录所有操作(谨慎使用,生产环境开销不小)
db.setProfilingLevel(2)
// 查看当前级别
db.getProfilingStatus()
开启之后,所有被记录的操作会写入当前数据库下的system.profile集合。这是一个固定大小的capped集合,默认1MB,空间满了会自动覆盖旧记录。如果你希望保留更长时间的分析数据,可以在启动时通过--profile 2 --slowms 50参数调整,并配合--profileSize 20把集合扩到20MB。
查询system.profile时,重点看这几个字段:op是操作类型,聚合操作显示为command;ns是命名空间,告诉你哪个集合;millis是总耗时;command字段里保存了完整的聚合语句;planSummary则粗略显示了执行计划特征,比如COLLSCAN代表全表扫描。把这些字段组合起来筛选,就能快速找到最耗时的操作:
// 找出耗时超过500毫秒的聚合操作,按耗时倒序
db.system.profile.find({
op: "command",
"command.aggregate": { $exists: true },
millis: { $gt: 500 }
}).sort({ millis: -1 }).pretty()
二、用explain深入分析聚合管道
profiler只能告诉你哪条语句慢,不能告诉你慢在哪个阶段。要精确到阶段级别,就得用explain。聚合语句的explain需要加上verbosity参数,推荐使用executionStats模式,它会输出每个阶段的实际执行数据。
db.orders.explain("executionStats").aggregate([
{ $match: { status: "paid", createdAt: { $gte: ISODate("2024-01-01") } } },
{ $group: { _id: "$userId", total: { $sum: "$amount" } } },
{ $sort: { total: -1 } },
{ $limit: 20 }
])
输出结果中,stages数组按顺序列出了管道的执行阶段。每个阶段都要关注三个核心指标:nReturned表示该阶段输出的文档数,docsExamined表示扫描的文档数,executionTimeMillisEstimate是预估耗时。如果第一个$match阶段的docsExamined是百万级而nReturned只有几千,说明索引没建好或者根本没用上,planSummary里出现COLLSCAN基本可以实锤。
对于包含$group和$sort的管道,explain还会显示是否触发了内存限制。MongoDB默认允许每个阶段使用100MB内存,超过就会报错,除非设置了allowDiskUse: true。一旦你看到结果里有usedDisk: true,意味着该阶段数据量大到溢出到了磁盘,速度会断崖式下跌,这往往就是性能瓶颈所在。
三、常见阶段的问题特征与优化手段
$match和$sort是最值得优先优化的两个阶段,因为它们有机会利用索引。优化器有一条重要规则:如果$match出现在$project或$group之前,它可以被下推到查询层直接走索引。所以写管道时务必把过滤条件放在最前面,并且确保复合索引的字段顺序与查询条件匹配。比如上面的例子,建立{ status: 1, createdAt: -1 }索引后,docsExamined通常能降到与nReturned同一量级。
$lookup是另一个常见的性能杀手。它本质上是对外表的逐条查询,如果主管道输出1万条文档,外表又没有连接字段的索引,就等于要做1万次全表扫描。解决办法有两个:一是给外表的被连接字段建索引,二是用$lookup的pipeline形式在连接时提前过滤外表数据,减少参与连接的文档量。
// 用pipeline形式在lookup内部提前过滤,减少扫描量
{ $lookup: {
from: "users",
let: { uid: "$userId" },
pipeline: [
{ $match: { $expr: { $and: [
{ $eq: ["$_id", "$$uid"] },
{ $eq: ["$status", "active"] }
]}}},
{ $project: { name: 1, level: 1 } }
],
as: "userInfo"
}}
除了索引层面,还要注意数据流转的体积。管道中每多传一个字段,后续阶段就要多处理一份内存。尽早用$project剔除不需要的字段,在合理位置用$limit截断数据流,这些看似不起眼的小改动,在大数据量下带来的收益往往比加索引还明显。
四、一套完整的排查流程总结
把前面的工具串起来,排查思路可以归纳为四步。第一步,确认profiler已开启,从system.profile中筛选出慢的聚合操作,拿到具体语句和总耗时。第二步,对语句执行explain("executionStats"),逐阶段对比耗时,锁定最重的阶段。第三步,根据阶段类型对症下药:COLLSCAN去补索引,usedDisk去考虑拆分数据或调整内存配额,$lookup慢去检查外表索引。第四步,优化后重新explain验证,确认docsExamined与nReturned的比例回归合理区间。
还有一点提醒:级别2的profiler和executionStats本身都有额外开销,排查完毕后记得把级别调回0或者1,避免分析工具反过来影响线上性能。性能优化没有银弹,但有了profiler和explain这两件武器,至少你不用再靠猜了。
MongoDB聚合管道$profile性能分析修改时间:2026-09-14 02:52:58