ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MongoDB 查询黄金测试解析:$unwind+$group 重写为 DISTINCT_SCAN 与 Multiplanning 的交互

MongoDB 查询黄金测试解析:$unwind+$group 重写为 DISTINCT_SCAN 与 Multiplanning 的交互 MongoDB 查询黄金测试解析$unwind$group 重写为 DISTINCT_SCAN 与 Multiplanning 的交互【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo本文围绕 MongoDB 仓库中的黄金测试Golden Data Test预期输出文件 unwind_group_to_distinct_scan_multiplanning.md 展开完整解析$unwind$group聚合管道被重写为DISTINCT_SCAN索引扫描这一优化在多计划枚举multiplanning场景下的四类行为多个候选索引如何收敛为单一候选、hint 何时阻断重写、hint 如何强制选择合适索引、以及整索引扫描候选如何与 DISTINCT_SCAN 竞争胜出。读完本文你可以读懂该黄金测试的四段 explain 输出背后的源码机制重写条件、multikey 展开、null 语义并掌握在 jstests/query_golden 套件的黄金测试框架中验证与更新此类输出的方法。一、这个文档是什么query_golden 黄金测试的预期输出该文件是 jstests/query_golden/unwind_group_to_distinct_scan_multiplanning_md.js 这个端到端查询测试的预期输出golden output。MongoDB 的黄金数据测试框架把确定性输出与仓库中签入的已知正确输出做逐字比对任何差异都会导致测试失败需要更新代码或预期输出之一框架的使用规范与 diff/accept 工具链参见 docs/golden_data_test_framework.md 和 buildscripts/golden_test.py。几个关键的定位信息引擎变体目录jstests/query_golden/expected_output 下按执行引擎变体分了classic、sbeFull、sbeRestricted、sbeDisabled等子目录。本文件位于sbeFull/下说明该测试在 SBEStaged Block Engine全量启用的构建变体下运行并签入对应变体的预期输出。测试标签测试文件头部声明了tags: [featureFlagShardFilteringDistinctScan, requires_fcv_91]即该行为依赖功能开关featureFlagShardFilteringDistinctScan且要求 FCV 9.1对应当前开发版本explain 输出中的isShardFiltering字段即与该开关相关。输出工具测试通过 jstests/libs/query/pretty_md.js 的section()划分小节用outputAggregationPlanAndResults来自jstests/libs/query/golden_test_utils.js打印管道、结果、集合索引全集与精简版 explainsummarized explain。这正是本文文档中### Pipeline / ### Results / ### Total indexes on the collection / ### Summarized explain四段结构的来源。测试文件注释给出了核心动机The rewrite requires an empty filter, so there are no predicates to enumerate competing plans from and the planner usually generates a single solution.该重写要求空过滤条件因此规划器没有谓词可以用来枚举竞争计划通常只生成单一方案。也就是说$unwind$group场景没有$match谓词参与规划器一般只会生成一个 DISTINCT_SCAN 候选该测试专门考察这种单候选假设被打破或被干扰时的行为。二、测试装置数据、索引与管道测试在集合test.unwind_group_to_distinct_scan_multiplanning_md由jsTestName()决定即 explain 输出中反复出现的nss上插入如下 5 个文档并建立两个二级索引coll.insertMany([{a: [1, 2], b: 1}, {a: [2, 3], b: 2}, {a: 7, b: 3}, {a: [], b: 4}, {b: 5}]); coll.createIndex({a: 1}); coll.createIndex({a: 1, b: 1});数据要点a在多个文档中是数组[1,2]、[2,3]因此a_1与a_1_b_1索引都是multikey索引一个文档a为空数组[]一个文档完全没有a字段——这两类文档在preserveNullAndEmptyArrays: true的$unwind下都会产生null值第三个文档a是标量7验证标量与数组混存时的展开。被测管道为[ { $unwind : { path : $a, preserveNullAndEmptyArrays : true } }, { $group : { _id : $a } } ]语义上等价于取a字段展开后的去重值。展开后的取值集合为{1, 2, 3, 7, null}7来自标量文档null来自空数组与缺字段文档各贡献一次所以前三段的 Results 都是{ _id : 1 } { _id : 2 } { _id : 3 } { _id : 7 } { _id : null }重写条件源码级该管道之所以能被重写条件定义在 src/mongo/db/pipeline/pipeline_d.cpp 的canRewriteUnwindGroupAsDistinctScan()中对应 SERVER-33715与文档中的管道逐条对应preserveNullAndEmptyArrays必须为true。源码注释解释了原因只有这样才能让索引中的 null/undefined 键值与空数组/缺字段产生 null 分组键的语义对齐若为false索引无法区分空数组/缺字段不产生分组与[null]产生 null 分组。$unwind不带includeArrayIndex、非 strict 模式。$group不能有任何累加器accumulators 需要展开后的全部文档而 DISTINCT_SCAN 每个键只回一个文档。分组键必须是单一字段路径表达式且路径尾部恰好是 unwind 路径不支持点路径索引键生成会对点路径的每个组件展开数组而$unwind不会。重写入口是tryDistinctGroupRewrite()pipeline_d.cpp它识别位于管道开头的$unwind$group组合并返回用于替换它们的$groupByDistinctScan阶段。这就是 explain 输出中$groupByDistinctScan与newRoot: {_id: $a}的来源游标只按索引返回每个不同键值加一个覆盖投影分组逻辑退化成一个按索引序透传、去重的投影。DISTINCT_SCAN 阶段本身执行侧实现在 src/mongo/db/exec/classic/distinct_scan.h。DistinctParamsL40-L90中的fieldNo表示对复合索引中第几列做去重扫描类注释说明了核心机制——扫描索引范围时跳过与前一索引键在该列取值相同的所有键只关心不同值。与本文四段输出直接相关的三个字段unwindsArraysL88-L89为true时表示这是一个被展开的 multikey 扫描DISTINCT_SCAN 会把每一个 multikey 索引条目都当作一个独立取值输出——这正是文档里a_1上isMultiKey: true且unwindsArrays: true的含义_replaceUndefinedWithNullL143-L145仅对展开的 multikey 扫描生效把 distinct 字段上的 undefined 键值表现为null并让下一次 seek 一并跳过 null 段——这是 Results 中出现{ _id : null }的底层保证_needsFetchL163-L164输出前是否需要 FETCH 回表。第一段输出中isFetching: false覆盖投影无需回表与第二段的 FETCH 场景形成对照。三、场景一多个合适索引只产生一个 DISTINCT_SCAN 候选对应文档第 1 节 Multiple suitable indexes generate a single DISTINCT_SCAN candidate。集合上有a_1和a_1_b_1两个都以a为前导键的合适索引但 explain 显示规划器只生成了一个候选{ queryShapeHash : 7369A25E4B52DA5797E4FFD461F50CE164B53CC62F7CD4D3BFEA04A3342FA081, stages : [ { $cursor : { rejectedPlans : [ ], winningPlan : [ { stage : PROJECTION_COVERED, transformBy : { _id : 0, a : 1 } }, { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ] }, indexName : a_1, isFetching : false, isMultiKey : true, isPartial : false, isShardFiltering : false, isSparse : false, isUnique : false, keyPattern : { a : 1 }, multiKeyPaths : { a : [ a ] }, stage : DISTINCT_SCAN, unwindsArrays : true } ] } }, { $groupByDistinctScan : { newRoot : { _id : $a } } } ] }Execution Engine: classic要点解读rejectedPlans为空a_1_b_1并没有生成第二个 DISTINCT_SCAN 候选参与竞争。原因是重写路径走的是distinct 访问路径式的规划CanonicalDistinct携带unwindsArrays标记见 src/mongo/db/query/canonical_distinct.h而非普通查询基于谓词枚举的 multiplanning——空过滤条件下规划器按一个 DISTINCT_SCAN 方案生成选择前导键最短最窄的合适索引a_1。PROJECTION_COVERED中_id: 0, a: 1表示只从索引键读取a与_id配合isFetching: false整条管道不触碰数据文档。游标之上只剩$groupByDistinctScan原$unwind$group两阶段被折叠为一个透传去重阶段。multiKeyPaths.a [a]与isMultiKey: true记录了该索引是 multikey 的配合unwindsArrays: true完成每个索引条目一个值的展开去重。四、场景二hint 到需要回表的索引时不会重写为 DISTINCT_SCAN文档第 2 节给管道加上{ hint : { a : 1, b : 1 } }即指定复合索引a_1_b_1。预期输出为Execution Engine: sbe{ queryShapeHash : 7369A25E4B52DA5797E4FFD461F50CE164B53CC62F7CD4D3BFEA04A3342FA081, stages : [ { $cursor : { rejectedPlans : [ ], winningPlan : [ { stage : PROJECTION_SIMPLE, transformBy : { _id : false, a : true } }, { nss : test.unwind_group_to_distinct_scan_multiplanning_md, stage : FETCH }, { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ], b : [ [MinKey, MaxKey] ] }, indexName : a_1_b_1, isMultiKey : true, isPartial : false, isSparse : false, isUnique : false, keyPattern : { a : 1, b : 1 }, multiKeyPaths : { a : [ a ], b : [ ] }, nss : test.unwind_group_to_distinct_scan_multiplanning_md, stage : IXSCAN } ] } }, { $unwind : { path : $a, preserveNullAndEmptyArrays : true } }, { $group : { $willBeMerged : false, _id : $a } } ] }对照场景一可以读出两层含义管道未被重写$unwind与$group原样保留在 explain 的 stages 中$group上多了$willBeMerged: false元信息表示后续不再做合并优化。从源码结构看hint 把索引选择从distinct 访问路径规划拽回到了常规查询规划路径——被指定的索引上无法按 distinct 语义直接枚举$unwind的目标字段a虽是前导键但该计划形态下无法保持无回表 按a去重的覆盖扫描形态DISTINCT_SCAN 需要回表即needsFetch重写因而放弃退化为IXSCAN 全扫描 FETCH 普通$unwind/$group求值。执行引擎切换未重写的聚合整体由 SBE 引擎执行输出标注Execution Engine: sbe而场景一的重写路径走 classic 规划器产出的 DISTINCT_SCAN 游标Execution Engine: classic。可以推断该重写目前实现在 classic 查询规划链路中SBE 全量启用变体下未命中重写时聚合由 SBE 承接——这正是本文件按引擎变体分目录签入不同预期输出的意义。值得注意multiKeyPaths.b []b虽是索引列但不构成 multikey 路径只有a是这与a_1上的multiKeyPaths.a [a]一致说明展开去重只针对真正 multikey 的字段。五、场景三hint 可以强制选回合适的索引文档第 3 节改为{ hint : { a : 1 } }即指定单列索引a_1。预期输出Execution Engine: sbe{ queryShapeHash : 7369A25E4B52DA5797E4FFD461F50CE164B53CC62F7CD4D3BFEA04A3342FA081, stages : [ { $cursor : { rejectedPlans : [ ], winningPlan : [ { stage : PROJECTION_SIMPLE, transformBy : { _id : false, a : true } }, { nss : test.unwind_group_to_distinct_scan_multiplanning_md, stage : FETCH }, { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ] }, indexName : a_1, isMultiKey : true, isPartial : false, isSparse : false, isUnique : false, keyPattern : { a : 1 }, multiKeyPaths : { a : [ a ] }, nss : test.unwind_group_to_distinct_scan_multiplanning_md, stage : IXSCAN } ] } }, { $unwind : { path : $a, preserveNullAndEmptyArrays : true } }, { $group : { $willBeMerged : false, _id : $a } } ] }这一段的测试意图A hint can force the suitable index是验证规划器在多索引候选中可以被 hint 精确钉住对比场景二hint{a:1}使计划回落到a_1索引indexName: a_1而不再使用a_1_b_1queryShapeHash与场景一、二相同7369A25E...说明同一管道形状下 hint 只影响索引选择而不变查询形状哈希。在 SBE 引擎变体中该 hint 路径下管道同样保持$unwind$group的常规求值IXSCAN FETCH黄金输出把这一hint 生效但计划形态固化下来防止未来规划行为在 hint 语义上发生漂移。六、场景四整索引扫描候选与 DISTINCT_SCAN 的 Multiplanning 竞争文档第 4 节 A whole index scan candidate multiplans against the DISTINCT_SCAN 换了一个标量数据集与新的索引组合是四段中唯一真正发生 multiplanning一个候选胜出、一个候选进入rejectedPlans的场景const scalarColl db[jsTestName() _scalar]; scalarColl.insertMany([{a: 1, b: 1}, {a: 1, b: 2}, {a: 2, b: 3}, {a: 3, b: 4}, {b: 5}]); scalarColl.createIndex({b: 1, a: 1}); scalarColl.createIndex({a: 1});注意这里a全部是标量无数组所以a_1不是 multikey且测试显式打开内部旋钮internalQueryPlannerGenerateCoveredWholeIndexScans来强制生成整索引扫描候选测试注释解释了原因$match is not supported yet, so we need to use internalQueryPlannerGenerateCoveredWholeIndexScans to force a multiplanning scenario.$match 尚不支持因此需要用该旋钮强制构造 multiplanning 场景。该旋钮定义于 src/mongo/db/query/query_knob_descriptors_optimization.hkPlannerGenerateCoveredWholeIndexScans。测试在 try/finally 中保存并恢复旋钮原值避免污染其他小节。该场景的 Results 为注意没有7因为标量数据中a的取值是 1、1、2、3 与缺省{ _id : 1 } { _id : 2 } { _id : 3 } { _id : null }预期 explainExecution Engine: classicqueryShapeHash变为B1FC3E85...因为集合/数据形态不同{ queryShapeHash : B1FC3E856EACC9C5AE3947F65357563FC1F315A4C9CB8BF8E9AC856CDF9A94D4, stages : [ { $cursor : { rejectedPlans : [ [ { stage : PROJECTION_COVERED, transformBy : { _id : 0, a : 1 } }, { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ], b : [ [MinKey, MaxKey] ] }, indexName : b_1_a_1, isMultiKey : false, isPartial : false, isSparse : false, isUnique : false, keyPattern : { a : 1, b : 1 }, multiKeyPaths : { a : [ ], b : [ ] }, nss : test.unwind_group_to_distinct_scan_multiplanning_md_scalar, stage : IXSCAN } ] ], 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, unwindsArrays : true } ] } }, { $groupByDistinctScan : { newRoot : { _id : $a } } } ] }这段输出固化了两个规划决策被拒绝的候选rejectedPlansb_1_a_1上的覆盖整索引扫描PROJECTION_COVEREDIXSCAN双列边界均为[MinKey, MaxKey]。它不是 multikeyisMultiKey: false、multiKeyPaths为空因为它扫描的是索引整体而非对 distinct 字段的去重扫描。胜出的计划a_1上的 DISTINCT_SCAN 覆盖投影isFetching: false。即便b_1_a_1也能覆盖投影所需字段DISTINCT_SCAN 凭借按 distinct 键跳跃扫描、每键一条记录的特性赢得多计划竞争这与测试标题 A whole index scan candidate multiplans against the DISTINCT_SCAN 的断言一致DISTINCT_SCAN 候选参与了常规 multiplanning 并且胜出了。细节上标量集合上a_1的isMultiKey: false、multiKeyPaths.a []但unwindsArrays: true依然出现——该标志属于 distinct 访问参数表示本扫描承担展开 multikey 条目的语义契约在标量数据上退化为普通去重扫描。从 src/mongo/db/query/canonical_distinct.h 的结构看unwindsArrays是CanonicalDistinct的固有字段与索引是否实际 multikey 相互独立。七、四个场景的行为对照把 unwind_group_to_distinct_scan_multiplanning.md 的四段输出合起来可以提炼出$unwind$group→ DISTINCT_SCAN 优化在多索引/hint 环境下的完整行为矩阵场景索引/hint是否重写为 DISTINCT_SCAN胜出计划执行引擎1. 多个合适索引a_1、a_1_b_1无 hint是a_1上 DISTINCT_SCAN PROJECTION_COVERED无回表classic2. hint 到需回表索引hint{a:1, b:1}否a_1_b_1上 IXSCAN FETCH保留$unwind/$groupsbe3. hint 强制合适索引hint{a:1}否本变体下a_1上 IXSCAN FETCH保留$unwind/$groupsbe4. 整索引扫描竞争a_1、b_1_a_1 旋钮是a_1上 DISTINCT_SCAN 胜出b_1_a_1覆盖扫描被拒classic其中两条规律值得记住重写与否决定执行链路。命中重写时管道折叠为游标 $groupByDistinctScanexplain 标注 classic 引擎未命中时保留完整$unwind$group在 SBE 变体下由 SBE 执行。这是黄金测试按sbeFull等目录签入输出的原因——同一测试在不同引擎变体下固化的行为不同。DISTINCT_SCAN 的候选资格取决于能否免回表按 distinct 字段去重枚举。场景一选择最窄的前导索引a_1场景二的 hint 使计划落到需要 FETCH 的形态重写被放弃场景四则证明 DISTINCT_SCAN 在真正的 multiplanning 中是与覆盖整索引扫描平级、且可胜出的候选。八、如何运行与更新该黄金测试结合 docs/golden_data_test_framework.md 的规范实际操作要点运行测试通过 resmoke 执行 jstests/query_golden 套件套件配置位于 buildscripts/resmokeconfig/suites 下的query_golden_*.yml如 query_golden_classic.yml不同 passthrough/引擎变体对应expected_output/下不同子目录的期望文件。工作区准备首次使用 buildscripts/golden_test.py 时执行buildscripts/golden_test.py setup并将GOLDEN_TEST_CONFIG_PATH指向生成的 YAML 配置配置项outputRootPattern、diffCmd的语义见该文档附录。对比与接受测试失败后框架会在actual/与expected/路径下分别落盘实际/期望输出buildscripts/golden_test.py diff执行差异展示buildscripts/golden_test.py accept把新输出批量签回仓库同一测试跨多个 passthrough 时用buildscripts/golden_test.py --verbose clean-run-accept jstests/query_golden/unwind_group_to_distinct_scan_multiplanning_md.js一次性更新所有期望文件。评审纪律按框架规范预期输出应打印输入与输出并排以便核对本文各节的 Pipeline 与 Summarized explain 并置正是这一规范且不应手工编辑.md期望文件——它们是自动生成产物只能由重新运行测试后拷贝而来。九、小结unwind_group_to_distinct_scan_multiplanning.md 这个看似静态的 Markdown 文件实际上是 MongoDB 查询优化器行为的一份可执行契约它把$unwind(preserveNullAndEmptyArrays) 无累加器$group管道重写为 DISTINCT_SCAN 的资格判定pipeline_d.cpp、multikey 展开与 null 语义distinct_scan.h、以及该候选在多索引与 hint 环境下的规划行为含internalQueryPlannerGenerateCoveredWholeIndexScans旋钮触发的竞争全部固化进四个 explain 快照。对阅读 explain 输出的 DBA 而言它是识别$groupByDistinctScan、DISTINCT_SCAN、unwindsArrays、isFetching等字段的实例教材对查询规划器开发者而言任何触碰 distinct 重写或 multiplanning 枚举的改动都必须以这份黄金输出为回归基线进行验证与更新。【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表