MongoDB 查询计划中的 OR 谓词下推:从 SERVER-106983 回归测试看嵌套 $and/$or 的索引利用与计划稳定性
MongoDB 查询计划中的 OR 谓词下推从 SERVER-106983 回归测试看嵌套 $and/$or 的索引利用与计划稳定性【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo本文以 MongoDB 仓库mo/mongo中internalEnableJoinOptimization特性下的 golden 测试期望输出 match_or_predicate_pushdown.md 为核心完整解读一个专门为复现查询计划器缺陷SERVER-106983而设计的嵌套$and/$or聚合查询包括数据构造、Pipeline 语义、最终执行计划OR 谓词下推的逐步拆解以及该期望输出文件在 golden 测试体系中的产生与校验方式。读完本文你将能读懂 MongoDB explain 输出中 OR 节点、IXSCAN 分支与下推 filter 的对应关系并理解计划器在多键索引场景下生成单计划但双边界振荡问题的测试思路。测试背景一个为复现查询计划振荡 Bug 而生的用例该文档对应的测试源码位于 match_or_predicate_pushdown_md.js。文件头注释明确说明了它的来历Test a particular nested $and/$or query. This test was designed to reproduce SERVER-106983, a bug in which the plan enumerator only generates one plan for this query, but the plan oscillates between two sets of bounds.也就是说这是一个回归测试在某个历史版本中查询计划枚举器plan enumerator对这条嵌套$and/$or查询只生成一个候选计划但这个唯一的计划会在两组索引边界bounds之间来回振荡导致执行计划不稳定。golden 测试将正确的、稳定的执行计划固化为期望输出防止后续改动重新引入该缺陷。值得注意的一点是这份期望输出存放在expected_output/internalEnableJoinOptimization/目录下说明它是在开启内部 Join 优化特性internalEnableJoinOptimization时生成的基线。对比同目录下其他变体sbeFull 版本、sbeDisabled 版本可以看出Join 优化特性开启后explain 输出中的每个 stage 都会额外携带usedJoinOptimization字段而本用例中该字段全部为false说明这条查询并未被 Join 优化器接管仍由经典查询计划器 classic 执行引擎处理——这正是Join 优化特性下非 Join 查询行为保持不变的一手证据。数据与索引一条空数组如何让索引变成多键索引测试通过resetCollection工具初始化集合该工具实现在 jstests/query_golden/libs/utils.js 中先drop集合再批量插入文档并创建索引。文档包含两个关键样本{ _id : 187, a : NumberInt(0), array : [ ], b : NumberInt(0), m : { m1 : NumberInt(0), m2 : NumberInt(0) }, t : ISODate(1970-01-01T00:00:00Z) } { _id : 83, a : NumberInt(0), array : , b : NumberInt(0), m : { m1 : NumberInt(0), m2 : ISODate(1970-01-01T00:00:00Z) }, t : ISODate(1970-01-01T00:00:00Z) }{ array : 1, t : 1 }两个样本的设计各有深意_id: 187的array字段是空数组[]。测试注释// This makes the index we create below multikey.明确说明正是这条数据使得{t: 1, array: 1}索引被标记为多键索引multikey。_id: 83的array字段是空字符串它是唯一能够同时满足 Pipeline 中array相关条件的结果样本。从最终的 explain 输出可以印证这一点t_1_array_1索引节点的isMultiKey : true且multiKeyPaths : { array : [ array ], t : [ ] }——即只有array字段是多键路径t字段不是。多键索引在查询计划、索引边界和过滤语义上都有特殊规则这正是该测试希望覆盖的复杂性来源之一。被测试的 Pipeline嵌套 $and/$or 的 $match测试通过outputAggregationPlanAndResults实现在 jstests/libs/query/golden_test_utils.js执行聚合 Pipeline 并输出期望文档。完整 Pipeline 如下[ { $match : { $or : [ { t : { $exists : true } }, { _id : 0, a : 0 } ], $and : [ { array : { $nin : [ 0 ] } }, { array : { $eq : } } ] } } ]对查询语义进行拆解$or分支一{t: {$exists: true}}——t字段存在。两条样本文档都满足。$or分支二{_id: 0, a: 0}——注意这是一个同时约束两个字段的复合条件等价于_id 0 AND a 0属于 MongoDB 查询语法中对象内多个字段的隐式 AND。$and条件一{array: {$nin: [0]}}——array不包含值0也不等于0。$and条件二{array: {$eq: }}——array恰好等于空字符串。整体条件即(t 存在 OR (_id0 且 a0)) AND (array 不包含 0) AND (array )。为什么只有_id: 83能通过array字段必须精确等于_id: 187的array是空数组[][] ! 因此被过滤掉_id: 83的array是同时也不属于$nin: [0]排除的范围空字符串不等于数字 0因此同时满足两个$and条件。最终结果只有一条{ _id : 83, a : 0, array : , b : 0, m : { m1 : 0, m2 : ISODate(1970-01-01T00:00:00Z) }, t : ISODate(1970-01-01T00:00:00Z) }执行计划拆解OR 谓词下推后的最终形态文档的 Summarized explain 部分给出了计划器为该查询选择的最终执行计划Execution Engine: classic。这是一棵典型的OR 谓词下推树可以按从下往上、从左到右的顺序解读{ queryShapeHash : 22F20D87ABD4D959DDE7E39FDB212E091657B9BF7FA2D6EB366103791557D83C, rejectedPlans : [ ], winningPlan : [ { filter : { $and : [ { array : { $not : { $eq : 0 } } }, { array : { $eq : } } ] }, nss : test.or_pred_pushdown_coll, stage : FETCH, usedJoinOptimization : false }, { stage : OR, usedJoinOptimization : false }, { filter : { t : { $exists : true } }, nss : test.or_pred_pushdown_coll, stage : FETCH, usedJoinOptimization : false }, { direction : forward, indexBounds : { array : [ [\\, \\] ], t : [ [MinKey, MaxKey] ] }, indexName : t_1_array_1, isMultiKey : true, ... stage : IXSCAN, usedJoinOptimization : false }, { filter : { a : { $eq : 0 } }, nss : test.or_pred_pushdown_coll, stage : FETCH, usedJoinOptimization : false }, { direction : forward, indexBounds : { _id : [ [0.0, 0.0] ] }, indexName : _id_, ... stage : IXSCAN, usedJoinOptimization : false } ] }逐层解读顶层FETCH 剩余 filter$and中的两个array条件$nin: [0]被规范化为$not: {$eq: 0}以及$eq: 没有下沉到任何一个$or分支的索引边界中而是作为整体过滤器挂在最外层 FETCH 上。这是因为两个$or分支分别使用了不同的索引t_1_array_1和_id_而array条件无法同时成为两个索引的边界。OR节点对两个分支的结果做并集去重。分支一FETCHfilter 为t: {$exists: true}→IXSCAN t_1_array_1。有意思的是索引{t: 1, array: 1}本可以把array 也作为边界但索引边界显示array : [ [\\, \\] ]其实已经用上了array的等值边界而t上则是全开区间[MinKey, MaxKey]由于索引是多键索引且t的$exists语义无法直接用边界表达t的过滤被保留在 FETCH 阶段。这一分支正是OR 分支一的下推结果。分支二FETCHfilter 为a: {$eq: 0}→IXSCAN _id_索引边界_id : [ [0.0, 0.0] ]精确命中了_id 0的等值边界a 0因为不在_id_索引键中作为 filter 保留在 FETCH 阶段。两个分支各用各的索引、各走各的边界最后在 OR 节点汇合再经顶层 FETCH 的arrayfilter 过滤——这就是OR 谓词下推的典型执行形态。rejectedPlans : [ ]表示计划器在本次运行中只保留了一个候选计划与该测试要复现的单计划场景吻合而queryShapeHash则是查询形状的稳定指纹用于跨版本识别同一查询。多引擎/多特性开关下的一致性将本文档与 sbeFull 变体 和 sbeDisabled 变体 对照可以发现除了 Execution Engine 标注与usedJoinOptimization字段的有无之外winningPlan的 stage 树、indexBounds、multiKeyPaths 完全一致。这说明该查询在 classic 引擎、SBE 引擎以及开启 Join 优化特性的情况下生成的计划结构保持稳定——这正是回归测试想要锁定的行为。Golden 测试机制这份期望输出从何而来、如何校验这份.md文件不是手写的教程而是golden黄金测试的期望输出基线由测试运行框架自动生成并与实际输出逐字比对。整个流程由以下几个环节构成测试驱动脚本match_or_predicate_pushdown_md.js 调用resetCollection重置集合、插入文档、创建{t: 1, array: 1}索引再调用outputAggregationPlanAndResults(coll, pipeline)。输出工具outputAggregationPlanAndResults在 jstests/libs/query/golden_test_utils.js 中定义。它依次输出### PipelinePipeline 原文、### Results去重排序后的结果、### Total indexes on the collection集合上全部索引名、### Summarized explain经过formatExplainRoot扁平化、键排序后的稳定 explain 字段并标注Execution Engine: classic/sbe。数据初始化工具resetCollection在 jstests/query_golden/libs/utils.js 中定义负责 drop、插入sequentialIds为缺省_id的文档补号并打印 Resetting collection. Inserting docs: 与 Creating indexes: 日志——这就是期望输出文件开头两段[jsTest]日志的来源。多变体基线同一测试会在不同的特性开关组合下生成多份期望输出分别存放于 expected_output/internalEnableJoinOptimization/、expected_output/sbeFull/、expected_output/sbeDisabled/、expected_output/sbeRestricted/ 等目录。这份文档属于internalEnableJoinOptimization启用内部 Join 优化的基线。如何在本地复现构建 MongoDB 后用buildscripts/resmoke.py运行对应测试即可例如具体套件名以 jstests/query_golden 目录下的 BUILD 配置为准python3 buildscripts/resmoke.py --run jstests/query_golden/match_or_predicate_pushdown_md.js运行后可在jstests/query_golden/expected_output/对应目录下查看/比对新生成的基线文件。golden 测试框架会将实际输出与基线 diff任何执行计划的意外改变比如重新引入 SERVER-106983 的边界振荡都会造成测试失败从而在 CI 中被及时捕获。从该用例得到的查询优化启示OR 谓词下推的本质$or的每个分支会被计划器独立考虑索引与边界无法用同一索引服务所有分支时计划会退化为多分支 IXSCAN OR 节点合并 顶层残余 filter的形态。理解这一点有助于阅读真实生产环境中的 explain 输出。参数化$or分支{_id: 0, a: 0}这样的分支同时约束两个字段计划器会挑出可索引字段_id走 IXSCAN把其余字段a留作 FETCH filter。这是复合条件分支的标准处理方式。多键索引的边界限制本用例中array的空数组导致索引多键化isMultiKey: true、multiKeyPaths标记array多键索引在$exists、范围边界等语义上受限部分过滤必须上提到 FETCH。计划振荡风险的回归防线对于计划器只枚举出一个计划、但边界在两组值之间摆动这类隐蔽问题golden 测试把稳定计划固化为基线是最直接有效的防回退手段。同类的边界类回归测试在 jstests/query_golden/expected_output/internalEnableJoinOptimization/ 目录中还有多份可作为理解 MongoDB 查询计划器行为的第一手资料。【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考