MongoDB Join 优化计划缓存键(Join Plan Cache Key)设计与验证指南
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
导读
本文基于 MongoDB 仓库中的 golden 测试输出文档 join_plan_cache_key.md 及其生成脚本 join_plan_cache_key_md.js,系统讲解 MongoDB 新一代 Join 优化器如何为$lookup/$unwind连接查询计算"连接计划缓存键"(join plan cache key):哪些查询可以安全地复用同一份缓存计划(哈希相同)、哪些查询必须使用不同的缓存条目(哈希不同)、以及哪些查询当前尚不参与缓存。读完本文,你将理解缓存键的生成原理、参数归一化规则、explain输出中queryPlanner.joinPlanCacheKey字段的解读方法,以及如何借助仓库中的测试套件验证和追踪缓存键行为。
背景:Join 优化与计划缓存键
什么是 Join 优化(Join Optimization)
自 MongoDB 9.0(feature compatibility version 为 9.0,测试标记为requires_fcv_90)起,查询引擎引入了基于 SBE 的 Join 优化能力:当聚合管道中连续出现可连接的$lookup与$unwind组合时,优化器会将其内部的嵌套循环扫描重写为显式的连接(join)执行计划,从而避免对基表(base collection)逐行触发对被连接集合的查询。该能力由服务器参数internalEnableJoinOptimization控制,详见配套单元测试 join_plan_cache_key.js。
为什么需要"连接计划缓存键"
与经典的计划缓存(plan cache)类似,Join 优化器也需要决定:两条不同的查询请求是否应该复用同一份缓存下来的连接计划。如果两条查询在结构上等价、仅字面量(literal)取值不同,那么让它们共享同一份计划可以显著提升吞吐;反之,如果查询结构发生了实质变化(如更换了连接集合、改变了localField/foreignField、增删了$match谓词字段),复用旧计划会导致错误或次优执行。
为此,Join 优化器在完成连接图(JoinGraph)构建后,会为查询计算一个稳定的哈希值,这个值就是join plan cache key。它在explain输出中位于queryPlanner.joinPlanCacheKey字段。仓库中的实现文件为 join_plan_cache_key.h 与 join_plan_cache_key.cpp,其中makeJoinPlanCacheKey()接收完整的连接图、路径解析结果与集合访问器,为图中的每个节点生成PlanCacheKeyInfo(匹配表达式形状 + 索引可用性判别子),从而保证当集合的索引集合发生变化而影响查询资格时,缓存键随之变化。
如何读取缓存键的 golden 测试输出
jstests/query_golden/expected_output/join_plan_cache_key.md是 golden 测试的输出文件,由 join_plan_cache_key_md.js 执行后生成。文件按三个大节组织,对应测试脚本中的三个断言函数:
| 大节 | 测试脚本断言 | 含义 |
|---|---|---|
Queries where identical join plan cache keys are expected | assertIdenticalKeys() | 两条命令哈希必须相同(且都不能为undefined),否则打印> [!WARNING] |
Queries where different join plan cache keys are expected | assertDifferentKeys() | 两条命令哈希必须不同,否则打印> [!WARNING] |
Queries that currently do not have a join plan cache key | assertNoHashKey() | 两条命令的哈希都必须为undefined,若出现哈希则提示把查询移到合适的章节 |
每个用例输出两条命令的完整 JSON 以及各自的哈希:
### Identical keys for completely identical queries Command 1: {"aggregate":"foo","pipeline":[...]} Command 2: {"aggregate":"foo","pipeline":[...]} Hash 1: C161941F Hash 2: C161941F测试脚本读取哈希的方式值得注意(join_plan_cache_key_md.js):
function getJoinPlanCacheKey(command) { const explain = assert.commandWorked(db.runCommand({explain: command})); if (explain.hasOwnProperty("queryPlanner")) { return explain.queryPlanner.joinPlanCacheKey; } else if (explain.hasOwnProperty("stages")) { return explain.stages[0]["$cursor"].queryPlanner.joinPlanCacheKey; } else { return undefined; } }它优先从顶层queryPlanner读取;若 explain 以stages形式返回(即查询经由$cursor阶段执行),则深入到stages[0]["$cursor"].queryPlanner中读取。测试运行前会为foo、foo2、bar、bar2四个集合创建{a: 1}与{b: 1}索引并各插入一条文档(join_plan_cache_key_md.js),确保用例之间索引环境一致、结果可复现。
哈希相同的场景:允许复用同一份连接计划
golden 输出中第一组用例用于回答"什么样的查询可以共享缓存计划"。以下案例全部以$lookup(from: "bar"、localField: "a"、foreignField: "a")+$unwind为核心骨架。
完全相同的查询
两条逐字节相同的命令自然得到相同哈希:Hash 1: C161941F == Hash 2: C161941F。
后缀阶段不同但被归一化
Command 1: [... ,{"$unwind":"$bar"},{"$sort":{"a":1}}] Command 2: [... ,{"$unwind":"$bar"},{"$sort":{"b":1}}] Hash 1: C161941F Hash 2: C161941F两条查询的$sort字段不同,但缓存键仍相同。这印证了缓存键关注的是影响连接计划的要素,而非管道后缀的细节——排序发生在连接之后,不改变连接的形状,因此可以共享计划。
前缀/后缀$match字面量不同
Command 1: [{"$match":{"a":1}}, {"$lookup":...}, {"$unwind":"$bar"}] Command 2: [{"$match":{"a":2}}, {"$lookup":...}, {"$unwind":"$bar"}] Hash 1: DD95352F Hash 2: DD95352F无论$match位于$lookup之前(前缀)还是$unwind之后(后缀),仅谓词字面量从1变为2时,哈希保持DD95352F不变。这体现了**参数化(parameterization)**策略:查询参数值属于"运行时数据",不进入计划缓存键,从而让不同参数值的同类查询共享同一份计划。
子管道$match字面量不同
当$match出现在$lookup.pipeline(子管道)中时,字面量差异同样被归一化:
Command 1: {"$lookup":{"from":"bar","localField":"a","foreignField":"a","as":"bar", "pipeline":[{"$match":{"a":1}}]}} Command 2: {"$lookup":{"from":"bar","localField":"a","foreignField":"a","as":"bar", "pipeline":[{"$match":{"a":2}}]}} Hash 1: DD95352F Hash 2: DD95352F谓词下推后的语义等价查询
这是最有趣的一组:子管道$match与顶层前缀$match在语义上等价,因此得到相同哈希:
Command 1: {"$lookup":{..., "pipeline":[{"$match":{"a":1}}]}}, {"$unwind":"$bar"} Command 2: {"$match":{"a":1}}, {"$lookup":{...}}, {"$unwind":"$bar"} Hash 1: DD95352F Hash 2: DD95352FMongoDB 的 Join 优化器在执行前会进行**谓词下推(predicate pushdown)**分析,把可下推的谓词移动到合适位置。由于两条查询下推后语义一致,缓存键也设计为一致,使优化后的连接计划能够被两者复用。
$in列表内容与长度不同
Command 1: {"$match":{"a":{"$in":[1,2]}}} → Hash 1: 61042BC2 Command 2: {"$match":{"a":{"$in":[2,3]}}} → Hash 2: 61042BC2 Command 2': {"$match":{"a":{"$in":[1,2,3]}}} → Hash 2: 61042BC2$in的取值列表无论内容不同([1,2]vs[2,3])还是长度不同([1,2]vs[1,2,3]),哈希都保持61042BC2——列表长度与元素值均不进入缓存键。
$expr中的字面量不同
子管道中使用$expr/$eq时,字面量差异被归一化(哈希FAC4B54C);在顶层$match中使用$expr引用 aggregate 级let变量时同理(哈希91DE93BF):
Command 1: {"$match":{"$expr":{"$eq":["$a","$$var"]}}}, ..., "let":{"var":1} Command 2: {"$match":{"$expr":{"$eq":["$a","$$var"]}}}, ..., "let":{"var":2} Hash 1: 91DE93BF Hash 2: 91DE93BFlet变量名不同
更进一步,变量名本身也不影响哈希——只要引用结构一致:
Command 1: {"$expr":{"$eq":["$a","$$var1"]}}, "let":{"var1":1} Command 2: {"$expr":{"$eq":["$a","$$var2"]}}, "let":{"var2":2} Hash 1: 91DE93BF Hash 2: 91DE93BF这与底层实现中encodeResolvedPath使用encodeUserString(path.underlyingFieldPath.fullPath(), &sb)对路径字符串进行用户级编码的处理方式一致:字段路径进入编码,而变量名在归一化后不再区分。
哈希不同的场景:必须隔离的连接计划
第二组用例回答"什么样的差异会迫使两条查询各自独立缓存"。这些差异都属于连接图的结构性要素,一旦变化,连接计划本身就会失效。
基表或连接集合不同
Command 1: aggregate "foo" → Hash 1: C161941F Command 2: aggregate "foo2" → Hash 2: 40843C41 Command 1: from "bar" → Hash 1: C161941F Command 2: from "bar2" → Hash 2: 39F550E3更换基表(foo→foo2)或连接集合(bar→bar2)都会产生不同的缓存键。这符合 join_plan_cache_key.h 的设计——每个节点(集合)的PlanCacheKeyInfo都包含该节点集合的匹配表达式形状与索引判别子,集合不同,键必然不同。
$match谓词字段不同
字面量不同不影响哈希,但谓词作用的字段不同则影响:
Command 1: {"$match":{"a":1}} → Hash 1: DD95352F Command 2: {"$match":{"b":1}} → Hash 2: 3F5F7E27前缀$match、后缀$match、子管道$match三种位置的行为一致:字段从a换成b,哈希全部改变(分别为3F5F7E27与B544BC9A)。因为字段决定了谓词能否被下推、能匹配哪些索引,属于计划的"形状"。
$lookup的localField/foreignField/as不同
localField: a → C161941F foreignField: a → C161941F as: bar1 → 03AE615C localField: b → E6D387DD foreignField: b → B645791A as: bar2 → A36B6538连接键字段(localField、foreignField)是连接图的边(JoinEdge)谓词,直接决定连接方式;as决定输出字段名与后续阶段对数组字段的引用。三者的任意改变都会生成不同缓存键。
谓词下推后不再语义等价
Command 1: {"$lookup":{..., "pipeline":[{"$match":{"a":1}}]}} → DD95352F Command 2: {"$match":{"a":{"$gt":1}}}, {"$lookup":...} → FF5AC6A6对比"哈希相同"一节中的等价用例可见:子管道的$match: {a: 1}与顶层$match: {a: {$gt: 1}}在下推后并不等价(一个是等值、一个是范围),因此哈希不同。这说明缓存键的归一化建立在语义等价而非语法相同之上。
$project后缀不同
Command 1: ...,{"$project":{"a":1}} → 5316786E Command 2: ...,{"$project":{"b":1}} → D55A8C7D$project改变了输出形状,即使位于管道末尾,也会进入缓存键计算。
$expr引用字段不同、$unwind的preserveNullAndEmptyArrays不同
$expr中比较的字段从$a变为$b(哈希91DE93BF→11614ABD)会改变键;$unwind的preserveNullAndEmptyArrays从true变为false时,两条命令的哈希分别为undefined与C161941F(输出文件中Hash 1: undefined),说明这种情况下查询因$unwind选项的变化而在键计算层面产生了差异。
已知缺口:TODO 场景与未来工作
golden 输出中还记录了三类特殊状况,是仓库作者有意暴露的已知问题,每处都标注了对应的 Jira 工单号。
期望不同但实际相同(带> [!WARNING]标记)
以下用例被放在"期望不同"章节,但运行时哈希相同,golden 输出以 GitHub 风格告警块标注,提示"本应不同却没有不同"(Hash keys were expected to be different but were not!):
$limit有无之辩(SERVER-121078):有无$limit的两条查询哈希均为C161941F,但理论上$limit会影响计划选择,需要后续区分。allowDiskUse: true/false(SERVER-131472):两者哈希相同,但allowDiskUse影响可用的执行策略。$_internalJoinHint(SERVER-131752):该内部阶段用于按子集级别指定连接方法提示(如"method":"HJ"哈希连接 vs"method":"INLJ"索引嵌套循环连接)。有/无 hint 以及 hint 方法不同的查询,哈希目前都相同,未来应产生不同键。
这些 WARNING 是测试系统主动暴露缺陷的机制——一旦实现修复,golden 输出会相应变化,提醒维护者更新预期。
当前不产生哈希的查询(Hash 1: undefined)
第三组用例验证以下场景当前不参与连接计划缓存(哈希为undefined),一旦未来具备资格,测试会失败以促使开发者决策其缓存键行为(join_plan_cache_key_md.js):
$lookup带let参数(SERVER-115652):子管道引用let变量时尚未支持缓存键。- 带
collation的 aggregate:不同collation.strength(1vs2)都返回undefined。 $unwind带includeArrayIndex:即使includeArrayIndex字段名不同也均无哈希。- aggregate 级
hint:hint: {a: 1}与hint: {b: 1}均无哈希。
从源码看缓存键的生成原理
编码路径
join_plan_cache_key.cpp 是核心实现。关键逻辑包括:
- 对连接图中每个节点的访问路径调用
canonical_query_encoder::encodeCanonicalQueryForJoin(*node.accessPath)(第 36 行),得到规范查询编码; - 通过
plan_cache_detail::encodeIndexability(...)(第 44 行)计算索引可用性判别子,保证索引集合变化时缓存键失效; - 将两者封装为
PlanCacheKeyInfo(第 49 行),构成该节点的键贡献; - 对连接边上的解析路径(
ResolvedPath)使用encodeResolvedPathlambda 编码(第 59-80 行),其中encodeUserString把字段路径编码为用户可读字符串,JoinEdge 左右两侧的路径分别编码。
这一设计解释了 golden 输出中的两类行为:字面量不进入键(参数被规范化),而字段路径、索引判别子、连接集合等"形状"信息进入键。对应的单元测试见 join_plan_cache_key_test.cpp,缓存键失效行为见 join_plan_cache_invalidation_test.cpp。
explain 中的暴露
queryPlanner.joinPlanCacheKey由查询计划解释器输出。配套单元测试 join_plan_cache_key.js 通过MongoRunner.runMongod({setParameter: {internalEnableJoinOptimization: true}})启动实例,断言:
- 启用 Join 优化后,
queryPlanner必然包含joinPlanCacheKey,且为非空十六进制字符串; - 结构完全相同的查询哈希相同;
- 仅谓词常量不同的查询哈希相同(
matchValue: 0与matchValue: 1); - 连接结构(
foreignField从a改为b)不同的查询哈希不同。
该测试还使用joinOptUsed(explain)断言 Join 优化确实被使用(join_plan_cache_key.js),避免"哈希来自普通计划缓存而非连接计划缓存"的误判。
运行与验证方式
golden 测试属于query_golden_join_optimization系列。运行入口在 BUILD.bazel,本地可通过 resmoke 执行:
buildscripts/resmoke.py run \ --suites=query_golden_join_optimization \ jstests/query_golden/join_opt/join_plan_cache_key_md.js \ --runAllFeatureFlagTests若实际输出与 golden 文件不一致,使用仓库的 golden 测试框架查看差异:
buildscripts/golden_test.py setup buildscripts/golden_test.py diff配套的非 golden 单测则按requires_fcv_90、requires_sbe标记运行(join_plan_cache_key.js),两者共同构成了"行为断言 + 可读基线"的双重保障。
总结
MongoDB 的连接计划缓存键通过 join_plan_cache_key.cpp 将连接图编码为稳定哈希:字面量、$in列表长度、let变量名等参数细节被归一化,允许参数化查询共享计划;集合身份、连接键字段、谓词作用字段、输出形状等结构性要素进入键值,确保计划正确隔离。golden 测试文件 join_plan_cache_key.md 以可读方式固化了这些预期,并主动暴露了$limit、allowDiskUse、$_internalJoinHint等尚未解决的已知缺口(对应 SERVER-121078 / SERVER-131472 / SERVER-131752)。理解这套缓存键规则,可以帮助你在设计聚合管道时预判查询是否能够复用缓存计划,从而写出对查询优化器更友好的管道结构。
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考