ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MongoDB 聚合 DISTINCT_SCAN 多计划选择机制:基于 query_golden 黄金测试的深度解析

MongoDB 聚合 DISTINCT_SCAN 多计划选择机制:基于 query_golden 黄金测试的深度解析 MongoDB 聚合 DISTINCT_SCAN 多计划选择机制基于 query_golden 黄金测试的深度解析【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo本文基于 MongoDB 仓库中的黄金测试期望输出文件 distinct_aggregation_multiplanning.md 及其对应测试脚本 distinct_aggregation_multiplanning_md.js系统讲解当聚合管道aggregation pipeline可以被改写为 distinct 扫描 形态时查询计划器planner如何在多种候选计划之间做多计划选择multiplanning包括扫描方向与索引选择规则、covering 与 fetch 的取舍、根 $or 边界合并、multikey 索引与冲突排序规格下的回退行为并结合 distinct_scan.h、pipeline_d.cpp、plan_ranker_util.h 等源码实现印证每一类选择的底层依据。1. 这是什么测试锁定 distinct 聚合 multiplanning 行为的黄金测试该期望输出文件属于 MongoDB 的Golden Data 测试框架框架总述见 golden_data_test_framework.md测试运行后产生确定性文本输出与签入仓库的期望文件逐字比对任何差异都会导致测试失败。对查询计划这类没有客观判据、只能靠快照比对的行为黄金测试是最直接的回归保护手段。测试脚本 distinct_aggregation_multiplanning_md.js 的头部注释说明了其目的Tests that the aggregation will go through the process of multiplanning when the pipeline can be rewritten for the distinct case.脚本声明了requires_fcv_82与featureFlagShardFilteringDistinctScan两个 tag即期望输出是在FCVfeature compatibility version8.2 语义下记录的。脚本通过 golden_test_utils.js 中的outputAggregationPlanAndResults逐用例输出四部分内容Pipeline管道原文、Results查询结果、Total indexes on the collection集合全部索引、Summarized explain含执行引擎标识与计划树其中section/subSection标题来自 pretty_md.js。文件位于expected_output/sbeFull/目录下从目录结构可以推断这是SBE 全量启用sbeFull变体下记录的期望输出——同一测试在其它执行引擎变体下有各自目录的期望文件golden_data_test_framework.md 中提到的query_golden_classicpassthrough 即此类变体之一。因此文件中每个用例的 Execution Engine: classic / sbe 标识会因引擎能力不同而出现差异这本身就是测试锁定内容的一部分。测试首先建集合并创建 11 个索引测试脚本 L13-L25这组索引刻意覆盖了 恰好匹配排序、前缀更短、后缀更长、反向键、无关字段 等各种形态构成 multiplanning 的候选池coll.createIndex({a: 1}); // a_1 coll.createIndex({b: 1}); // b_1 coll.createIndex({a: 1, b: 1}); // a_1_b_1 coll.createIndex({a: -1, b: 1}); // a_-1_b_1 coll.createIndex({a: 1, b: -1}); // a_1_b_-1 coll.createIndex({a: 1, b: 1, c: 1});// a_1_b_1_c_1 coll.createIndex({a: 1, b: 1, d: 1});// a_1_b_1_d_1 coll.createIndex({b: 1, a: 1}); // b_1_a_1 coll.createIndex({b: 1, c: 1}); // b_1_c_1 coll.createIndex({d: 1, c: -1}); // d_1_c_-1 // 另有默认的 _id_ 唯一索引运行与更新方式遵循黄金测试框架的通用流程详见 golden_data_test_framework.md一次性执行buildscripts/golden_test.py setup初始化配置并导出GOLDEN_TEST_CONFIG_PATH之后用buildscripts/golden_test.py list / diff / accept / get查看与批量接受新输出更新测试时官方建议用buildscripts/golden_test.py --verbose clean-run-accept jstests/query_golden/distinct_aggregation_multiplanning_md.js让其按所属 passthrough 套件在所有相关变体下重跑而不是手工编辑期望文件。2. 核心机制$group 如何被改写为 DISTINCT_SCAN 计划2.1 执行端的 DistinctScan 阶段DISTINCT_SCAN 阶段 的类注释直接说明了它与普通 IXSCAN 的区别Executes an index scan over the provided bounds. However, rather than looking at every key in the bounds, it skips to the next value of the _params.fieldNo-th indexed field. This is because distinct only cares about distinct values for that field...即它沿索引在给定边界内扫描但跳过同一 distinct 字段的连续重复键值只输出每个取值一次。其参数结构DistinctParamsdistinct_scan.h L40-L90承载了 explain 中可见的全部关键信息nameindexName、keyPattern、multikeyPaths/isMultiKey、scanDirectiondirection、boundsindexBounds与fieldNodistinct 字段在索引键模式中的位置例如索引{a:1,b:1}对a做 distinct 时 fieldNo0、对b时 fieldNo1。单测见 distinct_scan_test.cpp。2.2 计划端tryPrepareDistinctExecutor 与组阶段改写聚合入口在 pipeline_d.cpp 中先尝试 distinct 路径// See if could use DISTINCT_SCAN with the pipeline (SERVER-9507 SERVER-84347). auto swExecOrCq tryPrepareDistinctExecutor(expCtx, collections, nss, pipeline, ...);若 distinct 执行器准备成功则直接返回否则取出查询解中的 distinct 信息——cq-getDistinct()-releaseRewrittenGroupStage()拿到被改写后的组阶段GroupFromFirstDocumentTransformation并置位QueryPlannerParams::STRICT_DISTINCT_ONLY强制后续规划只产出 distinct 兼容计划pipeline_d.cpp L1424-L1431。这与期望文件中每个 winningPlan 上方都挂着的聚合改写阶段一一对应{ $groupByDistinctScan : { newRoot : { _id : $a, accum : $b } } }也就是说$sort $group{$first/$last}、$group{$top/$bottom}这类管道被整体改写成一次 ordered distinct 扫描 按组取首/末行的形态newRoot记录了改写后组阶段保留的字段例如$top的output表达式会映射为对应的字段引用。2.3 explain 输出如何读以第 1 节用例 1 为例$sort{a:1,b:1}$group{_id:$a, accum:{$first:$b}}的获胜计划为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, isUnique : false, keyPattern : { a : 1, b : 1 }, stage : DISTINCT_SCAN } ]isFetching: false且上面是PROJECTION_COVERED索引本身包含所需全部字段covering scan无需回表directionindexBounds共同表达逻辑输出序[MinKey, MaxKey]表示顺向遍历该键[MaxKey, MinKey]表示反向遍历rejectedPlans则是 multiplanning 过程保留下来的其它候选是观察 选择规则 最直接的证据。同一管道形状pipeline shape会得到相同的queryShapeHash例如384E008C9BC532E8...在第 1 节与第 6 节中复现说明计划决策绑定于形状 索引目录而非数据取值。3. 场景一只有 DISTINCT_SCAN 候选时的选择规则对应测试section(Only DISTINCT_SCAN candidates considered)测试脚本 L27-L103数据为{_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},3.1 扫描方向随 $first/$last 与排序方向自动翻转管道结果winningPlanindex / directionrejectedPlans$sort{a:1,b:1}→$group{_id:$a, {$first:$b}}{_id:4,accum:2} {_id:5,accum:4}a_1_b_1/ forwarda_1_b_1_c_1、a_1_b_1_d_1的 DISTINCT_SCAN$sort{a:1,b:-1}→$first:$b{_id:4,accum:3} {_id:5,accum:4}a_-1_b_1/ backwarda 边界[MaxKey,MinKey]a_1_b_-1/ forward$sort{a:-1,b:-1}→$last:$b{_id:4,accum:2} {_id:5,accum:4}a_1_b_1/ forwarda_1_b_1_c_1、a_1_b_1_d_1$sort{a:1,b:-1}→$last:$b{_id:4,accum:2} {_id:5,accum:4}a_-1_b_1/ forwarda_1_b_-1/ backward$sort{a:-1,b:1}→$last:$b{_id:4,accum:3} {_id:5,accum:4}a_-1_b_1/ backwarda_1_b_-1/ forward$group{_id:$a, {$first:$b}}无 $sort{_id:4,accum:2} {_id:5,accum:4}a_1_b_1/ forward无可以归纳出的规则$first 与 $last 对扫描方向的要求不同$last 取组内最后一行因此 降序 $sort $last 等价于 升序 $first计划器直接改用正向扫描第三行a_1_b_1/forward而不是硬凑反向扫描。测试脚本中也有明确注释Ensure the planner correctly reverses the DISTINCT_SCAN direction for $first and $top。方向由索引键模式与遍历方向的组合决定a_-1_b_1的 backward 扫描[MaxKey,MinKey]边界产生的逻辑序就是 (a 升, b 降)与 $sort 要求一致计划器在a_-1_b_1/backward 与a_1_b_-1/forward 两个等价方案中按计划评分择一。候选集只含 DISTINCT_SCAN当 distinct 改写成立时rejectedPlans 中出现的也全是 DISTINCT_SCAN 变体前缀更长的a_1_b_1_c_1、a_1_b_1_d_1说明 multiplanning 在 distinct 家族内部 比较索引选择。3.2 $top/$bottomsortBy 决定索引output 字段决定 covering 还是 fetch$top/$bottom携带内嵌sortBy其排序要求直接映射到索引选择管道winningPlanrejectedPlans$group{_id:$a, $top{sortBy:{a:1,b:1}, output:$c}}a_1_b_1_c_1/ forwardPROJECTION_COVERED{_id,a,b,c}isFetching:falsea_1_b_1isFetching:true、a_1_b_1_d_1isFetching:true$group{_id:$a, $bottom{sortBy:{a:-1,b:-1}, output:$c}}a_1_b_1_c_1/ forward同上$group{_id:$a, $bottom{sortBy:{a:1,b:-1}, output:$c}}a_-1_b_1/ forwardisFetching:truea_1_b_-1/ backwardfetch$group{_id:$d, $top{sortBy:{d:-1}, output:$c}}d_1_c_-1/ backward无$sort{d:-1}→$group{_id:$d, {$first:$c}}d_1_c_-1/ backward无这里有两个值得注意的细节covering 优先于 fetch$top的 output 字段c不在a_1_b_1中若选它就必须 fetch 回表期望输出表明计划器放弃a_1_b_1与a_1_b_1_d_1改选能完全覆盖{a,b,c}的a_1_b_1_c_1rejectedPlans 中两者均标isFetching: truewinningPlan 标isFetching: false。这与DistinctScan构造函数中的needsFetch参数distinct_scan.h L100-L105对应fetch 与否在计划构造期就已确定。反向键也能被利用sortBy:{d:-1}选择了d_1_c_-1并 backward 扫描sortBy:{a:1,b:-1}选择了a_-1_b_1正向扫描。计划器匹配的是 逻辑排序序列而非字面索引名。3.3 以_id分组与 hint 强制索引$group{_id:$_id, accum:{$first:$b}}直接命中默认唯一索引winningPlan 为_id_索引的 DISTINCT_SCANisUnique: true、isFetching: true且rejectedPlans为空——group 键就是_id时distinct over_id是零成本的最优解。hint 可以压过 multiplanning 的选择用例 1 加hint: a_1_b_1winningPlan 与自动选择一致rejectedPlans清空同管道加hint: a_1_b_1_c_1获胜计划即变为a_1_b_1_c_1本可被更小的a_1_b_1满足但 hint 强制了该索引$top{sortBy:{a:1,b:1}, output:$c}加hint: a_1_b_1时获胜计划是a_1_b_1且isFetching: true——hint 强制后不再享受 covering 换索引的优化只能回表取c。测试脚本注释概括为Force particular DISTINCT_SCAN using hint, even if auto-selected by multiplanning / even if different from multiplanning。4. 场景二DISTINCT_SCAN 与非 DISTINCT_SCAN 候选并存section(Both DISTINCT_SCAN and non-DISTINCT_SCAN candidates considered)的数据中引入数组字段{a: 4, b: 2, c: 3}, {a: 4, b: 3, c: 6}, {a: 5, b: 4, c: 7, d: [1, 2, 3]}, // d 使 a_1_b_1_d_1 成为 multikey 索引DISTINCT_SCAN 胜出$sort{a:-1,b:-1}$last:$b的获胜计划是a_1_b_1DISTINCT_SCANforwardrejectedPlans 中既包含a_1_b_1_c_1的 DISTINCT_SCAN也包含a_1_b_1_d_1的IXSCANisMultiKey: true因d为数组。$first变体则选出a_1_b_1backward 扫描。这说明 distinct 候选与非 distinctmultikey IXSCAN候选同场比较distinct 方案得分更高。hint 指向 multikey 索引时回退为非 distinct 计划同一管道加hint: a_1_b_1_d_1后Execution Engine 变为sbewinningPlan 为GROUP → PROJECTION_COVERED{a,b} → IXSCAN a_1_b_1_d_1 (backward, isMultiKey: true)——multikey 索引上无法走 DISTINCT_SCAN 改写执行落回 SBE 的普通 GROUP 计划。hint$natural强制自然序winningPlan 退化为GROUP → SORT{a:-1,b:-1}simple, memLimit 104857600→ PROJECTION_SIMPLE → COLLSCAN是没有任何索引优势时的兜底形态恰好展示了 distinct 改写所规避的 全扫 显式排序 成本。5. 场景三覆盖投影优先覆盖不了就选最小索引section(DISTINCT_SCAN candidates choose index that covers projection, or smallest index if impossible)用三条$group无 $sort用例精确刻画了这条规则管道所需字段winningPlan$group{_id:$a}仅aa_1PROJECTION_COVERED{_id,a}isFetching:false$group{_id:$a, accumB:{$first:$b}, accumC:{$first:$c}}a,b,ca_1_b_1_c_1PROJECTION_COVERED{_id,a,b,c}isFetching:false$group{_id:$a, accumB:…, accumC:…, accumD:{$first:$d}}a,b,c,da_1isFetching:true规则表述即小节标题存在能覆盖全部投影的索引时选覆盖投影的那个第二条用例中a_1_b_1_c_1胜出且 rejectedPlans 为空没有任何索引能覆盖全部投影时退回最小键最少的可行索引并接受 fetch第三条用例选a_1回表取b,c,d。改写后的组阶段newRoot也会随投影扩大如{_id:$a, accumB:$b, accumC:$c, accumD:$d}。6. 场景四根 $or 的边界可合并性小节标题即规则陈述Rooted $or can only use a DISTINCT_SCAN when all predicates have mergeable bounds for a single index scan。同字段、边界可合并$match{$or: [{a:{$lte:5}}, {a:{$gt:8}}]}$group{_id:$a}。获胜计划是a_1的 DISTINCT_SCAN其indexBounds.a是一个两区间列表indexBounds : { a : [ [-inf, 5.0], (8.0, inf] ] }即单条扫描携带多段不连续边界按序扫完两段。rejectedPlans 同时出现了a_1_b_1、a_-1_b_1、a_1_b_-1、a_1_b_1_c_1、a_1_b_1_d_1的 DISTINCT_SCAN 变体以及OR IXSCAN(a_1_b_1) IXSCAN(a_1)等非 distinct 计划——最终评分选择了键最少的a_1。同字段但 $or 内含 $and三段边界$or: [{a:{$lte:5}}, {a:{$gt:6,$lt:7}}, {a:{$gt:8}}]无法产出 DISTINCT_SCAN 候选计划落为 SBEGROUP → OR → IXSCAN a_1(边界 [-inf,5.0], (8.0,inf]) IXSCAN a_1((6.0,7.0))即拆成同索引上的两次扫描再 OR 合并。跨字段 $or$or: [{a:{$gt:0}}, {b:{$lt:10}}]以及复合形式的$or: [{a:{$gt:0},b:{$lt:10}}, {a:{$lt:10}}]同样无法合并为单索引单方向扫描winningPlan 为 SBEGROUP → OR → IXSCAN b_1_a_1 IXSCAN a_1/OR → IXSCAN a_1 IXSCAN a_1_b_1。6.1 平局裁决DISTINCT_SCAN 优先除非代价明显更高期望文件专门锁定了两类 平局/成本 裁决DISTINCT_SCAN 与 IXSCAN 打平时偏向 DISTINCT_SCAN小节名即 Multiplanning tie between DISTINCT_SCAN and IXSCAN favors DISTINCT_SCAN在仅有a_-1_b_1、a_1_b_1两个复合索引的集合上执行$match{a:{$gt:0}}$group{_id:$a, $top{sortBy:{a:1,b:1}, output:$b}}winningPlan 是a_1_b_1DISTINCT_SCAN边界a: (0.0, inf]被拒的候选是a_-1_b_1上边界[inf, 0.0)的 IXSCAN等价反向扫描。源码层面有直接对应计划平局裁决时对含STAGE_DISTINCT_SCAN节点的候选追加奖励分见 plan_ranker_util.h L117-L133 的calcDistinctScanBonusBoost the score of distinct scan plans in case of a tie及TieBreakingScores::distinctScanBonus字段plan_ranker_util.h L42-L59。更选择性强的谓词可压过 distinct 方案20 条文档、索引a_1_b_1与b_1_c_1的集合上执行$match{a:{$gt:0}, b:{$gt:-18}}$top组查询SBE 获胜计划是GROUP → FETCH(filter: {a:{$gt:0}}) → IXSCAN b_1_c_1边界 b: (-18.0, inf]而被拒的正是a_1_b_1的 DISTINCT_SCAN边界a:(0.0,inf],b:(-18.0,inf]。也就是说 multiplanning 的代价估计认为走b上更选择性的前缀 后置过滤比全段 distinct 扫描更便宜时会放弃 distinct 方案。7. 场景五排序规格冲突使 DISTINCT_SCAN 不可用单条索引扫描只能提供一种全序因此当管道里出现两个互相矛盾的排序需求时distinct 改写不成立$sort{a:1,b:1}$group{_id:$a, $top{sortBy:{b:1,a:1}, output:$c}}外层要求 (a,b) 序、内层要求 (b,a) 序无法由同一扫描同时满足。winningPlan 为 SBEGROUP → PROJECTION_COVERED{a,b,c} → IXSCAN a_1_b_1_c_1forwardrejectedPlans 为a_1_b_1与a_1_b_1_d_1multikey两条 IXSCANFETCH 计划。$sort{a:1,b:1}$group{_id:$a, $bottom{sortBy:{a:-1,b:-1}, output:$c}}$bottom的排序与$sort方向相反同样无法共用一个扫描方向。期望文件同样锁定 SBE IXSCANa_1_b_1_c_1获胜。测试脚本中留有前瞻性注释This query could (and after SERVER-94369, possibly will) be answered by a forward distinct scan on a_1_b_1测试脚本 L209-L214说明该行为的演进方向已按 JIRA 工单跟踪。8. 场景六multikey 索引阻断 DISTINCT_SCAN当 group 字段本身是数组{a: [1, 2, 3], b: 4, c: 7, d: 5}时a_1、a_1_b_1等全部变成 multikey 索引distinct over multikey 字段无法做 跳过同值键 的简单语义改写被放弃$sort{a:1,b:1}$group{_id:$a, {$first:$b}}SBE 获胜计划GROUP → FETCH → IXSCAN a_1_b_1isMultiKey: true, multiKeyPaths.a [a]rejectedPlans 为a_1_b_1_c_1、a_1_b_1_d_1两条 multikey IXSCANFETCH$group{_id:$a}winningPlan 直接是GROUP → COLLSCAN——相比 multikey 索引扫描全表扫描反而是被选中的计划$group{_id:$a, $top{sortBy:{a:1,b:1}, output:$b}}同样落回GROUP → COLLSCAN。结果值中{_id: [1,2,3], accum: 4}这样的数组分组键也直观印证了 multikey 场景下组键本身是数组、无法按标量序 distinct 的事实。9. 场景七group 非 multikey 字段、对 multikey 字段取 $first/$last与上一节相反的情况group 键b不是 multikey只是输出字段a是 multikey。此时改写依然成立$group{_id:$b, accum:{$first:$a}}classic 引擎获胜b_1索引 DISTINCT_SCANforwardisFetching: truea不在b_1索引中需回表newRoot: {_id:$b, accum:$a}$last变体同一索引但backward扫描边界[MaxKey, MinKey]再次印证 $last 的方向语义。本节最后的小节 Multiplanning tie between DISTINCT_SCANs favors fewest index keys 锁定了 distinct 家族内部的平局裁决集合上并存a_1、a_1_b_1、a_1_b_1_c_1、a_1_b_1_c_1_d_1、a_1_b_1_c_1_d_1_e_1五个索引执行$match{a:{$gt:0}}$group{_id:$a}后winningPlan 是a_1边界a: (0.0, inf]其余四个更长的复合索引全部以 DISTINCT_SCAN 形态出现在 rejectedPlans 中。键最少的索引胜出。10. 规则总览把整份期望输出沉淀下来DISTINCT_SCAN 聚合改写的 multiplanning 行为可概括为决策点锁定行为改写成立的前提管道可化为 单一有序索引扫描 组内首/末行$sort$first/$last、$top/$bottom、纯 $group见 pipeline_d.cpp不可改写的情况group 字段为 multikey外层 $sort 与内层 sortBy 排序冲突根 $or 边界无法合并为单索引单方向扫描$or 跨字段索引选择覆盖投影优先无覆盖索引时选最小可行索引并 fetch多 distinct 候选平局时键最少者胜方向$first/$last 与 $sort/sortBy 共同决定逻辑序扫描方向forward/backward与边界[MinKey,MaxKey]/[MaxKey,MinKey]随之自动翻转平局裁决DISTINCT_SCAN 对 IXSCAN 平局时获奖励分偏向plan_ranker_util.h代价估计认为选择性更高的非 distinct 计划更便宜时则弃用 distincthint强制指定索引含 multikey 索引回退 IXSCAN、$natural回退 COLLSCANSORT执行引擎distinct 改写计划在 sbeFull 变体下由 classic 引擎执行非 distinct 计划出现 sbe 引擎计划GROUP/OR/SORT/COLLSCAN 等11. 相关路径索引期望输出本文主体jstests/query_golden/expected_output/sbeFull/distinct_aggregation_multiplanning.md测试脚本7 个 section 的输入与断言组织jstests/query_golden/distinct_aggregation_multiplanning_md.js输出工具jstests/libs/query/golden_test_utils.js、jstests/libs/query/pretty_md.js黄金测试框架说明含 diff/accept 工作流docs/golden_data_test_framework.md、buildscripts/golden_test.pyDISTINCT_SCAN 执行阶段及单测src/mongo/db/exec/classic/distinct_scan.h、src/mongo/db/exec/classic/distinct_scan_test.cpp聚合 distinct 改写入口src/mongo/db/pipeline/pipeline_d.cpp计划平局裁决distinctScanBonussrc/mongo/db/query/plan_ranker_util.h适用前提提示以上全部计划形态均以测试声明的 FCV 8.2 语义与 sbeFull 变体为准在不同 feature compatibility version、sharding 模式或执行引擎开关下Execution Engine与 winningPlan 的具体形态可能不同仓库中对应的其它期望输出变体文件即为各自的基准。【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表