机制解析:DISTINCT_SCAN 与 IXSCAN 的竞选、约束与 hint 干预)
MongoDB Distinct 命令多计划规划Multi-Planning机制解析DISTINCT_SCAN 与 IXSCAN 的竞选、约束与 hint 干预【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo本文基于 MongoDB 官方仓库中的 golden 测试期望输出 distinct_command_multiplanning.md完整剖析distinct命令在sbeRestrictedSBE 受限查询引擎下的多计划规划multi-planning行为哪些条件下优化器会枚举DISTINCT_SCAN覆盖索引扫描候选、哪些条件会将其排除multikey 索引、根级$or、成本排名如何在DISTINCT_SCAN与FETCH IXSCAN之间做出取舍以及hint如何强制指定执行计划。读完后你可以掌握 distinct 命令执行计划的判定规则并能利用 golden 测试框架复现和验证这些计划形态。1. 文档定位这是一个 golden 测试的金标准输出distinct_command_multiplanning.md 是query_goldengolden 测试目录下的期望输出文件对应的测试脚本是 distinct_command_multiplanning_md.js。测试头注释明确其目的Tests that the distinct command will go through the process of multiplanning并带有featureFlagShardFilteringDistinctScan标签与requires_fcv_82要求即需要 FCV 8.2 及以上。golden 测试的运行方式在 README.plan_stability.md 中有通用说明通过 resmoke 指定 suite 运行例如buildscripts/resmoke.py run \ --suitesquery_golden_classic \ jstests/query_golden/distinct_command_multiplanning_md.js测试框架的核心工具函数是 golden_test_utils.js 中的outputDistinctPlanAndResults它通过db.runCommand({distinct: ..., key, query, ...options})真实执行 distinct 命令并取出.values再通过db.runCommand({explain: cmdArgs})获取 explain 输出经formatExplainRoot压平后与expected_output下对应配置目录sbeRestricted、sbeFull、sbeDisabled、internalEnableJoinOptimization、featureFlagSbeFull各有一份同名期望输出中的文件做逐字节比对。因此本文所引用的每一份 Summarized explain 都是真实执行并固化下来的计划形态而非人工推演。测试开头还做了一个关键前置动作读取并关闭featureFlagCostBasedRanker成本型计划排名器 CBR注释写明 This golden test requires CBR to be disabled测试结束后在finally中恢复原值。也就是说本期望输出记录的是经典计划排名器classic ranker在多计划规划下的选择这是理解后文所有 winning plan 的前提。1.1 测试数据与索引布局测试脚本按 8 个section分别重建集合每节的索引和文档都不同这解释了期望输出中不同queryShapeHash与不同候选索引的出现Section 1索引{x:1}、{x:-1}、{y:1,x:1}、{z:1,y:1}插入 7 条单值文档{x, y, z}后再插入两条数组文档{x: [1, 2, 3]}、{x: [3, 4, 5]}使x_1/x_-1索引变为multikey索引Section 2索引{x:1,y:1}、{x:-1,y:1}、{y:1,x:1}、{x:1,z:1,y:1}7 条单值文档全部索引非 multikeySection 3for (i 0; i 100; i) coll.insert({x: i % 2, y: i 100, z: i 200})即x 只有 0/1 两个取值、存在大量重复值索引{x:1}、{x:1,y:1}、{y:1,z:1}Section 4同样 100 条但x: i使 x 无重复用于对比 Section 3 验证排名器对重复值密度的敏感度Section 5回到 7 条单值文档索引{x:1,y:1}、{y:1,x:1}、{x:1,z:1,y:1}考察 hintSection 6/7沿用 Section 3 的 100 条重复值数据考察 hintSection 8沿用 Section 4 的 100 条无重复数据考察 hint。2. Section 1DISTINCT_SCAN 候选不被考虑的两种情形2.1 非 multikey 时也无 DISTINCT_SCAN 候选第一组查询distinct(x)filter 为{ x: { $gt: 3 }, z: 5 }distinct 结果为[ 5, 6, 7 ]。期望的 summarized explain 中winningPlan为{ stage : FETCH, filter : { x : { $gt : 3 } }, inputStage : { stage : IXSCAN, indexName : z_1_y_1, indexBounds : { y : [[MinKey, MaxKey]], z : [[5.0, 5.0]] } } }即优化器选择了z_1_y_1复合索引等值条件z: 5精确收窄剩余谓词x 3作为 FETCH 的 filter。rejectedPlans中则是x_-1扫描区间[inf, 3.0)方向 forward 时等价于反向索引的前缀与x_1区间(3.0, inf]两个 FETCHIXSCAN 候选均带z等值过滤。注意此时x_1/x_-1还是非 multikey 索引isMultiKey: false但依然没有任何 DISTINCT_SCAN 候选——因为 filter 非空且x 3的范围条件使 distinct 字段无法仅靠索引顺序去重完成优化器没有为该形状枚举出覆盖 distinct 扫描方案。2.2 multikey 索引排除 DISTINCT_SCAN随后插入两条x为数组的文档x_1与x_-1变成isMultiKey: trueexplain 中可见multiKeyPaths: { x: [x] }。distinct(x)filter{ x: 3 }结果[ 1, 2, 3, 4, 5 ]数组元素 1、2、3、4、5 被展平后去重。winning plan 是x_1上的FETCH IXSCAN区间[3.0, 3.0]rejected 中仅有x_-1的同类计划。标题即为No DISTINCT_SCAN candidate considered due to multikeyness——multikey 索引上distinct 字段在索引中被展平为数组元素无法保证按索引顺序扫描即可无重复地取到 distinct 值这一 DISTINCT_SCAN 的语义前提。distinct(x)filter{ x: { $gt: 3 }, z: 5 }结果[ 5, 6, 7 ]与 2.1 相同的查询形状相同queryShapeHashDB842DD7...winning plan 仍为z_1_y_1上的 FETCHIXSCAN两个被拒候选此时均标记isMultiKey: true。反例distinct(x)无 filter结果[ 1, 2, 3, 4, 5, 6, 7 ]winning plan却是{ stage : PROJECTION_COVERED, transformBy : { _id : 0, x : 1 }, inputStage : { stage : DISTINCT_SCAN, indexName : x_1, isFetching : false, isMultiKey : true } }标题为 Only DISTINCT_SCAN candidates considered despite multikeyness。这说明 multikey 的排除并非一刀切当 filter 为空时优化器仍可为 multikey 索引生成DISTINCT_SCAN其语义变为扫描到与已知 distinct 值相同的下一个值即跳过允许重复值存在由扫描本身去重而带 filter 的查询形状则因 multikey 不产生该候选。这与源码中 distinct_access.h 的注释相互印证multikey 索引在投影指向数组元素等场景下不适合 DistinctNode但对于以 distinct 字段本身为扫描序的场景扫描可以容忍重复值。3. Section 2只有 DISTINCT_SCAN 候选的情形该节所有索引均以x为前导字段参与覆盖且无 multikey多个查询形状的候选集里全部是 DISTINCT_SCANrejected 计划也带PROJECTION_COVEREDDISTINCT_SCAN这来自源码中constructCoveredDistinctScan/createDistinctScanSolution对覆盖式 distinct 扫描的主动构造见 distinct_access.h 中对constructCoveredDistinctScan与createDistinctScanSolution的职责划分前者基于索引构造 distinct 扫描方案后者在无 filter 且无 sort时手动补建 DISTINCT_SCAN 方案。3.1 无 filter 全量扫描distinct(x)filter{}结果[ 3, 5, 6, 7, 8 ]。winning plan 为x_1_y_1上的DISTINCT_SCAN区间x: [MinKey, MaxKey]rejected 中无条目——因为对无 filter 的形状非前导覆盖候选没有被枚举。3.2 等值 filterfilter{ x: 3 }结果[ 3 ]。winning plan 为x_1_y_1上DISTINCT_SCAN区间x: [3.0, 3.0]。rejected 中可见另外两个同样合法的 DISTINCT_SCAN 候选x_-1_y_1与三字段复合索引x_1_z_1_y_1x为前导列才能被用于 distinct 序。这展示了同一查询在多个可用前导索引下各产生一个 DISTINCT_SCAN 候选的竞争格局。3.3 范围 filterfilter{ x: { $gt: 3 }, y: 5 }结果[ 5, 6, 7 ]。winning plan 选择了以 y 为前导列的y_1_x_1索引indexBounds 中y: [5.0, 5.0]、x: (3.0, inf]rejected 中则是x_1_y_1、x_-1_y_1、x_1_z_1_y_1上的 DISTINCT_SCAN。这说明 classic 排名器会在全候选中做成本/排名比较并不偏爱 distinct 字段在前的索引——当辅助字段y是等值收窄时以y打头的索引扫描键更少反而胜出。3.4 $or 下合并边界combined boundsfilter 为{ $or: [ { x: { $lt: 4 } }, { x: { $gt: 6 } } ] }结果[ 3, 7, 8 ]。标题 Prefer DISTINCT_SCAN with combined bounds under $or。winning plan 是x_1_y_1上的DISTINCT_SCAN其indexBounds将两个 $or 分支合并进同一个 x 字段的区间数组indexBounds : { x : [ [ -inf, 4.0 ), ( 6.0, inf ] ], y : [ [ MinKey, MaxKey ] ] }rejected 中除了其他索引上的同构 DISTINCT_SCAN 候选外还有大量经典OR阶段计划两路 IXSCAN OR去重 PROJECTION_DEFAULT均被排名器判负。这是该期望输出最有代表性的结论之一当 $or 的两个分支作用在同一个可排序字段上时优化器优先用单个 DISTINCT_SCAN 携带多段扫描区间而不是 OR 阶段的两次扫描。3.5 $or 归约到 $infilter{ $or: [ { x: { $eq: 2 } }, { x: { $eq: 4 } }, { x: { $eq: 6 } } ] }结果[ 6 ]数据集中只有 x6 一条文档同时满足集合语义。标题 Prefer DISTINCT_SCAN with $or - $in optimization多个同字段$eq分支被归约为类似$in的多点区间winning plan 为x_1_y_1上的DISTINCT_SCAN区间为三个点值[[2.0, 2.0], [4.0, 4.0], [6.0, 6.0]]x_-1_y_1上点值顺序倒排[6,6],[4,4],[2,2]的候选被拒。3.6 根级 $or 阻断 DISTINCT_SCAN两个关键反例filter{ $or: [ { x: { $gt: 3 } }, { y: { $eq: 5 } } ] }结果[ 3, 5, 6, 7, 8 ]标题 No DISTINCT_SCAN candidate considered due to rooted $or。winning plan 退化为SUBPLAN - FETCH - OR - (IXSCAN on y_1_x_1) / (IXSCAN on x_1_y_1)的经典 OR 计划rejectedPlans为空。$or 分支涉及不同字段无法把区间合并到单一索引列的扫描序上DISTINCT_SCAN 候选因此不被枚举。filter{ $or: [ { x: { $eq: 5 }, z: { $ne: 4 } }, { y: { $lt: 7 } } ] }结果[ 3, 5, 6, 7 ]。同样是SUBPLAN - FETCH - OR - IXSCAN(y_1_x_1) / IXSCAN(x_1_z_1_y_1)。注意第二个分支的z字段出现了两段区间[[MinKey, 4.0), (4.0, MaxKey]]——这是$ne被转换为补集区间后落入 IXSCAN 边界的表现进一步佐证 OR 计划内部仍可做区间级优化。4. Section 3/4重复值密度如何改变排名结论这两节使用完全相同的查询形状相同queryShapeHash261552A4...仅数据集不同用来证明 classic 排名器对集合中重复值多少的感知。4.1 大量重复值时偏 DISTINCT_SCANSection 3x 仅 0/1 两值100 条文档filter{ x: { $gt: -1 }, y: { $lt: 250 } }结果[ 0, 1 ]。winning plan{ stage : PROJECTION_COVERED, transformBy : { _id : 0, x : 1 }, inputStage : { stage : DISTINCT_SCAN, indexName : x_1_y_1, indexBounds : { x : [ (-1.0, inf] ], y : [ [-inf, 250.0) ] } } }被拒的两个候选是x_1与y_1_z_1上的 FETCHIXSCAN分别以y 250或x -1作为 FETCH filter。重复值密集时DISTINCT_SCAN 的跳到下一个不同值跳过机制能少取大量文档排名占优。4.2 无重复值且 y 谓词更具选择性时偏 FETCHIXSCANSection 4x 为 0..99 各不相同filter{ x: { $gt: -1 }, y: { $lt: 105 } }结果[ 0, 1, 2, 3, 4 ]数据里 y x 100。此时 winning plan 变为{ stage : FETCH, filter : { x : { $gt : -1 } }, inputStage : { stage : IXSCAN, indexName : y_1_z_1, indexBounds : { y : [ [-inf, 105.0) ], z : [[MinKey, MaxKey]] } } }即Prefer FETCH filter IXSCAN for more selective predicate on yy 105只命中 5 条文档走y_1_z_1直接点查 FETCH 过滤更便宜而x_1_y_1上的 DISTINCT_SCAN 候选区间x: (-1.0, inf]、y: [-inf, 105.0)进入 rejected。由于 x 无重复DISTINCT_SCAN 的跳过优势消失排名器据此翻转结论。其子节 Maintain prior behavior even under a rooted $or 将上述条件包进{ $or: [ { x: { $gt: -1 }, y: { $lt: 105 } }, { x: { $eq: 0 } } ] }结果相同[ 0, 1, 2, 3, 4 ]winning plan 为SUBPLAN - FETCH - OR - (IXSCAN on x_1) / (FETCH IXSCAN on y_1_z_1)分支一内部的 FETCHIXSCAN 选择保持一致说明根级 $or 只阻断 DISTINCT_SCAN 候选的枚举不影响各 OR 分支内部的计划选择。5. Section 5–8hint 对 distinct 多计划规划的强制干预这一组验证hint参数在 distinct 命令上的优先级全部为rejectedPlans: []的单候选输出场景hint结果winning planSection 5 Use hinted DISTINCT_SCAN{ x: 1, y: 1 }[ 5, 6, 7 ]x_1_y_1上DISTINCT_SCANx: (3.0, inf],y: [5.0, 5.0]Section 6 Use hinted IXSCAN, even with preferable DISTINCT_SCAN{ x: 1 }[ 0, 1 ]x_1上FETCH(y 250) IXSCAN(x -1)Section 7 Use hinted COLLSCAN, even with preferable DISTINCT_SCAN{ $natural: 1 }[ 0, 1 ]纯COLLSCANfilter 合并为{ $and: [ { y: { $lt: 250 } }, { x: { $gt: -1 } } ] }Section 8 Use hinted DISTINCT_SCAN, even with no duplicate values{ x: 1, y: 1 }[ 0, 1, 2, 3, 4 ]x_1_y_1上DISTINCT_SCANx: (-1.0, inf],y: [-inf, 105.0)四个结论依次是hint 命中可覆盖索引时强制走 DISTINCT_SCANhint 指定单列索引时压过成本上更优的 DISTINCT_SCAN 走 IXSCAN$natural强制 COLLSCAN 且把 filter 归并为$and数组hint 指定的 DISTINCT_SCAN 即使数据无重复值跳过机制无收益也会被使用——即 hint 语义是必须使用该访问路径而不是若该路径可用则优先。这四点与 distinct 命令 hint 在 query_planner.cpp 中参与候选集裁剪的行为一致hint 会把枚举范围限制在指定索引再在该索引上决定用普通 IXSCAN 还是可转化为 DISTINCT_SCAN 的覆盖方案。6. 源码层面的支撑期望输出中的计划形态可以在查询规划器源码中找到对应实现distinct_access.h 定义了三类关键接口isIndexSuitableForDistinct判断索引是否适合 DISTINCT_SCAN注释给出了与本文 2.2 节一致的条件BTREE/HASHED、非 partialmultikey 在投影指向数组元素或点号路径时不可用wildcard 索引需覆盖 distinct 字段finalizeDistinctScan即注释所称 distinct hack把普通 QuerySolution 就地改写为带 DistinctNode 的方案把 ShardingFilter/Fetch 节点推进 distinct 扫描内部constructCoveredDistinctScan与createDistinctScanSolution则分别对应 Section 2 中覆盖式 DISTINCT_SCAN 候选和无 filter 时手动补建 DISTINCT_SCAN 候选两条生成路径。canonical_distinct.h 与 parsed_distinct_command.cpp 负责把distinct命令规范化为CanonicalDistinct其queryShapeHash正是 golden 输出中用于标识查询形状的稳定哈希相同 filter 形状如 Section 1 的两个查询共享DB842DD7...。query_planner.cpp 中 distinct 查询的多计划规划入口将普通查询候选与 distinct 专用候选合并交给计划排名器classic 或 CBR本文期望输出对应 classic 排名器路径featureFlagCostBasedRanker打开后期望输出会变化这也是为什么 golden 测试按sbeRestricted、sbeFull等配置目录分别维护独立期望文件。测试侧golden_test_utils.js 的outputDistinctPlanAndResults展示了输出格式契约子节标题为Distinct on key, with filter: tojson有 options 时追加, and options: ...随后是单行Distinct results和Summarized explain两个子节——这正是期望输出中重复出现的结构。7. 实用结论综合本期望输出与源码可以归纳出 distinct 命令多计划规划的判定规则DISTINCT_SCAN 候选的枚举前提索引以 distinct 字段或其可覆盖组合参与、非 partialmultikey 索引在带 filter 的形状下不产生候选但空 filter 时仍可枚举允许重复值由扫描跳过$or 处理同字段的$or含$eq归约为$in会合并为单个 DISTINCT_SCAN 的多段indexBounds跨字段的根级$or则彻底阻断 DISTINCT_SCAN 候选退回OR/SUBPLAN计划但 OR 各分支内部计划选择不受此影响排名依据classic 排名器会区分集合重复值密度——重复值多时 DISTINCT_SCAN 胜出谓词在辅助字段上高度选择且 distinct 字段无重复时FETCH IXSCAN胜出hint 是强制语义hint 指定索引后rejectedPlans为空且计划形态完全由 hint 决定DISTINCT_SCAN / IXSCAN / COLLSCAN 三态均可强制。若要复现本文的全部计划形态可按 README.plan_stability.md 的说明运行jstests/query_golden/distinct_command_multiplanning_md.js需 FCV 8.2且测试内部自行关闭 CBR计划变化时可用buildscripts/golden_test.py diff查看差异、用buildscripts/golden_test.py accept接受新形态。【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考