)
MongoDB 黄金测试剖析DISTINCT_SCAN 在 distinct 查询与聚合管道中的计划缓存行为sbeDisabled 模式【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo本文以 MongoDB 仓库中的黄金golden测试期望输出文件 distinct_plan_cache.md 为主体完整解读DISTINCT_SCAN执行计划在五种典型场景下如何被写入计划缓存、以何种isActive状态流转以及计划缓存键planCacheKey在“不同谓词、相同查询形状”下如何保持复用。读完本文你可以掌握如何用getPlanCache().clear()、$planCacheStats聚合阶段验证计划缓存条目如何解读cachedPlan、createdFromQuery、planCacheKey、isActive四个核心字段的语义以及 DISTINCT_SCAN 与普通IXSCANFETCH计划在竞争中的取舍逻辑结合 distinct_scan.h 与 query_planner.cpp 源码。1. 这份黄金文件在验证什么该文件属于query_golden测试套件见 jstests/query_golden/ 目录与 jstests/query_golden/README.plan_stability.md其运行机制是测试脚本执行查询/管道把“计划缓存的当前状态”输出为 Markdown与expected_output下的期望文件逐字节比对一旦计划结构发生变化测试即失败由人工判断变化是否合理。与本文档一一对应的测试脚本是 distinct_plan_cache_md.js其文件头注释写明测试目的Tests DISTINCT_SCANs generated from multiplanning correctly utilize the plan cache.该测试带有两个关键标签即运行前提featureFlagShardFilteringDistinctScan需要启用分片过滤版 DISTINCT_SCAN 的特性开关requires_fcv_82要求功能兼容性版本FCV不低于 8.2。测试共组织 5 个场景section每个场景先建索引、灌入特定形态的数据再反复执行distinct命令或aggregate管道并在每次执行后打印计划缓存状态。期望输出按引擎模式分目录存放——本文档位于expected_output/sbeDisabled/子目录即 SBE 查询引擎关闭使用经典引擎模式下的黄金基线。测试使用的工具函数集中在 golden_test_utils.jsoutputPlanCacheStats执行coll.aggregate([{$planCacheStats: {}}])只保留cachedPlan、planCacheKey、createdFromQuery、isActive、shard五个字段后输出runDistinctAndOutputPlanCacheStats执行一次distinct命令{key, query}形式后输出计划缓存validateDistinctPlanCacheUse先调用coll.getPlanCache().clear()清空缓存再对同一 key 与 filter 连续执行两次distinct分别以 “DISTINCT_SCAN stored as inactive plan” / “DISTINCT_SCAN used as active plan” 两个小节输出缓存状态validateAggPlanCacheUse对聚合管道做同样的“清缓存 → 跑两次 → 各输出一次缓存”验证。这套“执行两次”的设计意图很明确第一次执行时规划器生成新计划并以 inactive 状态存入缓存第二次执行同形状查询时命中缓存条目并以 active 状态执行且planCacheKey保持不变。2. 场景一distinct 命令利用计划缓存测试数据见 distinct_plan_cache_md.jscoll.createIndex({x: 1, y: 1}); coll.createIndex({y: 1, x: 1}); coll.createIndex({x: 1, z: 1, y: 1}); // 12 条文档x 取 3/5/6/7/8/9y 取 5/7/8存在 x 重复值查询为对x求 distinct过滤条件{ x: { $gt: 3 }, y: 5 }。DISTINCT_SCAN stored as inactive plan[ { cachedPlan : { inputStage : { direction : forward, indexBounds : { x : [ (3.0, inf] ], y : [ [5.0, 5.0] ] }, indexName : y_1_x_1, indexVersion : 2, isFetching : false, isMultiKey : false, isPartial : false, isShardFiltering : false, isSparse : false, isUnique : false, keyPattern : { x : 1, y : 1 }, multiKeyPaths : { x : [ ], y : [ ] }, stage : DISTINCT_SCAN }, stage : PROJECTION_COVERED, transformBy : { } }, createdFromQuery : { distinct : { key : x }, projection : { }, query : { x : { $gt : 3 }, y : 5 }, sort : { } }, isActive : false, planCacheKey : 8BC7F223 } ]DISTINCT_SCAN used as active plan第二次执行同一查询后缓存条目结构完全相同只有isActive由false变为trueplanCacheKey保持8BC7F223不变[ { cachedPlan : { inputStage : { direction : forward, indexBounds : { x : [ (3.0, inf] ], y : [ [5.0, 5.0] ] }, indexName : y_1_x_1, indexVersion : 2, isFetching : false, isMultiKey : false, isPartial : false, isShardFiltering : false, isSparse : false, isUnique : false, keyPattern : { x : 1, y : 1 }, multiKeyPaths : { x : [ ], y : [ ] }, stage : DISTINCT_SCAN }, stage : PROJECTION_COVERED, transformBy : { } }, createdFromQuery : { distinct : { key : x }, projection : { }, query : { x : { $gt : 3 }, y : 5 }, sort : { } }, isActive : true, planCacheKey : 8BC7F223 } ]要点解读规划器选择了y_1_x_1复合索引distinct 字段x是索引的第二个字段等值谓词y: 5落在索引首字段使x的取值可以在索引内直接“跳读”获得isFetching: false表示这是覆盖式covered扫描不需要回表取文档上层PROJECTION_COVERED阶段只保留 distinct 所需字段createdFromQuery展示了该缓存条目的“来源形状”distinct.key x、query为原始过滤器——计划缓存键由查询形状字段与谓词结构决定而不包含谓词中的具体数值。3. 场景二不同谓词复用同一计划缓存条目测试数据改为 100 条{x: i % 2, y: i 100, z: i 200}并建立{x:1}、{x:1,y:1}、{y:1,z:1}、{x:1,y:1,z:1}四个索引。连续执行两次谓词不同的 distinct见 distinct_plan_cache_md.js注意这里用的是runDistinctAndOutputPlanCacheStats且不清空缓存用以证明缓存条目跨谓词复用。Distinct on x, with filter: { x : { $gt : 12 }, y : { $lt : 200 } }[ { cachedPlan : { inputStage : { direction : forward, indexBounds : { x : [ (12.0, inf] ], y : [ [-inf, 200.0) ] }, indexName : x_1_y_1, indexVersion : 2, isFetching : false, isMultiKey : false, isPartial : false, isShardFiltering : false, isSparse : false, isUnique : false, keyPattern : { x : 1, y : 1 }, multiKeyPaths : { x : [ ], y : [ ] }, stage : DISTINCT_SCAN }, stage : PROJECTION_COVERED, transformBy : { } }, createdFromQuery : { distinct : { key : x }, projection : { }, query : { x : { $gt : 12 }, y : { $lt : 200 } }, sort : { } }, isActive : false, planCacheKey : A1EB69D7 } ]Distinct on x, with filter: { x : { $gt : 12 }, y : { $lt : 250 } }第二次查询把y的上界从 200 换成 250计划缓存中仍然只有一个条目planCacheKey依旧是A1EB69D7状态转为 active且indexBounds中的y边界更新为[-inf, 250.0)[ { cachedPlan : { inputStage : { direction : forward, indexBounds : { x : [ (12.0, inf] ], y : [ [-inf, 250.0) ] }, indexName : x_1_y_1, indexVersion : 2, isFetching : false, isMultiKey : false, isPartial : false, isShardFiltering : false, isSparse : false, isUnique : false, keyPattern : { x : 1, y : 1 }, multiKeyPaths : { x : [ ], y : [ ] }, stage : DISTINCT_SCAN }, stage : PROJECTION_COVERED, transformBy : { } }, createdFromQuery : { distinct : { key : x }, projection : { }, query : { x : { $gt : 12 }, y : { $lt : 250 } }, sort : { } }, isActive : true, planCacheKey : A1EB69D7 } ]这正是场景标题“uses same plan cache entry with different predicate”的含义计划缓存键基于查询形状对哪些字段做 distinct、各字段上挂了什么类型的谓词而indexBounds中的具体数值在命中条目时会被重新求值。这也解释了cachedPlan里为何同时保留了数值边界——它描述的是最近一次实例化的扫描参数而非缓存键的组成部分。4. 场景三字段无重复值时缓存中保留的是 IXSCAN 而非 DISTINCT_SCAN测试数据改为 100 条x值全部唯一x: i, y: i 100, z: i 200的文档索引为{x:1}、{x:1,y:1}、{y:1,z:1}。查询{ x: { $gt: -1 }, y: { $lt: 105 } }对x求 distinct。DISTINCT_SCAN stored as inactive plan[ { cachedPlan : { filter : { x : { $gt : -1 } }, inputStage : { direction : forward, indexBounds : { y : [ [-inf, 105.0) ], z : [ [MinKey, MaxKey] ] }, indexName : y_1_z_1, indexVersion : 2, isMultiKey : false, isPartial : false, isSparse : false, isUnique : false, keyPattern : { y : 1, z : 1 }, multiKeyPaths : { y : [ ], z : [ ] }, stage : IXSCAN }, stage : FETCH }, createdFromQuery : { distinct : { key : x }, projection : { }, query : { x : { $gt : -1 }, y : { $lt : 105 } }, sort : { } }, isActive : false, planCacheKey : 634664FD } ]DISTINCT_SCAN used as active plan[ { cachedPlan : { filter : { x : { $gt : -1 } }, inputStage : { direction : forward, indexBounds : { y : [ [-inf, 105.0) ], z : [ [MinKey, MaxKey] ] }, indexName : y_1_z_1, indexVersion : 2, isMultiKey : false, isPartial : false, isSparse : false, isUnique : false, keyPattern : { y : 1, z : 1 }, multiKeyPaths : { y : [ ], z : [ ] }, stage : IXSCAN }, stage : FETCH }, createdFromQuery : { distinct : { key : x }, projection : { }, query : { x : { $gt : -1 }, y : { $lt : 105 } }, sort : { } }, isActive : true, planCacheKey : 634664FD } ]注意小节标题沿用了validateDistinctPlanCacheUse的固定文案“DISTINCT_SCAN stored/used …”但实际胜出计划是IXSCAN走y_1_z_1索引加FETCH且x -1谓词被下放到 FETCH 层的filter中。从 query_planner.cpp 的结构看规划器在多计划枚举中会检查 DISTINCT 合格查询是否产生了任何 DISTINCT_SCAN 候选因此这里的黄金输出固化的是在该数据集x无重复、y_1_z_1前缀过滤后文档数少下经典引擎成本排名选出的是普通索引扫描计划DISTINCT_SCAN 落选但无论胜出者是谁该计划都会以同样的“inactive → active、planCacheKey 稳定”模式进入缓存。5. 场景四聚合管道产生的 DISTINCT_SCAN 同样走计划缓存该场景见 distinct_plan_cache_md.js建立 10 个覆盖a/b/c/d的索引插入 6 条文档其中a存在重复值 4 和 5然后验证两类“形状上等价于 distinct”的聚合管道。管道 A$sort$group(_id: $a, accum: {$first: $b})[ { $sort : { a : 1, b : 1 } }, { $group : { _id : $a, accum : { $first : $b } } } ]聚合框架把该前缀重写为 distinct 形状的查询createdFromQuery中可见distinct.key a、sort {a:1, b:1}、projection {_id: 0, a: 1, b: 1}、query为空。第一次执行后DISTINCT_SCAN stored as inactive plan[ { cachedPlan : { inputStage : { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ], b : [ [MinKey, MaxKey] ] }, indexName : a_1_b_1, indexVersion : 2, isFetching : false, isMultiKey : false, isPartial : false, isShardFiltering : false, isSparse : false, isUnique : false, keyPattern : { a : 1, b : 1 }, multiKeyPaths : { a : [ ], b : [ ] }, stage : DISTINCT_SCAN }, stage : PROJECTION_COVERED, transformBy : { } }, createdFromQuery : { distinct : { key : a }, projection : { _id : 0, a : 1, b : 1 }, query : { }, sort : { a : 1, b : 1 } }, isActive : false, planCacheKey : F1984C97 } ]DISTINCT_SCAN used as active plan[ { cachedPlan : { inputStage : { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ], b : [ [MinKey, MaxKey] ] }, indexName : a_1_b_1, indexVersion : 2, isFetching : false, isMultiKey : false, isPartial : false, isShardFiltering : false, isSparse : false, isUnique : false, keyPattern : { a : 1, b : 1 }, multiKeyPaths : { a : [ ], b : [ ] }, stage : DISTINCT_SCAN }, stage : PROJECTION_COVERED, transformBy : { } }, createdFromQuery : { distinct : { key : a }, projection : { _id : 0, a : 1, b : 1 }, query : { }, sort : { a : 1, b : 1 } }, isActive : true, planCacheKey : F1984C97 } ]管道 B$group$bottom累加器[ { $group : { _id : $a, accum : { $bottom : { sortBy : { a : -1, b : -1 }, output : $c } } } } ]该管道按_id: $a分组并用$bottom取组内a、b都最小的文档的c值。由于分组键就是a其输入扫描同样可以 DISTINCT_SCAN 化createdFromQuery中distinct.key a、sort为空、投影扩展为{_id: 0, a: 1, b: 1, c: 1}使用三字段索引a_1_b_1_c_1。第一次执行planCacheKey 6EBAD35AinactiveDISTINCT_SCAN stored as inactive plan[ { cachedPlan : { inputStage : { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ], b : [ [MinKey, MaxKey] ], c : [ [MinKey, MaxKey] ] }, indexName : a_1_b_1_c_1, indexVersion : 2, isFetching : false, isMultiKey : false, isPartial : false, isShardFiltering : false, isSparse : false, isUnique : false, keyPattern : { a : 1, b : 1, c : 1 }, multiKeyPaths : { a : [ ], b : [ ], c : [ ] }, stage : DISTINCT_SCAN }, stage : PROJECTION_COVERED, transformBy : { } }, createdFromQuery : { distinct : { key : a }, projection : { _id : 0, a : 1, b : 1, c : 1 }, query : { }, sort : { } }, isActive : false, planCacheKey : 6EBAD35A } ]DISTINCT_SCAN used as active plan[ { cachedPlan : { inputStage : { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ], b : [ [MinKey, MaxKey] ], c : [ [MinKey, MaxKey] ] }, indexName : a_1_b_1_c_1, indexVersion : 2, isFetching : false, isMultiKey : false, isPartial : false, isShardFiltering : false, isSparse : false, isUnique : false, keyPattern : { a : 1, b : 1, c : 1 }, multiKeyPaths : { a : [ ], b : [ ], c : [ ] }, stage : DISTINCT_SCAN }, stage : PROJECTION_COVERED, transformBy : { } }, createdFromQuery : { distinct : { key : a }, projection : { _id : 0, a : 1, b : 1, c : 1 }, query : { }, sort : { } }, isActive : true, planCacheKey : 6EBAD35A } ]6. 场景五带内嵌 FETCH 的 DISTINCT_SCAN 的计划缓存$top/$bottom累加器需要输出组内特定文档的字段这里是$c当该字段已包含在覆盖索引中时DISTINCT_SCAN 可以内嵌 fetch 语义完成输出。测试在场景四的同一数据上验证了两种管道同样先清空计划缓存再各执行两次。管道 C$group$top(sortBy: {a:1,b:1}, output: $c)[ { $group : { _id : $a, accum : { $top : { sortBy : { a : 1, b : 1 }, output : $c } } } } ]DISTINCT_SCAN stored as inactive plan[ { cachedPlan : { inputStage : { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ], b : [ [MinKey, MaxKey] ], c : [ [MinKey, MaxKey] ] }, indexName : a_1_b_1_c_1, indexVersion : 2, isFetching : false, isMultiKey : false, isPartial : false, isShardFiltering : false, isSparse : false, isUnique : false, keyPattern : { a : 1, b : 1, c : 1 }, multiKeyPaths : { a : [ ], b : [ ], c : [ ] }, stage : DISTINCT_SCAN }, stage : PROJECTION_COVERED, transformBy : { } }, createdFromQuery : { distinct : { key : a }, projection : { _id : 0, a : 1, b : 1, c : 1 }, query : { }, sort : { } }, isActive : false, planCacheKey : 75F71EAE } ]DISTINCT_SCAN used as active plan[ { cachedPlan : { inputStage : { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ], b : [ [MinKey, MaxKey] ], c : [ [MinKey, MaxKey] ] }, indexName : a_1_b_1_c_1, indexVersion : 2, isFetching : false, isMultiKey : false, isPartial : false, isShardFiltering : false, isSparse : false, isUnique : false, keyPattern : { a : 1, b : 1, c : 1 }, multiKeyPaths : { a : [ ], b : [ ], c : [ ] }, stage : DISTINCT_SCAN }, stage : PROJECTION_COVERED, transformBy : { } }, createdFromQuery : { distinct : { key : a }, projection : { _id : 0, a : 1, b : 1, c : 1 }, query : { }, sort : { } }, isActive : true, planCacheKey : 75F71EAE } ]管道 D$bottom(sortBy: {a:-1, b:-1}, output: $c)[ { $group : { _id : $a, accum : { $bottom : { sortBy : { a : -1, b : -1 }, output : $c } } } } ]第一次执行inactiveDISTINCT_SCAN stored as inactive plan[ { cachedPlan : { inputStage : { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ], b : [ [MinKey, MaxKey] ], c : [ [MinKey, MaxKey] ] }, indexName : a_1_b_1_c_1, indexVersion : 2, isFetching : false, isMultiKey : false, isPartial : false, isShardFiltering : false, isSparse : false, isUnique : false, keyPattern : { a : 1, b : 1, c : 1 }, multiKeyPaths : { a : [ ], b : [ ], c : [ ] }, stage : DISTINCT_SCAN }, stage : PROJECTION_COVERED, transformBy : { } }, createdFromQuery : { distinct : { key : a }, projection : { _id : 0, a : 1, b : 1, c : 1 }, query : { }, sort : { } }, isActive : false, planCacheKey : 6EBAD35A } ]第二次执行activeDISTINCT_SCAN used as active plan[ { cachedPlan : { inputStage : { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ], b : [ [MinKey, MaxKey] ], c : [ [MinKey, MaxKey] ] }, indexName : a_1_b_1_c_1, indexVersion : 2, isFetching : false, isMultiKey : false, isPartial : false, isShardFiltering : false, isSparse : false, isUnique : false, keyPattern : { a : 1, b : 1, c : 1 }, multiKeyPaths : { a : [ ], b : [ ], c : [ ] }, stage : DISTINCT_SCAN }, stage : PROJECTION_COVERED, transformBy : { } }, createdFromQuery : { distinct : { key : a }, projection : { _id : 0, a : 1, b : 1, c : 1 }, query : { }, sort : { } }, isActive : true, planCacheKey : 6EBAD35A } ]一个值得注意的细节管道 C 与管道 D 的planCacheKey不同75F71EAEvs6EBAD35A且管道 D 的键与场景四中$bottom管道 B 的键6EBAD35A完全相同——从黄金输出可以推断distinct 形状查询的缓存键由{distinct.key, projection, query, sort}整体决定而$top与$bottom的差异体现在管道层面最终落入缓存的 distinct 查询形状若一致则共享条目。cachedPlan中的isFetching字段在$planCacheStats输出里记录的是缓存的计划形状本身此处均为false真正的 fetch 行为发生在计划实例化阶段——distinct_scan.h 中DistinctScan通过_needsFetch标志“在输出前执行 fetch”并持有_fetchCursor与 yield 重试状态_idRetrying。7. 源码级原理DISTINCT_SCAN 是如何规划与执行的结合黄金输出中的stage: DISTINCT_SCAN可以对照三处核心源码理解其机制执行阶段定义distinct_scan.h 的类注释直接说明了与IXSCAN的本质区别——“执行指定边界上的索引扫描但不逐一看边界内的每个键而是跳到索引字段中第fieldNo个字段下一个取值因为 distinct 只关心该字段的去重值检查相同取值的所有键没有意义”。DistinctParams中的fieldNo即 distinct 字段在索引 key pattern 中的位置例如索引{a:1, b:1}对a做 distinct 时fieldNo 0对b做时为 1这解释了场景一中x作为y_1_x_1第二字段仍可覆盖扫描的原因。规划器入口query_planner.cpp 中有一段专门注释“distinct 查询即使没有 sort 或 filter 也能从索引受益此前步骤不会为这种情况考虑任何带索引的计划因此在这里尝试生成覆盖式 distinct 扫描constructCoveredDistinctScan”。该分支受isDistinctMultiplanningEnabled控制并仅在过滤器为空、无排序要求时触发与场景四/五中query: {}的聚合形状相符。特性开关与分片过滤测试标签featureFlagShardFilteringDistinctScan对应规划器中多处isFeatureFlagShardFilteringDistinctScanEnabled()检查query_planner.cpp控制 DISTINCT_SCAN 在分片场景下的过滤行为distinct_scan.h 中_shardFilterer、_chunkSkipper与_needsSequentialScan三个成员即其运行时体现——当分片过滤器拒绝某条目时扫描必须退化为顺序检查同值的其他条目。计划缓存的通用实现可继续参阅 classic_plan_cache.cpp。8. 运行与回归如何复现这份黄金输出该测试是标准黄金测试与 README.plan_stability.md 描述的query_golden套件机制一致。运行方式以仓库根目录执行buildscripts/resmoke.py run \ --suitesquery_golden_cbr_automatic \ jstests/query_golden/distinct_plan_cache_md.js预定义 suite 覆盖了不同计划排名模式query_golden_classic、query_golden_cbr_automatic、query_golden_cbr_sampling、query_golden_cbr_histogram等本文档所在的sbeDisabled目录对应 SBE 引擎关闭的模式运行经典引擎的套件时可参考internalQueryFrameworkControl: forceClassicEngine一类--mongodSetParameters配置。当输出变化时查看差异buildscripts/golden_test.py diff该 README 还给出了为plan_stability类文件定制diffCmd、按 pipeline 分段的技巧对query_golden下的_md.js测试同样适用确认新行为合理后接受基线buildscripts/golden_test.py accept。调试单个失败场景时可参照 README 的“暂停灌数”手法--pauseAfterPopulate后手工连接mongodb://127.0.0.1:20000对db[测试名]集合手动执行 distinct/aggregate 并用explain()观察再结合$planCacheStats阶段确认planCacheKey与isActive的实际流转是否与期望文件一致。9. 小结这份黄金基线固化的三条行为契约DISTINCT_SCAN 是一等缓存对象无论来源是distinct命令还是被 distinct 化的聚合管道生成的 DISTINCT_SCAN 计划都会进入计划缓存并遵循“首次 inactive、二次 active、planCacheKey稳定”的生命周期场景一、四、五。缓存键基于查询形状而非谓词数值谓词数值变化200 → 250只更新indexBounds实例化结果不产生新条目场景二。DISTINCT_SCAN 不是唯一候选当数据形态让普通IXSCANFETCH胜出时缓存中记录的就是胜出计划distinct 形状查询的createdFromQuery依然如实保留场景三。对维护查询执行器的工程师而言该文件是DISTINCT_SCAN与计划缓存交互行为的回归红线任何改动只要影响上述三种契约之一distinct_plan_cache_md.js 就会在 sbeDisabled 模式下失败提示人工评估变化的利弊。【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考