$group前必须有$match才能使索引生效,因mongodb索引仅对$match、$sort和部分$lookup有效;$group本身不走索引,需将过滤条件前置并确保命中复合索引最左前缀,必要时将分组字段纳入索引以支持后续排序或匹配。

$group 前必须有 $match,否则索引基本无效
直接在 $group 阶段上建索引没用——MongoDB 的索引只对 $match、$sort 和部分 $lookup 生效,$group 本身不走索引。如果你的聚合管道是 [{$group: {...}}, {$match: {...}}],那整个过程就是全表扫描。
真正起作用的是:把过滤条件尽可能提前,且确保它能命中索引。比如你要按用户分组统计订单数,就得先用 $match 锁定时间范围和用户状态:
[
{ $match: { status: "paid", created_at: { $gte: ISODate("2025-01-01") } } },
{ $group: { _id: "$user_id", count: { $sum: 1 } } }
]
这时才轮到索引上场。否则 $group 就得先把全部几千万条记录拉进来再分组,内存爆、速度慢、还可能触发 allowDiskUse 报错。
复合索引字段顺序必须匹配 $match 条件顺序
索引不是“有就行”,字段顺序错了,照样不生效。MongoDB 复合索引遵循“最左前缀原则”:只有查询条件覆盖索引最左侧连续字段时,索引才被使用。
- 你建了
{ status: 1, created_at: 1, user_id: 1 },但$match只写了{ user_id: ObjectId(...) }→ 不走索引 -
$match: { status: "paid", user_id: ... }→ 只用上status,user_id因跳过created_at而无法利用索引 -
$match: { status: "paid", created_at: { $gte: ... } }→ 完全命中前两个字段,高效
用 explain("executionStats") 看 executionStages.stage 是否为 IXSCAN,再核对 indexName 和 keysExamined 是否远小于 docsExamined,才能确认索引真正在干活。
$group 分组键字段要不要加进索引?看场景
$group 的 _id 字段(比如 "$category")本身不参与索引加速,但如果你后续还要对分组结果排序或二次过滤,就值得考虑:
- 纯统计后直接返回?不用加——索引只加速输入,不加速分组逻辑
- 分组后紧跟
$sort: { _id: 1 }?可以把分组字段加到复合索引末尾,让排序也走索引(避免in-memory sort) - 分组后
$match: { _id: { $in: [...] } }?这个$match是管道中第二个阶段,优化器**可能**把它下推,但不可靠;更稳的方式是把分组字段放在索引靠前位置,并确保第一个$match已大幅缩小数据集
例如高频查某几个类别的销售总额,索引设为 { category: 1, status: 1, created_at: 1 },再配合 $match: { category: { $in: ["A", "B"] }, status: "sold" },效果比单纯 { status: 1, created_at: 1 } 更精准。
别忽略 $unwind + $group 的隐式膨胀成本
如果 $group 前有 $unwind(比如展开数组),数据量会指数级放大。一个含 5 个作者的文档 $unwind 后变成 5 行,再 $group 就要处理 5 倍原始行数——索引再好也扛不住膨胀后的中间数据量。
这时候优化重点不是索引,而是:
- 确认是否真需要
$unwind:能否用$filter+$size等数组操作替代? - 把
$unwind尽量往后放:先$match过滤大集合,再$unwind小结果集 - 加
preserveNullAndEmptyArrays: false避免空数组产生无意义文档 - 必要时启用
allowDiskUse: true,但这是兜底手段,不是性能解法
真正卡住的往往不是磁盘或 CPU,而是 $unwind 后数据暴涨导致内存溢出或网络传输变慢——这点在分析 explain 输出里的 nReturned 和 totalDocsExamined 对比时特别明显。











