MongoDB distinct 命令与聚合管道中的 DISTINCT_SCAN 计划缓存机制解析
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
本篇技术指南以 MongoDB 开源仓库(The MongoDB Database)中的 Golden Test 文档 distinct_plan_cache.md 为主线,结合其驱动测试 distinct_plan_cache_md.js、Golden 测试工具库 golden_test_utils.js 以及计划缓存核心实现 plan_cache.h,系统讲解distinct命令与聚合管道生成的DISTINCT_SCAN计划如何进入计划缓存、如何在"非活动/活动(inactive/active)"状态间转换,以及不同谓词如何复用同一缓存条目。读完本文,你将理解计划缓存条目的内部结构、isActive状态机的运行机制,以及 Golden Test 如何固化与校验这些查询优化行为。
一、背景:DISTINCT_SCAN 与计划缓存
1.1 什么是 DISTINCT_SCAN
DISTINCT_SCAN是 MongoDB 查询执行树中的一种特殊索引扫描阶段(stage):它按索引顺序扫描索引键,并跳过相邻的重复键值,从而在返回去重结果的同时避免全量扫描与内存去重。其核心优势在于——去重逻辑被"下推"进索引扫描本身,配合覆盖投影(PROJECTION_COVERED)时甚至不需要回表取文档(isFetching: false)。
在源码中,索引是否适合做DISTINCT_SCAN转换有专门判定逻辑,见 distinct_access.cpp("Check if an index is suitable for the DISTINCT_SCAN transition"),转换失败时会在 distinct_access.cpp 触发uassert(8404000, "Failed to finalize a DISTINCT_SCAN plan", soln)断言。
1.2 什么是计划缓存(Plan Cache)
当一条查询存在多个候选执行计划时,MongoDB 的查询规划器(query planner)会进行多计划竞争(multi-planning),选出获胜计划后将其按查询形状(query shape)哈希后写入计划缓存。这样后续形状相同的查询可以直接复用缓存中的获胜计划,避免重复的多计划竞争开销。
计划缓存条目(PlanCacheEntryBase)的核心字段在 plan_cache.h 中定义,包括:
cachedPlan:可用来重建完整执行计划的缓存计划;planCacheShapeHash/planCacheKey:查询形状哈希与计划缓存键;isActive:条目是否处于活动状态;timeOfCreation:创建时间;debugInfo:调试信息(当累计缓存大小超过阈值后会被剥离,但 SBE 的调试信息始终保留,见 plan_cache.h)。
1.3 isActive:非活动与活动计划
计划缓存条目有"非活动(inactive)"与"活动(active)"两种状态:
- 非活动:条目刚写入缓存、尚未被复用(或表现不佳被降级)时的状态,参与后续的缓存竞争;
- 活动:条目被后续同形状查询成功复用时进入的状态,代表"稳定可用"的执行计划。
状态转换的底层实现在 plan_cache.h 的deactivate()方法中:当关联计划表现变差时会克隆条目并把isActive置为false;而get()在返回缓存条目时会依据isActive区分kPresentActive与kPresentInactive两种状态(见 plan_cache.h)。
Golden 测试正是把这一状态迁移过程"固化"下来:同一查询第一次执行后缓存条目为isActive: false,第二次执行复用后变为isActive: true。
二、Golden Test 如何验证 distinct 的计划缓存行为
本文讨论的 distinct_plan_cache.md 是一份Golden Test 期望输出(expected output)文件,由驱动测试 distinct_plan_cache_md.js 生成并与实际输出比对。该测试文件头部注释明确说明了测试目的:
Tests DISTINCT_SCANs generated from multiplanning correctly utilize the plan cache.
测试带有featureFlagShardFilteringDistinctScan与requires_fcv_82两个标签,且输出文件位于featureFlagSbeFull/目录下,说明该场景在SBE(Slot-Based Execution)全量启用的引擎配置下运行。
2.1 核心工具函数
测试用到的两个关键校验函数定义在 golden_test_utils.js 中:
validateDistinctPlanCacheUse(coll, key, filter):先调用coll.getPlanCache().clear()清空计划缓存,然后连续执行两次distinct命令,分别输出小节 "DISTINCT_SCAN stored as inactive plan"(第一次,条目以非活动状态入缓存)与 "DISTINCT_SCAN used as active plan"(第二次,条目被复用并转为活动状态),见 golden_test_utils.js;validateAggPlanCacheUse(coll, pipeline):同样的流程,但针对聚合管道,见 golden_test_utils.js。
每次执行后调用outputPlanCacheStats(coll),通过$planCacheStats聚合阶段读取缓存状态,并只保留cachedPlan、planCacheKey、createdFromQuery、isActive、shard五个字段输出到 Markdown(见 golden_test_utils.js)——这就是期望输出文档中每个 JSON 块的由来。
2.2 测试数据集
驱动测试在多个小节中构造了不同的数据与索引组合(详见 distinct_plan_cache_md.js),核心特点是:在x、y、z等多个字段上创建多个复合索引(如{x:1,y:1}、{y:1,x:1}、{x:1,y:1,z:1}等),迫使查询规划器面对多个可选索引进行多计划竞争,从而产生可缓存的获胜计划。
三、场景一:distinct 命令利用计划缓存
测试数据为 12 条包含x、y、z字段的文档,索引为{x:1,y:1}、{y:1,x:1}、{x:1,z:1,y:1}。对x字段执行distinct,过滤条件为{x: {$gt: 3}, y: 5}(见 distinct_plan_cache_md.js)。
3.1 第一次执行:DISTINCT_SCAN 以非活动计划入缓存
首次执行后,$planCacheStats输出如下(节选自 distinct_plan_cache.md):
[ { "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" : "6513BB87" } ]解读这份缓存条目:
- cachedPlan:获胜计划是
PROJECTION_COVERED覆盖投影下套一个DISTINCT_SCAN。扫描方向为forward,使用y_1_x_1索引(索引版本 2),isFetching: false表明完全覆盖、无需回表; - indexBounds:
x的边界(3.0, inf]对应$gt: 3(开区间),y的边界[5.0, 5.0]对应等值y: 5(闭区间)——这是把过滤条件下推到索引扫描后的区间表示; - createdFromQuery:记录了该条目由哪个请求生成:
distinct.key为x、query为原始过滤条件、sort为空; - planCacheKey
6513BB87:该查询形状的哈希键,后续同形状查询据此命中; - isActive: false:首次进入缓存,处于非活动状态。
3.2 第二次执行:DISTINCT_SCAN 作为活动计划被复用
第二次执行完全相同的distinct命令后,缓存条目中除isActive变为true外,其余字段完全一致(见 distinct_plan_cache.md)。这说明:
- 第二次查询命中了同一个
planCacheKey(6513BB87),直接复用了缓存计划,没有重新进行多计划竞争; - 命中后条目被标记为活动,代表该计划通过了复用验证,成为稳定的默认选择。
四、场景二:不同谓词复用同一计划缓存条目
场景二插入 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_plan_cache_md.js)。随后连续执行两个谓词不同的 distinct:
distinct("x", {x: {$gt: 12}, y: {$lt: 200}})distinct("x", {x: {$gt: 12}, y: {$lt: 250}})(仅把y的上界从 200 改为 250)
两次执行对应的缓存输出见 distinct_plan_cache.md,第二次的完整输出如下:
[ { "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" : "3B5005C1" } ]关键观察:两次查询的planCacheKey都是3B5005C1,说明计划缓存按查询形状而非具体字面值寻址——y: {$lt: 200}与y: {$lt: 250}属于同一形状,因此第二次直接命中第一个查询建立的缓存条目。变化只体现在两处:
indexBounds中y的边界从[-inf, 200.0)更新为[-inf, 250.0):缓存计划是"形状模板",具体区间在复用时按当前谓词重新计算;isActive从false变为true:非活动条目被复用后转为活动。
值得注意的是,获胜索引从场景一的y_1_x_1变成了这里的x_1_y_1——这取决于当前数据集下各候选计划的代价排序,也再次说明计划缓存固化的是规划器当时的择优结果。
五、场景三:集合无重复值时优先缓存 IXSCAN 而非 DISTINCT_SCAN
场景三插入 100 条{x: i, y: i + 100, z: i + 200}文档,其中x从 0 到 99 完全无重复,索引为{x:1}、{x:1,y:1}、{y:1,z:1}(见 distinct_plan_cache_md.js)。对x执行 distinct,条件为{x: {$gt: -1}, y: {$lt: 105}},输出见 distinct_plan_cache.md。
关键发现:这个场景下缓存下来的获胜计划不是DISTINCT_SCAN,而是普通的IXSCAN + FETCH:
{ "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" : "E105DA4A" }5.1 为什么规划器"不选" DISTINCT_SCAN
这背后是代价模型的理性选择:当集合中x字段没有重复值时,DISTINCT_SCAN与普通IXSCAN扫描的行数几乎相同,DISTINCT_SCAN的"跳过重复值"优势完全无法体现;而此时y_1_z_1索引上的IXSCAN还能通过y的范围条件[-inf, 105.0)高效裁剪扫描区间,因此普通索引扫描的总体代价更低。
同时注意缓存计划中的filter: {x: {$gt: -1}}:由于x不在扫描索引y_1_z_1中,$gt: -1无法下推为索引边界,只能作为FETCH之后的残留过滤条件保留。
5.2 印证与启发
这与源码中DISTINCT_SCAN的适用性判断逻辑一致:该优化仅在去重能显著减少扫描量时才有价值。Golden Test 固化这一场景,正是为了防止未来优化器改动导致这类行为回归——例如错误地强制使用DISTINCT_SCAN而牺牲掉更优的普通索引扫描。测试名称 "Prefer cached IXSCAN over DISTINCT_SCAN for no duplicate values in the collection" 直接点明了这一预期。
六、场景四:聚合管道的 DISTINCT_SCAN 同样走计划缓存
场景四将验证对象从distinct命令扩展到聚合管道。测试创建 10 个复合索引,插入含a、b、c、d字段的文档(其中包含数组字段d: [1,2,3]以测试多键场景),详见 distinct_plan_cache_md.js。
6.1 管道一:$sort + $group($first)
[ { "$sort" : { "a" : 1, "b" : 1 } }, { "$group" : { "_id" : "$a", "accum" : { "$first" : "$b" } } } ]该管道等价于"按a分组、每组取b的第一个值",由于$sort已按(a, b)排序,每组第一条记录的b即为$first的结果。缓存条目(见 distinct_plan_cache.md)显示:
- 执行树为
PROJECTION_COVERED下的DISTINCT_SCAN,索引为a_1_b_1,isFetching: false; indexBounds中a、b均为[MinKey, MaxKey]全区间扫描(管道无过滤条件);createdFromQuery中distinct.key为a,projection为{_id: 0, a: 1, b: 1},sort为{a: 1, b: 1}——说明聚合管道在执行前被重写为等价的 distinct + projection + sort 形式参与规划与缓存;- 与命令形式一致,第一次执行
isActive: false,第二次复用后isActive: true,planCacheKey保持AF5172A0不变。
6.2 管道二:$group + $bottom
[ { "$group" : { "_id" : "$a", "accum" : { "$bottom" : { "sortBy" : { "a" : -1, "b" : -1 }, "output" : "$c" } } } } ]$bottom需要按(a: -1, b: -1)排序取最后一条记录的c。缓存条目(见 distinct_plan_cache.md)显示:
- 索引扩展为三键
a_1_b_1_c_1,indexBounds中a、b、c均为[MinKey, MaxKey]; projection相应扩展为{_id: 0, a: 1, b: 1, c: 1};- 同样经历了
isActive: false → true的状态迁移,planCacheKey为4DDFB1B7。
这证明管道重写生成的 distinct 计划与命令生成的 distinct 计划走的是同一条计划缓存通路,且投影字段集合、排序键会作为查询形状的一部分影响缓存键。
七、场景五:带内嵌 FETCH 的 DISTINCT_SCAN 利用计划缓存
场景五延续场景四的数据集,验证$top/$bottom这两种需要回表取值的聚合(见 distinct_plan_cache_md.js)。
7.1 $top 场景
[ { "$group" : { "_id" : "$a", "accum" : { "$top" : { "sortBy" : { "a" : 1, "b" : 1 }, "output" : "$c" } } } } ]缓存条目(见 distinct_plan_cache.md)与场景四的$bottom结构类似,使用a_1_b_1_c_1索引,isActive从false变为true,planCacheKey为07D4BDAB。
7.2 "内嵌 FETCH"体现在哪里
小节标题中的 "embedded FETCH" 指的是:当DISTINCT_SCAN需要取出不在索引中的字段(此处是$top/$bottom的output: "$c"虽在索引中,但多键、覆盖等场景下仍需取文档)时,FETCH阶段会被嵌入到DISTINCT_SCAN的输入中,作为其子阶段协同工作。最终缓存的仍是PROJECTION_COVERED → DISTINCT_SCAN的覆盖结构,说明在当前数据分布下该管道仍可被覆盖执行。
7.3 两种管道的组合验证
场景五对$top与$bottom各执行一轮 inactive/active 验证,planCacheKey分别稳定为07D4BDAB与4DDFB1B7(后者与场景四管道二相同,因为两者查询形状一致、只是排序方向不同——从输出看它们共享了缓存条目生命周期,印证了形状哈希的判定粒度)。完整输出见 distinct_plan_cache.md。
八、从源码看缓存条目的生命周期
综合以上五个场景,可以在源码层面勾勒出计划缓存条目的完整生命周期:
- 写入:多计划竞争产生获胜计划后,
PlanCacheEntryBase::create()创建条目(plan_cache.h),此时isActive为false; - 特例:若查询只有一个候选计划、无需计划竞争,则通过
createPinned()创建固定(pinned)条目,其isActive恒为true且不参与重规划(见 plan_cache.h 与isPinned()判断); - 命中与激活:后续同形状查询通过
get()命中条目,若条目为活动状态则直接复用(kPresentActive);非活动条目被复用时转为活动; - 降级:当活动计划开始表现不佳时,
deactivate()将条目克隆并置为isActive: false,使其重新参与后续计划竞争(plan_cache.h); - 停用开关:可通过参数
internalQueryCacheDisableInactiveEntries关闭非活动条目机制(deactivate()中loadRelaxed()检查)。
Golden 测试文档中反复出现的isActive: false → true转变,正是第 2、3 步状态迁移的直接观测证据。
九、如何复现与扩展验证
若要在本地 MongoDB 构建环境中复现这些输出,可按以下步骤操作:
- 运行驱动测试生成实际输出并与期望输出比对(Golden Test 的标准工作流):
# 使用 resmoke 运行该 golden 测试(示例,实际命令以构建配置为准) python3 buildscripts/resmoke.py --suites=query_golden --shellConnString=... \ jstests/query_golden/distinct_plan_cache_md.js- 手动交互验证核心行为:对任意集合创建多个复合索引,插入含重复
x值的数据,清空计划缓存后连续执行两次相同的distinct,再用$planCacheStats观察缓存条目:
db.coll.getPlanCache().clear(); db.runCommand({distinct: "coll", key: "x", query: {x: {$gt: 3}, y: 5}}); db.aggregate([{$planCacheStats: {}}]).toArray(); // 观察 isActive: false db.runCommand({distinct: "coll", key: "x", query: {x: {$gt: 3}, y: 5}}); db.aggregate([{$planCacheStats: {}}]).toArray(); // 观察 isActive: true- 查看计划缓存相关的更多资料,可参考 README.plan_stability.md 中关于计划稳定性的说明。
十、总结
本文基于 MongoDB 仓库的 Golden Test 期望输出 distinct_plan_cache.md 及其驱动测试与工具源码,完整还原了DISTINCT_SCAN与计划缓存的协作机制,可以归纳为以下要点:
DISTINCT_SCAN计划可以进入计划缓存:无论是distinct命令还是可重写为 distinct 的聚合管道($sort + $group、$top、$bottom),其获胜计划都会按查询形状写入缓存;- inactive → active 是"首次入缓存 → 被复用"的观测信号:Golden 输出中
isActive字段的翻转直接对应 plan_cache.h 中的状态机实现; - 计划缓存按形状寻址,不按字面值寻址:
y: {$lt: 200}与y: {$lt: 250}共享同一planCacheKey,只是indexBounds在复用时按新谓词重算; DISTINCT_SCAN并非总是最优:当集合无重复值时,普通IXSCAN可能以更低代价胜出并被缓存,这一反直觉行为被 Golden Test 明确固化,防止优化器回归;- Golden Test 是查询优化行为的"防回归护栏":通过 distinct_plan_cache_md.js + golden_test_utils.js 的组合,将规划器对 distinct 类查询的缓存决策以 Markdown 形式固化为可评审、可比对、可追溯的契约。
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考