MongoDB DISTINCT_SCAN 查询规划:$group/$top 聚合的索引选择规则与黄金测试证据
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
本文基于 MongoDB 仓库中的黄金测试(golden test)预期输出文档 distinct_query_planner.md,系统讲解查询规划器(query planner)在何时为$group/$top聚合管线生成DISTINCT_SCAN索引扫描计划、何时回退到全表扫描或普通索引扫描,并结合 distinct_query_planner_md.js 测试脚本与 document_source_group_base.cpp、distinct_scan.cpp 源码,剖析$groupByDistinctScan改写背后的排序模式比对逻辑与规划决策边界。读完本文,你可以准确判断哪类索引能让“按字段分组取 Top/First/Last”的聚合免排序、免物化,并能用 explain 输出验证规划结果。
1. 文档定位:一份规划器行为的黄金预期输出
该文档是 MongoDB 集成测试体系query_golden的预期输出快照。它由测试 distinct_query_planner_md.js 运行生成并比对,测试文件头部明确标注了两个运行前提(tags):
featureFlagShardFilteringDistinctScan:需要启用shardFilteringDistinctScan特性开关(该特性同时控制$group下推与 DISTINCT_SCAN 相关改写);requires_fcv_82:需要 featureCompatibilityVersion 8.2 及以上。
测试脚本通过jstests/libs/query/下的工具库(pretty_md.js、analyze_plan.js、golden_test_utils.js)将每次管线执行的管道定义、查询结果、集合上的全部索引、汇总后的 explain 计划四要素渲染为 Markdown 并落盘到jstests/query_golden/expected_output/sbeFull/目录,供回归比对。也就是说,文档中的每一个 JSON 块都是规划器在特定“索引 + 数据 + 管线”组合下必须稳定复现的行为契约。
文档整体分为三节,分别覆盖三条规划路径:
- 为
$groupByDistinctScan补加排序模式(Sort Pattern)时的索引匹配规则; - 无排序、无过滤场景下 DISTINCT_SCAN 的构造条件;
- DISTINCT_SCAN 与“谓词索引”竞争时的优先级。
2. 背景:$groupByDistinctScan改写与 DISTINCT_SCAN 阶段
理解文档中的 explain 输出,需要先了解两个核心机制。
2.1$group到$groupByDistinctScan的流水线改写
当$group满足特定条件时,MongoDB 会将其改写为$groupByDistinctScan:不再让上游返回全部文档,而是让底层游标只产出去重后的分组键,配合索引扫描直接输出每个分组键,再取组内首/末文档完成$first、$last、$top、$bottom语义。
源码 document_source_group_base.cpp 中getRewriteGroupRequirements()给出了改写的准入条件,与文档中各小节的行为一一对应:
// Distinct Scan rewrite is only intended for $group stages that group on a single field. static constexpr size_t kNumberOfGroupFieldsInDistinctScanRewrite = 1;_id必须是单个字段路径(如_id: "$a"),复合分组键或表达式分组不适用;- 分组字段不能是
$$CURRENT/$$ROOT这类“永远不相等”的系统变量(源码中以tassert(5943200, ...)显式拦截); - 累加器必须全部是
$first/$last/$top/$bottom之一,且(对多个$top/$bottom)所要求的文档与排序模式必须一致(allAccsNeedFirstOrLastDoc())。
2.2 排序模式比对:compareSortPatterns
$top/$bottom的sortBy引入了一个排序模式。若管线前方还有显式$sort(或规划器为 distinct scan 计划临时补加的排序模式),则两者的方向关系决定能否命中 DISTINCT_SCAN。源码 document_source_group_base.cpp 定义了三种关系:
enum class SortPatternDirectionComparison { sameDirection, // 方向一致 reverseDirection, // 方向完全相反(索引反向扫描即可满足) incompatible; // 字段不匹配或方向不一致 };其中findMatchedSortingInfix()/getMatchedDirection()的语义是:把分组排序模式当作“infix”在索引排序模式中查找完全匹配的连续片段,且匹配起点必须是第 0 或第 1 个字段位置(kNumberOfGroupFieldsInDistinctScanRewrite = 1),否则判定为 incompatible。这解释了文档第 1 节中“反向索引也能命中 DISTINCT_SCAN”的行为:方向相反不是失败,而是reverseDirection,只需索引反向扫描。
2.3 执行端:DistinctScan计划阶段
执行端由 distinct_scan.cpp 中的DistinctScan阶段实现:它在索引上按键序前进,每当“第 fieldNo 个字段”的值发生变化时即产出一个新分组键(因此 explain 中的isFetching表示是否需要回表取完整文档)。构造函数(distinct_scan.cpp)还处理了多键索引下 undefined 与 null 分组的边界语义,并在启用 shard 过滤时创建OrphanChunkSkipper跳过孤儿 chunk——这正是featureFlagShardFilteringDistinctScan特性开关在规划端与执行端共同的作用面,也是该测试打上此 tag 的原因。
3. 第 1 节:为$groupByDistinctScan补加排序模式的规划规则
本节回答一个核心问题:当 DISTINCT_SCAN 计划唯一需要的“排序”(即让分组键有序)由规划器自行补加时,什么样的索引算“可用”?四个小节构成一组对照实验,同一管线、同一数据{a:1,b:1}, {a:1,b:2}, {a:2,b:3},仅改变索引。
管线统一为:
[ { "$group" : { "_id" : "$a", "accum" : { "$top" : { "output" : "$b", "sortBy" : { "a" : 1, "b" : 1 } } } } } ]查询结果在三类“可用”/“不可用”场景下均为:
{ "_id" : 1, "accum" : 1 } { "_id" : 2, "accum" : 3 }3.1 正序合适索引 => DISTINCT_SCAN
集合索引:[ "_id_", "a_1_b_1" ]。
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" } } } ] }要点:
$group已被改写为$groupByDistinctScan,newRoot表明上游只需返回{_id: "$a", accum: "$b"}两个字段;- 游标侧胜出计划是
PROJECTION_COVERED+DISTINCT_SCAN两层结构,isFetching: false说明纯覆盖扫描,无需回表; - 索引
a_1_b_1的indexBounds为全空间[MinKey, MaxKey],direction: forward,与sortBy: {a: 1, b: 1}完全同向。
3.2 反向合适索引(Inverse Order)=> DISTINCT_SCAN
集合索引:[ "_id_", "a_-1_b_-1" ]。管线与结果同上,但胜出计划的 DISTINCT_SCAN 段变为:
{ "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" }即direction: backward。这正是 2.2 节reverseDirection语义的行为落点:索引字段与sortBy字段一一对应、方向全部相反时,规划器选择反向扫描索引而非回退 COLLSCAN。查询语义(每组内按a升序、b升序取 top)与索引反向扫描产出的组内顺序一致,因为 DISTINCT_SCAN 只关心“分组键有序”这一前提,反向扫描同样保证分组键有序。
3.3 无合适排序索引 => 不生成 DISTINCT_SCAN,也不做阻塞排序
集合索引:[ "_id_", "a_1" ](只有a,缺b)。explain(Execution Engine: sbe):
{ "queryShapeHash" : "A8D462371CDE9BE554D607AD88916CF20C2B5633E0454E7FFF6F7D0A89C142CD", "rejectedPlans" : [ ], "winningPlan" : [ { "stage" : "GROUP" }, { "direction" : "forward", "filter" : { }, "nss" : "test.distinct_query_planner_md", "stage" : "COLLSCAN" } ] }两个关键行为:
- 计划仍是普通
GROUP+COLLSCAN,$group未被改写; - 值得注意:规划器没有插入阻塞排序(SORT)来凑齐
sortBy: {a:1, b:1}再走 distinct scan 路径。由于$top是流式累加器(每组只保留一个候选值),即使走全表扫描也不需额外排序内存,COLLSCAN 直接胜出。这从“不补排序”的角度界定了 DISTINCT_SCAN 的触发边界。
3.4 过滤索引合适但排序索引不合适 => 不生成 DISTINCT_SCAN,也不做阻塞排序
管线前置$match: {a: {$gt: 3}},数据扩充为 9 条(a取 1..7、b交错),集合索引:[ "_id_", "a_1" ]。结果:
{ "_id" : 5, "accum" : 4 } { "_id" : 6, "accum" : 7 } { "_id" : 7, "accum" : 3 }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_1对a > 3的IXSCAN,bounds(3.0, inf])而不满足 distinct scan 所需的排序模式时,规划器选择“谓词优先”的GROUP + FETCH + IXSCAN计划,并且不为了凑 distinct scan 而追加排序。注意此处计划为GROUP而非$groupByDistinctScan:$match的谓词消费后,distinct scan 改写不再被选中。
4. 第 2 节:无排序、无过滤场景下 DISTINCT_SCAN 的构造
这一节测试没有任何sortBy/$sort/$match的极简管线[ { "$group" : { "_id" : "$a" } } ](即无累加器,等价于去重取a的 distinct 值),数据同为 3 条。这正是“手动覆盖式 distinct scan 构造”路径:分组字段恰好是某单字段索引的前缀时,规划器可直接构造 DISTINCT_SCAN。
4.1 有合适索引 => DISTINCT_SCAN
集合索引:[ "_id_", "a_1" ]。结果:
{ "_id" : 1 } { "_id" : 2 }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" } } } ] }与第 1 节相同的两层结构:$groupByDistinctScan只要求游标给出_id分组键,游标侧用a_1前缀做覆盖式 DISTINCT_SCAN(isFetching: false)。这里没有sortBy参与,对应源码中getRewriteGroupRequirements()在无累加器时返回的纯groupId需求。
4.2 无合适索引 => 不生成 DISTINCT_SCAN
集合索引改为[ "_id_", "b_1_a_1" ](a不是前缀字段)。explain(Execution Engine: sbe)回退为:
{ "queryShapeHash" : "CA2B2C90B53877652CBF1F4F7692F1DA0FA9476BC770590F5D8BCC5820FB58BA", "rejectedPlans" : [ ], "winningPlan" : [ { "stage" : "GROUP" }, { "direction" : "forward", "filter" : { }, "nss" : "test.distinct_query_planner_md", "stage" : "COLLSCAN" } ] }结论:DISTINCT_SCAN 要求分组字段是索引的(前缀)字段。b_1_a_1中a位于第二位,无法支撑对a的去重扫描,规划器直接回退GROUP + COLLSCAN,同样没有插入 SORT。注意两小节queryShapeHash相同(CA2B2C90...),因为管线形状一致,仅索引集合不同,规划结果随之不同——这也是 query shape 缓存/plan cache 语义下的稳定行为对照。
5. 第 3 节:DISTINCT_SCAN 优先于竞争的谓词索引
最后一节验证规划器在“低基数分组”与“高选择性谓词索引”之间的取舍:即使谓词索引能显著过滤行数,只要分组键有序可支撑 DISTINCT_SCAN,DISTINCT_SCAN 计划依然胜出。
5.1 场景设置
测试 distinct_query_planner_md.js 中构造:
coll.createIndex({a: 1}); coll.createIndex({b: 1}); coll.createIndex({a: 1, b: 1}); const docs = []; for (let a = 0; a < 110; a++) { docs.push({a, b: "x"}); docs.push({a, b: "y"}); } coll.insertMany(docs); const pipeline = [ {$match: {a: {$gte: 0}, b: "x"}}, {$group: {_id: "$a", accum: {$top: {output: "$b", sortBy: {a: -1}}}}}, ];即 220 条文档、a取值 0~109(低基数分组)、b为"x"/"y"两类值;同时存在可服务谓词b: "x"的b_1索引(竞争者)。
管线:
[ { "$match" : { "a" : { "$gte" : 0 }, "b" : "x" } }, { "$group" : { "_id" : "$a", "accum" : { "$top" : { "output" : "$b", "sortBy" : { "a" : -1 } } } } } ]5.2 胜出计划
[ { "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"]" ] } } ]解读:
- 胜出者是
a_1_b_1上的DISTINCT_SCAN而非b_1上的 IXSCAN:谓词b = "x"被编码进索引边界b: ["x", "x"](点查区间),a >= 0变为a: [inf, 0.0]; direction: backward与sortBy: {a: -1}对应——反向扫描复合索引,组内按a降序产出,正好满足$top的sortBy;isFetching: false、PROJECTION_COVERED表明全程覆盖扫描,110 个分组只物化 110 个分组键加各组的 top 候选,而不是 110 条(或全部 220 条)输入文档。
从小节标题“Low-cardinality $group with a competing predicate index => DISTINCT_SCAN”可见,该行为被刻意固化为契约:低基数分组 + 可用 distinct 索引时,DISTINCT_SCAN 压过竞争谓词索引。
6. 决策规则汇总与复现方式
将三节行为归纳为一张判定表:
| 场景 | 索引条件 | 规划结果 | 文档小节 |
|---|---|---|---|
$topsortBy 与索引同向 | a_1_b_1覆盖 sortBy | $groupByDistinctScan+DISTINCT_SCAN(forward) | 1.1 |
$topsortBy 与索引反向 | a_-1_b_-1字段一一对应、方向全反 | DISTINCT_SCAN(backward 扫描) | 1.2 |
| sortBy 无索引支撑 | 仅a_1,缺b | GROUP + COLLSCAN,不插 SORT | 1.3 |
索引仅服务$match | 仅a_1 | GROUP + FETCH + IXSCAN(a_1) | 1.4 |
| 无 sortBy/过滤,分组字段为索引前缀 | a_1 | $groupByDistinctScan+DISTINCT_SCAN | 2.1 |
| 无 sortBy/过滤,分组字段非索引前缀 | b_1_a_1 | GROUP + COLLSCAN | 2.2 |
| 低基数分组 vs 谓词索引竞争 | a_1/b_1/a_1_b_1并存 | DISTINCT_SCAN(a_1_b_1, backward)胜出 | 3 |
复现上述验证只需运行对应黄金测试。测试入口为 jstests/query_golden/distinct_query_planner_md.js,其通过outputAggregationPlanAndResults(coll, pipeline)逐小节输出“Pipeline / Results / Total indexes on the collection / Summarized explain”四段 Markdown,与预期文件 expected_output/sbeFull/distinct_query_planner.md 做文本级比对。注意两个适用前提:
- 需启用
shardFilteringDistinctScan特性开关(测试 tagfeatureFlagShardFilteringDistinctScan); - 需 FCV 8.2 及以上(tag
requires_fcv_82)。
7. 工程启示
从这份黄金测试与配套源码可以得到几条对聚合查询优化有直接指导意义的结论:
- 索引前缀与字段顺序决定 DISTINCT_SCAN 的生死。分组字段(或
$top的 sortBy 前缀字段)必须是索引的起始字段,且字段顺序与 sortBy 一致(或完全反向);b_1_a_1这类“分组字段在第二位”的索引直接出局。 - DISTINCT_SCAN 不触发阻塞排序。当排序索引缺失时,规划器宁可回退
COLLSCAN + GROUP或谓词 IXSCAN,也不为凑 distinct scan 追加 SORT——因为$top/$first/$last均为流式累加器,回退路径本身代价可控。 - 覆盖性是隐藏收益。所有命中 DISTINCT_SCAN 的计划
isFetching均为false,配合PROJECTION_COVERED避免回表;若你的聚合只需要分组键和少量输出字段,把所需字段放进复合索引前缀可以稳定吃到该优化。 - 低基数分组是 DISTINCT_SCAN 的主场。在“谓词选择性高但分组基数低”的场景下,去重后的分组键远小于谓词命中行数,规划器明确偏向 DISTINCT_SCAN 计划。
该文档作为黄金测试快照的价值在于:以上每一条规则都以“索引集合 + explain 全文”的形式被固化为可回归比对的契约,任何规划器行为漂移都会使测试失败,这为依赖 DISTINCT_SCAN 语义的聚合负载提供了长期稳定性保障。
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考