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)两种状态下执行find+explain,输出三段内容: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 }),计划为 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" } ]三个要点:
- 新增的谓词是
{ "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. 开关定义: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_varname | internalQueryExtraPredicateForReversedIn | C++ 侧原子布尔变量,Atomic<bool> |
default | false | 默认关闭,属于需显式开启的内部优化 |
set_at | [startup, runtime] | 既可在启动时配置,也可运行时通过setParameter动态调整(测试正是这么做的) |
wire_name | extraPredicateForReversedIn | 对外暴露的 query knob 名称 |
| FLE 特例 | 无条件生效 | 即使开关为 false,FLE(Field 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_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.
两类语义差异决定了索引谓词只能返回“超集”:
- 隐式数组遍历: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),仅供参考