聚合优化的三个杠杆
聚合管道的性能归结为三件事:尽早过滤、尽量走索引、控制中间结果体积。优化器会尽可能把后续阶段上提,但首阶段能否用上索引完全取决于写法;因此把 $match 与 $limit 放在最前、用 $project 提前瘦身、把排序与截断贴在一起,通常就能拿到大部分收益。
基本信息
| 适用平台 | MongoDB 聚合管道(4.x / 5.x / 6.x 行为有差异) |
|---|---|
| 首阶段 | 只有首阶段能用集合索引,索引无法支撑时退化为 COLLSCAN |
| 内存上限 | 单个阶段默认 100MB,超出需 allowDiskUse 或调整批次 |
| 验证工具 | explain("executionStats") 查看阶段类型、扫描数与返回数 |
| 优化器行为 | 会做阶段合并与重排(如把 $match 上提),但不会替你改写表达式 |
优化清单
- $match 前移:首阶段直接利用索引,能砍掉后续全部计算量
- 用 $project 或 $unset 裁剪字段:越早减小文档体积,后续排序分组越轻
- $sort 紧跟 $limit:两者相邻可走堆排序,只需保留前 N 条
- $lookup 控制关联侧:foreignField 建索引,或改用管道式写法先过滤再关联
- 大分组显式设置 allowDiskUse:避免 100MB 上限报错
- 过滤条件写在 $match 而不是 $project 表达式里,前者才能裁剪输入文档
先瘦身再分组
先瘦身再分组
// 差:先分组再过滤,全量文档进入 $group
// db.events.aggregate([{ $group: { _id: "$userId", n: { $sum: 1 } } }, { $match: { n: { $gt: 10 } } }])
// 好:过滤与投影在前,中间结果只保留需要的两个字段
db.events.aggregate([
{ $match: { day: "2026-10-12", action: { $in: ["click", "buy"] } } }, // 首阶段走索引
{ $project: { userId: 1, action: 1, _id: 0 } }, // 提前瘦身
{ $group: { _id: { userId: "$userId", action: "$action" }, n: { $sum: 1 } } },
{ $sort: { n: -1 } },
{ $limit: 20 } // 与排序相邻,可走堆排序
])
// 验证执行计划:首阶段应为 IXSCAN,且 examined 与 returned 量级接近
db.events.explain("executionStats").aggregate([/* ... */])写法与代价对照
| 写法 | 代价来源 | 改进方向 |
|---|---|---|
| 首阶段 $match 无法走索引 | 全集合扫描 | 补建匹配的复合索引,或改写条件使前缀可用 |
| $group 前字段过多 | 中间文档体积大、哈希表占用高 | 先 $project 去掉大字段与数组 |
| $sort 未跟 $limit | 全量排序并落盘 | 把 $limit 紧贴 $sort,或让排序命中索引 |
| $lookup 关联侧无索引 | 每个输入文档都要扫关联集合 | foreignField 建索引,或用 pipeline 形式先过滤 |
| 正则表达式匹配 | 无法使用索引前缀 | 改为前缀锚定正则,或改用文本索引 |
用 explain 的 executionStats 看三个数:返回文档数、扫描文档数、执行耗时。扫描量远大于返回量说明索引没吃到;开启 verbose 还能看到每个阶段的时间分布,定位瓶颈块更直接。
$project 里的表达式计算不会减少进入管道的文档数:真正的裁剪只能靠 $match,投影的价值在于减小后续阶段的单文档体积。