MongoDB 查询计划黄金测试:sbeFull 变体下 $or 谓词下推场景的预期输出解析
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
本文以 MongoDB 源码仓库中的黄金测试(Golden Data Test)预期输出文件 match_or_predicate_pushdown.md 为核心样本,讲解sbeFull执行引擎变体下,一个带嵌套$or/$and谓词的聚合查询是如何被优化器枚举、选出获胜计划(winning plan)并固化为可回归比对的金标准的;同时结合其对应的测试脚本 match_or_predicate_pushdown_md.js 与黄金测试框架文档,说明该场景所复现的规划器振荡缺陷(SERVER-106983)、预期输出各小节的含义,以及如何用仓库自带工具对这类输出做 diff 与批量接受。
1. 这个文件在仓库中的定位:查询黄金测试的变体预期输出
jstests/query_golden/是 MongoDB 的查询优化黄金测试套件。它的机制是:每个*_md.js测试脚本在 mongod 上执行查询/聚合管道,把输入(数据、管道)+ 输出(结果集、explain 摘要、索引列表)序列化成 Markdown 文本,然后与expected_output/<变体名>/目录下签入的.md文件逐字节比对,任何差异都会导致测试失败。这一机制的通用规范见 golden_data_test_framework.md,核心原则包括:
- 输出必须确定且可重复(跨平台一致),因此框架对 explain 做了"摘要化"处理,剥除不稳定字段;
- 期望输出文件禁止手工修改,只能通过重新运行测试后拷贝生成物来更新;
- 测试应同时打印输入与输出,便于评审者直接核对期望值是否正确。
之所以按目录切分多个变体(sbeFull、sbeRestricted、sbeDisabled、featureFlagSbeFull、internalEnableJoinOptimization等),是因为同一查询在不同执行引擎/功能开关组合下可能产生不同的计划形态。sbeFull目录对应 SBE(Stage-Based Executor,基于代码生成的聚合/查询执行引擎)完全启用的构建变体。本文分析的正是其中一个文件的完整预期内容。
测试脚本本身通过 Bazel 规则聚合进套件:jstests/query_golden/BUILD.bazel 中all_javascript_files通过glob(["*.js"])收集本目录全部测试脚本。
2. 测试场景设计:复现规划器 bounds 振荡缺陷
预期输出由测试脚本 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. */即这是一个针对嵌套$and/$or谓词的特定查询的复现用例:规划器枚举器(plan enumerator)对此查询只生成一份计划,但该计划的 index bounds 会在两组取值间振荡。把它固化成黄金输出后,任何导致 bounds 漂移或计划树变化的改动都会被 diff 出来。
2.1 数据与索引
脚本使用db.or_pred_pushdown_coll集合,插入两条文档(见预期输出开头段落):
const docs = [ { "_id": 187, "t": ISODate("1970-01-01T00:00:00Z"), "m": {"m1": NumberInt(0), "m2": NumberInt(0)}, "array": [], // This makes the index we create below multikey. "a": NumberInt(0), "b": NumberInt(0), }, { "_id": 83, "t": ISODate("1970-01-01T00:00:00Z"), "m": {"m1": NumberInt(0), "m2": ISODate("1970-01-01T00:00:00Z")}, "array": "", "a": NumberInt(0), "b": NumberInt(0), }, ];其中_id: 187文档的"array": []是刻意设计的——注释写明它使复合索引成为multikey 索引。随后通过resetCollection(实现于 jstests/query_golden/libs/utils.js)完成 drop → 插入 → 打印文档 → 建索引的完整复位:
resetCollection(coll, docs, /* indexes */ [{t: 1, array: 1}]);预期输出中对应段落为:
[jsTest] Resetting collection. Inserting docs: ... Collection count: 2 [jsTest] Creating indexes: ... { "array" : 1, "t" : 1 }resetCollection的完整行为是:先coll.drop(),对未显式指定_id的文档补顺序_id(sequentialIds),逐条打印文档后批量插入,打印集合计数,再按给定顺序创建全部索引。这一"打印输入"的动作正是黄金测试框架"输入输出并排呈现"最佳实践的具体落地。
2.2 查询管道
被测的管道是一个单一$match阶段,同时携带$or与$and两个逻辑分组:
const pipeline = [ { "$match": { "$or": [{"t": {"$exists": true}}, {"_id": 0, "a": 0}], "$and": [{"array": {"$nin": [0]}}, {"array": {"$eq": ""}}], }, }, ]; outputAggregationPlanAndResults(coll, pipeline);各谓词的匹配语义(决定了 2.3 节为什么只返回一条结果):
$or: [ {t: {$exists: true}}, {_id: 0, a: 0} ]:文档满足"t字段存在"或"_id == 0且a == 0"之一。两条文档均有t,故该分支全部通过;第二条分支则精确指向_id: 0的文档(实际不存在),它被保留是为了让规划器枚举出_id_索引扫描这一分支计划。$and: [ {array: {$nin: [0]}}, {array: {$eq: ""}} ]:array数组中不含0,且array字段值本身等于空字符串。_id: 187文档的array: []虽满足$nin(空数组不匹配任何元素),但不满足$eq: "";_id: 83文档的array: ""同时满足两者。
3. 预期输出逐段解析
以下各小节标题(### Pipeline/### Results/### Total indexes on the collection/### Summarized explain)均由公共工具函数 outputAggregationPlanAndResults 生成:它执行coll.aggregate(pipeline)拿结果、coll.explain().aggregate(pipeline)拿 explain,再用formatExplainRoot把 explain 摘要化、用tojsonMultiLineSortKeys做键排序序列化以保证输出稳定。下面对照 预期输出文件 逐段说明。
3.1 Pipeline 小节
该小节把输入的管道原样序列化进输出(键排序后的 JSON),保证评审者能把"输入"与后面的计划并排核对:
[ { "$match" : { "$or" : [ { "t" : { "$exists" : true } }, { "_id" : 0, "a" : 0 } ], "$and" : [ { "array" : { "$nin" : [ 0 ] } }, { "array" : { "$eq" : "" } } ] } } ]3.2 Results 小节
结果集只有一条——_id: 83的文档,与 2.2 节的语义分析一致:
{ "_id" : 83, "a" : 0, "array" : "", "b" : 0, "m" : { "m1" : 0, "m2" : ISODate("1970-01-01T00:00:00Z") }, "t" : ISODate("1970-01-01T00:00:00Z") }3.3 Total indexes on the collection 小节
该小节由outputAvailableIndexes生成,列出集合上的全部索引名:
[ "_id_", "t_1_array_1" ]即除默认_id_索引外,只存在测试显式创建的复合索引t_1_array_1(即{t: 1, array: 1})。
3.4 Summarized explain 小节:获胜计划与 bounds
小节开头的Execution Engine: sbe行由getEngine(explain)提取,确认该变体下查询以 SBE 引擎执行。核心是queryPlanner摘要:queryShapeHash稳定(标识查询形状用于计划缓存),rejectedPlans为空,winningPlan为如下阶段树:
{ "queryShapeHash" : "22F20D87ABD4D959DDE7E39FDB212E091657B9BF7FA2D6EB366103791557D83C", "rejectedPlans" : [ ], "winningPlan" : [ { "filter" : { "$and" : [ { "array" : { "$not" : { "$eq" : 0 } } }, { "array" : { "$eq" : "" } } ] }, "nss" : "test.or_pred_pushdown_coll", "stage" : "FETCH" }, { "stage" : "OR" }, { "filter" : { "t" : { "$exists" : true } }, "nss" : "test.or_pred_pushdown_coll", "stage" : "FETCH" }, { "direction" : "forward", "indexBounds" : { "array" : [ "[\"\", \"\"]" ], "t" : [ "[MinKey, MaxKey]" ] }, "indexName" : "t_1_array_1", "isMultiKey" : true, "isPartial" : false, "isSparse" : false, "isUnique" : false, "keyPattern" : { "array" : 1, "t" : 1 }, "multiKeyPaths" : { "array" : [ "array" ], "t" : [ ] }, "nss" : "test.or_pred_pushdown_coll", "stage" : "IXSCAN" }, { "filter" : { "a" : { "$eq" : 0 } }, "nss" : "test.or_pred_pushdown_coll", "stage" : "FETCH" }, { "direction" : "forward", "indexBounds" : { "_id" : [ "[0.0, 0.0]" ] }, "indexName" : "_id_", "isMultiKey" : false, "isPartial" : false, "isSparse" : false, "isUnique" : true, "keyPattern" : { "_id" : 1 }, "nss" : "test.or_pred_pushdown_coll", "stage" : "IXSCAN" } ] }从源码结构看,这棵树体现了典型的OR 计划分解 + 谓词下推(predicate pushdown)形态,可以分三层理解:
- 索引扫描层:两个 IXSCAN 分别对应
$or的两个分支——t_1_array_1复合索引扫描:isMultiKey: true,multiKeyPaths显示只有array是 multikey 路径(呼应 2.1 节array: []的设计)。bounds 为t: [MinKey, MaxKey]、array: ["", ""]:即t分支没有可用的定值条件(仅$exists),而array的等值边界["", ""]是从$and组中下推到索引扫描的等值谓词。_id_唯一索引扫描:bounds 为[0.0, 0.0],对应$or第二分支的_id: 0点查。
- FETCH 过滤层:每个 IXSCAN 之上各挂一个 FETCH,携带无法进入索引 bounds 的残留过滤条件——复合索引分支上的 FETCH 保留
t: {$exists: true};_id_分支上的 FETCH 保留a: {$eq: 0};最外层 FETCH 再应用$and的完整过滤。 - 顶层 FETCH 中的
$nin重写:值得注意的是,输入管道写的是"array": {"$nin": [0]},而获胜计划顶层 FETCH 的 filter 显示为"$not": {"$eq": 0}。这是优化器对单元素$nin的等价改写($nin: [v]≡NOT ($eq: v)),也是该黄金输出锁定的可观测行为之一:如果 SBE 全量启用下的谓词重写规则发生变化,这一行会直接出现在 diff 里。
中间的{"stage": "OR"}节点把两个分支的扫描结果做并集合并——这正是 "or predicate pushdown"($or谓词下推)测试名的由来:规划器把$or分解为多个独立的索引扫描分支,各自携带下推的 bounds,再由 OR 阶段归并。而 SERVER-106983 的缺陷正是:枚举器为这种分解只产出一个计划,但该计划的 bounds 会在两组取值间振荡(例如array边界在["", ""]与全范围之间漂移)。把整个winningPlan结构(含 bounds、multikey 标记、各 FETCH 的 filter)全部签入金标准,就是对"振荡"这一非确定性行为最敏感的回归探针——任何 bounds 或阶段顺序变化都会使 diff 非空。
4. 如何验证、diff 与更新这类黄金输出
框架层面,golden_data_test_framework.md 给出了标准工作流:
- 首次使用需一次性初始化本地配置(
buildscripts/golden_test.py setup,或手工创建~/.golden_test_config.yml并导出GOLDEN_TEST_CONFIG_PATH),配置项包括outputRootPattern(每次运行写 expected/actual 输出的根路径模式)与diffCmd(默认git diff --no-index "{{expected}}" "{{actual}}"); - 运行测试后,若实际输出与签入文件不一致,测试失败并各自落盘
actual/与expected/两份产物; - 用 buildscripts/golden_test.py 管理输出:
# 列出最近一次测试运行的全部输出 buildscripts/golden_test.py list # diff 最近一次运行中所有不一致的输出 buildscripts/golden_test.py diff # 将最近一次运行的实际输出批量拷贝为新的期望输出 buildscripts/golden_test.py accept对于像本文场景这样会跑多个 passthrough/构建变体(因此存在sbeFull、sbeDisabled等多个期望文件)的测试,文档专门提供了批量更新方式:
buildscripts/golden_test.py --verbose clean-run-accept jstests/query_golden/NAME_OF_TEST.js该命令通过resmoke.py find-suites判定测试所属的 passthrough 套件并逐一运行;若测试仅属于query_golden_classicpassthrough,则会假定其需要在多种internalQueryFrameworkControl取值下运行(对应不同执行引擎变体),从而一次更新全部变体目录下的期望文件。这正解释了expected_output/下按变体分目录的组织方式与本文文件所在目录sbeFull/的来源。
5. 小结
- match_or_predicate_pushdown.md 是 SBE 完全启用变体下、嵌套
$or/$and查询的黄金输出:它固化了输入文档、复合索引{t: 1, array: 1}、管道、单条匹配结果,以及"双 IXSCAN + OR + 分层 FETCH"的完整获胜计划树; - 该计划树是
$or谓词下推的直接证据:_id分支点查 bounds[0.0, 0.0]、复合索引分支array: ["", ""]等值边界 +t: [MinKey, MaxKey]全范围,顶层 FETCH 同时锁定了单元素$nin被改写为$not {$eq}的可观测行为; - 作为 SERVER-106983 的复现用例,它把规划器 bounds 振荡这一难以用断言精确表述的缺陷,转化成了对
winningPlan全结构的确定性比对; - 相关工具链(
resetCollection、outputAggregationPlanAndResults、golden_test.py)均位于 jstests/query_golden/libs/utils.js、jstests/libs/query/golden_test_utils.js 与 buildscripts/golden_test.py,配合 docs/golden_data_test_framework.md 的 diff/accept 流程即可完成该输出的验证与更新。
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考