MongoDB distinct 命令与聚合管道中的 DISTINCT_SCAN 计划缓存机制解析
2026/9/14 5:17:50 网站建设 项目流程

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区分kPresentActivekPresentInactive两种状态(见 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.

测试带有featureFlagShardFilteringDistinctScanrequires_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聚合阶段读取缓存状态,并只保留cachedPlanplanCacheKeycreatedFromQueryisActiveshard五个字段输出到 Markdown(见 golden_test_utils.js)——这就是期望输出文档中每个 JSON 块的由来。

2.2 测试数据集

驱动测试在多个小节中构造了不同的数据与索引组合(详见 distinct_plan_cache_md.js),核心特点是:在xyz等多个字段上创建多个复合索引(如{x:1,y:1}{y:1,x:1}{x:1,y:1,z:1}等),迫使查询规划器面对多个可选索引进行多计划竞争,从而产生可缓存的获胜计划。

三、场景一:distinct 命令利用计划缓存

测试数据为 12 条包含xyz字段的文档,索引为{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表明完全覆盖、无需回表;
  • indexBoundsx的边界(3.0, inf]对应$gt: 3(开区间),y的边界[5.0, 5.0]对应等值y: 5(闭区间)——这是把过滤条件下推到索引扫描后的区间表示;
  • createdFromQuery:记录了该条目由哪个请求生成:distinct.keyxquery为原始过滤条件、sort为空;
  • planCacheKey6513BB87:该查询形状的哈希键,后续同形状查询据此命中;
  • isActive: false:首次进入缓存,处于非活动状态。

3.2 第二次执行:DISTINCT_SCAN 作为活动计划被复用

第二次执行完全相同的distinct命令后,缓存条目中除isActive变为true外,其余字段完全一致(见 distinct_plan_cache.md)。这说明:

  1. 第二次查询命中了同一个planCacheKey6513BB87),直接复用了缓存计划,没有重新进行多计划竞争;
  2. 命中后条目被标记为活动,代表该计划通过了复用验证,成为稳定的默认选择。

四、场景二:不同谓词复用同一计划缓存条目

场景二插入 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:

  1. distinct("x", {x: {$gt: 12}, y: {$lt: 200}})
  2. 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}属于同一形状,因此第二次直接命中第一个查询建立的缓存条目。变化只体现在两处:

  • indexBoundsy的边界从[-inf, 200.0)更新为[-inf, 250.0):缓存计划是"形状模板",具体区间在复用时按当前谓词重新计算;
  • isActivefalse变为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 个复合索引,插入含abcd字段的文档(其中包含数组字段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_1isFetching: false
  • indexBoundsab均为[MinKey, MaxKey]全区间扫描(管道无过滤条件);
  • createdFromQuerydistinct.keyaprojection{_id: 0, a: 1, b: 1}sort{a: 1, b: 1}——说明聚合管道在执行前被重写为等价的 distinct + projection + sort 形式参与规划与缓存;
  • 与命令形式一致,第一次执行isActive: false,第二次复用后isActive: trueplanCacheKey保持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_1indexBoundsabc均为[MinKey, MaxKey]
  • projection相应扩展为{_id: 0, a: 1, b: 1, c: 1}
  • 同样经历了isActive: false → true的状态迁移,planCacheKey4DDFB1B7

这证明管道重写生成的 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索引,isActivefalse变为trueplanCacheKey07D4BDAB

7.2 "内嵌 FETCH"体现在哪里

小节标题中的 "embedded FETCH" 指的是:当DISTINCT_SCAN需要取出不在索引中的字段(此处是$top/$bottomoutput: "$c"虽在索引中,但多键、覆盖等场景下仍需取文档)时,FETCH阶段会被嵌入到DISTINCT_SCAN的输入中,作为其子阶段协同工作。最终缓存的仍是PROJECTION_COVERED → DISTINCT_SCAN的覆盖结构,说明在当前数据分布下该管道仍可被覆盖执行。

7.3 两种管道的组合验证

场景五对$top$bottom各执行一轮 inactive/active 验证,planCacheKey分别稳定为07D4BDAB4DDFB1B7(后者与场景四管道二相同,因为两者查询形状一致、只是排序方向不同——从输出看它们共享了缓存条目生命周期,印证了形状哈希的判定粒度)。完整输出见 distinct_plan_cache.md。

八、从源码看缓存条目的生命周期

综合以上五个场景,可以在源码层面勾勒出计划缓存条目的完整生命周期:

  1. 写入:多计划竞争产生获胜计划后,PlanCacheEntryBase::create()创建条目(plan_cache.h),此时isActivefalse
  2. 特例:若查询只有一个候选计划、无需计划竞争,则通过createPinned()创建固定(pinned)条目,其isActive恒为true且不参与重规划(见 plan_cache.h 与isPinned()判断);
  3. 命中与激活:后续同形状查询通过get()命中条目,若条目为活动状态则直接复用(kPresentActive);非活动条目被复用时转为活动;
  4. 降级:当活动计划开始表现不佳时,deactivate()将条目克隆并置为isActive: false,使其重新参与后续计划竞争(plan_cache.h);
  5. 停用开关:可通过参数internalQueryCacheDisableInactiveEntries关闭非活动条目机制(deactivate()loadRelaxed()检查)。

Golden 测试文档中反复出现的isActive: false → true转变,正是第 2、3 步状态迁移的直接观测证据。

九、如何复现与扩展验证

若要在本地 MongoDB 构建环境中复现这些输出,可按以下步骤操作:

  1. 运行驱动测试生成实际输出并与期望输出比对(Golden Test 的标准工作流):
# 使用 resmoke 运行该 golden 测试(示例,实际命令以构建配置为准) python3 buildscripts/resmoke.py --suites=query_golden --shellConnString=... \ jstests/query_golden/distinct_plan_cache_md.js
  1. 手动交互验证核心行为:对任意集合创建多个复合索引,插入含重复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
  1. 查看计划缓存相关的更多资料,可参考 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询