ARTICLE DETAIL

资讯详情

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

MongoDB 查询优化:$group + $top 聚合下 DISTINCT_SCAN 的规划器行为深度解析

MongoDB 查询优化:$group + $top 聚合下 DISTINCT_SCAN 的规划器行为深度解析 MongoDB 查询优化$group $top 聚合下 DISTINCT_SCAN 的规划器行为深度解析【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo导读本文基于 MongoDB 官方仓库中的 golden 测试文档 distinct_query_planner.md系统讲解查询规划器Query Planner如何为聚合管道中的$group阶段生成DISTINCT_SCAN执行计划。你会学到DISTINCT_SCAN与$groupByDistinctScan内部阶段的关系、排序模式Sort Pattern与索引方向匹配的判定规则、在何种条件下规划器会拒绝 distinct scan 并回退到 COLLSCAN/IXSCANFETCH以及$group去重重写的源码级前置条件。读完本文你将具备为$group/$top类聚合查询设计索引、并通过 explain 输出判断优化是否生效的实战能力。背景DISTINCT_SCAN 与 $groupByDistinctScan在 MongoDB 聚合框架中$group是典型的高代价阶段需要对输入流做哈希分组。当分组键_id是单一字段、且累积器只关心每个分组内的“第一个/最后一个/最大/最小”文档如$first、$last、$top、$bottom时查询规划器可以把$group重写为一个基于索引顺序的“去重扫描”DISTINCT_SCAN索引扫描阶段利用索引键的有序性跳过重复键值只返回每个分组键的“边界”文档无需哈希表、无需阻塞排序Blocking Sort。$groupByDistinctScan聚合层内部阶段承接 DISTINCT_SCAN 输出的有序流把$group语义_id 累积器输出还原到文档形态。这段$groupByDistinctScan的生成与消费逻辑分别位于 document_source_group_base.cpp$group的 distinct scan 重写与 pipeline_d.cpp阶段名称注册、与$unwind$group重写的协同约束。golden 测试源文件是 distinct_query_planner_md.js其顶层注释明确说明了测试目标Tests that we generate DISTINCT_SCANs for the specific cases treated by the query planner (sort that is required only for distinct scan plans and manual covered distinct scan construction).该测试通过 golden_test_utils.js 中的outputAggregationPlanAndResults输出“Pipeline / Results / Total indexes / Summarized explain”四段内容形成与当前仓库一致的期望输出文件。本文以下所有 explain 均取自仓库中featureFlagSbeFull配置下的真实 golden 输出。一、为 $groupByDistinctScan 引入排序模式三种典型走向1.1 有适合排序的索引 Distinct Scan场景$group的_id为字段a$top累积器按{a: 1, b: 1}取每个分组内b的最小值集合上存在正序复合索引a_1_b_1。[ { $group : { _id : $a, accum : { $top : { output : $b, sortBy : { a : 1, b : 1 } } } } } ]Results数据来自测试用例的 insertMany{a:1,b:1}、{a:1,b:2}、{a:2,b:3}{ _id : 1, accum : 1 } { _id : 2, accum : 3 }Total indexes on the collection[ _id_, a_1_b_1 ]Summarized 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 } } } ] }解读plan 由两层组成——$cursor内的PROJECTION_COVERED - DISTINCT_SCANclassic 执行引擎下的物理执行树其上是聚合层阶段$groupByDistinctScan。索引a_1_b_1的键序恰好与$top的sortBy一致因此扫描按a分组前进时每个组遇到的第一个文档就是b最小的文档DISTINCT_SCAN直接“covered”isFetching: false无需回表取文档完全避免了阻塞排序。1.2 反向索引Inverse Order 同样的 Distinct Scan相同的管道与数据但集合上的索引换成全反向复合索引a_-1_b_-1。注意Pipeline、Results、queryShapeHash 均不变变的只是索引定义与扫描方向Total indexes on the collection[ _id_, a_-1_b_-1 ]Summarized explainExecution Engine: classic{ queryShapeHash : A8D462371CDE9BE554D607AD88916CF20C2B5633E0454E7FFF6F7D0A89C142CD, stages : [ { $cursor : { rejectedPlans : [ ], winningPlan : [ { stage : PROJECTION_COVERED, transformBy : { _id : 0, a : 1, b : 1 } }, { 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 } ] } }, { $groupByDistinctScan : { newRoot : { _id : $a, accum : $b } } } ] }解读与 1.1 相比仅有两处差异——indexName变为a_-1_b_-1direction由forward变为backward。规划器对索引方向做了“取反等价”判断整体反向的索引在反向扫描后等价于正向排序依然能满足$top的sortBy。这正是 document_source_group_base.cpp 中SortPatternDirectionComparisonsameDirection/reverseDirection/incompatible与getMatchedDirection()所处理的场景方向全部一致判定为 sameDirection方向全部取反判定为 reverseDirection此时重写仍成立只是在计划中把扫描方向翻转。1.3 无适合排序的索引 无 Distinct Scan 且无阻塞排序同样的管道但集合上只有单字段索引a_1。此时规划器无法用索引同时满足“分组有序 $top 的 b 排序”于是放弃 DISTINCT_SCANTotal indexes on the collection[ _id_, a_1 ]Summarized explainExecution Engine: sbe{ queryShapeHash : A8D462371CDE9BE554D607AD88916CF20C2B5633E0454E7FFF6F7D0A89C142CD, rejectedPlans : [ ], winningPlan : [ { stage : GROUP }, { direction : forward, filter : { }, nss : test.distinct_query_planner_md, stage : COLLSCAN } ] }解读winningPlan 退化为 SBE 的GROUP直接叠在COLLSCAN之上——$group由 SBE 引擎的 GROUP 算子原生执行不再有$groupByDistinctScan阶段。值得注意标题中的“No Blocking Sort”$top的语义本可以借助$sort $group的经典优化$sort使用SORT算子阻塞排序完成但这里 planner 选择了GroupTop 风格的直接哈希分组方案对每个分组用内存累积器跟踪 top-N既不使用索引也不引入阻塞排序仅以哈希表换取流式处理。同样地queryShapeHash 与 1.1、1.2 完全一致A8D462...说明三种场景查询形状相同只是物理计划不同。1.4 索引适合过滤但不适合排序 无 Distinct Scan 且无阻塞排序在管道前追加$match: {a: {$gt: 3}}集合上仍只有a_1索引。数据为a取值 1、3、5、5、5、6、6、7、7 的九条文档[ { $match : { a : { $gt : 3 } } }, { $group : { _id : $a, accum : { $top : { output : $b, sortBy : { a : 1, b : 1 } } } } } ]Results{ _id : 5, accum : 4 } { _id : 6, accum : 7 } { _id : 7, accum : 3 }Total indexes on the collection[ _id_, a_1 ]Summarized 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 3让a_1索引有了用武之地——winningPlan 使用IXSCANindexBounds 为(3.0, inf]FETCH先过滤出满足条件的文档再由 SBEGROUP完成分组。但该索引仍无法满足$top的{a:1, b:1}排序缺少b因此同样没有 DISTINCT_SCAN、没有阻塞排序。此场景的 queryShapeHash 变为4D59D9B7...因为查询形状多了$match与前三者不同。这个用例说明索引只覆盖谓词、不覆盖 $top 排序模式时规划器宁可选择 IXSCAN哈希 GROUP也不会退化为阻塞排序。二、无排序、无过滤时 DISTINCT_SCAN 的构建当管道只有裸$group无$sort、无$match时规划器同样会尝试“手工构建 covered distinct scan”。2.1 $group 无 $sort 且有合适索引 DISTINCT_SCAN管道仅为[ { $group : { _id : $a } } ]集合上有a_1索引数据同上a取值 1、1、2。Results{ _id : 1 } { _id : 2 }Total indexes on the collection[ _id_, a_1 ]Summarized 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 } } } ] }解读这是“manual covered distinct scan construction”的典型形态$group无任何累积器DISTINCT_SCAN沿a_1顺序扫描每个键值只输出一次$groupByDistinctScan直接把它映射为{_id: $a}文档。整个计划 coveredisFetching: false比 COLLSCAN 哈希 GROUP 省掉整张哈希表。2.2 $group 无 $sort 且无合适索引 无 DISTINCT_SCAN管道不变但集合上的索引换成b_1_a_1分组键a不在索引前缀上。Total indexes on the collection[ _id_, b_1_a_1 ]Summarized explainExecution Engine: sbe{ queryShapeHash : CA2B2C90B53877652CBF1F4F7692F1DA0FA9476BC770590F5D8BCC5820FB58BA, rejectedPlans : [ ], winningPlan : [ { stage : GROUP }, { direction : forward, filter : { }, nss : test.distinct_query_planner_md, stage : COLLSCAN } ] }解读b_1_a_1虽然覆盖了a但a不是索引的最左前缀无法对a做去重有序扫描于是规划器回退到COLLSCAN GROUP。注意两个场景的 queryShapeHash 完全相同CA2B2C90...——查询形状一致物理计划因索引配置而异这正是 golden 测试想固化的行为同样的查询索引存在与否直接决定是否触发 DISTINCT_SCAN。三、DISTINCT_SCAN 优先于竞争的谓词索引最后一个场景验证规划器的“取舍”逻辑当谓词索引与 distinct 索引同时可竞争时低基数low-cardinality$group会偏向 DISTINCT_SCAN。管道为$match {a: {$gte: 0}, b: x}后接$group$top的sortBy为{a: -1}集合上同时存在三个索引a_1、b_1、a_1_b_1。测试数据为a取 0..109共 110 个不同值、b取x/y的 220 条文档即a的基数高、但过滤后每个a值只有一条bx文档。[ { $match : { a : { $gte : 0 }, b : x } }, { $group : { _id : $a, accum : { $top : { output : $b, sortBy : { a : -1 } } } } } ]Winning plan[ { 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\] ] } } ]解读表面上b_1索引可以精确命中b xa_1也可以辅助a 0但 winning plan 最终选择了a_1_b_1上的DISTINCT_SCANindexBounds显示b被精确限定为[x, x]a的边界为[inf, 0.0]direction: backward对应$top的sortBy: {a: -1}扫描沿(a, b)复合键前进由于每个a值下只有一条bx的记录DISTINCT_SCAN的“按a去重 取首条”语义恰好等价于“每个a组内b唯一的文档”因此$top结果零成本获得isFetching: false表示该计划完全 coveredPROJECTION_COVERED之上无需任何回表。这个用例说明当 distinct 索引本身也覆盖了过滤谓词时规划器愿意让 DISTINCT_SCAN 与谓词索引竞争并因分组去重的低开销而胜出——去重扫描不仅替代了哈希分组还把谓词过滤一并吸收进索引边界中。四、源码视角$group 去重重写的前置条件上述所有行为最终由 document_source_group_base.cpp 中的重写逻辑决定。从源码结构可以提炼出以下硬性约束单字段分组static constexpr size_t kNumberOfGroupFieldsInDistinctScanRewrite 1第 437 行重写只针对_id为单一字段的$groupgetRewriteGroupRequirements()中会检查idExpressions.size() ! 1直接返回空且_id必须是ExpressionFieldPath且不能是变量引用、不能是整个文档$$CURRENT/$$ROOT见路径长度为 1 的检查。累积器受限重写仅适用于所有累积器都是$first、$last、$top、$bottom或完全没有累积器的$group源码注释We do this transformation only if there are all $first, all $last, all $top, all $bottom, or no accumulators。排序方向判定SortPatternDirectionComparison枚举sameDirection/reverseDirection/incompatiblegetMatchedDirection()/findMatchedSortingInfix()/compareSortPatterns()三个函数实现了“$sort模式与$top/$bottom累积器排序模式的子串匹配”。1.1 与 1.2 两种方向的索引分别对应sameDirection与reverseDirection只要方向不一致或字段名对不上就判为incompatible重写中止对应 1.3/1.4 的退化路径。重写产物rewriteGroupAsTransformOnFirstDocument()生成GroupFromFirstDocumentTransformation把$top的output部分提取为普通字段见kFirstOutputDocument/kLastOutputDocument分支并携带sortDirectionChangeIsRequired标记供后续阶段构建反向扫描计划。特性开关该能力由featureFlagShardFilteringDistinctScan特性标志feature flag门控expression_context.h 中的isFeatureFlagShardFilteringDistinctScanEnabled()测试文件中同时带有requires_fcv_82标签说明该优化随 FCV 8.2 演进。分片场景下split_pipeline.cpp 与distributedPlanLogic()还会利用该标志决定是否将整个$group下推。元数据约束$groupByDistinctScan不保留元数据metadata因此 pipeline_d.cpp 中与$unwind$group重写交互时必须显式检查该阶段是否存在避免在需要元数据的优化路径上误用。五、如何复现与验证5.1 运行 golden 测试复现本文所有计划输出只需运行 distinct_query_planner_md.jspython3 buildscripts/resmoke.py run --suitesquery_golden jstests/query_golden/distinct_query_planner_md.js测试会为每个场景依次执行coll.createIndex(...)insertMany(...)outputAggregationPlanAndResults(coll, pipeline)把稳定化的 explain 与结果写入 Markdown供与期望输出比对golden test 机制。测试数据规模很小3~9 条文档目的不是测性能而是固化规划器在特定索引配置下的计划形状。5.2 不同特性配置下的期望输出规划器的行为受 SBE 与相关特性开关影响因此仓库为同一测试维护了多份期望输出featureFlagSbeFull/distinct_query_planner.md本文主体SBE 全量开启 相关 feature flag 开启golden 输出显示 classic 与 sbe 引擎并存——1.1/1.2/2.1 走 classic 执行树1.3/1.4/2.2 走 sbe 执行树体现了规划器按计划形态选择执行引擎sbeFull/distinct_query_planner.md、sbeRestricted/distinct_query_planner.md、sbeDisabled/distinct_query_planner.mdSBE 不同开关组合下的对照输出internalEnableJoinOptimization/distinct_query_planner.md开启 join 优化时的对照输出。对比这些文件可以直观看出SBE 开启与否只影响最终选中的执行引擎与GROUP算子的呈现方式而“是否出现 DISTINCT_SCAN”主要由索引与查询形状决定。5.3 生产环境中的实操建议结合本文四个场景可以为实际业务总结出可直接落地的索引设计原则让索引前缀与分组键一致$group: {_id: $a, accum: {$top: {output: $b, sortBy: {...}}}}场景下优先考虑以a为最左前缀的复合索引若排序模式还有附加字段如{a: 1, b: 1}把b也纳入索引可让 DISTINCT_SCAN 完全 coveredisFetching: false。方向可以整体取反若业务上$top/$bottom与索引方向相反规划器仍可通过backward扫描命中 DISTINCT_SCAN无需为反向排序另建索引。索引只覆盖谓词、不覆盖排序时哈希 GROUP 是兜底此时不会出现阻塞排序但哈希表内存开销仍存在如需进一步优化应补齐复合索引使$top的 sortBy 可被索引满足。用 explain 验证关注 winningPlan 中是否出现stage: DISTINCT_SCAN以及isFetching是否为 false两个 true 条件同时满足即代表去重重写已生效。explain 输出的稳定字段与提取工具可参考 analyze_plan.jsgetWinningPlanFromExplain、normalizePlan与 pretty_md.jssection/subSection/code等 Markdown 排版函数。总结本文围绕 golden 文档 distinct_query_planner.md 完整还原了 MongoDB 规划器在$group聚合上的 DISTINCT_SCAN 决策矩阵场景索引配置是否 DISTINCT_SCAN执行形态$top排序 正向复合索引a_1_b_1是classicPROJECTION_COVERED → DISTINCT_SCAN →$groupByDistinctScan$top排序 反向复合索引a_-1_b_-1是backward 扫描classic同上$top排序 无匹配索引a_1否sbeGROUP → COLLSCAN无阻塞排序$match$top索引仅覆盖过滤a_1否sbeGROUP → FETCH → IXSCAN裸$group 分组键索引a_1是classicDISTINCT_SCAN →$groupByDistinctScan裸$group 非前缀索引b_1_a_1否sbeGROUP → COLLSCAN低基数$group 竞争谓词索引a_1、b_1、a_1_b_1是胜出DISTINCT_SCANb谓词并入 indexBounds核心结论DISTINCT_SCAN 是$group去重优化中“以索引顺序替代哈希分组”的关键手段其触发与否取决于分组键是否为单字段、累积器类型、索引键序与排序模式的方向匹配关系通过 golden 测试固化行为、结合 explain 验证是让聚合查询稳定吃到该优化的最佳实践。【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表