拓冰建站拓冰建站
首页 / 资讯中心 / 正文

MongoDB 分片集群 $group 下推(Pushdown)深度解析:基于 Golden 测试的完整行为图谱

MongoDB 分片集群 $group 下推Pushdown深度解析基于 Golden 测试的完整行为图谱【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo导读本文以 MongoDB 仓库中query_golden_sharding测试套件的金标准输出文档 group_targeting.md 为骨架系统讲解分片集合上聚合管道$group阶段的下推pushdown优化什么情况下$group能被完整下推到各分片执行$willBeMerged: false分片间无需合并什么情况下只能部分下推shards 先分组、router 再$doingMerge合并以及哪些边界条件collation、shard key 被改写、非简单重命名等会阻止下推。读完本文你将掌握$group下推的判定规则、explain输出中关键标志$willBeMerged/$doingMerge/$groupByDistinctScan的解读方法以及如何通过 explain 判断自己的聚合管道是否踩中了无法下推的陷阱。一、背景为什么$group需要“下推”1.1 分片集群聚合的两阶段模型在 MongoDB 分片集群中聚合管道默认在 mongosrouter上解析并采用分片侧执行 router 合并的两阶段模型shardsPart下发给每个分片的管道片段mergerPart在 router 上执行的合并片段mergeTyperouter表示在 mongos 上完成最终合并。对于$group阶段如果每个分组键_id在集群范围内只会出现在单个分片上那么各分片先行分组、router 直接拼接结果即可无需二次聚合——这就是完整下推fully pushdownexplain 中表现为 shards 片段的$group带有$willBeMerged : false且 mergerPart 只有$mergeCursors。反之如果同一个分组键可能散布在多个分片上就必须先让各分片分组再在 router 上做一次合并分组merge——explain 中 mergerPart 会多出一个$doingMerge : true的$groupshards 片段中的$group则没有$willBeMerged或为true。1.2 下推的价值完整下推能把分组、去重、聚合计算全部压到离数据最近的分片执行router 只做游标拼接$mergeCursors从而大幅减少跨分片的数据传输量不必把全量原始文档传到 router 再分组降低 router 的内存与 CPU 压力让分组计算天然并行化。二、测试环境与数据准备读懂 Golden 输出的前提该 golden 测试的驱动脚本是 group_targeting.js其期望输出即本文主题文档 group_targeting.md。测试通过 golden_test_utils.js 中的outputAggregationPlanAndResults()依次输出 Pipeline、Options、Results、集合索引以及 Summarized explain。2.1 单分片键场景的数据布局测试启动一个 2 分片shards: 2的ShardingTest对集合test.group_targeting按{shardKey: 1}分片并人为构造出大小写混合的 shardKey 值来制造孤儿/分布陷阱// 集合索引与初始数据 coll.createIndex({shardKey: 1}); coll.insertMany([ {_id: 1, shardKey: shard0_1, otherField: a}, {_id: 1.5, shardKey: shard0_1, otherField: A}, {_id: 2, shardKey: shard0_2, otherField: b}, {_id: 2.5, shardKey: sHaRd0_2, otherField: b}, // 大小写混合 {_id: 3, shardKey: shard0_3, otherField: c}, {_id: 3.5, shardKey: shard0_3, otherField: C}, ]); // 在 shard1 处 split并把 shard1_* chunk 移到 shard1 shardingTest.s.adminCommand({split: coll.getFullName(), middle: {shardKey: shard1}}); shardingTest.s.adminCommand({moveChunk: coll.getFullName(), find: {shardKey: shard1_1}, to: otherShard}); // 移动 chunk 后再插入避免孤儿文档 coll.insertMany([ {_id: 4, shardKey: shard1_1, otherField: a}, {_id: 4.5, shardKey: shard1_1, otherField: A}, {_id: 5, shardKey: shard1_2, otherField: b}, {_id: 6, shardKey: shard1_3, otherField: c}, // 注意由于 shARD1_3 shard1_1这条文档实际上落在 shard0 {_id: 6.5, shardKey: shARD1_3, otherField: c}, ]);_id字段特意采用1, 1.5, 2, 2.5, ...这样的半整数递增与字符串 shardKey 组合出丰富的分组与排序场景。全文每个用例都输出Total indexes on the collection单键场景为[ _id_, shardKey_1 ]复合键场景为[ _id_, sk0_1_sk1_1_sk2_1 ]。阅读提示golden 输出中的group_targeting-rs0/group_targeting-rs1分别对应两个分片副本集queryShapeHash是查询形状的哈希指纹用于观测查询计划缓存的变化。2.2 复合分片键场景第二部分对集合test.group_targeting_compound使用复合分片键{sk0: 1, sk1: 1, sk2: 1}同样 split 出一个{sk0: s0/1, sk1: 1, sk2: h}的 chunk 并移动到另一分片再插入 11 条_id取 1~8含半整数的文档。单键场景与复合键场景的用例一一对应用于验证下推判定对复合键同样成立。三、可以完整下推Fully Pushdown的情形3.1 最简形式_id shard key[ { $group : { _id : $shardKey } } ]Results{ _id : sHaRd0_2 } { _id : shARD1_3 } { _id : shard0_1 } { _id : shard0_2 } { _id : shard0_3 } { _id : shard1_1 } { _id : shard1_2 } { _id : shard1_3 }Summarized explain 关键片段group_targeting-rs0{ $cursor : { rejectedPlans : [ ], winningPlan : [ { stage : PROJECTION_COVERED, transformBy : { _id : 0, shardKey : 1 } }, { stage : DISTINCT_SCAN, indexName : shardKey_1, indexBounds : { shardKey : [ [MinKey, MaxKey] ] }, isFetching : false, isShardFiltering : true, keyPattern : { shardKey : 1 }, isMultiKey : false, isPartial : false, isSparse : false, isUnique : false } ] }, { $groupByDistinctScan : { newRoot : { _id : $shardKey } } } ]这里出现了三个重要的执行特征DISTINCT_SCAN分组键与索引shardKey_1完全对齐SBE 引擎直接用索引做去重扫描isFetching: false表示无需回表取文档是纯索引覆盖的 distinct 扫描isShardFiltering: true该索引扫描附带分片过滤丢弃孤儿文档保证同一分片键值只会在一个分片上出现这个前提成立$groupByDistinctScan聚合层把$group改写为基于 distinct scan 的执行形式不再需要$willBeMerged标志两个分片分别产出结果后由 router 的$mergeCursors直接拼接。整份 explain 中mergeType为routermergerPart 仅含$mergeCursors无二次聚合shardsPart 中的$group带有$willBeMerged : false。这三个信号共同表明$group被完整下推分片间不存在重复分组键。3.2 管道前缀$group distinct scan含$top累加器与后置$match/$sort[ { $group : { _id : $shardKey, otherField : { $top : { output : $otherField, sortBy : { shardKey : 1 } } } } }, { $match : { _id : { $lte : shard1_1 } } }, { $sort : { _id : 1 } } ]Results{ _id : sHaRd0_2, otherField : b } { _id : shARD1_3, otherField : c } { _id : shard0_1, otherField : a } { _id : shard0_2, otherField : b } { _id : shard0_3, otherField : c } { _id : shard1_1, otherField : a }explain 关键点分片内使用DISTINCT_SCANindexBounds 收窄为[ , shard1_1 ]$match被下推进索引边界isFetching: true因$top需要取otherField字段$cursor之上是SORT_KEY_GENERATOR用于生成后续$sort需要的排序键每个分片的$group均带$willBeMerged : falseshardsPart 依次为$match → $group → $sortmergerPart 的$mergeCursors携带sort : { _id : 1 }由 router 完成全局归并排序。这个用例证明只要$group本身满足下推条件紧随其后的$match与$sort也会随之下推并在分片本地完成。3.3 累加器正确性验证$avg[ { $group : { _id : $shardKey, avg : { $avg : $_id } } } ]Results节选{ _id : shard0_1, avg : 1.25 } { _id : shard0_2, avg : 2 } { _id : shard0_3, avg : 3.25 } { _id : shard1_1, avg : 4.25 } { _id : shard1_2, avg : 5 } { _id : shard1_3, avg : 6 }注意结果中shard0_1的avg是1.25该组包含_id: 1otherField: a与_id: 1.5otherField: A两条文档(1 1.5) / 2 1.25。同时sHaRd0_2与shard0_2是两个不同的分组键大小写不同各自独立求均值——这验证了完整下推不会把不同键错误合并。explain 中两个分片的 winningPlan 均为GROUP → SHARDING_FILTER → COLLSCAN分组未走索引 distinct scan因为累加了$_id字段但 shardsPart 的$group依然带$willBeMerged : falsemergerPart 只有$mergeCursors——说明只要分组键不跨分片重复无论底层用 COLLSCAN 还是 DISTINCT_SCAN都可以完整下推。3.4 简单重命名Rename不影响下推[ { $project : { renamedShardKey : $shardKey } }, { $group : { _id : $renamedShardKey } } ]Results与 3.1 完全一致8 个分组键。explain 中分片执行GROUP → PROJECTION_DEFAULT → SHARDING_FILTER → COLLSCANshardsPart 为$project → $group($willBeMerged: false)。下推判定做了语义追踪$group的_id虽然引用的是$renamedShardKey但它能追溯到原始文档的$shardKey字段因此视为在分片键上分组。3.5_id是分片键的超集Superset[ { $group : { _id : [ $shardKey, $_id ] } } ]Results节选{ _id : [ shard0_1, 1 ] } { _id : [ shard0_1, 1.5 ] } { _id : [ shard0_2, 2 ] } { _id : [ shard0_3, 3 ] } { _id : [ shard0_3, 3.5 ] } ...分组键在 shardKey 之外追加了$_id。由于_id全局唯一任何分组键仍只落在单个分片上因此依然可以完整下推shardsPart 的$group带$willBeMerged: falsemergerPart 仅$mergeCursors。同理超集 简单重命名$project重命名 shardKey 后再与$_id组成文档型分组键与 _id直接引用$$ROOT.shardKey 也能完整下推后者同样走DISTINCT_SCAN$groupByDistinctScan。3.6 多个$group可以同时下推的情况[ { $group : { _id : { key : $shardKey, other : $otherField } } }, { $group : { _id : $_id } } ]第二个$group的_id是$_id第一个$group的完整分组键其中包含 shardKey。由于第一个$group的下推保证了{key, other}组合不会跨分片重复第二个$group也不会产生跨分片合并需求于是两个$group都以$willBeMerged: false下推分片执行GROUP → GROUP → SHARDING_FILTER → COLLSCANrouter 端仅$mergeCursors。测试中复合键场景group_targeting.js也验证了同样的结论。四、只能部分下推Partial Pushdown的情形当分组键可能跨分片重复时就必须保留 router 合并阶段shards 先执行分组router 再用$doingMerge : true的$group做二次合并。4.1_id是分片键的导出值derived value[ { $group : { _id : { $min : [ $shardKey, 1 ] } } } ]Results{ _id : 1 }$min($shardKey, 1)的结果对所有文档都是常量1——这个分组键完全丢失了 shardKey 的区分能力必然跨分片重复。explain 显示shardsPart$group无$willBeMergedmergerPart$mergeCursors之后追加{ $group : { $doingMerge : true, _id : $$ROOT._id } }分片底层为PROJECTION_SIMPLE仅投影shardKeySHARDING_FILTERCOLLSCAN。这就是部分下推分片各自先归并成本地组router 再做一次全局合并。4.2 更复杂的导出值 依赖其他字段带$top累加器与后置$match/$sort的版本分片各自执行$group($min$top)router 在$doingMerge: true的$group中同样以$$ROOT._id分组、用$top对$$ROOT.otherField重新聚合再执行$match与$sort_id依赖$_id{ $min : [ $shardKey, $_id ] }的版本因为$min会取到每条文档自身的_id结果对每条文档都不同但 router 仍需$doingMerge合并merger 以$$ROOT._id分组。对比 3.5 的超集用例可以提炼出一条关键规则_id直接引用 shardKey含简单重命名、$$ROOT.shardKey、作为超集成员→ 可完整下推_id由 shardKey 经过运算/函数导出 → 只能部分下推。4.3 多个$group混合完全下推 部分下推[ { $group : { _id : $shardKey, avg : { $avg : $_id } } }, { $group : { _id : $avg, num : { $count : { } } } } ]Results节选{ _id : 1.25, num : 1 } { _id : 2, num : 1 } { _id : 2.5, num : 1 } ...第一个$group在 shardKey 上分组可完整下推$willBeMerged: false但第二个$group的键是$avg聚合产物相同平均值完全可能出现在多个分片因此 router 需要$doingMerge : true的合并$group用$sum对$$ROOT.num重新计数。分片本地执行GROUP → GROUP → SHARDING_FILTER → COLLSCAN合并阶段位于 router。4.4 非分片键分组[ { $group : { _id : $otherField } } ]Results{ _id : A } { _id : C } { _id : a } { _id : b } { _id : c }otherField不是 shardKey同一取值必然跨分片重复只能部分下推mergerPart 带$doingMerge: true的$group_id: $$ROOT._id。测试中还验证了把$otherField投影成一个名为shardKey的字段再分组$project: {shardKey: $otherField}同样只能部分下推——优化器识别出该字段已不是原始 shardKey。4.5 非简单重命名Non-simple Rename复合键场景中的反例[ { $project : { sk0Renamed : $sk0, sk1Renamed : { $add : [ 1, $sk1 ] }, sk2Renamed : $sk2, _id : 0 } }, { $group : { _id : { sk0 : $sk0Renamed, sk1 : $sk1Renamed, sk2 : $sk2Renamed } } } ]sk1Renamed是$sk1 1的计算值不再是 shardKey 的直接映射无法保证_id与分片键一一对应因此只能部分下推mergerPart 出现$doingMerge: true。对比 3.4 的简单重命名$project直接引用可以完整下推判定依据正是重命名是否保持原字段语义。五、禁止下推的边界条件以下情形连部分下推的分片内分组资格判定都会受到影响具体表现见对应用例核心目的是保证正确性优先于性能。5.1 聚合使用非简单 Collation[ { $group : { _id : $shardKey } }, { $addFields : { _id : { $toLower : $_id } } } ] // Options { collation : { locale : en_US, strength : 2 } }Results{ _id : shard0_1 } { _id : shard0_2 } { _id : shard0_3 } { _id : shard1_1 } { _id : shard1_2 } { _id : shard1_3 }在strength: 2的英文 collation 下shard0_1 与 SHARD0_1 等大小写变体被视作同一分组键。若盲目下推各分片按各自分区内的键分组后router 直接拼接会得到比期望更多的重复_id。因此测试输出特意标注Note: If we have duplicate _ids in the output, that signals a bug here.解释分组结果中sHaRd0_2等变体已被$toLower归一化合并最终只有 6 个键。若错误地下推分片各自产出的原始大小写键无法在 router 处正确合并就会出现重复_id。golden 测试正是用这条断言约束优化器非简单 collation 下禁止完整下推。5.2 shard key 未被保留或已被改写三个典型的shard key 失真场景场景管道示意结果/结论shard key 被剔除$project: {otherField: 1}后按$shardKey分组_id为nullshardKey 已不存在仅部分下推shard key 被$addFields覆盖$addFields: {shardKey: $otherField}后按$shardKey分组实际按otherField分组仅部分下推先剔除再重建$project: {shardKey: 0}→$addFields: {shardKey: $otherField}→ 分组重建的 shardKey 非原始键仅部分下推第三个用例的 explain 展示了多层投影叠加分片执行GROUP → PROJECTION_DEFAULT(重建) → PROJECTION_DEFAULT(剔除) → PROJECTION_SIMPLE → SHARDING_FILTER → COLLSCANshardsPart 为$project → $addFields → $grouprouter 端带$doingMerge。5.3_id引用整篇文档$$ROOT[ { $group : { _id : $$ROOT } } ]Results节选{ _id : { _id : 1, otherField : a, shardKey : shard0_1 } } { _id : { _id : 1.5, otherField : A, shardKey : shard0_1 } } ..._id是整个文档对象优化器无法保证整篇文档只在一个分片因此只做部分下推mergerPart 带$doingMerge: true。而 3.5 已验证$$ROOT.shardKey这种精确到字段路径的引用可以完整下推——判定粒度细化到了字段路径级别。5.4 复合键的部分子集 / 混入非 shardKey 字段复合键场景还有两个补充用例_id是复合分片键的子集{_id: {sk0: $sk0, sk2: $sk2}}缺少sk1→ 仅部分下推_id混入非简单来源字段$addFields: {complex: {$rand: {}}}后把complex塞进分组键再通过第二个$group重新收敛回 shardKey 子集 → 第一个$group只能部分下推第二个$group键为$_id.sk0/sk1/sk2可完整下推形成部分下推在前、完整下推在后的混合形态。六、底层原理优化器如何判定能否下推从源码层面看下推判定集中在 document_source_group_base.cpp 的DocumentSourceGroupBase::distributedPlanLogic()与groupIsOnShardKey()。6.1 前置关卡distributedPlanLogic进入完整下推判定前依次检查以下条件任一不满足则回退到无上下文的常规分布式计划Feature FlagisFeatureFlagShardFilteringDistinctScanEnabled()必须开启测试标签中也显式依赖featureFlagShardFilteringDistinctScan见 group_targeting.jsCollation 必须为简单 collatorCollatorInterface::isSimpleCollator对应 5.1 的边界readConcern 不能是available——该级别下不能保证多个分片不会返回同一条文档对应孤儿/重复文档风险管道后缀不能是$merge——$merge可能触发$exchange优化完整下推会使其失效不能处于嵌套子管道中getSubPipelineDepth() 1对应 TODO SERVER-99094。6.2 核心判定groupIsOnShardKeybool DocumentSourceGroupBase::groupIsOnShardKey( const Pipeline pipeline, const boost::optionalOrderedPathSet initialShardKeyPaths) const { if (!initialShardKeyPaths) { return false; // 没有 shard key 信息 } const auto shardKeyPaths *initialShardKeyPaths; const auto groupExprs getTriviallyReferencedPaths(); // 取 $group _id 中简单引用的路径 if (groupExprs.empty()) { // _id 不含任何简单路径引用可能是常量或复杂计算不保证可下推 return false; } // 沿管道向前追溯这些字段的来源路径还原 rename / projection const auto originPaths semantic_analysis::traceOriginatingPaths(stages, groupExprs); if (originPaths.empty()) { // _id 中的字段并非由 shard key 经纯 rename/projection 派生而来 return false; } // originPaths 必须覆盖 shardKeyPaths 的全部路径超集或相等 if (!std::includes(originPaths.begin(), originPaths.end(), shardKeyPaths.begin(), shardKeyPaths.end(), originPaths.key_comp())) { return false; } return true; }判定逻辑可归纳为三条规则分组键必须包含简单路径引用getTriviallyReferencedPaths排除了常量、$min/$add等复杂表达式这些路径必须能通过traceOriginatingPaths追溯到原始 shardKey这解释了为什么简单重命名可下推而shardKey字段被$addFields覆盖 / 剔除重建不可下推追溯出的来源路径集合必须是 shard key 路径集合的超集std::includes这解释了为什么_id shardKey、_id ⊇ shardKey可下推而_id ⊂ shardKey缺少复合键某分量不可下推。判定通过后代码执行_groupProcessor-setWillBeMerged(false)并返回boost::none表示不需要 router 合并阶段即 3.x 各用例中看到的$willBeMerged: false。6.3 部分下推的合并构造distributedPlanLogicWithoutContext当完整下推不成立时distributedPlanLogicWithoutContext()构造标准的两段式计划分片侧克隆一个$groupclone并setWillBeMerged(true)router 侧创建合并分组以$$ROOT._id为键累加器参数改写为$$ROOT.fieldName并setDoingMerge(true)——对应 explain 中的$doingMerge : true特殊处理$percentile/$median准确值算法等无法并行合并的累加器出现时禁止把整个$group下推分片只做数据筛选。这就是 4.x 各用例中mergerPart出现$group($doingMerge: true)的根源。七、如何运行与验证把 Golden 测试变成自己的实验台7.1 运行 golden 测试该测试属于query_golden_sharding套件运行方式与普通 jstest 一致需要支持requires_fcv_82的版本例如# 需要 featureFlagGetExecutorDeferredEngineChoice 与 featureFlagShardFilteringDistinctScan python3 buildscripts/resmoke.py run --suitequery_golden_sharding jstests/query_golden_sharding/group_targeting.js测试会实际启动 2 分片的ShardingTest执行管道并调用 outputAggregationPlanAndResults() 输出规范化结果与expected_output/下的 golden 文件逐字节比对结果默认排序后输出可用shouldSortResults关闭。expected_output/目录下同时存在多个引擎变体sbeFull本文主体、sbeDisabled、sbeRestricted、featureFlagSbeFull 与 featureFlagSbeAccumulatorExpressions可用于对比不同执行引擎 / feature flag 下的下推行为差异。7.2 用 explain 快速判断自己的管道在任意分片集群上对测试集合执行然后观察两个信号mergerPart 是否只有$mergeCursors是 → 完整下推出现带$doingMerge : true的$group→ 部分下推shardsPart 中$group是否带$willBeMerged : false是 → 该分组完全在分片完成分片执行计划是否出现DISTINCT_SCAN$groupByDistinctScan是 → 分组已下推到索引级去重性能最优。7.3 如何判断自己是否踩中反下推陷阱对照第五节归纳出四条自查清单聚合是否使用了非简单 collationlocale非 simple /strength使分组变粗管道前缀是否对 shardKey 字段做过剔除$project排除、覆盖$addFields重写或计算$add、$min等$group._id是否引用了整篇文档$$ROOT而非精确字段路径复合分片键是否完整出现在分组键中缺少任一分量即为子集无法下推。八、小结本文以 group_targeting.md 的完整 golden 输出为依据把 MongoDB 分片集群$group下推行为归纳为一张可执行图谱完整下推_id为 shardKey 本身、其简单重命名、$$ROOT.shardKey或 shardKey 的超集特征为$willBeMerged: false mergerPart 仅$mergeCursors能走索引时进一步升级为DISTINCT_SCAN$groupByDistinctScan部分下推_id为 shardKey 的导出值、非分片键、复合键子集、混入非 shardKey 计算字段特征为 router 端出现$doingMerge: true的合并$group禁止下推非简单 collation、shardKey 被剔除/覆盖/重建、$$ROOT整体引用等正确性风险场景。这些行为背后有清晰的源码逻辑支撑groupIsOnShardKey()通过简单路径引用 语义追溯 超集包含三步判定是否完整下推distributedPlanLogicWithoutContext()则构造带$doingMerge的合并分组作为兜底。理解这张图谱后读者既能读懂线上 explain 中每个标志的含义也能在设计聚合管道时有意识地让$group对齐 shardKey从而获得分片集群下的最优聚合性能。【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门