原理与 Golden 测试深度解读)
MongoDB 聚合管道 DISTINCT_SCAN 多计划竞争Multiplanning原理与 Golden 测试深度解读【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo导读本文以 MongoDB 开源仓库中的 golden 测试文档 featureFlagSbeFull/distinct_aggregation_multiplanning.md 为主体系统讲解聚合管道在可被重写为 distinct 场景时查询规划器如何通过**多计划竞争multiplanning**在多个 DISTINCT_SCAN 候选、以及 DISTINCT_SCAN 与普通 IXSCAN/COLLSCAN 候选之间挑选最优执行计划。读完本文你将掌握 DISTINCT_SCAN 的适用前提、$sort/$group/$top/$bottom与索引方向的关系、hint对计划选择的强制作用、根级$or的边界约束、multikey 索引对去重扫描的限制以及如何读懂 classic 与 sbe 两种执行引擎的 explain 输出。一、背景distinct 重写与 DISTINCT_SCAN在 MongoDB 中$group聚合如果满足特定形态如_id直接引用某个字段其语义等价于对某个字段求去重后的值。此时查询规划器可以把管道重写为 distinct 场景进而考虑使用DISTINCT_SCAN一种沿索引顺序扫描、通过跳过重复键来直接产出去重结果的访问路径无需构建哈希表或进行内存排序。从 测试源文件 的注释可以看到该测试的目标是Tests that the aggregation will go through the process of multiplanning when the pipeline can be rewritten for the distinct case.即验证管道可被重写为 distinct 场景时聚合会进入多计划竞争流程。测试带有featureFlagShardFilteringDistinctScan与requires_fcv_82两个标签说明该行为与分片过滤去重扫描特性标志及 FCV 8.2 相关。DISTINCT_SCAN 的底层执行实现在 classic 引擎中位于 src/mongo/db/exec/classic/distinct_scan.cpp其规划层的 distinct 访问路径构造见 src/mongo/db/query/distinct_access.h 与 canonical_distinct.h。1.1 Golden 测试的产出机制这份 md 文件并非手写文档而是 golden 测试框架自动生成的期望输出。核心调用链如下测试脚本调用outputAggregationPlanAndResults()定义于 jstests/libs/query/golden_test_utils.js它对每个管道同时执行coll.aggregate(pipeline, options)与coll.explain().aggregate(pipeline, options)再借助formatExplainRoot来自 jstests/libs/query/analyze_plan.js整理出稳定的 explain 结构section()/subSection()定义于 jstests/libs/query/pretty_md.js负责按## 编号. 标题与### 小节的格式输出 MarkdownoutputAvailableIndexes()会打印集合上全部索引名称用于对照规划器实际考虑的候选。同一测试在不同特性组合下还有多份期望输出分别位于expected_output/下的sbeFull、sbeRestricted、sbeDisabled、internalEnableJoinOptimization等目录本文解读的是featureFlagSbeFull变体。1.2 测试数据集与索引测试在集合test.distinct_aggregation_multiplanning_mdshell 变量jsTestName()上先创建如下 11 个索引[ _id_, a_1, b_1, a_1_b_1, a_-1_b_1, a_1_b_-1, a_1_b_1_c_1, a_1_b_1_d_1, b_1_a_1, b_1_c_1, d_1_c_-1 ]第一批数据3 条文档用于大部分场景{ _id : 1, a : 4, b : 2, c : 3, d : 4 } { _id : 2, a : 4, b : 3, c : 6, d : 5 } { _id : 3, a : 5, b : 4, c : 7, d : 5 }二、场景一仅 DISTINCT_SCAN 候选被考虑当管道形态$sort$group或直接$group与$first/$last/$top/$bottom累加器组合能够完全由一次去重扫描满足时规划器产生的候选计划全部是 DISTINCT_SCAN竞争只发生在选哪个索引、什么方向扫描之间。2.1$sort {a:1,b:1}$group _id:$a, accum:$first($b)管道[ { $sort : { a : 1, b : 1 } }, { $group : { _id : $a, accum : { $first : $b } } } ]结果{ _id : 4, accum : 2 } { _id : 5, accum : 4 }explainExecution Engine: classic核心rejectedPlans中被淘汰的是a_1_b_1_c_1与a_1_b_1_d_1两个 DISTINCT_SCAN均带PROJECTION_COVERED投影_id:0,a:1,b:1indexBounds为全范围[MinKey, MaxKey]最终winningPlan选择索引a_1_b_1winningPlan : [ { 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, keyPattern : { a : 1, b : 1 }, stage : DISTINCT_SCAN } ]管道末尾接$groupByDistinctScannewRoot为{_id: $a, accum: $b}说明 classic 引擎用去重扫描 逐组取首值的方式直接完成了分组PROJECTION_COVERED保证a、b两列均从索引覆盖无需回表isFetching: false。关键点DISTINCT_SCAN 的 indexBounds 总是全范围[MinKey, MaxKey]它不靠谓词过滤而是靠索引有序性跳过重复键。2.2$sort {a:1,b:-1}$first($b)方向反转[ { $sort : { a : 1, b : -1 } }, { $group : { _id : $a, accum : { $first : $b } } } ]结果{ _id : 4, accum : 3 } { _id : 5, accum : 4 }winningPlan使用a_1_b_-1方向 forwardindexBounds中 b 为[MaxKey, MinKey]rejectedPlans只有a_1_b_1b 区间[MinKey, MaxKey]。这印证了测试源码中的注释Ensure the planner correctly reverses the DISTINCT_SCAN direction for $first and $top——规划器会根据$sort的降序要求把 DISTINCT_SCAN 放到能正向产出该顺序的索引键上而不是在扫描后做一次反向排序。2.3$sort {a:-1,b:-1}$last($b)升序索引反向扫描[ { $sort : { a : -1, b : -1 } }, { $group : { _id : $a, accum : { $last : $b } } } ]结果与 2.1 一致{_id:4, accum:2}、{_id:5, accum:4}。有趣的是winningPlan选了a_-1_b_1directionbackwardrejectedPlans含a_1_b_1_c_1forward与a_1_b_1_d_1backwardisMultiKey: truestage 为 IXSCAN。$last语义要求取组内最后一个值通过反向扫描直接让组内首个遇到的值即最大值方向的末值从而仍用每键取首的方式实现$last。2.4$group$topsortBy a:1,b:1, output c[ { $group : { _id : $a, accum : { $top : { sortBy : { a : 1, b : 1 }, output : $c } } } } ]结果{ _id : 4, accum : 3 } { _id : 5, accum : 7 }winningPlan选择a_1_b_1_c_1DISTINCT_SCANPROJECTION_COVERED投影_id:0,a:1,b:1,c:1isFetching: false。注意$top的输出字段是c因此只有含c键的a_1_b_1_c_1能全覆盖投影rejectedPlans中的a_1_b_1、a_1_b_1_d_1均因无法覆盖c而需要回表isFetching: true。这直接引出场景三的索引选择规则。2.5$bottomsortBy a:-1,b:-1与$bottomsortBy a:1,b:-1管道sortBy a:-1,b:-1[ { $group : { _id : $a, accum : { $bottom : { sortBy : { a : -1, b : -1 }, output : $c } } } } ]结果同样是{_id:4, accum:3}、{_id:5, accum:7}winningPlan仍为a_1_b_1_c_1forward、全覆盖。当sortBy变为{a:1, b:-1}时winningPlan变为a_-1_b_1backwardisFetching: true需回表取c说明降序组合下索引方向会随之翻转且在无法覆盖c时接受回表。2.6 按_id分组唯一索引直通[ { $group : { _id : $_id, accum : { $first : $b } } } ]结果{ _id : 1, accum : 2 } { _id : 2, accum : 3 } { _id : 3, accum : 4 }rejectedPlans为空数组winningPlan直接使用_id_索引isUnique: true做 DISTINCT_SCANisFetching: true回表取b。_id天然唯一去重扫描与唯一索引组合没有竞争余地。2.7 其余方向组合$sort {a:1,b:-1}$last($b)winningPlan为a_-1_b_1forwardb 区间[MinKey, MaxKey]。$sort {a:-1,b:1}$last($b)winningPlan为a_-1_b_1backwarda 区间[MaxKey, MinKey]。无$sort的$group {_id:$a, accum:$first($b)}rejectedPlans为空直接选a_1_b_1forward全覆盖。$group {_id:$d, accum:$top(sortBy:{d:-1}, output:$c)}与$sort {d:-1}$first($c)两者winningPlan均为d_1_c_-1directionbackwardd 区间[MaxKey, MinKey]。$top的sortBy:{d:-1}与$sort {d:-1}表达同样的降序需求规划器把该索引反向扫描从而复用首键即结果的 distinct 语义。2.8 用 hint 强制指定 DISTINCT_SCAN当自动多计划竞争被hint覆盖时规划器不再竞争直接使用指定索引即使它与自动选择不同甚至更大hint: a_1_b_1与hint: a_1_b_1_c_1作用于同一管道$sort {a:1,b:1}$first($b)分别产出a_1_b_1与a_1_b_1_c_1两个 DISTINCT_SCANrejectedPlans均为空两者queryShapeHash相同384E008C...说明查询形状不受 hint 影响hint: a_1_b_1作用于$top/$bottom管道时同样强制使用该索引即使需要isFetching: true回表取c。实战结论hint可以锁定某个 DISTINCT_SCAN代价是可能放弃索引覆盖带来的回表开销。三、场景二DISTINCT_SCAN 与非 DISTINCT_SCAN 候选并存在第二批数据中测试插入了一条d为数组的文档{a: 4, b: 2, c: 3}、{a: 4, b: 3, c: 6}、{a: 5, b: 4, c: 7, d: [1, 2, 3]}。由于d是 multikey 字段a_1_b_1_d_1变为 multikey 索引DISTINCT_SCAN 与普通 IXSCAN 同时进入候选池。3.1 平局时倾向 DISTINCT_SCAN$sort {a:-1,b:-1}$last($b)的winningPlan仍是a_1_b_1DISTINCT_SCAN、forward、全覆盖rejectedPlans包括a_1_b_1_c_1DISTINCT_SCANforwarda_1_b_1_d_1multikeyisMultiKey: truestage 为 IXSCAN方向 backward。而$sort {a:-1,b:-1}$first($b)时winningPlan为a_1_b_1DISTINCT_SCAN、backwardrejectedPlans中a_1_b_1_c_1与a_1_b_1_d_1均为 DISTINCT_SCAN。说明只要去重扫描可行规划器在竞争平局时会优先 DISTINCT_SCANmultikey 索引则降级为 IXSCAN 参与竞争。3.2 用 hint 强制非 DISTINCT_SCANhint: a_1_b_1_d_1执行引擎切换为sbewinningPlan为GROUPPROJECTION_COVERED IXSCANa_1_b_1_d_1backwardisMultiKey: truenss: test.distinct_aggregation_multiplanning_mdrejectedPlans为空。即 hint 让管道放弃 distinct 重写退化为普通分组 索引扫描hint: { $natural : 1 }执行引擎仍为sbewinningPlan为GROUPSORTmemLimit: 104857600sortPattern: {a:-1, b:-1}type: simplePROJECTION_SIMPLECOLLSCAN。天然顺序扫描无法保证排序因此引入显式SORT阶段内存上限 100 MB。从源码结构看这两个 case 也是文档中Execution Engine: sbe的代表当管道无法或被迫不走 distinct 重写时聚合在 SBE 引擎下以GROUP/SORT/IXSCAN/COLLSCAN原样执行。四、场景三索引选择规则——覆盖投影优先否则最小索引该场景用三个对照实验总结 DISTINCT_SCAN 候选内部的择优逻辑管道结果选中的索引原因{$group: {_id: $a}}无投影{_id:4}、{_id:5}a_1最小索引只需a键去重a_1键最少{$group: {_id: $a, accumB: $first(b), accumC: $first(c)}}{_id:4, accumB:2, accumC:3}、{_id:5, accumB:4, accumC:7}a_1_b_1_c_1覆盖投影a/b/c全部从索引读出isFetching: false同上有accumD: $first(d){_id:4, accumB:2, accumC:3, accumD:4}、{_id:5, accumB:4, accumC:7, accumD:5}a_1isFetching: true任何索引都无法覆盖d退回最小索引并回表第三个 case 的 explain 明确显示winningPlan为a_1DISTINCT_SCAN 回表且$groupByDistinctScan的newRoot同时携带accumB/accumC/accumD四个字段。规则可总结为优先找能全覆盖投影的最短索引若不存在覆盖索引则选键数最少的索引用 FETCH 回表补齐剩余字段。五、场景四根级$or与 DISTINCT_SCAN 的边界规则根级$or只有在所有分支谓词都能合并进同一个索引扫描mergeable bounds时才能使用 DISTINCT_SCAN。5.1 可合并单字段多区间[ { $match : { $or : [ { a : { $lte : 5 } }, { a : { $gt : 8 } } ] } }, { $group : { _id : $a } } ]结果{_id:4}、{_id:5}。winningPlan为a_1DISTINCT_SCANforwardindexBounds 为两个区间[-inf, 5.0]、(8.0, inf]isFetching: false。rejectedPlans中既有a_1_b_1、a_1_b_-1、a_1_b_1_c_1、a_1_b_1_d_1等 DISTINCT_SCAN 候选也有OR 多路 IXSCAN 的普通候选PROJECTION_DEFAULTOR 两个 IXSCAN。最终单索引合并区间的 DISTINCT_SCAN 胜出。5.2 不可合并一同一字段上的$and区间{ $match : { $or : [ { a : { $lte : 5 } }, { a : { $gt : 6, $lt : 7 } }, { a : { $gt : 8 } } ] } }中间的{a: {$gt: 6, $lt: 7}}是一个区间交叠无法与其余分支合并为单一连续区间。执行引擎切换为sbewinningPlan为GROUPOR 两个 IXSCANa_1上[-inf,5.0]、(8.0,inf]与(6.0,7.0)分别扫描DISTINCT_SCAN 被禁用。5.3 不可合并二不同字段的$or{ $match : { $or : [ { a : { $gt : 0 } }, { b : { $lt : 10 } } ] } }sbe引擎下winningPlan为GROUPOR IXSCANb_1_a_1上b:[-inf,10.0) IXSCANa_1上(0.0,inf]。$or分支落在不同字段上时DISTINCT_SCAN 完全不可用。另一个变体{$or: [{a: {$gt: 0}, b: {$lt: 10}}, {a: {$lt: 10}}]}同样退化GROUPOR IXSCANa_1上[-inf,10.0) IXSCANa_1_b_1上a:(0.0,inf], b:[-inf,10.0)。5.4 平局DISTINCT_SCAN 优先于 IXSCAN新集合coll2test.distinct_aggregation_multiplanning_md-2只有_id_、a_-1_b_1、a_1_b_1三个索引5 条数据{a: i, b: -i}管道为$match {a: {$gt: 0}}$group _id:$a, accum:$top(sortBy:{a:1,b:1}, output:$b)。winningPlan为a_1_b_1DISTINCT_SCANPROJECTION_COVEREDboundsa:(0.0,inf]rejectedPlans中唯一的a_-1_b_1是 IXSCANnss: test.distinct_aggregation_multiplanning_md-2。DISTINCT_SCAN 与 IXSCAN 竞争平局时去重扫描胜出。5.5 选择性谓词更偏好 FETCH filter IXSCAN集合coll3...-3有 20 条数据索引a_1_b_1与b_1_c_1管道为$match {a: {$gt: 0}, b: {$gt: -18}}$group _id:$a, accum:$top(sortBy:{a:1,b:1}, output:$b)。虽然存在 DISTINCT_SCAN 候选a_1_b_1rejectedPlans中可见但winningPlan选择了PROJECTION_SIMPLEFETCHfilter 保留a 0 IXSCANb_1_c_1上b:(-18.0, inf]管道最终以$group$willBeMerged: false、$top累加器原样执行收尾。当b上的谓词选择性更强、能大幅缩小扫描范围时优化器放弃全范围去重扫描 事后过滤改走窄区间索引扫描 FETCH 过滤——这正是成本模型在多计划竞争中发挥作用的例证。六、场景五排序规格冲突导致无 DISTINCT_SCAN 候选当管道中$sort与$top/$bottom的sortBy无法由同一个索引同时满足时DISTINCT_SCAN 整体退出竞争$sort {a:1,b:1}$top(sortBy:{b:1,a:1}, output:$c)$sort与sortBy键序相反a,bvsb,asbe引擎下rejectedPlans含a_1_b_1IXSCAN与a_1_b_1_d_1IXSCAN、multikeywinningPlan为GROUPPROJECTION_COVERED IXSCANa_1_b_1_c_1forward$sort {a:1,b:1}$bottom(sortBy:{a:-1,b:-1}, output:$c)方向完全相反同样退化winningPlan为GROUPPROJECTION_COVERED IXSCANa_1_b_1_c_1。测试源码注释指出该查询could (and after SERVER-94369, possibly will) be answered by a forward distinct scan on a_1_b_1暗示后续版本可能进一步放宽此限制。七、场景六multikey 索引禁用 DISTINCT_SCAN第三批数据加入了{a: [1, 2, 3], b: 4, c: 7, d: 5}使a成为 multikey 字段所有以a为前缀的索引均变为 multikey 索引。$sort {a:1,b:1}$first($b)结果中出现{_id: [1,2,3], accum: 4}。执行引擎为sberejectedPlans中a_1_b_1_c_1、a_1_b_1_d_1均以IXSCANisMultiKey: truemultiKeyPaths.a: [a]出现winningPlan为GROUPFETCH IXSCANa_1_b_1multikey无$sort的{$group: {_id: $a}}winningPlan直接退化为GROUPCOLLSCANfilter: {}No available indexes 场景$top管道同样GROUPCOLLSCAN。结论multikey 索引无法承载 DISTINCT_SCAN数组键无法保证有序去重语义规划器会退化为 IXSCANFETCH 甚至 COLLSCAN。八、场景七按非 multikey 字段分组、累加器读取 multikey 字段只要$group的_id字段本身不是 multikey即使$first/$last累加器读取的是 multikey 字段DISTINCT_SCAN 依然可用通过回表获取累加器字段{$group: {_id: $b, accum: {$first: $a}}}winningPlan为b_1DISTINCT_SCANforward、isFetching: true结果{_id:2, accum:4}、{_id:3, accum:4}、{_id:4, accum:5}{$group: {_id: $b, accum: {$last: $a}}}winningPlan为b_1DISTINCT_SCANdirectionbackward、isFetching: true结果{_id:2, accum:4}、{_id:3, accum:4}、{_id:4, accum:[1,2,3]}$last取到数组值本身。8.1 收尾实验DISTINCT_SCAN 平局时选择键数最少的索引新数据集 5 条创建 5 个前缀索引a_1_b_1_c_1_d_1_e_1、a_1_b_1_c_1_d_1、a_1_b_1_c_1、a_1_b_1、a_1管道为$match {a: {$gt: 0}}{$group: {_id: $a}}[ _id_, a_1_b_1_c_1_d_1_e_1, a_1_b_1_c_1_d_1, a_1_b_1_c_1, a_1_b_1, a_1 ]rejectedPlans依次列出a_1_b_1_c_1_d_1、a_1_b_1_c_1、a_1_b_1、a_1_b_1_c_1_d_1_e_1四个 DISTINCT_SCAN 候选winningPlan最终选择a_1键数最少、PROJECTION_COVERED、boundsa:(0.0,inf]。这是场景三规则的再次印证无额外投影需求时最短索引即最优。九、explain 输出速查classic 与 sbe 引擎的形态差异汇总本 golden 文件全部案例可以建立如下对照表形态Execution Engine结构特征典型场景$cursor含winningPlan/rejectedPlans$groupByDistinctScanclassic管道被重写为 distinct底层走 DISTINCT_SCANrejectedPlans中并列展示多个候选 DISTINCT_SCAN场景一、二DISTINCT_SCAN 选中、三、四可合并 $or、七GROUPPROJECTION_COVERED/PROJECTION_SIMPLEIXSCAN/COLLSCAN/OR/SORTsbe未走 distinct 重写按普通聚合执行hint 强制非 DISTINCT_SCAN3.2、不可合并$or5.2/5.3、排序冲突六、multikey七其中queryShapeHash是查询形状的稳定哈希同一管道即使 hint 不同也保持一致如场景一hint实验中两次输出均为384E008C...isShardFiltering、isPartial、isSparse、isUnique、multiKeyPaths等字段用于描述索引属性nss字段在 sbe 的扫描节点中标识集合全名test.distinct_aggregation_multiplanning_md、-2、-3后缀对应测试中的多个辅助集合。十、总结DISTINCT_SCAN 多计划竞争的完整决策模型综合 7 大场景、30 余条管道的期望输出MongoDB 对可重写为 distinct 的聚合管道执行如下决策流程形态判定管道能否被重写为 distinct$sort$group或$group含$first/$last/$top/$bottom_id直接引用字段候选生成生成所有可用的 DISTINCT_SCAN 候选若$or谓词可合并到单一索引区间则区间合并的 DISTINCT_SCAN 也入池资格排除multikey 索引不可作 DISTINCT_SCAN$sort与sortBy冲突时全部排除根级$or分支无法合并时全部排除竞争择优DISTINCT_SCAN 与 IXSCAN 平局时优先 DISTINCT_SCAN多个 DISTINCT_SCAN 竞争时优先能全覆盖投影的索引否则选键数最少的索引当普通索引谓词选择性显著更强时成本模型可能反选FETCH filter IXSCANhint 覆盖hint指定索引后跳过竞争直接执行指定 multikey 索引或$natural时管道退化为 sbe 普通聚合执行。阅读本 golden 输出时建议同时对照测试源码 distinct_aggregation_multiplanning_md.js、生成工具 golden_test_utils.js、底层执行器 distinct_scan.cpp并可横向对比同目录下 distinct_command_multiplanning.md、distinct_query_planner.md、distinct_index_eligibility.md 等姊妹 golden 文件以及sbeFull/sbeRestricted/sbeDisabled/internalEnableJoinOptimization变体获得不同特性开关下的完整行为画像。【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考