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.md(golden 测试预期输出)及其配套测试脚本 cached_partial_index_plan_not_reused_for_ineligible_query_md.js,深入剖析一个查询优化器的重要行为:当一个与部分索引(partial index)兼容的查询被缓存后,同形状但不兼容的查询绝不能误用该缓存计划。本文将完整还原测试的两个回归场景(SERVER-102825 与 SERVER-106023),逐段解读 find 查询与聚合管道的实测输出,并结合planner_ixselect与计划缓存键编码的源码,讲清楚"MongoDB 是如何保证缓存计划与索引资格判定保持一致"的底层机制。
一、背景:部分索引与计划缓存
1.1 部分索引(Partial Index)
MongoDB 的部分索引允许只对满足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-102825):find 查询
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 string"vs10。
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记录了产生该缓存条目的原始查询(即 Q1);cachedPlan是 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不是 0;a: 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与SORT(sortPattern: {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 既不小于字符串"a"(BSON 类型序中数字排在字符串之前,任何数字都小于任何字符串),也不满足_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.cpp:
stripInvalidAssignmentsToPartialIndexRoot先判断整个查询树是否为过滤表达式的子集(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 in
QueryPlannerIXSelect::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子句若为过滤表达式子集则标记为 eligible(true)"的规则,与规划器的豁免逻辑逐位对应。
5.3 两者合力的效果
- 场景一中,Q1(
a <= "a string")与 Q2(a <= 10)虽然外形相同,但各自的分支对a_1部分索引的资格不同,全局判别器在缓存键中编码出的位串不同,planCacheKey自然不同,Q2 根本查不到 Q1 的缓存条目(E41825BB),于是重新走多计划选择; - 场景二中,P1(
a: {$lt: "a"}分支合格)与 P2(a: {$lt: 1}分支不合格)同样因资格位串不同而拥有不同的缓存键(P1 为7949C151),P2 无法命中 P1 的缓存条目,从而重新规划并返回正确结果。
从源码结构可以推断,只要"缓存键编码算法"与"规划器资格剥离算法"保持同步,部分索引缓存就不会污染同形态的不合格查询;而本文档所对应的 golden 测试,正是把这两个回归修复(SERVER-102825、SERVER-106023)固化下来的"行为契约"。
六、如何运行与复现
该文档属于 query_golden 系列的预期输出(expected output),由测试脚本执行后自动比对生成。运行方式参见 README.plan_stability.md:
buildscripts/resmoke.py run \ --suites=query_golden_classic \ '--mongodSetParameters={internalQueryFrameworkControl: forceClassicEngine, featureFlagCostBasedRanker: ..., internalQueryPlanRanker: ..., internalQueryCBRCEMode: ...}' \ jstests/query_golden/plan_stability.js针对本文测试则直接运行:
buildscripts/resmoke.py run --suites=query_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等),可直接以--suites=query_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),仅供参考