必须先运行explain("executionStats")再调优,关键看totalDocsExamined和totalKeysExamined:若前者远大于后者说明索引未生效,若两者接近但过大则索引字段或顺序不当;COLLSCAN表明完全未走索引。

先看 explain("executionStats") 再动手
不跑 explain("executionStats") 就调优,等于蒙眼修车。MongoDB 7.0 的聚合优化器会自动重排部分阶段(比如把 $match 提前、合并连续 $match),但是否真生效,得靠 executionStats 里的两个关键数字说话:totalDocsExamined 和 totalKeysExamined。如果前者远大于后者,说明索引没用上;如果两者接近但依然很大,说明索引字段或顺序不对。
常见错误现象:管道跑得慢,explain 显示 totalDocsExamined 等于集合总文档数,executionStages.stage 是 COLLSCAN —— 这代表完全没走索引。
- 必须在生产等效数据量下执行
explain,空库或小样本结果无参考价值 - 注意
explain返回的stages是优化后的实际执行顺序,不是你写的原始顺序 - 对分片集群,要检查每个
shardHost的executionStats,单个分片慢就拖垮全局
$match 前置但别盲目挪动
$match 放最前通常是对的,但 MongoDB 7.0 的优化器已经能自动把某些 $match 拆解并前移——前提是它不依赖后续计算字段。比如你写了 $addFields 生成 full_name,然后在 $match 里查 full_name,这个 $match 就没法被提前,硬挪会报错 FieldPath field references must be prefixed with a '$' or start with '$_' 或直接过滤不到数据。
使用场景:高频查询字段(如 tenant_id、status、created_at)必须建复合索引,且顺序要匹配 $match 中等值条件在前、范围条件在后。
- 错误示例:
{ $match: { status: "active", created_at: { $gte: ... } } }配索引{ created_at: 1, status: 1 }→ 前导字段created_at没参与等值查询,索引失效 - 正确索引:
{ status: 1, created_at: 1 },且确保$match中status是精确值 - 含
$or的$match:只要任一子句含非索引字段,整个$or可能退化为全表扫描
$project 不是越早越好,而是该精简时就精简
MongoDB 7.0 官方文档明确说:在管道中间加 $project 减少字段,**不一定提升性能**,因为优化器会自动做投影下推(projection pushdown)。但这是有前提的:下游阶段真不需要那些字段。一旦你用了 $unwind、$lookup 或 $group 里引用了大字段(比如 raw_payload: { $binary: ... }),这些字段就会被完整复制、展开、传输,内存和 CPU 开销立刻飙升。
性能影响:一个 50KB 的嵌套 JSON 字段,在 $unwind 后被展开成 1000 条文档,就凭空多占 50MB 内存;再进 $group,可能触发 "Sort exceeded memory limit" 错误。
- 必须手动
$project的场景:含大字段的集合做$unwind前、$lookup关联前、$group前 - 写法要显式排除:
{ _id: 0, user_id: 1, action: 1 },别用{ _id: 0 }默认保留所有其他字段 - 对数组字段,慎用
$filter或$slice后不$project,中间结果容易膨胀
警惕 $lookup 和 $unwind 引发的数据爆炸
$lookup + $unwind 是聚合管道里最危险的组合。MongoDB 7.0 不会阻止你写 { $lookup: { from: "logs", localField: "user_id", foreignField: "user_id" } },但如果用户平均有 5000 条日志,1000 个用户就产生 500 万行中间文档——这还没算后续 $match 或 $group。
容易踩的坑:以为加了 $match 在 $lookup 后就能控制量,但此时关联已完成,爆炸已发生。
- 正确做法:在
$lookup的pipeline参数里嵌套$match,让关联只拉必要数据,例如:{ $lookup: { from: "logs", localField: "user_id", foreignField: "user_id", pipeline: [ { $match: { event_type: "login" } } ], as: "logins" } } - 对
$unwind,优先考虑是否真要展开全部——多数统计只需$size、$first或$max,避免$push收集全部子文档 - 启用
allowDiskUse: true是底线,但只是防崩,不是提速;真正要压的是输入数据量,不是靠磁盘扛
最常被忽略的点是阶段间的数据形状变化:一个 $unwind 可能让文档数翻几十倍,但后面紧跟着的 $project 如果还保留着原始大对象,这种隐性放大效应比单个慢操作更致命。调优时盯着每阶段输出的 nReturned 和平均文档大小,比只看总耗时更有用。


















