MongoDB Join 优化计划缓存键(Join Plan Cache Key)设计与验证指南 📅 发布时间:2026/9/13 19:34:26 👁 浏览次数: 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.0feature 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 expectedassertIdenticalKeys()两条命令哈希必须相同且都不能为undefined否则打印 [!WARNING]Queries where different join plan cache keys are expectedassertDifferentKeys()两条命令哈希必须不同否则打印 [!WARNING]Queries that currently do not have a join plan cache keyassertNoHashKey()两条命令的哈希都必须为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.jsfunction 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 输出中第一组用例用于回答什么样的查询可以共享缓存计划。以下案例全部以$lookupfrom: 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变量时同理哈希91DE93BFCommand 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/falseSERVER-131472两者哈希相同但allowDiskUse影响可用的执行策略。$_internalJoinHintSERVER-131752该内部阶段用于按子集级别指定连接方法提示如method:HJ哈希连接 vsmethod:INLJ索引嵌套循环连接。有/无 hint 以及 hint 方法不同的查询哈希目前都相同未来应产生不同键。这些 WARNING 是测试系统主动暴露缺陷的机制——一旦实现修复golden 输出会相应变化提醒维护者更新预期。当前不产生哈希的查询Hash 1: undefined第三组用例验证以下场景当前不参与连接计划缓存哈希为undefined一旦未来具备资格测试会失败以促使开发者决策其缓存键行为join_plan_cache_key_md.js$lookup带let参数SERVER-115652子管道引用let变量时尚未支持缓存键。带collation的 aggregate不同collation.strength1vs2都返回undefined。$unwind带includeArrayIndex即使includeArrayIndex字段名不同也均无哈希。aggregate 级hinthint: {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 \ --suitesquery_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),仅供参考