MongoDB 查询优化:`$expr: {$in: [常量, $字段路径]}` 的索引重写优化(Reversed $in Rewrite)解析
2026/9/13 17:06:15 网站建设 项目流程

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 中有说明:测试产生确定性的文本输出,与签入仓库的期望文件逐字节比对,任何差异都会导致测试失败——它既是对优化行为的“回归锁”,也是本文章所有结论的直接证据来源。

测试脚本做了以下事情:

  1. 建表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/]}, ]);
  1. 创建两个索引:m_1{m: 1})和{m.a: 1}——这正是后文所有 IXSCAN 计划的索引来源。
  2. 对每个 filter,分别在优化开关关闭(setParameter: internalQueryExtraPredicateForReversedIn = false)与打开(= true)两种状态下执行find+explain,输出三段内容:Find results(查询结果)、Parsed find query(解析后的查询,即优化是否生效的直接体现)、Summarized explain(归一化后的查询计划)。
  3. 关键校验: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 explain

2.1 基准用例:常量数字{ "$expr" : { "$in" : [ 1, "$m" ] } }

开关关闭时,Parsed find query 保持$expr原样(仅把字面量 1 规范化为{ "$const" : 1 }),计划为 COLLSCAN:

"winningPlan" : [ { "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" } ]

三个要点:

  1. 新增的谓词是{ "m" : { "$eq" : 1 } },它使m_1索引以精确等值区间[1.0, 1.0]参与定位;
  2. 原始$expr仍作为 FETCH 阶段的 filter 保留——这保证了语义严格等价(原因见第 4 节);
  3. 计划中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. 开关定义:internalQueryExtraPredicateForReversedIn

3.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++ 侧原子布尔变量,Atomic<bool>
defaultfalse默认关闭,属于需显式开启的内部优化
set_at[startup, runtime]既可在启动时配置,也可运行时通过setParameter动态调整(测试正是这么做的)
wire_nameextraPredicateForReversedIn对外暴露的 query knob 名称
FLE 特例无条件生效即使开关为 false,FLE(Field Level Encryption)查询也会触发该重写

测试脚本开头与结尾分别读取并还原参数值(getParameter保存、finallysetParameter恢复),保证不留副作用。

3.2 源码中的双分支重写逻辑

实现位于 rewrite_expr.cpp 的RewriteExpr::_rewriteInExpression$expr表达式树先被RewriteExpr::rewrite遍历(_rewriteExpression$and/$or/比较/$in分派),到达$in节点时按“哪一侧是字段路径”分为两条路径:

路径 A:常量在前(本开关控制的核心路径)。当左侧(lhs)不是字段路径、而是常量时:

auto lhsFieldPath = dynamic_cast<ExpressionFieldPath*>(lhs); if (!lhsFieldPath) { if (internalQueryExtraPredicateForReversedIn.load() || expr->getExpressionContext()->isFleQuery()) { // ... if (auto* lhsConst = dynamic_cast<ExpressionConstant*>(lhs); lhsConst) { auto* rhsFieldPath = dynamic_cast<ExpressionFieldPath*>(rhs); if (rhsFieldPath && validateFieldPathForExprInRewrite(*rhsFieldPath)) { if (lhsConst->getValue().getType() == BSONType::regEx) { // Would trigger BadValue: Cannot insert regex into InListData. return nullptr; } return rewriteExprInMatchExpression<true /* wrapConstInArray */>(*rhsFieldPath, *lhsConst); } } } return nullptr; }
  • 仅当knob 打开或这是 FLE 查询时才进入重写;
  • 右侧必须是本地文档字段路径(validateFieldPathForExprInRewrite排除了变量引用与$ROOT);
  • 裸正则常量被显式排除(否则会触发Cannot insert regex into InListData错误)——这解释了测试脚本中第 145 行的注释:“we don't test with regex outside an array because we don't 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 节“双谓词共存”必要性的直接依据。

两条路径最终汇合到同一个模板函数 rewriteExprInMatchExpression:

template <bool wrapConstInArray> std::unique_ptr<InMatchExpression> 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_unique<InMatchExpression>(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.cpp):

This 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.

两类语义差异决定了索引谓词只能返回“超集”:

  1. 隐式数组遍历: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] }这类文档就会被错误地纳入结果。
  2. 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,适用前提与验证步骤如下(均基于当前仓库代码行为,非公开承诺的稳定接口):

  1. 确认参数存在与默认值
db.adminCommand({getParameter: 1, internalQueryExtraPredicateForReversedIn: 1}) // 默认返回 false
  1. 运行时开启set_at: [startup, runtime]支持动态设置):
db.adminCommand({setParameter: 1, internalQueryExtraPredicateForReversedIn: true})
  1. 用 explain 验证计划切换:对db.coll.find({$expr: {$in: [1, "$m"]}})执行 explain,开关前winningPlan应出现带$exprfilter 的 COLLSCAN;开关后应出现IXSCAN(m_1)+FETCH(filter 保留原始 $expr)的组合,indexBounds 呈等值区间(如"[1.0, 1.0]")。
  2. 前提:目标字段存在等值可用索引且为 multikey(数组字段天然满足);常量不能是裸正则;FLE 查询则无需开关自动受益。
  3. 验证完毕建议还原参数,正如测试脚本在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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询