MongoDB 查询优化:$group + $top 聚合下 DISTINCT_SCAN 的规划器行为深度解析
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
导读
本文基于 MongoDB 官方仓库中的 golden 测试文档 distinct_query_planner.md,系统讲解查询规划器(Query Planner)如何为聚合管道中的$group阶段生成DISTINCT_SCAN执行计划。你会学到:DISTINCT_SCAN与$groupByDistinctScan内部阶段的关系、排序模式(Sort Pattern)与索引方向匹配的判定规则、在何种条件下规划器会拒绝 distinct scan 并回退到 COLLSCAN/IXSCAN+FETCH,以及$group去重重写的源码级前置条件。读完本文,你将具备为$group/$top类聚合查询设计索引、并通过 explain 输出判断优化是否生效的实战能力。
背景:DISTINCT_SCAN 与 $groupByDistinctScan
在 MongoDB 聚合框架中,$group是典型的高代价阶段:需要对输入流做哈希分组。当分组键(_id)是单一字段、且累积器只关心每个分组内的“第一个/最后一个/最大/最小”文档(如$first、$last、$top、$bottom)时,查询规划器可以把$group重写为一个基于索引顺序的“去重扫描”:
- DISTINCT_SCAN:索引扫描阶段,利用索引键的有序性跳过重复键值,只返回每个分组键的“边界”文档,无需哈希表、无需阻塞排序(Blocking Sort)。
- $groupByDistinctScan:聚合层内部阶段,承接 DISTINCT_SCAN 输出的有序流,把
$group语义(_id+ 累积器输出)还原到文档形态。
这段$groupByDistinctScan的生成与消费逻辑分别位于 document_source_group_base.cpp($group的 distinct scan 重写)与 pipeline_d.cpp(阶段名称注册、与$unwind+$group重写的协同约束)。golden 测试源文件是 distinct_query_planner_md.js,其顶层注释明确说明了测试目标:
Tests that we generate DISTINCT_SCANs for the specific cases treated by the query planner (sort that is required only for distinct scan plans and manual covered distinct scan construction).
该测试通过 golden_test_utils.js 中的outputAggregationPlanAndResults输出“Pipeline / Results / Total indexes / Summarized explain”四段内容,形成与当前仓库一致的期望输出文件。本文以下所有 explain 均取自仓库中featureFlagSbeFull配置下的真实 golden 输出。
一、为 $groupByDistinctScan 引入排序模式:三种典型走向
1.1 有适合排序的索引 => Distinct Scan
场景:$group的_id为字段a,$top累积器按{a: 1, b: 1}取每个分组内b的最小值;集合上存在正序复合索引a_1_b_1。
[ { "$group" : { "_id" : "$a", "accum" : { "$top" : { "output" : "$b", "sortBy" : { "a" : 1, "b" : 1 } } } } } ]Results(数据来自测试用例的 insertMany:{a:1,b:1}、{a:1,b:2}、{a:2,b:3}):
{ "_id" : 1, "accum" : 1 } { "_id" : 2, "accum" : 3 }Total indexes on the collection:
[ "_id_", "a_1_b_1" ]Summarized explain(Execution Engine: classic):
{ "queryShapeHash" : "A8D462371CDE9BE554D607AD88916CF20C2B5633E0454E7FFF6F7D0A89C142CD", "stages" : [ { "$cursor" : { "rejectedPlans" : [ ], "winningPlan" : [ { "stage" : "PROJECTION_COVERED", "transformBy" : { "_id" : 0, "a" : 1, "b" : 1 } }, { "direction" : "forward", "indexBounds" : { "a" : [ "[MinKey, MaxKey]" ], "b" : [ "[MinKey, MaxKey]" ] }, "indexName" : "a_1_b_1", "isFetching" : false, "isMultiKey" : false, "isPartial" : false, "isShardFiltering" : false, "isSparse" : false, "isUnique" : false, "keyPattern" : { "a" : 1, "b" : 1 }, "multiKeyPaths" : { "a" : [ ], "b" : [ ] }, "stage" : "DISTINCT_SCAN" } ] } }, { "$groupByDistinctScan" : { "newRoot" : { "_id" : "$a", "accum" : "$b" } } } ] }解读:plan 由两层组成——$cursor内的PROJECTION_COVERED -> DISTINCT_SCAN(classic 执行引擎下的物理执行树),其上是聚合层阶段$groupByDistinctScan。索引a_1_b_1的键序恰好与$top的sortBy一致,因此扫描按a分组前进时,每个组遇到的第一个文档就是b最小的文档,DISTINCT_SCAN直接“covered”(isFetching: false,无需回表取文档),完全避免了阻塞排序。
1.2 反向索引(Inverse Order)=> 同样的 Distinct Scan
相同的管道与数据,但集合上的索引换成全反向复合索引a_-1_b_-1。注意Pipeline、Results、queryShapeHash 均不变,变的只是索引定义与扫描方向:
Total indexes on the collection:
[ "_id_", "a_-1_b_-1" ]Summarized explain(Execution Engine: classic):
{ "queryShapeHash" : "A8D462371CDE9BE554D607AD88916CF20C2B5633E0454E7FFF6F7D0A89C142CD", "stages" : [ { "$cursor" : { "rejectedPlans" : [ ], "winningPlan" : [ { "stage" : "PROJECTION_COVERED", "transformBy" : { "_id" : 0, "a" : 1, "b" : 1 } }, { "direction" : "backward", "indexBounds" : { "a" : [ "[MinKey, MaxKey]" ], "b" : [ "[MinKey, MaxKey]" ] }, "indexName" : "a_-1_b_-1", "isFetching" : false, "isMultiKey" : false, "isPartial" : false, "isShardFiltering" : false, "isSparse" : false, "isUnique" : false, "keyPattern" : { "a" : -1, "b" : -1 }, "multiKeyPaths" : { "a" : [ ], "b" : [ ] }, "stage" : "DISTINCT_SCAN" } ] } }, { "$groupByDistinctScan" : { "newRoot" : { "_id" : "$a", "accum" : "$b" } } } ] }解读:与 1.1 相比仅有两处差异——indexName变为a_-1_b_-1,direction由forward变为backward。规划器对索引方向做了“取反等价”判断:整体反向的索引在反向扫描后等价于正向排序,依然能满足$top的sortBy。这正是 document_source_group_base.cpp 中SortPatternDirectionComparison(sameDirection/reverseDirection/incompatible)与getMatchedDirection()所处理的场景:方向全部一致判定为 sameDirection,方向全部取反判定为 reverseDirection,此时重写仍成立,只是在计划中把扫描方向翻转。
1.3 无适合排序的索引 => 无 Distinct Scan 且无阻塞排序
同样的管道,但集合上只有单字段索引a_1。此时规划器无法用索引同时满足“分组有序 + $top 的 b 排序”,于是放弃 DISTINCT_SCAN:
Total indexes on the collection:
[ "_id_", "a_1" ]Summarized explain(Execution Engine: sbe):
{ "queryShapeHash" : "A8D462371CDE9BE554D607AD88916CF20C2B5633E0454E7FFF6F7D0A89C142CD", "rejectedPlans" : [ ], "winningPlan" : [ { "stage" : "GROUP" }, { "direction" : "forward", "filter" : { }, "nss" : "test.distinct_query_planner_md", "stage" : "COLLSCAN" } ] }解读:winningPlan 退化为 SBE 的GROUP直接叠在COLLSCAN之上——$group由 SBE 引擎的 GROUP 算子原生执行,不再有$groupByDistinctScan阶段。值得注意标题中的“No Blocking Sort”:$top的语义本可以借助$sort + $group的经典优化($sort使用SORT算子阻塞排序)完成,但这里 planner 选择了GroupTop 风格的直接哈希分组方案:对每个分组用内存累积器跟踪 top-N,既不使用索引,也不引入阻塞排序,仅以哈希表换取流式处理。同样地,queryShapeHash 与 1.1、1.2 完全一致(A8D462...),说明三种场景查询形状相同,只是物理计划不同。
1.4 索引适合过滤但不适合排序 => 无 Distinct Scan 且无阻塞排序
在管道前追加$match: {a: {$gt: 3}},集合上仍只有a_1索引。数据为a取值 1、3、5、5、5、6、6、7、7 的九条文档:
[ { "$match" : { "a" : { "$gt" : 3 } } }, { "$group" : { "_id" : "$a", "accum" : { "$top" : { "output" : "$b", "sortBy" : { "a" : 1, "b" : 1 } } } } } ]Results:
{ "_id" : 5, "accum" : 4 } { "_id" : 6, "accum" : 7 } { "_id" : 7, "accum" : 3 }Total indexes on the collection:
[ "_id_", "a_1" ]Summarized explain(Execution Engine: sbe):
{ "queryShapeHash" : "4D59D9B70CAA743C51507B7F4CF216F652E91F22553A942308E91D358F754C44", "rejectedPlans" : [ ], "winningPlan" : [ { "stage" : "GROUP" }, { "nss" : "test.distinct_query_planner_md", "stage" : "FETCH" }, { "direction" : "forward", "indexBounds" : { "a" : [ "(3.0, inf]" ] }, "indexName" : "a_1", "isMultiKey" : false, "isPartial" : false, "isSparse" : false, "isUnique" : false, "keyPattern" : { "a" : 1 }, "multiKeyPaths" : { "a" : [ ] }, "nss" : "test.distinct_query_planner_md", "stage" : "IXSCAN" } ] }解读:$match的a > 3让a_1索引有了用武之地——winningPlan 使用IXSCAN(indexBounds 为(3.0, inf])+FETCH先过滤出满足条件的文档,再由 SBEGROUP完成分组。但该索引仍无法满足$top的{a:1, b:1}排序(缺少b),因此同样没有 DISTINCT_SCAN、没有阻塞排序。此场景的 queryShapeHash 变为4D59D9B7...,因为查询形状多了$match,与前三者不同。这个用例说明:索引只覆盖谓词、不覆盖 $top 排序模式时,规划器宁可选择 IXSCAN+哈希 GROUP,也不会退化为阻塞排序。
二、无排序、无过滤时 DISTINCT_SCAN 的构建
当管道只有裸$group(无$sort、无$match)时,规划器同样会尝试“手工构建 covered distinct scan”。
2.1 $group 无 $sort 且有合适索引 => DISTINCT_SCAN
管道仅为[ { "$group" : { "_id" : "$a" } } ],集合上有a_1索引,数据同上(a取值 1、1、2)。
Results:
{ "_id" : 1 } { "_id" : 2 }Total indexes on the collection:
[ "_id_", "a_1" ]Summarized explain(Execution Engine: classic):
{ "queryShapeHash" : "CA2B2C90B53877652CBF1F4F7692F1DA0FA9476BC770590F5D8BCC5820FB58BA", "stages" : [ { "$cursor" : { "rejectedPlans" : [ ], "winningPlan" : [ { "stage" : "PROJECTION_COVERED", "transformBy" : { "_id" : 0, "a" : 1 } }, { "direction" : "forward", "indexBounds" : { "a" : [ "[MinKey, MaxKey]" ] }, "indexName" : "a_1", "isFetching" : false, "isMultiKey" : false, "isPartial" : false, "isShardFiltering" : false, "isSparse" : false, "isUnique" : false, "keyPattern" : { "a" : 1 }, "multiKeyPaths" : { "a" : [ ] }, "stage" : "DISTINCT_SCAN" } ] } }, { "$groupByDistinctScan" : { "newRoot" : { "_id" : "$a" } } } ] }解读:这是“manual covered distinct scan construction”的典型形态:$group无任何累积器,DISTINCT_SCAN沿a_1顺序扫描,每个键值只输出一次,$groupByDistinctScan直接把它映射为{_id: "$a"}文档。整个计划 covered,isFetching: false,比 COLLSCAN + 哈希 GROUP 省掉整张哈希表。
2.2 $group 无 $sort 且无合适索引 => 无 DISTINCT_SCAN
管道不变,但集合上的索引换成b_1_a_1(分组键a不在索引前缀上)。
Total indexes on the collection:
[ "_id_", "b_1_a_1" ]Summarized explain(Execution Engine: sbe):
{ "queryShapeHash" : "CA2B2C90B53877652CBF1F4F7692F1DA0FA9476BC770590F5D8BCC5820FB58BA", "rejectedPlans" : [ ], "winningPlan" : [ { "stage" : "GROUP" }, { "direction" : "forward", "filter" : { }, "nss" : "test.distinct_query_planner_md", "stage" : "COLLSCAN" } ] }解读:b_1_a_1虽然覆盖了a,但a不是索引的最左前缀,无法对a做去重有序扫描,于是规划器回退到COLLSCAN + GROUP。注意两个场景的 queryShapeHash 完全相同(CA2B2C90...)——查询形状一致,物理计划因索引配置而异,这正是 golden 测试想固化的行为:同样的查询,索引存在与否直接决定是否触发 DISTINCT_SCAN。
三、DISTINCT_SCAN 优先于竞争的谓词索引
最后一个场景验证规划器的“取舍”逻辑:当谓词索引与 distinct 索引同时可竞争时,低基数(low-cardinality)$group会偏向 DISTINCT_SCAN。
管道为$match {a: {$gte: 0}, b: "x"}后接$group,$top的sortBy为{a: -1};集合上同时存在三个索引:a_1、b_1、a_1_b_1。测试数据为a取 0..109(共 110 个不同值)、b取"x"/"y"的 220 条文档,即a的基数高、但过滤后每个a值只有一条b="x"文档。
[ { "$match" : { "a" : { "$gte" : 0 }, "b" : "x" } }, { "$group" : { "_id" : "$a", "accum" : { "$top" : { "output" : "$b", "sortBy" : { "a" : -1 } } } } } ]Winning plan:
[ { "stage" : "PROJECTION_COVERED", "transformBy" : { "a" : 1, "b" : 1, "_id" : 0 } }, { "stage" : "DISTINCT_SCAN", "keyPattern" : { "a" : 1, "b" : 1 }, "indexName" : "a_1_b_1", "isMultiKey" : false, "multiKeyPaths" : { "a" : [ ], "b" : [ ] }, "isUnique" : false, "isSparse" : false, "isPartial" : false, "isShardFiltering" : false, "isFetching" : false, "direction" : "backward", "indexBounds" : { "a" : [ "[inf, 0.0]" ], "b" : [ "[\"x\", \"x\"]" ] } } ]解读:表面上b_1索引可以精确命中b = "x",a_1也可以辅助a >= 0;但 winning plan 最终选择了a_1_b_1上的DISTINCT_SCAN:
indexBounds显示b被精确限定为["x", "x"],a的边界为[inf, 0.0],direction: backward对应$top的sortBy: {a: -1};- 扫描沿
(a, b)复合键前进,由于每个a值下只有一条b="x"的记录,DISTINCT_SCAN的“按a去重 + 取首条”语义恰好等价于“每个a组内b唯一的文档”,因此$top结果零成本获得; isFetching: false表示该计划完全 covered,PROJECTION_COVERED之上无需任何回表。
这个用例说明:当 distinct 索引本身也覆盖了过滤谓词时,规划器愿意让 DISTINCT_SCAN 与谓词索引竞争,并因分组去重的低开销而胜出——去重扫描不仅替代了哈希分组,还把谓词过滤一并吸收进索引边界中。
四、源码视角:$group 去重重写的前置条件
上述所有行为最终由 document_source_group_base.cpp 中的重写逻辑决定。从源码结构可以提炼出以下硬性约束:
单字段分组:
static constexpr size_t kNumberOfGroupFieldsInDistinctScanRewrite = 1(第 437 行),重写只针对_id为单一字段的$group;getRewriteGroupRequirements()中会检查idExpressions.size() != 1直接返回空,且_id必须是ExpressionFieldPath且不能是变量引用、不能是整个文档($$CURRENT/$$ROOT,见路径长度为 1 的检查)。累积器受限:重写仅适用于所有累积器都是
$first、$last、$top、$bottom或完全没有累积器的$group(源码注释:We do this transformation only if there are all $first, all $last, all $top, all $bottom, or no accumulators)。排序方向判定:
SortPatternDirectionComparison枚举(sameDirection/reverseDirection/incompatible)+getMatchedDirection()/findMatchedSortingInfix()/compareSortPatterns()三个函数实现了“$sort模式与$top/$bottom累积器排序模式的子串匹配”。1.1 与 1.2 两种方向的索引分别对应sameDirection与reverseDirection;只要方向不一致或字段名对不上就判为incompatible,重写中止,对应 1.3/1.4 的退化路径。重写产物:
rewriteGroupAsTransformOnFirstDocument()生成GroupFromFirstDocumentTransformation(把$top的output部分提取为普通字段,见kFirstOutputDocument/kLastOutputDocument分支),并携带sortDirectionChangeIsRequired标记,供后续阶段构建反向扫描计划。特性开关:该能力由
featureFlagShardFilteringDistinctScan特性标志(feature flag)门控(expression_context.h 中的isFeatureFlagShardFilteringDistinctScanEnabled()),测试文件中同时带有requires_fcv_82标签,说明该优化随 FCV 8.2 演进。分片场景下,split_pipeline.cpp 与distributedPlanLogic()还会利用该标志决定是否将整个$group下推。元数据约束:
$groupByDistinctScan不保留元数据(metadata),因此 pipeline_d.cpp 中与$unwind+$group重写交互时必须显式检查该阶段是否存在,避免在需要元数据的优化路径上误用。
五、如何复现与验证
5.1 运行 golden 测试
复现本文所有计划输出只需运行 distinct_query_planner_md.js:
python3 buildscripts/resmoke.py run --suites=query_golden jstests/query_golden/distinct_query_planner_md.js测试会为每个场景依次执行coll.createIndex(...)+insertMany(...)+outputAggregationPlanAndResults(coll, pipeline),把稳定化的 explain 与结果写入 Markdown,供与期望输出比对(golden test 机制)。测试数据规模很小(3~9 条文档),目的不是测性能,而是固化规划器在特定索引配置下的计划形状。
5.2 不同特性配置下的期望输出
规划器的行为受 SBE 与相关特性开关影响,因此仓库为同一测试维护了多份期望输出:
- featureFlagSbeFull/distinct_query_planner.md:本文主体,SBE 全量开启 + 相关 feature flag 开启(golden 输出显示 classic 与 sbe 引擎并存——1.1/1.2/2.1 走 classic 执行树,1.3/1.4/2.2 走 sbe 执行树,体现了规划器按计划形态选择执行引擎);
- sbeFull/distinct_query_planner.md、sbeRestricted/distinct_query_planner.md、sbeDisabled/distinct_query_planner.md:SBE 不同开关组合下的对照输出;
- internalEnableJoinOptimization/distinct_query_planner.md:开启 join 优化时的对照输出。
对比这些文件可以直观看出:SBE 开启与否只影响最终选中的执行引擎与GROUP算子的呈现方式,而“是否出现 DISTINCT_SCAN”主要由索引与查询形状决定。
5.3 生产环境中的实操建议
结合本文四个场景,可以为实际业务总结出可直接落地的索引设计原则:
- 让索引前缀与分组键一致:
$group: {_id: "$a", accum: {$top: {output: "$b", sortBy: {...}}}}场景下,优先考虑以a为最左前缀的复合索引;若排序模式还有附加字段(如{a: 1, b: 1}),把b也纳入索引,可让 DISTINCT_SCAN 完全 covered(isFetching: false)。 - 方向可以整体取反:若业务上
$top/$bottom与索引方向相反,规划器仍可通过backward扫描命中 DISTINCT_SCAN,无需为反向排序另建索引。 - 索引只覆盖谓词、不覆盖排序时,哈希 GROUP 是兜底:此时不会出现阻塞排序,但哈希表内存开销仍存在;如需进一步优化,应补齐复合索引使
$top的 sortBy 可被索引满足。 - 用 explain 验证:关注 winningPlan 中是否出现
"stage": "DISTINCT_SCAN"以及isFetching是否为 false;两个 true 条件同时满足即代表去重重写已生效。explain 输出的稳定字段与提取工具可参考 analyze_plan.js(getWinningPlanFromExplain、normalizePlan)与 pretty_md.js(section/subSection/code等 Markdown 排版函数)。
总结
本文围绕 golden 文档 distinct_query_planner.md 完整还原了 MongoDB 规划器在$group聚合上的 DISTINCT_SCAN 决策矩阵:
| 场景 | 索引配置 | 是否 DISTINCT_SCAN | 执行形态 |
|---|---|---|---|
$top排序 + 正向复合索引 | a_1_b_1 | 是 | classic:PROJECTION_COVERED → DISTINCT_SCAN →$groupByDistinctScan |
$top排序 + 反向复合索引 | a_-1_b_-1 | 是(backward 扫描) | classic:同上 |
$top排序 + 无匹配索引 | a_1 | 否 | sbe:GROUP → COLLSCAN(无阻塞排序) |
$match+$top,索引仅覆盖过滤 | a_1 | 否 | sbe:GROUP → FETCH → IXSCAN |
裸$group+ 分组键索引 | a_1 | 是 | classic:DISTINCT_SCAN →$groupByDistinctScan |
裸$group+ 非前缀索引 | b_1_a_1 | 否 | sbe:GROUP → COLLSCAN |
低基数$group+ 竞争谓词索引 | a_1、b_1、a_1_b_1 | 是(胜出) | DISTINCT_SCAN,b谓词并入 indexBounds |
核心结论:DISTINCT_SCAN 是$group去重优化中“以索引顺序替代哈希分组”的关键手段,其触发与否取决于分组键是否为单字段、累积器类型、索引键序与排序模式的方向匹配关系;通过 golden 测试固化行为、结合 explain 验证,是让聚合查询稳定吃到该优化的最佳实践。
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考