MongoDB 部分索引与计划缓存:不适用查询为何不会复用缓存的部分索引计划 📅 发布时间:2026/9/13 22:08:51 👁 浏览次数: MongoDB 部分索引与计划缓存不适用查询为何不会复用缓存的部分索引计划【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo导读本文基于 MongoDB 仓库中的 golden test 用例cached_partial_index_plan_not_reused_for_ineligible_query展开深入讲解一个与部分索引partial index和计划缓存plan cache相关的查询正确性问题当一条符合部分索引过滤条件的查询被缓存后另一条形状相同但不符合该部分索引条件的查询是否会错误复用缓存计划从而返回错误结果本文给出完整的复现步骤、explain 证据、计划缓存快照并结合src/mongo/db/query/plan_cache等源码解释底层机制。读者学完后将能复现该类回归、读懂 golden 输出、理解计划缓存键与部分索引资格判定的关系并掌握通过 resmoke golden 测试验证计划选择正确性的方法。一、背景部分索引与计划缓存的交互风险MongoDB 的**部分索引partial index**只索引满足partialFilterExpression的文档。查询优化器只有在确认查询条件蕴含imply了部分索引的过滤表达式时才会把该索引纳入候选范围。**计划缓存plan cache**则会按查询形状query shape由 plan cache key 标识保存第一次选出的获胜计划后续相同形状的查询直接复用避免重复进行计划选择。两者交互时存在一个经典风险两条查询形状相同planCacheKey 相同但一条符合部分索引条件、另一条不符合。如果缓存机制只按形状匹配而忽略部分索引的资格差异后者就可能错误地复用前者缓存的部分索引计划导致漏掉本应返回的文档。这正是 SERVER-102825 与 SERVER-106023 两个缺陷的核心场景也是本 golden 测试要锁定的回归点。二、测试结构概览一条用例两个回归场景本用例源码位于 cached_partial_index_plan_not_reused_for_ineligible_query_md.js文件头注释明确说明Test that a query eligible for partial index and gets cached does not affect the results of a query with the same shape but ineligible for the partial index. This file is a regression testcase for SERVER-102825 and SERVER-106023.测试由两个独立场景组成场景复现的缺陷查询形态索引情况场景一SERVER-102825find()命令单个部分索引{a: 1}场景二SERVER-106023聚合管道$match$sort三个不同键组合的部分索引两个场景遵循完全相同的验证模式先无索引跑两遍查询作为基线再建部分索引并连续执行符合条件的查询以填充计划缓存验证缓存内容后再执行形状相同但不符条件的查询断言其结果与基线一致——即没有复用缓存的部分索引计划。本用例属于 Markdown golden test测试执行时通过 pretty_md.js 中的section/subSection/code/line辅助函数将查询、结果、explain 和计划缓存统计输出为 Markdown与 expected_output 目录下的期望文件逐字比对。期望文件按不同的查询执行引擎与功能标志位分别维护默认classic 引擎SBE 引擎sbeFullSBE 引擎 相关 feature flagfeatureFlagSbeFull即本文所依据的文档内部连接优化internalEnableJoinOptimization三、场景一SERVER-102825find 查询错误复用部分索引计划3.1 数据与无索引基线向集合插入唯一一条文档[ { _id : 1, a : 0 } ]在不创建任何索引的情况下执行两条查询作为基线。两条查询形状完全相同均由两个$or分支加上_id约束组成差异仅在第二个$or分支的右边界上字符串a string对比数值10// 基线查询 1q1返回空结果 { $or : [ { a : 1 }, { a : { $lte : a string } } ], _id : { $lte : 5 } }// 结果[ ]// 基线查询 2q2命中文档 {_id:1, a:0} { $or : [ { a : 1 }, { a : { $lte : 10 } } ], _id : { $lte : 5 } }// 结果[ { _id : 1, a : 0 } ]注意q1 的两个$or分支分别匹配a:1或a ≤ a string而集合中的文档a:0数值 0并不满足任一分支因此返回空集q2 的分支a ≤ 10是数值比较a:0满足条件返回该文档。q1 返回空、q2 返回一条文档这是后续验证正确性的黄金标准。3.2 创建与查询形状精确匹配的部分索引创建部分索引其partialFilterExpression与 q1 的$or部分逐字一致{ a : 1, partialFilterExpression : { $or : [ { a : 1 }, { a : { $lte : a string } } ] } }也就是说q1 的过滤条件恰好蕴含该部分索引的过滤表达式因此 q1 有资格使用该索引而 q2 的a ≤ 10并不蕴含$or分支中的任一条件没有资格使用它。3.3 连续执行 q1 以填充计划缓存连续三次执行 q1触发计划缓存写入。golden 输出中三次查询与结果均为空数组[ ]一一对应。从源码可见这一循环刻意复用了相同的谓词对象// Ensure q1 is cached which uses the partial filter. for (let i 0; i 3; i) { runFindTest(q1); }3.4 explain 证据获胜计划确实使用了部分索引对 q1 执行explain()并抽取获胜计划源码中使用getWinningPlanFromExplaingolden 输出展示了完整的执行树{ stage : FETCH, planNodeId : 2, filter : { _id : { $lte : 5 } }, nss : test.cached_partial_index_plan_not_reused_for_ineligible_query_md, inputStage : { stage : IXSCAN, planNodeId : 1, nss : test.cached_partial_index_plan_not_reused_for_ineligible_query_md, keyPattern : { a : 1 }, indexName : a_1, isMultiKey : false, multiKeyPaths : { a : [ ] }, isUnique : false, isSparse : false, isPartial : true, indexVersion : 2, direction : forward, indexBounds : { a : [ [1.0, 1.0], [\\, \a string\] ] } } }关键字段解读isPartial: true索引扫描明确标记为部分索引顶层stage: FETCH携带_id ≤ 5的 filter而$or谓词被下推为 IXSCAN 的两个索引边界区间[1.0, 1.0]与[, a string]由部分索引直接覆盖这证明 q1 的计划依赖部分索引a_1一旦被缓存任何错误复用它的人都可能丢失非索引覆盖的文档。3.5 计划缓存快照缓存条目已包含部分索引计划通过 golden_test_utils.js 中的outputPlanCacheStats内部执行$planCacheStats聚合并裁剪字段输出缓存条目。缓存的cachedPlan与上一步 explain 的获胜计划一致IXSCAN 于a_1部分索引 FETCH同时给出planCacheKey与来源查询[ { cachedPlan : { filter : { _id : { $lte : 5 } }, inputStage : { direction : forward, indexBounds : { a : [ [1.0, 1.0], [\\, \a string\] ] }, indexName : a_1, indexVersion : 2, isMultiKey : false, isPartial : true, isSparse : false, isUnique : false, keyPattern : { a : 1 }, multiKeyPaths : { a : [ ] }, stage : IXSCAN }, stage : FETCH }, createdFromQuery : { projection : { }, query : { $or : [ { a : 1 }, { a : { $lte : a string } } ], _id : { $lte : 5 } }, sort : { } }, isActive : true, planCacheKey : 92EAA2B3 } ]这里有一个值得注意的点q1 与 q2 形状相同它们的planCacheKey相同均为92EAA2B3测试用createdFromQuery与cachedPlan直观展示了键所代表的形状。如果缓存只按planCacheKey判断能否复用q2 就会命中这条缓存——而这条缓存恰恰不该被 q2 使用。3.6 关键验证q2 未复用缓存计划并返回正确文档紧接着执行 q2$or中$lte边界为数值10不符合部分索引条件。golden 输出断言其结果为[ { _id : 1, a : 0 } ]与无索引基线完全一致。这证明尽管 q1 的缓存条目含部分索引计划已在集合中生效q2 依然被判定为不符合该部分索引的资格从而绕开缓存中的部分索引计划重新做计划选择最终正确返回文档。这正是该回归用例的核心断言。四、场景二SERVER-106023聚合管道 多部分索引的同类问题4.1 数据与索引同样只插入[ { _id : 1, a : 0 } ]但这次部分索引的过滤表达式更加复杂是两个$or分支的合取{ spec : { a : 1 }, partialFilterExpression : { $or : [ { a : { $lt : a } }, { _id : { $eq : 0 }, a : { $eq : 0 } } ] } }同一过滤表达式同时施加于三个不同键组合的索引上源码中以indexSpecs数组循环创建// 索引 1{a: 1} { spec : { a : 1 }, partialFilterExpression : { ... } } // 索引 2{a: 1, m: 1} { spec : { a : 1, m : 1 }, partialFilterExpression : { ... } } // 索引 3{b: 1, a: 1} { spec : { b : 1, a : 1 }, partialFilterExpression : { ... } }4.2 符合部分索引条件的管道 p1管道 p1 的$match中第二个$or分支是{_id: 0, a: -1}注意它不完全等于部分索引过滤表达式中的{_id: 0, a: 0}分支。真正与过滤表达式精确匹配的是 p2 的第二个分支。// p1连续执行三次 [ { $match : { $or : [ { a : { $lt : a } }, { _id : { $eq : 0 }, a : { $eq : -1 } } ] } }, { $sort : { b : 1 } } ] // 结果三次均为[ ]4.3 缓存快照OR 分支 SORT 的复杂缓存计划三次执行 p1 后$planCacheStats输出的缓存条目是一个嵌套结构SORT包裹FETCH其下是OR节点OR的两个输入分别是_id_唯一索引的 IXSCAN区间[0.0, 0.0]isPartial: false与a_1部分索引的 IXSCAN区间[, a)isPartial: true[ { cachedPlan : { inputStage : { inputStage : { inputStages : [ { filter : { a : { $eq : -1 } }, inputStage : { direction : forward, indexBounds : { _id : [ [0.0, 0.0] ] }, indexName : _id_, indexVersion : 2, isMultiKey : false, isPartial : false, isSparse : false, isUnique : true, keyPattern : { _id : 1 }, stage : IXSCAN }, stage : FETCH }, { direction : forward, indexBounds : { a : [ [\\, \a\) ] }, indexName : a_1, indexVersion : 2, isMultiKey : false, isPartial : true, isSparse : false, isUnique : false, keyPattern : { a : 1 }, multiKeyPaths : { a : [ ] }, stage : IXSCAN } ], stage : OR }, stage : FETCH }, memLimit : 104857600, sortPattern : { b : 1 }, stage : SORT, type : simple }, createdFromQuery : { projection : { }, query : { $or : [ { a : { $lt : a } }, { _id : { $eq : 0 }, a : { $eq : -1 } } ] }, sort : { b : 1 } }, isActive : true, planCacheKey : 788F01B0 } ]该快照的要点$sort阶段memLimit: 104857600即默认 100MB 内存排序上限出现在缓存计划中OR分支通过_id_扫描处理_id:0分支其a条件以 FETCH 之上的 filter 施加通过部分索引a_1处理a a分支。缓存键788F01B0对应这一形状。4.4 关键验证p2 未复用缓存计划并返回正确文档管道 p2 与 p1 形状相同但第二个$or分支改为{_id: 0, a: 0}——这正是部分索引过滤表达式中的精确分支// p2形状与 p1 相同但 a 的取值从 -1 变为 0 [ { $match : { $or : [ { a : { $lt : 1 } }, { _id : { $eq : 0 }, a : { $eq : 0 } } ] } }, { $sort : { b : 1 } } ] // 结果[ { _id : 1, a : 0 } ]p2 返回了文档{_id: 1, a: 0}。若错误复用了 p1 缓存的部分索引计划该计划依赖a a与_id:0 AND a:0的部分索引资格则a:0这条文档可能因不满足a a字符串比较而被漏掉。最终结果正确说明优化器在复用计划前重新判定了部分索引资格将 p2 排除在缓存计划之外。补充p2 中第一个分支a 1数值与 p1 的a a字符串在 BSON 类型排序下语义不同这一设计让两条管道形状相同却资格不同精准地构造出回归场景。五、底层原理部分索引资格判定与计划缓存键5.1 部分索引资格的判定发生在何处从源码结构看部分索引的可选性eligibility由查询规划器在计划生成阶段判定。核心逻辑位于 planner_ixselect.cpp索引选择与 planner_analysis.cpp索引分析相关行为在 query_planner_partialidx_test.cpp 中有大量单测覆盖。其基本规则是只有当查询谓词能够证明匹配该索引的文档集合是匹配查询的文档集合的超集即查询蕴含部分索引过滤表达式时该索引才被纳入候选。5.2 部分索引如何进入计划缓存键计划缓存键由 plan_cache_key_factory.cpp 生成。关键在于缓存键不仅编码查询形状还编码了索引的可选性与索引能力信息。相关的判别式discriminator构建逻辑位于 plan_cache_indexability.cpp其中明确对部分索引过滤表达式中涉及的路径添加判别式// If necessary, add discriminators for the paths mentioned in the partial filter // expression. Unlike most of the discriminator logic, this is shared for wildcard and // non-wildcard indexes. if (idx.filterExpr) { ... }也就是说部分索引的filterExpr过滤表达式会被转译为路径判别式参与缓存键计算。查询谓词在这些路径上的取值差异例如$lte: a string与$lte: 10、a: -1与a: 0会反映为不同的索引能力判断从而影响该形状是否与缓存条目关联的部分索引计划匹配。这从机制上解释了为什么形状相同的两条查询在部分索引资格不同时缓存条目不会被错误复用——索引资格是缓存键语义的一部分资格变化意味着缓存失效或重建。5.3 回归缺陷的成因与修复方向SERVER-102825 / SERVER-106023 所暴露的问题从测试意图看是历史上曾存在缓存命中时未充分重放部分索引资格判定的路径导致不符合条件的查询复用了部分索引计划。本测试通过 golden 输出固化缓存条目存在 后续查询结果正确这两个不可分割的断言任何后续改动若重新引入该缺陷都会在expected_output比对中立即失败从而起到回归防线的作用。测试文件被放置于 jstests/query_golden 目录并由 query_golden_classic.yml 等套件自动收集roots: jstests/query_golden/**/*.js。六、如何运行与调试本测试6.1 通过 resmoke 运行本测试是标准 golden test使用 resmoke 运行。按 README.plan_stability.md 的说明可直接指定测试文件运行buildscripts/resmoke.py run --suitesquery_golden_classic \ jstests/query_golden/cached_partial_index_plan_not_reused_for_ineligible_query_md.jsquery_golden_classic套件默认关闭成本排序器featureFlagCostBasedRanker: false并开启featureFlagExtendedAutoSpilling见 query_golden_classic.yml。SBE 引擎与相关功能标志位的期望输出通过套件参数切换后生成对应expected_output/featureFlagSbeFull与expected_output/sbeFull目录。执行时query_golden_classic.yml 的eval钩子会加载 golden_overrides.js 并调用beginGoldenTest将实际输出与期望文件比对。6.2 golden 输出的产生与比对机制测试脚本中所有 Markdown 结构均由 pretty_md.js 生成section/subSection输出##/###标题code输出 JSON 代码块line输出普通文本explain 的获胜计划通过getWinningPlanFromExplain抽取定义于 analyze_plan.js保证只对比稳定的计划结构计划缓存快照通过$planCacheStats聚合获取并由outputPlanCacheStats裁剪为cachedPlan、planCacheKey、createdFromQuery、isActive等稳定字段避免缓存内部计数等易变字段干扰比对若计划变更导致输出与期望不一致可使用 golden 测试工具查看 diff 或接受新输出buildscripts/golden_test.py diff/accept。6.3 手工复现与验证若想脱离测试框架手工验证行为可按测试逻辑用 mongosh 逐步执行// 1. 数据与索引 db.coll.drop(); db.coll.insert({_id: 1, a: 0}); db.coll.createIndex( {a: 1}, {partialFilterExpression: {$or: [{a: 1}, {a: {$lte: a string}}]}} ); // 2. 连续执行符合部分索引条件的查询使其进入计划缓存 const q1 {$or: [{a: 1}, {a: {$lte: a string}}], _id: {$lte: 5}}; db.coll.find(q1).toArray(); db.coll.find(q1).toArray(); db.coll.find(q1).toArray(); // 3. 查看缓存中是否包含部分索引计划 db.coll.aggregate([{$planCacheStats: {}}]).toArray(); // 4. 执行形状相同但不符部分索引条件的查询应返回 {_id: 1, a: 0} const q2 {$or: [{a: 1}, {a: {$lte: 10}}], _id: {$lte: 5}}; db.coll.find(q2).toArray();若第 4 步返回空集即说明出现了部分索引计划被错误复用的回归。七、总结本文所依据的 golden 期望文档 cached_partial_index_plan_not_reused_for_ineligible_query.md 及其配套测试脚本完整刻画了 MongoDB 中部分索引与计划缓存交互的正确性要求查询形状相同 ≠ 计划可复用部分索引的资格判定查询是否蕴含partialFilterExpression是计划是否可复用的关键前提缓存条目必须携带资格信息从 plan_cache_indexability.cpp 可见部分索引过滤表达式会被编码为判别式参与计划缓存键计算从机制上防止形状相同、资格不同的查询错误命中缓存golden 测试固化行为本用例以explain 证明缓存使用了部分索引 后续查询结果正确的双重断言锁定 SERVER-102825 与 SERVER-106023 的修复结果是理解与保障查询规划正确性的极佳参考样例。对于需要排查部分索引查询结果缺失类问题的开发者本用例同时提供了可复现的查询构造思路、explain/缓存快照的判读方法以及完整的 golden 测试运行与调试流程可作为同类问题分析的标准范式。【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考