news 2026/9/13 6:40:01

MongoDB DISTINCT_SCAN 查询规划:$group/$top 聚合的索引选择规则与黄金测试证据

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MongoDB DISTINCT_SCAN 查询规划:$group/$top 聚合的索引选择规则与黄金测试证据

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 块都是规划器在特定“索引 + 数据 + 管线”组合下必须稳定复现的行为契约。

文档整体分为三节,分别覆盖三条规划路径:

  1. $groupByDistinctScan补加排序模式(Sort Pattern)时的索引匹配规则;
  2. 无排序、无过滤场景下 DISTINCT_SCAN 的构造条件;
  3. 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/$bottomsortBy引入了一个排序模式。若管线前方还有显式$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已被改写为$groupByDistinctScannewRoot表明上游只需返回{_id: "$a", accum: "$b"}两个字段;
  • 游标侧胜出计划是PROJECTION_COVERED+DISTINCT_SCAN两层结构,isFetching: false说明纯覆盖扫描,无需回表;
  • 索引a_1_b_1indexBounds为全空间[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_1a > 3IXSCAN,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_1a位于第二位,无法支撑对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: backwardsortBy: {a: -1}对应——反向扫描复合索引,组内按a降序产出,正好满足$topsortBy
  • isFetching: falsePROJECTION_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,缺bGROUP + COLLSCAN不插 SORT1.3
索引仅服务$matcha_1GROUP + FETCH + IXSCAN(a_1)1.4
无 sortBy/过滤,分组字段为索引前缀a_1$groupByDistinctScan+DISTINCT_SCAN2.1
无 sortBy/过滤,分组字段非索引前缀b_1_a_1GROUP + COLLSCAN2.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 及以上(tagrequires_fcv_82)。

7. 工程启示

从这份黄金测试与配套源码可以得到几条对聚合查询优化有直接指导意义的结论:

  1. 索引前缀与字段顺序决定 DISTINCT_SCAN 的生死。分组字段(或$top的 sortBy 前缀字段)必须是索引的起始字段,且字段顺序与 sortBy 一致(或完全反向);b_1_a_1这类“分组字段在第二位”的索引直接出局。
  2. DISTINCT_SCAN 不触发阻塞排序。当排序索引缺失时,规划器宁可回退COLLSCAN + GROUP或谓词 IXSCAN,也不为凑 distinct scan 追加 SORT——因为$top/$first/$last均为流式累加器,回退路径本身代价可控。
  3. 覆盖性是隐藏收益。所有命中 DISTINCT_SCAN 的计划isFetching均为false,配合PROJECTION_COVERED避免回表;若你的聚合只需要分组键和少量输出字段,把所需字段放进复合索引前缀可以稳定吃到该优化。
  4. 低基数分组是 DISTINCT_SCAN 的主场。在“谓词选择性高但分组基数低”的场景下,去重后的分组键远小于谓词命中行数,规划器明确偏向 DISTINCT_SCAN 计划。

该文档作为黄金测试快照的价值在于:以上每一条规则都以“索引集合 + explain 全文”的形式被固化为可回归比对的契约,任何规划器行为漂移都会使测试失败,这为依赖 DISTINCT_SCAN 语义的聚合负载提供了长期稳定性保障。

【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 6:38:49

Libvio.link影视爬虫技术解析与反反爬实践

1. Libvio.link爬虫技术概述Libvio.link作为影视资源聚合平台&#xff0c;其数据爬取面临着多重技术挑战。这个平台的典型特征包括&#xff1a;采用JavaScript动态渲染内容、实施IP访问频率限制、使用分布式CDN存储资源&#xff0c;以及部署了多层次的反爬机制。要有效爬取这类…

作者头像 李华
网站建设 2026/9/13 6:37:22

Replit集成Databricks实现云原生数据探索

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 6:36:06

高斯混合模型GMM与EM算法原理及手写实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 6:36:03

提示词工程实战:10个立刻能上手的技巧与可直接复制的模板库

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 6:33:17

Windows AI开发环境重建:从注册表到WSL2 GPU直通的全链路指南

1. 这不是“装软件”&#xff0c;而是为AI时代重建Windows开发神经中枢 你点开这个标题&#xff0c;大概率正坐在一台Windows电脑前&#xff0c;屏幕右下角还挂着未关闭的微信窗口&#xff0c;桌面上堆着几个压缩包——node-v20.18.0-x64.msi、docker-desktop-installer.exe、r…

作者头像 李华
网站建设 2026/9/13 6:29:21

机器学习经典算法Python实战:从原理到部署全流程

1. 这不是“速成课”&#xff0c;而是一份机器学习算法的实操手账你搜过“机器学习入门”“Python怎么学”“期末复习抱佛脚”&#xff0c;点开一堆视频&#xff0c;前五分钟讲定义、讲历史、讲图灵测试——结果关掉页面&#xff0c;连“梯度下降到底在算什么”都还没搞清。我带…

作者头像 李华