尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

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 运行生成并比对测试文件头部明确标注了两个运行前提tagsfeatureFlagShardFilteringDistinctScan需要启用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 块都是规划器在特定“索引 数据 管线”组合下必须稳定复现的行为契约。文档整体分为三节分别覆盖三条规划路径为$groupByDistinctScan补加排序模式Sort Pattern时的索引匹配规则无排序、无过滤场景下 DISTINCT_SCAN 的构造条件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/$bottom的sortBy引入了一个排序模式。若管线前方还有显式$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 ]。explainExecution 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_COVEREDDISTINCT_SCAN两层结构isFetching: false说明纯覆盖扫描无需回表索引a_1_b_1的indexBounds为全空间[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。explainExecution Engine: sbe{ queryShapeHash : A8D462371CDE9BE554D607AD88916CF20C2B5633E0454E7FFF6F7D0A89C142CD, rejectedPlans : [ ], winningPlan : [ { stage : GROUP }, { direction : forward, filter : { }, nss : test.distinct_query_planner_md, stage : COLLSCAN } ] }两个关键行为计划仍是普通GROUPCOLLSCAN$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 }explainExecution 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_1对a 3的IXSCANbounds(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 }explainExecution 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_SCANisFetching: false。这里没有sortBy参与对应源码中getRewriteGroupRequirements()在无累加器时返回的纯groupId需求。4.2 无合适索引 不生成 DISTINCT_SCAN集合索引改为[ _id_, b_1_a_1 ]a不是前缀字段。explainExecution Engine: sbe回退为{ queryShapeHash : CA2B2C90B53877652CBF1F4F7692F1DA0FA9476BC770590F5D8BCC5820FB58BA, rejectedPlans : [ ], winningPlan : [ { stage : GROUP }, { direction : forward, filter : { }, nss : test.distinct_query_planner_md, stage : COLLSCAN } ] }结论DISTINCT_SCAN 要求分组字段是索引的前缀字段。b_1_a_1中a位于第二位无法支撑对a的去重扫描规划器直接回退GROUP COLLSCAN同样没有插入 SORT。注意两小节queryShapeHash相同CA2B2C90...因为管线形状一致仅索引集合不同规划结果随之不同——这也是 query shape 缓存/plan cache 语义下的稳定行为对照。5. 第 3 节DISTINCT_SCAN 优先于竞争的谓词索引最后一节验证规划器在“低基数分组”与“高选择性谓词索引”之间的取舍即使谓词索引能显著过滤行数只要分组键有序可支撑 DISTINCT_SCANDISTINCT_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: backward与sortBy: {a: -1}对应——反向扫描复合索引组内按a降序产出正好满足$top的sortByisFetching: false、PROJECTION_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$groupByDistinctScanDISTINCT_SCANforward1.1$topsortBy 与索引反向a_-1_b_-1字段一一对应、方向全反DISTINCT_SCANbackward 扫描1.2sortBy 无索引支撑仅a_1缺bGROUP COLLSCAN不插 SORT1.3索引仅服务$match仅a_1GROUP FETCH IXSCAN(a_1)1.4无 sortBy/过滤分组字段为索引前缀a_1$groupByDistinctScanDISTINCT_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. 工程启示从这份黄金测试与配套源码可以得到几条对聚合查询优化有直接指导意义的结论索引前缀与字段顺序决定 DISTINCT_SCAN 的生死。分组字段或$top的 sortBy 前缀字段必须是索引的起始字段且字段顺序与 sortBy 一致或完全反向b_1_a_1这类“分组字段在第二位”的索引直接出局。DISTINCT_SCAN 不触发阻塞排序。当排序索引缺失时规划器宁可回退COLLSCAN GROUP或谓词 IXSCAN也不为凑 distinct scan 追加 SORT——因为$top/$first/$last均为流式累加器回退路径本身代价可控。覆盖性是隐藏收益。所有命中 DISTINCT_SCAN 的计划isFetching均为false配合PROJECTION_COVERED避免回表若你的聚合只需要分组键和少量输出字段把所需字段放进复合索引前缀可以稳定吃到该优化。低基数分组是 DISTINCT_SCAN 的主场。在“谓词选择性高但分组基数低”的场景下去重后的分组键远小于谓词命中行数规划器明确偏向 DISTINCT_SCAN 计划。该文档作为黄金测试快照的价值在于以上每一条规则都以“索引集合 explain 全文”的形式被固化为可回归比对的契约任何规划器行为漂移都会使测试失败这为依赖 DISTINCT_SCAN 语义的聚合负载提供了长期稳定性保障。【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表