ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

MongoDB 查询优化:`$expr: {$in: [常量, $字段路径]}` 的索引重写优化(Reversed $in Rewrite)解析

MongoDB 查询优化:`$expr: {$in: [常量, $字段路径]}` 的索引重写优化(Reversed $in Rewrite)解析 MongoDB 查询优化$expr: {$in: [常量, $字段路径]}的索引重写优化Reversed $in Rewrite解析【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo本文聚焦 MongoDB 源码仓库中的 Golden Data 测试期望输出文件 expected_output/sbeFull/expr_in_rewrite.md它记录了一项查询优化器特性将聚合表达式形式的$in常量在前、字段路径在后重写为可走索引的 Match 谓词。通过逐条解读其中的 39 组查询计划并结合 rewrite_expr.cpp 与 query_optimization_knobs.idl 的源码实现你将掌握该重写的触发条件、开关参数internalQueryExtraPredicateForReversedIn的作用机制、双重过滤索引缩小候选集 原始$expr保证正确性的底层原理以及 null/数组/对象/字符串等边界类型的实际计划表现。1. 背景$expr为什么通常走不了索引在 MongoDB 中$expr允许在 find 的 filter 中直接使用聚合表达式。但聚合表达式按文档整体求值传统上查询规划器无法从中推导出可索引的谓词因此{$expr: ...}过滤的查询往往退化为 COLLSCAN全表扫描。本测试针对的正是其中一类高频写法——“反置”$in{ $expr : { $in : [ 1, $m ] } }语义是“判断常量1是否属于字段m的数组元素”与 Match 语言的{ m : 1 }multikey 隐式数组遍历语义高度相似。如果优化器能把这个表达式“翻译”成 Match 谓词{ m : { $eq : 1 } }规划器就能利用{ m: 1 }索引做 IXSCAN。1.1 测试数据的来源与组织方式该 .md 文件不是手写文档而是由测试脚本 expr_in_rewrite_md.js 运行生成的 Golden Data黄金数据期望输出。这套框架的工作方式在 golden_data_test_framework.md 中有说明测试产生确定性的文本输出与签入仓库的期望文件逐字节比对任何差异都会导致测试失败——它既是对优化行为的“回归锁”也是本文章所有结论的直接证据来源。测试脚本做了以下事情建表test.expr_in_rewrite_md插入约 40 个覆盖边界情况的文档普通数组、嵌套数组、含 null、空数组、空对象、{a: 1}对象、字符串与正则等coll.insertMany([ {m: []}, {m: [[]]}, {m: [[[]]]}, {a: 1, m: [1]}, {a: 2, m: [1, 2, 3]}, {m: [4, 5, 6, null, 10]}, {m: [null, null, null]}, // Nested array cases. {m: [[1]]}, {m: [[[1]]]}, {m: [[1, 2, 3, 4]]}, {m: [[2, 1]]}, {m: [[[null]]]}, // Object cases {m: [{}]}, {m: [{a: 1}]}, {m: [{a: 1, b: 1}]}, // String regex {m: [a, b, c]}, {m: [/abc/]}, ]);创建两个索引m_1{m: 1}和{m.a: 1}——这正是后文所有 IXSCAN 计划的索引来源。对每个 filter分别在优化开关关闭setParameter: internalQueryExtraPredicateForReversedIn false与打开 true两种状态下执行findexplain输出三段内容Find results查询结果、Parsed find query解析后的查询即优化是否生效的直接体现、Summarized explain归一化后的查询计划。关键校验assertArrayEq({expected: resOff, actual: resOn})——开关前后查询结果必须完全一致这是“优化不改变语义”的硬约束。2. 逐条解读期望输出39 组用例的共性与差异期望输出文件共 4835 行包含 39 个## N. Find filter小节每小节结构固定## N. Find filter 原始 filter ### Query knob off Find results / Parsed find query / Summarized explain ### Query knob on Find results / Parsed find query / Summarized explain2.1 基准用例常量数字{ $expr : { $in : [ 1, $m ] } }开关关闭时Parsed find query 保持$expr原样仅把字面量 1 规范化为{ $const : 1 }计划为 COLLSCANwinningPlan : [ { stage : PROJECTION_SIMPLE, transformBy : { _id : false } }, { direction : forward, filter : { $expr : { $in : [ { $const : 1 }, $m ] } }, nss : test.expr_in_rewrite_md, stage : COLLSCAN } ]开关打开后Parsed find query 变成{ $and : [ { m : { $eq : 1 } }, { $expr : { $in : [ { $const : 1 }, $m ] } } ] }计划随之变为三层结构winningPlan : [ { stage : PROJECTION_SIMPLE, transformBy : { _id : false } }, { filter : { $expr : { $in : [ { $const : 1 }, $m ] } }, stage : FETCH }, { direction : forward, indexBounds : { m : [ [1.0, 1.0] ] }, indexName : m_1, isMultiKey : true, keyPattern : { m : 1 }, multiKeyPaths : { m : [ m ] }, stage : IXSCAN } ]三个要点新增的谓词是{ m : { $eq : 1 } }它使m_1索引以精确等值区间[1.0, 1.0]参与定位原始$expr仍作为 FETCH 阶段的 filter 保留——这保证了语义严格等价原因见第 4 节计划中isMultiKey: true且出现multiKeyPaths说明依赖的是 multikey 索引的隐式数组遍历。测试脚本中也注释了这一点不能得到 covered plan因为索引必须是 multikey若m不是数组查询本身就会报错。另外注意queryShapeHash在开关前后保持不变如基准用例均为CAB0CA88D371E9913F6A2EDBD915529A5997210CA3F24EAB63BF1CE6A95E642D。形状哈希基于原始查询计算说明该重写发生在计划层面的谓词增强而不改变查询形状缓存的键。2.2 各类常量的统一模式39 组用例覆盖了几乎所有 BSON 常量类型重写后的额外谓词一律遵循{ 字段路径 : { $eq : 常量 } }的形式。归纳如下原始 filter 片段开关打开后的 Parsed find query典型 indexBounds对应小节{ $in : [ 1, $m ] }{ $and : [ { m : { $eq : 1 } }, ... ] }[1.0, 1.0]第 1 节{ $in : [ null, $m ] }{ m : { $eq : null } }[null, null]第 2 节{ $in : [ [], $m ] }{ m : { $eq : [] } }[undefined, undefined]与[[], []]两段第 3 节{ $in : [ [1], $m ] }{ m : { $eq : [1] } }[1.0, 1.0]与[[ 1.0 ], [ 1.0 ]]第 4 节{ $in : [ {}, $m ] }{ m : { $eq : {} } }[{}, {}]第 11 节{ $in : [ {a:1}, $m ] }{ m : { $eq : {a:1} } }[{ a: 1.0 }, { a: 1.0 }]第 13 节{ $in : [ a, $m ] }{ m : { $eq : a } }[\a\, \a\]第 17 节{ $in : [ [[[1]]], $m ] }{ m : { $eq : [[[1]]] } }嵌套区间结果为空集第 10 节值得留意的细节嵌套数组常量会产生多段 indexBounds。以第 3 节[]常量为例indexBounds 是[undefined, undefined]与[[], []]两个区间的并集——这是 multikey 索引把父级数组元素同时按其自身和展开后的子元素建键的结果IXSCAN 需要覆盖两段键空间。第 4、7、8、9 节的[1]、[1, 2]、[[1]]、[[[1,2]]]同样呈现这种“原始值 展开值”的双区间形态。查询结果与开关严格一致。例如第 1 节结果[{a:1, m:[1]}, {a:2, m:[1,2,3]}, {m:[1,2]}, {m:[5,2,1,3,6]}]在 off/on 两种状态下逐条相同第 10 节[[[1]]]三层嵌套在两种状态下都返回空集[]但开关打开后依然走 IXSCAN——即即使结果为空优化也照常生效。对象常量的 indexBounds 呈现{ a: 1.0 }形式第 13、15 节说明 Explain 输出对对象键值做了浮点规范化显示与数字常量的1.0表示一致。2.3 复合表达式$or/$and中嵌套的$in测试脚本最后几组用例把反置$in放进逻辑组合里对应期望输出第 26~29 节附近例如{ $expr : { $or : [ { $in : [ 1, $m ] }, { $in : [ 2, $m ] } ] } }{ $expr : { $and : [ { $in : [ $a, [1, 2] ] }, { $or : [ { $in : [ 1, $m ] }, { $in : [ 2, $m ] } ] } ] } }这些用例验证了重写在$or/$and下的逐子表达式独立触发能力符合“常量在前、字段路径在后”形态的$in子项被各自替换为{m: {$eq: ...}}的并集谓词而形如{ $in : [ $a, [1, 2, 10] ] }字段路径在前、常量数组在后的子项属于另一条重写路径见第 3.2 节不受本开关控制。结果一致性校验assertArrayEq确保复合表达式下语义同样不漂移。3. 开关定义internalQueryExtraPredicateForReversedIn3.1 参数语义与默认值该开关定义在 query_optimization_knobs.idl原文描述为Enable an optimization for queries like{$expr: {$in: [const, $fieldpath]}}that generates an extra predicate in order to allow indexes on$fieldpathto be used. This optimization is applied irrespective of this query knob if we are dealing with a FLE query.关键属性属性值含义cpp_varnameinternalQueryExtraPredicateForReversedInC 侧原子布尔变量Atomicbooldefaultfalse默认关闭属于需显式开启的内部优化set_at[startup, runtime]既可在启动时配置也可运行时通过setParameter动态调整测试正是这么做的wire_nameextraPredicateForReversedIn对外暴露的 query knob 名称FLE 特例无条件生效即使开关为 falseFLEField Level Encryption查询也会触发该重写测试脚本开头与结尾分别读取并还原参数值getParameter保存、finally中setParameter恢复保证不留副作用。3.2 源码中的双分支重写逻辑实现位于 rewrite_expr.cpp 的RewriteExpr::_rewriteInExpression。$expr表达式树先被RewriteExpr::rewrite遍历_rewriteExpression按$and/$or/比较/$in分派到达$in节点时按“哪一侧是字段路径”分为两条路径路径 A常量在前本开关控制的核心路径。当左侧lhs不是字段路径、而是常量时auto lhsFieldPath dynamic_castExpressionFieldPath*(lhs); if (!lhsFieldPath) { if (internalQueryExtraPredicateForReversedIn.load() || expr-getExpressionContext()-isFleQuery()) { // ... if (auto* lhsConst dynamic_castExpressionConstant*(lhs); lhsConst) { auto* rhsFieldPath dynamic_castExpressionFieldPath*(rhs); if (rhsFieldPath validateFieldPathForExprInRewrite(*rhsFieldPath)) { if (lhsConst-getValue().getType() BSONType::regEx) { // Would trigger BadValue: Cannot insert regex into InListData. return nullptr; } return rewriteExprInMatchExpressiontrue /* wrapConstInArray */(*rhsFieldPath, *lhsConst); } } } return nullptr; }仅当knob 打开或这是 FLE 查询时才进入重写右侧必须是本地文档字段路径validateFieldPathForExprInRewrite排除了变量引用与$ROOT裸正则常量被显式排除否则会触发Cannot insert regex into InListData错误——这解释了测试脚本中第 145 行的注释“we dont test with regex outside an array because we dont do the rewrite in that case”正则包在数组里、如[ /a/ ]时则可以重写重写时通过wrapConstInArray true把常量包一层数组见下。路径 B常量数组在后默认路径无需开关。左侧是字段路径、右侧是常量数组时直接检查数组元素类型// If any of the following types are present in the $in array, the expression is // ineligible for the rewrite because the semantics are different between // MatchExpression and agg. // - Array: MatchExpressions have implicit array traversal semantics... // - Null: MatchExpressions will also match on missing values... // - Regex: MatchExpressions will evaluate the regex, while agg only matches the exact regex for (const auto el : rhsVal.getArray()) { switch (el.getType()) { case BSONType::array: case BSONType::null: case BSONType::undefined: case BSONType::regEx: return nullptr; default: break; } }注意这里源码注释点明了 MatchExpression 与聚合语义的三处差异隐式数组遍历、null 匹配缺失值、正则求值这正是第 4 节“双谓词共存”必要性的直接依据。两条路径最终汇合到同一个模板函数 rewriteExprInMatchExpressiontemplate bool wrapConstInArray std::unique_ptrInMatchExpression rewriteExprInMatchExpression( const ExpressionFieldPath fieldPathExpr, const ExpressionConstant literalExpr) { auto fieldPath fieldPathExpr.getFieldPath().tail().fullPath(); BSONArray inArray; auto value literalExpr.getValue(); if constexpr (wrapConstInArray) { inArray BSON_ARRAY(value); // 反置情形把常量包进数组 } else { BSONArrayBuilder bb; // 正置情形常量数组直接展开 for (const auto val : value.getArray()) val.addToBsonArray(bb); inArray bb.arr(); } auto inMatch std::make_uniqueInMatchExpression(std::string_view(fieldPath)); uassertStatusOK(inMatch-setEqualitiesArray(inArray)); return inMatch; }产物是一个InMatchExpression即 Match 语言的path: { $in: [...] }等值列表由setEqualitiesArray装载。生成后的 Match 表达式再经过optimizeMatchExpression优化——期望输出中呈现的{ m : { $eq : 1 } }正是InMatchExpression单元素等值数组被规范化后的形态。4. 为什么额外谓词与原始$expr并存$and结构观察所有开关打开后的 Parsed find query无一例外是{ $and : [ { path : { $eq : 常量 } }, { $expr : { 原始表达式 } } ] }新增的索引谓词只负责“缩小候选集”原始$expr必须原样保留在 FETCH 阶段做最终裁决。源码中rewriteExprInMatchExpression上方的注释直接给出了理由rewrite_expr.cppThis is the first level of filtering that can take advantage of indexes. It may return a superset of results because MatchExpressions have implicit array traversal semantics that are not present in agg. The original predicate is maintained in the second level of filtering for correctness.两类语义差异决定了索引谓词只能返回“超集”隐式数组遍历Match 的{ m: {$in: [1]} }会下钻到m的任意层级数组元素去匹配multikey 语义而聚合$in只做严格的 BSON 相等比较。第 4 节用例常量[1]最能说明IXSCAN 的 indexBounds 同时包含[1.0, 1.0]展开值与[[ 1.0 ], [ 1.0 ]]原值两段即索引层面把“值等于 1”的文档也捞了进来此时若没有 FETCH 上的原始$expr复检{ m: [1] }这类文档就会被错误地纳入结果。null 与缺失值MatchExpression 中 null 谓词会匹配字段缺失的文档聚合$in只匹配显式的null。第 2 节常量null中若只靠{ m: {$eq: null} }索引过滤结果集会超集化正是 FETCH 阶段的$expr剔除了不含null的文档使 off/on 结果都精确为那三个含 null 的数组。这种“粗筛 精筛”结构是典型的 safe rewrite索引谓词保证召回完备不漏$expr保证精确不误而测试脚本对 39 组用例全部执行的结果一致性断言就是这套机制的端到端验证。5. 错误行为与边界情况测试脚本 expr_in_rewrite_md.js 还覆盖了“坏文档”场景插入一个m字段缺失的文档{_id: force failure due to non-array $m}后对$in目标字段求值会在查询执行期失败function validateError(filter) { assert.commandWorked(db.adminCommand({setParameter: 1, [paramName]: false})); assert.commandFailedWithCode(db.runCommand({find: coll.getName(), filter}), [40081, 5153700]); assert.commandWorked(db.adminCommand({setParameter: 1, [paramName]: true})); assert.commandFailedWithCode(db.runCommand({find: coll.getName(), filter}), [40081, 5153700]); }要点错误码40081对应 $expr 求值失败类别与5153700在开关开与关两种状态下都必须出现——重写优化不能改变错误语义测试注释还记录了一个已知边界{$expr: {$in: [1, $m]}}在 IXSCAN 路径下不会像 COLLSCAN 路径那样触发该错误“We accept this because we have precedent”因此被有意注释掉不校验。从源码结构看这与索引扫描按键定位、只在命中文档上做$expr求值的路径差异有关。$m.a点路径用例脚本 161~170 行验证了重写同样适用于点路径字段谓词变为{ m.a : { $eq : 常量 } }可利用{m.a: 1}索引validateFieldPathForExprInRewrite只排除变量引用与$ROOT点路径合法。6. 如何在实际环境验证该优化该特性默认关闭default: false且属于带internal前缀的查询 knob适用前提与验证步骤如下均基于当前仓库代码行为非公开承诺的稳定接口确认参数存在与默认值db.adminCommand({getParameter: 1, internalQueryExtraPredicateForReversedIn: 1}) // 默认返回 false运行时开启set_at: [startup, runtime]支持动态设置db.adminCommand({setParameter: 1, internalQueryExtraPredicateForReversedIn: true})用 explain 验证计划切换对db.coll.find({$expr: {$in: [1, $m]}})执行 explain开关前winningPlan应出现带$exprfilter 的 COLLSCAN开关后应出现IXSCAN(m_1)FETCH(filter 保留原始 $expr)的组合indexBounds 呈等值区间如[1.0, 1.0]。前提目标字段存在等值可用索引且为 multikey数组字段天然满足常量不能是裸正则FLE 查询则无需开关自动受益。验证完毕建议还原参数正如测试脚本在finally块中做的那样。7. 小结expected_output/sbeFull/expr_in_rewrite.md 这 39 组黄金数据完整地刻画了 MongoDB 对“反置$in”表达式的索引重写行为机制{$expr: {$in: [常量, $路径]}}在internalQueryExtraPredicateForReversedIn开启或 FLE 查询时被 RewriteExpr 增强为$and结构——索引可用的{路径: {$eq: 常量}}谓词 保留原始$expr效果计划从 COLLSCAN 升级为IXSCAN(m_1) → FETCH → PROJECTION_SIMPLE嵌套数组/对象常量体现为多段 indexBounds 的 multikey 键空间覆盖安全额外谓词允许返回超集原始$expr在 FETCH 阶段兜底保证语义严格等价39 组用例的 off/on 结果一致性断言与错误码一致性断言是该机制的双保险验证入口改动 expr_in_rewrite_md.js 或重写逻辑后运行该 golden test 并与expected_output/下对应文件 diff即可回归检测计划与结果的任何漂移——这正是 golden_data_test_framework.md 所述“输出可 diff、可增量演进”的测试方法论在本特性上的落地。【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表