)
MongoDB 部分索引计划缓存研究缓存计划为何不会复用到不合格查询SERVER-102825 / SERVER-106023 回归测试详解【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo导读本文基于 MongoDB 官方仓库中 cached_partial_index_plan_not_reused_for_ineligible_query.mdgolden 测试预期输出及其配套测试脚本 cached_partial_index_plan_not_reused_for_ineligible_query_md.js深入剖析一个查询优化器的重要行为当一个与部分索引partial index兼容的查询被缓存后同形状但不兼容的查询绝不能误用该缓存计划。本文将完整还原测试的两个回归场景SERVER-102825 与 SERVER-106023逐段解读 find 查询与聚合管道的实测输出并结合planner_ixselect与计划缓存键编码的源码讲清楚MongoDB 是如何保证缓存计划与索引资格判定保持一致的底层机制。一、背景部分索引与计划缓存1.1 部分索引Partial IndexMongoDB 的部分索引允许只对满足partialFilterExpression的文档建立索引从而显著缩小索引体积、降低写入与存储成本。例如测试中的索引{ a : 1, partialFilterExpression : { $or : [ { a : 1 }, { a : { $lte : a string } } ] } }意味着只有当文档满足a 1或a a string字符串比较时a字段才被纳入a_1索引。部分索引的资格判定属于全局性global判别必须把查询谓词与整个过滤表达式做子集subset比较而不是只看单个字段的谓词形式这正是它容易在计划缓存环节出问题的根源。1.2 计划缓存Plan Cache当同一查询形态query shape多次执行时MongoDB 会把首次获胜的执行计划缓存下来isCached标记后续相同形态的查询直接复用缓存计划跳过昂贵的多计划竞争multi-planner。缓存是否可复用由计划缓存键plan cache key决定——它必须精确区分形态相同但语义不同的查询否则就会把不合适的索引计划复用到错误查询上产生错误的查询结果。二、问题缘起两个回归缺陷 SERVER-102825 与 SERVER-106023测试脚本的文件头注释明确交代了它的使命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.也就是说历史上曾出现过这样的缺陷查询 Q1 因与部分索引兼容而入选并写入计划缓存随后同形态但部分索引不兼容的查询 Q2 错误地复用了缓存中的部分索引计划导致 Q2 本应命中的文档被过滤掉返回了错误结果。本文档对应的 golden 测试就是为了锁定不得复用的正确行为而设。测试全程只有一个文档[ { _id : 1, a : 0 } ]集合名为test.cached_partial_index_plan_not_reused_for_ineligible_query_md由jsTestName()自动生成见 测试脚本。三、场景一SERVER-102825find 查询3.1 无索引时的基线行为在创建任何索引之前先分别执行两个查询确立正确结果的基线查询 Q1与后续部分索引兼容{ $or : [ { a : 1 }, { a : { $lte : a string } } ], _id : { $lte : 5 } }结果[ ]文档a: 0既不等于 1也不满足a a string——数字 0 与字符串a string按 BSON 类型序比较数字小于字符串。查询 Q2与部分索引不兼容{ $or : [ { a : 1 }, { a : { $lte : 10 } } ], _id : { $lte : 5 } }结果[ { _id : 1, a : 0 } ]。Q2 的第二分支是a 10数值比较a: 0满足条件因此正确结果必须返回该文档。Q1 与 Q2 结构完全一致同为$or双分支 _id上限差别只在第二分支的常量a stringvs10。3.2 创建部分索引{ a : 1, partialFilterExpression : { $or : [ { a : 1 }, { a : { $lte : a string } } ] } }注意过滤表达式的第二个分支限定的是字符串比较a a string因此只有a为字符串类型的文档或a 1才进入索引。文档{_id: 1, a: 0}中a是数字 0不会被索引收录。3.3 让 Q1 三次执行并写入缓存测试循环 3 次执行 Q1测试脚本确保 Q1 的获胜计划被缓存MongoDB 计划缓存通常对反复出现的查询形态产生稳定条目。Explain 输出确认 Q1 确实命中了部分索引{ isCached : true, stage : FETCH, filter : { _id : { $lte : 5 } }, nss : test.cached_partial_index_plan_not_reused_for_ineligible_query_md, inputStage : { stage : IXSCAN, 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\] ] } } }关键字段解读isCached: true本次执行直接复用了计划缓存indexName: a_1、isPartial: true获胜计划基于部分索引indexBounds索引扫描边界为[1.0, 1.0]对应a 1与[, a string]对应a a string的字符串区间FETCH阶段带filter: {_id: {$lte: 5}}说明_id谓词作为残余过滤在取文档阶段应用。3.4 计划缓存内容验证测试通过$planCacheStats聚合阶段输出缓存条目对应工具函数 outputPlanCacheStats仅保留cachedPlan、planCacheKey、createdFromQuery、isActive、shard五个字段[ { 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 : E41825BB } ]要点createdFromQuery记录了产生该缓存条目的原始查询即 Q1cachedPlan是 FETCH IXSCAN(a_1) 的树形结构isPartial: true清晰标识这是部分索引计划planCacheKey: E41825BB是该查询形态的缓存键。3.5 核心验证同形态不合格查询 Q2 不得复用缓存{ $or : [ { a : 1 }, { a : { $lte : 10 } } ], _id : { $lte : 5 } }结果[ { _id : 1, a : 0 } ]。这是整个测试的回归断言核心。分析Q2 与 Q1 具有完全相同的外形$or_id如果单纯按外形匹配计划缓存可能误判为可复用但 Q2 的第二分支a 10是数值比较并不属于部分过滤表达式{$or: [{a: 1}, {a: {$lte: a string}}]}的子集10 不是 1也不满足字符串区间 a string因此 Q2整体不具备使用a_1部分索引的资格若错误复用了缓存中的部分索引计划IXSCAN 边界[1.0, 1.0]与[, a string]都扫不到a: 0数字 0结果将是空集——正确答案应该是返回{_id: 1, a: 0}实测输出返回了正确文档证明优化器没有复用缓存计划而是为 Q2 重新规划了执行计划。四、场景二SERVER-106023聚合管道中的部分索引资格场景二把同样的正确性要求扩展到了聚合管道且进一步复杂化同一partialFilterExpression挂在了三个不同键模式的索引上而两个管道只在$or分支的常量上存在细微差别。4.1 数据与索引设置文档依旧只有{_id: 1, a: 0}。创建三个索引键模式不同但共享同一个部分过滤表达式{ spec : { a : 1 }, partialFilterExpression : { $or : [ { a : { $lt : a } }, { _id : { $eq : 0 }, a : { $eq : 0 } } ] } }{ spec : { a : 1, m : 1 }, partialFilterExpression : { $or : [ { a : { $lt : a } }, { _id : { $eq : 0 }, a : { $eq : 0 } } ] } }{ spec : { b : 1, a : 1 }, partialFilterExpression : { $or : [ { a : { $lt : a } }, { _id : { $eq : 0 }, a : { $eq : 0 } } ] } }注意过滤表达式的语义分支一a a字符串比较a为字符串类型分支二_id 0 且 a 0严格等值组合。文档{_id: 1, a: 0}两个分支都不满足_id不是 0a: 0是数字不满足a a的字符串比较因此该文档不会被任何部分索引收录。4.2 管道 P1命中部分索引并写入缓存管道 P1注意第二分支a: {$eq: -1}与过滤表达式分支二a: {$eq: 0}不同且_id等值 0 使其可用_id_索引[ { $match : { $or : [ { a : { $lt : a } }, { _id : { $eq : 0 }, a : { $eq : -1 } } ] } }, { $sort : { b : 1 } } ]结果[ ]。P1 连续执行 3 次后写入计划缓存。计划缓存条目planCacheKey: 7949C151[ { 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 : 7949C151 } ]缓存计划的形态值得细读$or的两个分支被拆成两路独立子计划——_id_索引扫描isPartial: false、isUnique: true边界[0.0, 0.0] FETCH 阶段以{a: {$eq: -1}}作残余过滤对应$or第二分支{_id: {$eq: 0}, a: {$eq: -1}}a_1部分索引扫描isPartial: true边界[, a)即a a的字符串开区间对应$or第一分支{a: {$lt: a}}该分支是过滤表达式的子集因此有资格使用部分索引。外层再叠加FETCH与SORTsortPattern: {b: 1}memLimit: 104857600即默认 100MB 内存排序上限这是经典的多路 OR 分支各走各索引的复合计划。注意三个索引中最终只有_id_与a_1被选中{a: 1, m: 1}与{b: 1, a: 1}未被采用。4.3 核心验证管道 P2 不得复用缓存计划管道 P2与 P1 结构相同仅$or分支常量不同[ { $match : { $or : [ { a : { $lt : 1 } }, { _id : { $eq : 0 }, a : { $eq : 0 } } ] } }, { $sort : { b : 1 } } ]结果[ { _id : 1, a : 0 } ]。逐分支分析 P2 的索引资格第一分支{a: {$lt: 1}}$lt: 1是数值比较而过滤表达式第一分支是字符串比较a a。数字 1 既不小于字符串aBSON 类型序中数字排在字符串之前任何数字都小于任何字符串也不满足_id 0 且 a 0因此该分支不具部分索引资格不能走a_1的[, a)区间第二分支{_id: {$eq: 0}, a: {$eq: 0}}与过滤表达式分支二完全一致具备资格。如果 P2 错误复用缓存计划将沿用 P1 的 OR 子计划_id_扫[0.0, 0.0]、a_1扫[, a)。文档{_id: 1, a: 0}的_id: 1不在[0,0]a: 0数字不在字符串区间[, a)内结果将是空集而正确答案是{_id: 1, a: 0}P2 第一分支a: 0 1为真。实测返回了正确文档证明缓存计划未被复用。五、底层原理部分索引资格判定与计划缓存键的一致性为何同形态查询不会被错误复用答案在于 MongoDB 从两个层面同时保证正确性5.1 规划器侧stripInvalidAssignmentsToPartialIndex*查询规划器在给谓词节点分配索引时会执行部分索引的资格剥离逻辑位于 planner_ixselect.cppstripInvalidAssignmentsToPartialIndexRoot先判断整个查询树是否为过滤表达式的子集expression::isSubsetOf(root, idxEntry.filterExpr)是则整树保留否则递归进入stripInvalidAssignmentsToPartialIndexNode对每个节点移除strip指向该部分索引的相关性标记关键豁免规则当当前节点是$or且不在否定negation或$elemMatch对象内部时只要某个$or子句是过滤表达式的子集isSubsetOf(node-getChild(i), idxEntry.filterExpr)该子句就免于被剥离——这正是场景二缓存计划中$or两个分支各走各索引_id_ 部分索引a_1的实现基础。源码注释还解释了为何在否定或$elemMatch上下文中不能豁免谓词语义被反转或隐含路径前缀无法与过滤表达式直接比较。5.2 缓存键侧encodePartialIndexDiscriminator仅靠规划器正确还不够若两个查询算出相同的计划缓存键缓存复用仍会出错。为此plan_cache_key_factory.cpp 在编码索引能力indexability时引入了**全局判别器global discriminator**机制代码注释明确写道Encode partial index discriminator using the same algorithm that is used inQueryPlannerIXSelect::stripInvalidAssignmentsToPartialIndexRoot(). This is to ensure that the plan cache key agrees with the decisions that the planner makes around index eligibility.用与规划器完全相同的算法编码部分索引判别器确保计划缓存键与规划器的索引资格决策保持一致若整个查询是过滤表达式的子集直接编码true否则递归走encodePartialIndexDiscriminatorHelper同样应用非否定/非$elemMatch上下文中的$or子句若为过滤表达式子集则标记为 eligibletrue的规则与规划器的豁免逻辑逐位对应。5.3 两者合力的效果场景一中Q1a a string与 Q2a 10虽然外形相同但各自的分支对a_1部分索引的资格不同全局判别器在缓存键中编码出的位串不同planCacheKey自然不同Q2 根本查不到 Q1 的缓存条目E41825BB于是重新走多计划选择场景二中P1a: {$lt: a}分支合格与 P2a: {$lt: 1}分支不合格同样因资格位串不同而拥有不同的缓存键P1 为7949C151P2 无法命中 P1 的缓存条目从而重新规划并返回正确结果。从源码结构可以推断只要缓存键编码算法与规划器资格剥离算法保持同步部分索引缓存就不会污染同形态的不合格查询而本文档所对应的 golden 测试正是把这两个回归修复SERVER-102825、SERVER-106023固化下来的行为契约。六、如何运行与复现该文档属于 query_golden 系列的预期输出expected output由测试脚本执行后自动比对生成。运行方式参见 README.plan_stability.mdbuildscripts/resmoke.py run \ --suitesquery_golden_classic \ --mongodSetParameters{internalQueryFrameworkControl: forceClassicEngine, featureFlagCostBasedRanker: ..., internalQueryPlanRanker: ..., internalQueryCBRCEMode: ...} \ jstests/query_golden/plan_stability.js针对本文测试则直接运行buildscripts/resmoke.py run --suitesquery_golden_classic \ jstests/query_golden/cached_partial_index_plan_not_reused_for_ineligible_query_md.js此外resmoke 还预置了多种计划排序plan ranking模式的套件query_golden_cbr_automatic、query_golden_cbr_sampling、query_golden_cbr_histogram等可直接以--suitesquery_golden_cbr_automatic方式切换验证无需手动传mongodSetParameters。若计划发生变化导致 golden 比对失败可用buildscripts/golden_test.py accept接受新输出变更前需人工判断新计划更优还是更差也可通过配置~/.golden_test_config.yml的diffCmd与$HOME/.config/git/attributes中的plan_stabilitydiff 驱动获得逐条计划差异的可读 diff。七、总结与工程启示部分索引的资格判定是全局性的不能仅看字段名与操作符外形必须把查询子表达式与partialFilterExpression做子集比较并区分否定、$elemMatch等上下文计划缓存必须感知索引资格仅按查询外形生成缓存键是不够的MongoDB 通过encodePartialIndexDiscriminator把每个索引的资格位串编入缓存键从根上杜绝跨资格复用$or分支的部分豁免是性能关键允许合格子句使用部分索引、不合格子句走其他索引如_id_规划器与缓存键两侧采用同一套递归算法确保缓存命中与规划决策完全一致回归测试是行为契约本文文档连同其.js脚本把缓存计划不得复用到不合格查询这一正确性约束以可执行、可 diff 的 golden 形式固化防止未来优化器改动重新引入同类缺陷。对开发者而言理解这一机制的现实价值在于当使用部分索引并遇到同形态查询结果不一致的疑难问题时应优先检查不同查询对partialFilterExpression的子集资格是否相同同时可利用coll.aggregate([{$planCacheStats: {}}])观察缓存条目中的createdFromQuery、planCacheKey与cachedPlan.isPartial字段快速定位是否存在资格误判。【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考