ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

MongoDB DISTINCT_SCAN 优化与多计划竞争(Multi-Planning):基于 `$unwind + $group` 重写的 Golden 测试深度解析

MongoDB DISTINCT_SCAN 优化与多计划竞争(Multi-Planning):基于 `$unwind + $group` 重写的 Golden 测试深度解析 MongoDB DISTINCT_SCAN 优化与多计划竞争Multi-Planning基于$unwind $group重写的 Golden 测试深度解析【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo本文以 MongoDB 查询优化器中的$unwind $group → DISTINCT_SCAN重写优化为主线结合仓库中 golden 测试unwind_group_to_distinct_scan_multiplanning_md的期望输出深入剖析多个合适索引如何产生单一 DISTINCT_SCAN 候选、hint 对重写的影响以及全索引扫描候选与 DISTINCT_SCAN 的多计划竞争四个核心场景。读完本文你将掌握 DISTINCT_SCAN 的触发条件、explain 输出中各字段unwindsArrays、$groupByDistinctScan、rejectedPlans等的准确含义以及如何用hint与服务器参数控制该优化从而在真实业务中写出能被查询优化器高效重写的聚合管道。一、背景$unwind $group为什么能变成 DISTINCT_SCAN在 MongoDB 中DISTINCT_SCAN是一种仅扫描索引键的去重扫描它按索引顺序读取键值跳过重复项因此既能天然输出有序的互不重复键序列又能避免回表取文档fetch。传统上它服务于distinct()命令与去重 排序类查询。MongoDB 的聚合优化器在特定条件下可以把形如[ { $unwind: { path: $a, preserveNullAndEmptyArrays: true } }, { $group: { _id: $a } } ]的管道整体重写为一次对可能多键的索引的DISTINCT_SCAN再配合一个$groupByDistinctScan阶段直接产出分组结果。从测试源码 jstests/aggregation/optimization/unwind_group_to_distinct_scan.js 顶部的注释可以确认该优化的语义约束只针对顶层字段的$unwind后紧跟同字段、无累加器的$group必须设置preserveNullAndEmptyArrays: true。原因在于不带该选项时nullish 文档不产生任何分组而索引无法区分值为 null与数组元素为 [null]的文档只有保留空/null 数组时二者在分组语义上才等价管道中不能有其它中间 stage 修改该字段也不能存在$match过滤器当前实现下任何$match都会阻断重写。当该重写生效时explain 中会出现两个标志性产物计划树中的stage : DISTINCT_SCAN节点并携带unwindsArrays : true管道侧$unwind与$group两个 stage 被合并为单一的$groupByDistinctScanstage。二、测试环境与数据准备本文主体文档 jstests/query_golden/expected_output/featureFlagSbeFull/unwind_group_to_distinct_scan_multiplanning.md 是 golden 测试的期望输出。驱动它的测试脚本为 jstests/query_golden/unwind_group_to_distinct_scan_multiplanning_md.js其中主集合test.unwind_group_to_distinct_scan_multiplanning_md插入 5 条文档{a: [1, 2], b: 1}、{a: [2, 3], b: 2}、{a: 7, b: 3}、{a: [], b: 4}、{b: 5}建立索引{a: 1}与复合索引{a: 1, b: 1}加上_id_共 3 个索引被测管道固定为[{$unwind: {path: $a, preserveNullAndEmptyArrays: true}}, {$group: {_id: $a}}]。测试带featureFlagShardFilteringDistinctScan与requires_fcv_91两个标签说明该行为依赖分片过滤型 DISTINCT_SCAN 特性开关并要求 FCV 至少为 91。注意a字段在 5 条文档中既含数组如[1, 2]又含标量如7与缺失值因此索引a_1会被标记为多键索引isMultiKey: truemultiKeyPaths会列出a : [a]。由于$match尚不被该重写支持见测试注释管道没有任何谓词可供计划器枚举竞争计划因此多数场景下查询计划器只会生成一个解。golden 输出的 Summarized explain 正是围绕重写与 multi-planning 如何交互这一核心问题展开的。三、场景一多个合适索引只生成单一 DISTINCT_SCAN 候选3.1 场景设置与结果当集合同时存在a_1与a_1_b_1两个前缀为 a 的合适索引时理论上两者都足以支撑对a的 DISTINCT_SCAN。但观察 explainrejectedPlans : [ ], winningPlan : [ { stage : PROJECTION_COVERED, transformBy : { _id : 0, a : 1 } }, { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ] }, indexName : a_1, isFetching : false, isMultiKey : true, stage : DISTINCT_SCAN, unwindsArrays : true } ]关键信息解读rejectedPlans为空数组没有发生多计划竞争。原因正如测试注释所述——重写要求空过滤器没有谓词可供计划器枚举出不同的索引区间方案计划器通常会直接产出一个解计划器选择了a_1单字段索引而非a_1_b_1因为 DISTINCT_SCAN 只关心a的键单字段索引即可覆盖PROJECTION_COVEREDisFetching: false无需回表unwindsArrays: true告知执行器该 DISTINCT_SCAN 扫描的是多键索引中的数组元素等价于在展开数组之后取去重键索引边界[MinKey, MaxKey]表示全范围扫描无谓词约束下的默认边界。计划树之下聚合管道侧变为单一 stage{ $groupByDistinctScan : { newRoot : { _id : $a } } }也就是说$unwind与$group两个阶段被吸收进扫描最终只输出 5 个分组键{ _id: 1 / 2 / 3 / 7 / null }。其中null分组来自{a: []}空数组展开为空与{b: 5}字段缺失——在preserveNullAndEmptyArrays: true语义下它们都折叠进null分组。3.2 从源码看 DISTINCT_SCAN 的生成DISTINCT_SCAN 计划节点的生成逻辑位于 src/mongo/db/query/distinct_access.cpp 与 src/mongo/db/query/canonical_distinct.h 一带它负责从候选索引中挑出能够以键序去重方式回答 distinct 语义的索引而$groupByDistinctScan这一 explain 阶段的序列化与计划解释逻辑可在 src/mongo/db/query/plan_explainer_impl.cpp 与 src/mongo/db/query/compiler/physical_model/query_solution/query_solution.cpp 中找到对应实现。可以推断当聚合优化器识别出可重写管道后会直接构建单一候选的 distinct 计划因而不会触发 multi-planning。四、场景二需要回表的 hinted 索引不会被转换为 DISTINCT_SCAN4.1 场景设置如果通过 hint 显式指定复合索引{a: 1, b: 1}{ hint : { a : 1, b : 1 } }结果仍然正确_id仍是1, 2, 3, 7, null但计划形态完全不同winningPlan : [ { stage : PROJECTION_SIMPLE, transformBy : { _id : 0, a : 1 } }, { nss : test.unwind_group_to_distinct_scan_multiplanning_md, stage : FETCH }, { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ], b : [ [MinKey, MaxKey] ] }, indexName : a_1_b_1, isMultiKey : true, stage : IXSCAN } ]随后是两个未被合并的原始 stage{ $unwind : { path : $a, preserveNullAndEmptyArrays : true } }, { $group : { $willBeMerged : false, _id : $a } }4.2 为什么 hint 会阻断重写这是一个非常重要的实战结论可以被重写为 DISTINCT_SCAN 与被强制使用某个索引是两回事。a_1_b_1虽然以a为前缀但它携带多余的b键DISTINCT_SCAN 要求投影完全被索引覆盖isFetching: false。而a_1_b_1的键只有(a, b)若只取a扫描后仍需 FETCH 回表取原始文档explain 中的FETCHstage 与PROJECTION_SIMPLE证实了这一点从重写语义看$groupByDistinctScan需要的是恰好覆盖分组键的去重扫描带多余后缀键的复合索引会产生同一a值对应多条b不同的索引项无法直接作为去重源因此优化器放弃重写退化为常规的IXSCAN FETCH $unwind $group执行路径。换句话说hint 只能约束用哪个索引扫描却不能强制优化器做不合规的语义重写。优化器宁可保留完整管道也要保证结果正确。五、场景三hint 可以强制合适的索引作为对照如果 hint 指定的是可覆盖、键序恰好匹配的索引{ hint : { a : 1 } }计划重新回到 DISTINCT_SCAN 形态winningPlan : [ { stage : PROJECTION_SIMPLE, transformBy : { _id : 0, a : 1 } }, { nss : test.unwind_group_to_distinct_scan_multiplanning_md, stage : FETCH }, { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ] }, indexName : a_1, isMultiKey : true, stage : IXSCAN } ]等等——这里出现的仍是IXSCAN而非DISTINCT_SCAN而且多了FETCH这恰恰是本场景最有价值的信息在 classic 引擎Execution Engine: classic下当用户显式 hint 时计划器对该管道生成的是普通IXSCAN计划$unwind与$group原样保留带$willBeMerged : false标记对比场景一的PROJECTION_COVERED这里即使是a_1classic 引擎仍选择了FETCH PROJECTION_SIMPLE形态需要留意的是这份 golden 输出保存在featureFlagSbeFull目录下。同名的 sbeFull 版本期望输出 中场景二、三的 explain 标注为 Execution Engine: sbe投影字段布尔值形式_id : false, a : true也与 classic 的0/1形式不同——说明在完整 SBESlot-Based Execution引擎下$groupByDistinctScan的生成路径有所差异但需要回表的 hint 阻断重写这一结论在两个引擎中保持一致。因此将场景二与场景三放在一起读出的完整结论是DISTINCT_SCAN 重写不是一个hint 可强开的特性。hint 只能把扫描固定到指定索引是否重写取决于该索引是否满足键序可去重 投影可覆盖这两个硬性条件。实战中若想稳定获得 DISTINCT_SCAN应当为分组键建立单独的前缀索引如{a: 1}而不是依赖 hint 去拯救一个带多余后缀的复合索引。六、场景四全索引扫描候选与 DISTINCT_SCAN 的多计划竞争6.1 场景设置这是四个场景中唯一真正发生 multi-planning 的用例也是这份 golden 文档存在的核心意义。数据被放入另一个集合test.unwind_group_to_distinct_scan_multiplanning_md_scalar全部为标量数据{a: 1, b: 1}、{a: 1, b: 2}、{a: 2, b: 3}、{a: 3, b: 4}、{b: 5}并建立索引{b: 1, a: 1}与{a: 1}。注意这里没有{a: 1, b: 1}b_1_a_1是非多键索引。由于管道没有谓词正常不会产生多个计划。测试脚本为此临时打开了一个服务器参数const knob internalQueryPlannerGenerateCoveredWholeIndexScans; const priorKnobValue assert.commandWorked(db.adminCommand({getParameter: 1, [knob]: 1}))[knob]; assert.commandWorked(db.adminCommand({setParameter: 1, [knob]: true})); try { outputAggregationPlanAndResults(scalarColl, pipeline); } finally { assert.commandWorked(db.adminCommand({setParameter: 1, [knob]: priorKnobValue})); }internalQueryPlannerGenerateCoveredWholeIndexScans会让计划器额外生成整索引全扫描whole index scan的候选计划从而人为制造 multi-planning 场景。测试特意不用runWithKnobs()之类的辅助函数是因为那些工具会经由FixtureHelpers打开新连接新连接隐式会话的 id 会被打印进 golden 输出破坏结果稳定性——这个细节也说明了 golden 测试对输出的精确性要求。6.2 竞争结果DISTINCT_SCAN 胜出explain 中第一次出现了非空的rejectedPlansrejectedPlans : [ [ { stage : PROJECTION_COVERED, transformBy : { _id : 0, a : 1 } }, { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ], b : [ [MinKey, MaxKey] ] }, indexName : b_1_a_1, isMultiKey : false, stage : IXSCAN } ] ]被拒绝的计划是对b_1_a_1的整索引扫描——它虽然可覆盖PROJECTION_COVERED但必须扫过(b, a)全索引数据量明显更大。胜出的计划则是winningPlan : [ { stage : PROJECTION_COVERED, transformBy : { _id : 0, a : 1 } }, { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ] }, indexName : a_1, isMultiKey : false, stage : DISTINCT_SCAN, unwindsArrays : true } ]这里a_1是非多键索引isMultiKey: false、multiKeyPaths全空因为该集合数据全为标量DISTINCT_SCAN只遍历a的键并跳过重复值数据中a: 1出现两次配合PROJECTION_COVERED全程不回表rejectedPlans的queryShapeHash为B1FC3E85...与前三场景的7369A25E...不同——因为集合/命名空间与索引集合不同query shape 编码也会变化管道侧同样合并为$groupByDistinctScan$unwind/$group不再单独出现。这一场景证明即使把 DISTINCT_SCAN 候选放进 multi-planning 的公开竞争中它依然能靠更小的扫描代价胜出。这正是该优化对查询性能的实战价值——去重语义由索引扫描直接承担无需展开数组、无需哈希聚合、无需回表。七、重写的前提条件与失效边界围绕同一优化的完整单元测试 jstests/aggregation/optimization/unwind_group_to_distinct_scan.js 系统性地覆盖了重写的边界可作为本文四个场景之外的查漏补缺清单触发条件$unwind必须设置preserveNullAndEmptyArrays: true且不能带includeArrayIndex$group的_id必须与$unwind字段一致且不能有任何累加器支持多键索引与单字段/复合索引复合索引中展开字段须在前缀混合方向如{a: -1, b: 1}也可$unwind与$group之间不能插入任何修改该字段的 stage如$addFields$unwind之前也不能有$sort不支持虚线路径如$foo.a即使只有叶子字段是多键的见 TODO SERVER-133204不支持任何$match见 TODO SERVER-133195包括展开前/后的等值、范围、$in以及非数组前缀/后缀上的过滤。不适用/被拒绝的索引类型sparse 索引缺失值无索引项、partial 索引、wildcard 索引$**均不参与hashed 索引因键值非原始值而被拒绝但复合索引尾部带 hashed 字段{a: 1, b: hashed}仍可用非 simple collation 的索引不可用索引键可能已被整理无法等价于分组键。已知偏差known deviations多键索引场景下顶层undefined值会被折叠进null分组因为空数组[]与undefined产生相同的索引键而 BSONundefined类型已废弃这种偏差可接受——控制组$natural全扫描结果与 DISTINCT_SCAN 结果在 unwind_group_to_distinct_scan.js 中被显式对比断言。八、如何亲手复现与验证复现本文四个场景只需两步启动 MongoDBFCV ≥ 91启用featureFlagShardFilteringDistinctScan后运行 golden 测试脚本 jstests/query_golden/unwind_group_to_distinct_scan_multiplanning_md.js将脚本输出与三份期望文件逐行比对默认引擎jstests/query_golden/expected_output/unwind_group_to_distinct_scan_multiplanning.md完整 SBEjstests/query_golden/expected_output/sbeFull/unwind_group_to_distinct_scan_multiplanning.md特性开关组合jstests/query_golden/expected_output/featureFlagSbeFull/unwind_group_to_distinct_scan_multiplanning.md在 shell 中手工验证某条管道是否被重写可直接执行db.coll.explain().aggregate([...])检查计划中是否出现stage : DISTINCT_SCAN与unwindsArrays : true以及管道中是否合并出$groupByDistinctScan。若计划里出现FETCH、IXSCAN与独立的$unwind/$group则说明重写未生效可对照上文触发条件与失效边界逐项排查。九、总结通过这份 golden 期望输出与配套测试源码可以确认 MongoDB 查询优化器的如下行为当$unwindpreserveNullAndEmptyArrays: true 同字段无累加器$group且无过滤谓词时优化器将管道重写为对合适索引的DISTINCT_SCAN并在 explain 中以unwindsArrays: true与$groupByDistinctScan标识多个合适索引并存时不会触发 multi-planning计划器直接产出单一 DISTINCT_SCAN 候选优先选择可覆盖、无需回表的索引hint 不能强制该重写指向需要回表的复合索引的 hint 会使优化器放弃重写、退化为IXSCAN FETCH全管道执行只有指向键序匹配索引的 hint 才可能维持去重扫描形态即使在internalQueryPlannerGenerateCoveredWholeIndexScans制造出的 multi-planning 竞争中DISTINCT_SCAN 仍凭借更小的扫描代价胜出。对应用开发者而言最直接的实践启示是为高频的按数组字段去重统计类管道建立以该字段为前缀的独立索引{a: 1}并保持管道形态为展开 分组、无中间阶段、无过滤器即可让 MongoDB 自动以 DISTINCT_SCAN 的高效路径执行避免数组展开、回表与哈希聚合的开销。【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表