MongoDB `$expr: {$in: [常量, “$字段“]}` 反向重写与索引利用优化:`internalQueryExtraPredicateForReversedIn` 金标测试深度解析
2026/9/13 9:37:10 网站建设 项目流程

MongoDB$expr: {$in: [常量, "$字段"]}反向重写与索引利用优化:internalQueryExtraPredicateForReversedIn金标测试深度解析

【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo

本文基于 MongoDB 官方仓库中的 golden test(金标测试)输出文档 expected_output/expr_in_rewrite.md 及其配套测试脚本 expr_in_rewrite_md.js,深入剖析查询优化器对形如{$expr: {$in: [<常量>, "$字段路径>"]}}这类"反向$in"表达式的重写机制:它如何通过附加一条可索引的等值谓词,让原本只能走COLLSCAN的查询改用IXSCAN + FETCH执行。读完本文,你将掌握该优化开关的语义、开关前后执行计划的差异、不同数据类型(数字、null、数组、嵌套数组、对象、字符串、正则)下索引边界(indexBounds)的生成规律,以及如何运行与校验这套金标测试。

一、背景:金标测试如何固化"计划稳定性"

在 MongoDB 的查询优化器开发中,计划稳定性(plan stability)是防止回归的关键指标。jstests/query_golden/目录下维护了一批 golden test:测试执行一组查询,把**当前的获胜计划(winning plan)**以 Markdown 形式固化在expected_output/目录中;一旦优化器行为发生变化导致输出与期望不一致,测试即失败,由人来判断变化是改进还是退化。框架说明见 README.plan_stability.md。

expr_in_rewrite_md.js正是其中一员,它专门验证一个优化:{$expr: {$in: [<const>, "$fieldpath"]}}形式的查询能够使用$fieldpath上的索引。其期望输出文档即本次研究的对象 expr_in_rewrite.md,共包含39 个 find filter 用例,每个用例都在"Query knob off / Query knob on"两种开关状态下记录三组信息:

  • Find results:真实返回的文档(_id被投影剔除);
  • Parsed find query:查询解析后的形态(explain.queryPlanner.parsedQuery);
  • Summarized explain:由formatExplainRoot扁平化后的获胜计划,含queryShapeHashrejectedPlanswinningPlan的 stage 链。

二、优化开关:internalQueryExtraPredicateForReversedIn

开关在 IDL 文件中定义,见 query_optimization_knobs.idl,关键属性如下:

属性
参数名internalQueryExtraPredicateForReversedIn
wire 名(对外暴露名)extraPredicateForReversedIn
默认值false(默认关闭)
设置时机startup(启动参数)、runtime(运行期setParameter
类型Atomic<bool>
官方描述{$expr: {$in: [<const>, "$fieldpath"]}}这类查询启用优化,生成一条额外谓词以允许使用$fieldpath上的索引;若查询是 FLE(字段级加密)查询,则无论该开关取值如何都会应用此优化

测试脚本 expr_in_rewrite_md.js 在运行期通过setParameter反复切换开关(第 104、108 行),并在finally块中恢复原值(第 180-183 行),确保测试不污染后续用例。

三、重写机制:从 COLLSCAN 到 IXSCAN + FETCH

用例 1(数字常量)为完整样本。原始查询:

{ "$expr" : { "$in" : [ 1, "$m" ] } }

3.1 开关关闭(Query knob off)

  • Parsed find query:仅将常量包装为$const,不做任何改写:
{ "$expr" : { "$in" : [ { "$const" : 1 }, "$m" ] } }
  • Summarized explainwinningPlanPROJECTION_SIMPLE下挂一个带$expr过滤条件的COLLSCAN(全表扫描),无法利用m_1索引:
{ "queryShapeHash" : "CAB0CA88D371E9913F6A2EDBD915529A5997210CA3F24EAB63BF1CE6A95E642D", "rejectedPlans" : [ ], "winningPlan" : [ { "stage" : "PROJECTION_SIMPLE", "transformBy" : { "_id" : 0 } }, { "direction" : "forward", "filter" : { "$expr" : { "$in" : [ { "$const" : 1 }, "$m" ] } }, "nss" : "test.expr_in_rewrite_md", "stage" : "COLLSCAN" } ] }

3.2 开关打开(Query knob on)

  • Parsed find query:查询被改写为$and前置追加一条可索引的等值谓词{ "m" : { "$eq" : 1 } },原$expr保留作为残余过滤(residual predicate),保证语义不变:
{ "$and" : [ { "m" : { "$eq" : 1 } }, { "$expr" : { "$in" : [ { "$const" : 1 }, "$m" ] } } ] }
  • Summarized explainwinningPlan变为PROJECTION_SIMPLE → FETCH → IXSCAN,其中:
{ "stage" : "FETCH", "filter" : { "$expr" : { "$in" : [ { "$const" : 1 }, "$m" ] } } }, { "direction" : "forward", "indexBounds" : { "m" : [ "[1.0, 1.0]" ] }, "indexName" : "m_1", "isMultiKey" : true, "keyPattern" : { "m" : 1 }, "multiKeyPaths" : { "m" : [ "m" ] }, "stage" : "IXSCAN" }

关键观察

  1. queryShapeHash在开关前后完全一致CAB0CA88...),说明两条额外谓词的追加不改变查询形状哈希——这是刻意设计,避免影响计划缓存与查询形状统计;
  2. 结果集完全一致:开关前后 Find results 相同([{a:1,m:[1]}, {a:2,m:[1,2,3]}, {m:[1,2]}, {m:[5,2,1,3,6]}]),测试脚本通过assertArrayEq强制校验两者等价(见 expr_in_rewrite_md.js);
  3. FETCH 阶段仍保留$expr过滤:追加的谓词只是"预筛"(pre-filter),真正的$in语义仍由原始$expr保证,因此不会因多键(multikey)索引的路径语义差异而返回错误结果;
  4. 由于m字段是数组,索引必然是多键索引(isMultiKey: true),因此无法形成覆盖计划(covered plan)——脚本注释也明确指出这一点(expr_in_rewrite_md.js)。

四、测试数据与索引

测试集合test.expr_in_rewrite_md的数据(expr_in_rewrite_md.js)覆盖了丰富的数据形态:空数组、空对象、普通数字数组、null 数组、四层嵌套数组([[[[1]]]])、对象数组、字符串数组、正则数组等。索引方面创建了两个:

assert.commandWorked(coll.createIndex({m: 1})); assert.commandWorked(coll.createIndex({"m.a": 1}));

前者服务$m系列用例,后者服务$m.a系列用例。注意m.a是多级路径索引,其multiKeyPaths显示为["m", "m.a"](两层都为多键路径),而m_1multiKeyPaths仅为["m"]

五、按数据类型逐一拆解($m系列,用例 1-25)

5.1 数字与 null(用例 1-2)

用例Filter 常量Find resultsKnob on 追加谓词Index bounds
114 条(含a:1/a:2文档){"m": {"$eq": 1}}[1.0, 1.0]
2null3 条([4,5,6,null,10][null,null,null][[null],null]{"m": {"$eq": null}}[null, null]

用例 2 的改写结果值得注意——knob on 时解析查询变为{"$and": [{"m": {"$eq": null}}, {"$expr": ...}]},且 FETCH 阶段的 filter 也显式为$and形态;IXSCAN 的indexBounds[null, null]。这说明等值 null 可以被索引扫描直接定位,无需扫描所有条目再过滤。

5.2 数组与嵌套数组(用例 3-10)

这是最能体现"索引边界展开"能力的部分:

用例Filter 常量Find resultsKnob on 追加谓词Index bounds
3[]1 条(m: [[]]{"m": {"$eq": []}}[undefined, undefined][[], []]
4[1]2 条(m:[[1]]m:[[1],[2]]{"m": {"$eq": [1]}}[1.0, 1.0][[ 1.0 ], [ 1.0 ]]
5[2]2 条(m:[[2]]m:[[1],[2]]{"m": {"$eq": [2]}}[2.0, 2.0][[ 2.0 ], [ 2.0 ]]
6[null]1 条(m:[[null],null]{"m": {"$eq": [null]}}[null, null][[ null ], [ null ]]
7[1, 2]2 条(m:[[1,2]]m:[[1,2,3,4],[1,2]]{"m": {"$eq": [1,2]}}[1.0, 1.0][[ 1.0, 2.0 ], [ 1.0, 2.0 ]]
8[[1]]2 条(m:[[[1]]]m:[[[1]],[[2]]]{"m": {"$eq": [[1]]}}[[ 1.0 ], [ 1.0 ]][[ [ 1.0 ] ], [ [ 1.0 ] ]]
9[[1, 2]]1 条(m:[[[1,2]]]{"m": {"$eq": [[1,2]]}}[[ 1.0, 2.0 ], [ 1.0, 2.0 ]][[ [ 1.0, 2.0 ] ], [ [ 1.0, 2.0 ] ]]
10[[[1]]]0 条{"m": {"$eq": [[[1]]]}}[[ [ 1.0 ] ], [ [ 1.0 ] ]][[ [ [ 1.0 ] ] ], [ [ [ 1.0 ] ] ]]

规律提炼:对于数组常量,索引边界会被逐层展开。例如用例 4 的常量[1]生成的边界同时包含[1.0, 1.0](匹配数组中的元素1)和[[1.0], [1.0]](匹配数组元素本身恰为[1])。这正是 MongoDB 多键索引 + 数组等值匹配的边界语义:一个元素既可能以标量形式被索引,也可能以数组整体形式被索引。随着嵌套层级加深(用例 8/9/10),边界数量与嵌套深度同步增加。

Query shape 观察[1][2][1,2]三个不同常量共享同一个queryShapeHash6061C7642A45F0C552A2B27C2D0D95DEC4103AF55835DD9E44FDBBD56734483B),[[1]][[1,2]][[[1]]]共享90114C24...。这说明queryShapeHash只反映查询的结构形状(如"数组常量套字段路径"),与具体常量值无关。

5.3 对象(用例 11-16)

用例Filter 常量Find resultsKnob on 追加谓词Index bounds
11{}1 条(m:[{}]{"m": {"$eq": {}}}[{}, {}]
12[{}]1 条(m:[[{}]]{"m": {"$eq": [{}]}}[{}, {}][[ {} ], [ {} ]]
13{a: 1}1 条(m:[{a:1}]{"m": {"$eq": {a:1}}}[{ a: 1.0 }, { a: 1.0 }]
14[{a: 1}]1 条(m:[[{a:1}]]{"m": {"$eq": [{a:1}]}}[{ a: 1.0 }, { a: 1.0 }][[ { a: 1.0 } ], [ { a: 1.0 } ]]
15{a: 1, b: 1}1 条(m:[{a:1,b:1}]{"m": {"$eq": {a:1,b:1}}}[{ a: 1.0, b: 1.0 }, { a: 1.0, b: 1.0 }]
16[{}](重复用例)1 条同用例 12同用例 12

对象常量同样可以进入索引边界。注意一个容易误解的点:用例 11 中{}的边界[{}, {}]表示索引中确有一个空对象条目,而用例 13 的{a:1}边界显示为[{ a: 1.0 }, { a: 1.0 }]——BSON 中的整型1在索引键中被规范化为 double1.0。另外{a:1}{a:1,b:1}共享queryShapeHash7905AD4C...),再次印证哈希与字段内容无关。

5.4 字符串(用例 17-22)

用例Filter 常量Find resultsKnob on 追加谓词Index bounds
17"a"1 条(m:["a","b","c"]{"m": {"$eq": "a"}}["a", "a"]
18"ab"0 条{"m": {"$eq": "ab"}}["ab", "ab"]
19"abc"2 条(m:["abc"]m:["ghi","abc","def"]{"m": {"$eq": "abc"}}["abc", "abc"]
20["a"]0 条{"m": {"$eq": ["a"]}}["a", "a"][[ "a" ], [ "a" ]]
21["ab"]0 条{"m": {"$eq": ["ab"]}}["ab", "ab"][[ "ab" ], [ "ab" ]]
22["abc"]0 条{"m": {"$eq": ["abc"]}}["abc", "abc"][[ "abc" ], [ "abc" ]]

值得注意:数据集里存在["abc"](大小写敏感比较)等字符串,但没有与"a"/"ab"恰好相等的条目,因此用例 18/20/21/22 返回空结果集——但这不影响重写发生,IXSCAN 依然执行。所有字符串用例共享queryShapeHashC60A7B3F...7D05D020...)。

5.5 正则(用例 23-25)

正则常量只以数组形式[/a/][/b/][/abc/])出现在测试中,因为源码注释明确指出:正则不在数组中时不会触发该重写(expr_in_rewrite_md.js)。

用例Filter 常量Find resultsIndex bounds
23[/a/]0 条[[ /a/ ], [ /a/ ]][/a/, /a/]
24[/b/]0 条[[ /b/ ], [ /b/ ]][/b/, /b/]
25[/abc/]0 条[[ /abc/ ], [ /abc/ ]][/abc/, /abc/]

这里出现了一个非平凡的边界语义$in语义要求数组元素逐元素相等,因此数组[/a/]要匹配m中恰好等于[/a/]的数组元素;但正则的"相等"在 BSON 比较中遵循特殊规则(正则与正则、正则与字符串的匹配语义),于是索引边界被展开为两层:一层是"数组元素为[/a/]数组"的边界[[ /a/ ], [ /a/ ]],另一层是"元素本身就是/a/正则"的边界[/a/, /a/]。这三个用例共享queryShapeHash4E434920...),且均返回空结果集(数据集中没有包含正则的数组条目)。

六、复合谓词场景(用例 26-30)

优化不仅作用于单个$in,还能穿透$or/$and组合,从子表达式中提取可索引谓词并合并:

6.1$or嵌套(用例 26-27)

用例 26 的{$expr: {$or: [{$in: [1, "$m"]}]}}在 knob on 时被改写为{"$and": [{"m": {"$eq": 1}}, {$expr: {$or: [...]}}]},同样从 COLLSCAN 转为 IXSCAN。

用例 27 的两个$in(常量 1 和 2)在 knob on 时合并为一条$in谓词

{ "$and" : [ { "m" : { "$in" : [ 1, 2 ] } }, { "$expr" : { "$or" : [ ...原两个 $in... ] } } ] }

对应 IXSCAN 的indexBounds也合并为两个区间:

"indexBounds" : { "m" : [ "[1.0, 1.0]", "[2.0, 2.0]" ] }

6.2$or混合字段路径(用例 28)

用例 28 混合了三类$in[1, "$m"][2, "$m"]["$a", [1, 2, 10]](最后一个是"字段路径 in 常量数组"的正向形式)。knob on 时生成的额外谓词是一个$or

{ "$or" : [ { "a" : { "$in" : [ 1, 2, 10 ] } }, { "m" : { "$in" : [ 1, 2 ] } } ] }

有趣的是,这个用例即便在 knob on 时仍走 COLLSCAN(无单一索引可同时服务am两条路径),说明重写只负责"生成额外谓词",最终是否使用索引仍由规划器决定。这也再次印证:追加谓词是语义等价的——a字段在测试数据中只有12两个值,$or前缀谓词不改变任何结果。

6.3$and嵌套(用例 29-30)

用例 29{$and: [{$in: ["$a", [1, 2]]}, {$or: [{$in: [1, "$m"]}, {$in: [2, "$m"]}]}]}在 knob on 时同时提取两条可索引谓词:

{ "$and" : [ { "a" : { "$in" : [ 1, 2 ] } }, { "m" : { "$in" : [ 1, 2 ] } }, { "$expr" : { ...原表达式... } } ] }

执行计划成功转为FETCH → IXSCANm_1,边界[1.0, 1.0][2.0, 2.0]),因为集合上存在m_1索引。用例 30 结构与 29 类似但第二个$in常量为[null],knob on 时只提取了m$in谓词,同样转为 IXSCAN——而{"$in": ["$a", [null]]}a字段无索引未提取为可索引谓词,保留在$expr中。

七、多级路径$m.a系列(用例 31-39)

$in的第二个操作数是多级路径$m.a时,重写同样生效,并且使用m.a_1索引。以用例 31(常量1)为例,knob on 时:

{ "$and" : [ { "m.a" : { "$eq" : 1 } }, { "$expr" : { "$in" : [ { "$const" : 1 }, "$m.a" ] } } ] }

IXSCAN 使用m.a_1

"indexBounds" : { "m.a" : [ "[1.0, 1.0]" ] }, "indexName" : "m.a_1", "isMultiKey" : true, "multiKeyPaths" : { "m.a" : [ "m", "m.a" ] }

完整 9 个用例一览(m.a_1索引,multiKeyPaths均为["m", "m.a"]):

用例Filter 常量Find resultsIndex bounds
3112 条(m:[{a:1}]m:[{a:1,b:1}][1.0, 1.0]
32[1]1 条(m:[{a:[1]}][1.0, 1.0][[ 1.0 ], [ 1.0 ]]
33[[1]]0 条[[ 1.0 ], [ 1.0 ]][[ [ 1.0 ] ], [ [ 1.0 ] ]]
34[]1 条(m:[{a:[]}][undefined, undefined][[], []]
35[[]]0 条[[], []][[ [] ], [ [] ]]
36{}1 条(m:[{a:{}}][{}, {}]
37[{}]0 条[{}, {}][[ {} ], [ {} ]]
38null1 条(m:[{a:null}][null, null]
39[null]0 条[null, null][[ null ], [ null ]]

多级路径的索引边界展开规律与$m系列完全一致(标量/数组双层边界、null/undefined 特殊处理),证明了该重写在任意合法字段路径上都成立。

八、错误行为验证(用例 40 附近的隐藏逻辑)

金标输出文档本身没有记录错误用例,但测试脚本 expr_in_rewrite_md.js 在 39 个正例之后插入了{_id: "force failure due to non-array $m"}(无m字段的文档),验证异常语义:

assert.commandFailedWithCode(db.runCommand({find: coll.getName(), filter}), [40081, 5153700]);
  • {$in: [null, "$m"]}:在开关开与关两种状态下都报错(错误码 40081 / 5153700)。原因是$in的第二个操作数要求是数组,而该文档的m字段缺失(视为非数组),$expr求值时触发类型错误;
  • {$or: [{$in: [null, "$m"]}, {$in: [1, "$m"]}, {$in: [2, "$m"]}]}:同样报错;
  • 注释特别指出:{$in: [1, "$m"]}这一条在 IXSCAN 路径下不会失败,但在 COLLSCAN 下会失败——因为索引扫描天然跳过不含m的文档,而全表扫描会逐文档求值$expr从而暴露类型错误。团队接受这一不对称行为,理由是"已有先例"(见 expr_in_rewrite_md.js)。

这一细节提示使用者:重写虽然语义等价于原查询,但在"坏数据"存在时,选择索引路径可能改变"是否报错"这一可观察行为,属于优化器在正确性边界上的有意取舍。

九、如何运行与维护该金标测试

9.1 运行

与其他 golden test 一致,使用 resmoke 执行:

buildscripts/resmoke.py run --suites=query_golden_classic jstests/query_golden/expr_in_rewrite_md.js

测试内部会自动完成:插入数据集 → 建索引 → 逐个用例关闭/打开internalQueryExtraPredicateForReversedIn→ 对比结果集 → 输出金标文本("Find results / Parsed find query / Summarized explain"三段式结构由 expr_in_rewrite_md.js 中的outputPlanAndResults生成,formatExplainRoot负责扁平化 explain 树,tojsonMultiLineSortKeys/tojsonOnelineSortKeys负责规范化 JSON 输出)。

9.2 更新期望输出

若优化器行为有意变更,使用 golden test 的标准接受流程:

buildscripts/golden_test.py accept

计划稳定性框架的运行、失败 diff 展示与调试指引见 README.plan_stability.md。

9.3 手动验证

在生产 mongod 上可用setParameter即时验证(开关支持 runtime 设置):

db.adminCommand({setParameter: 1, internalQueryExtraPredicateForReversedIn: true}); db.coll.find({$expr: {$in: [1, "$m"]}}).explain();

注意该参数在 query_optimization_knobs.idl 中标有mod_visibility: needs_replacement,属于内部优化参数,默认关闭,生产环境开启前应充分评估。

十、总结

通过这份 39 用例的金标输出,可以完整还原 MongoDB 对反向$in{$expr: {$in: [<const>, "$fieldpath"]}})的优化管线:

  1. 触发条件$in第一操作数为常量(数字、null、数组、嵌套数组、对象、字符串均可),第二操作数为字段路径(可为多级路径);正则常量仅在数组内触发;FLE 查询无条件触发;
  2. 重写动作:在$and顶层(或与既有$and/$or组合)追加一条形如{"fieldpath": {"$eq": <const>}}的可索引谓词,多个$in可合并为$in/$or谓词;原始$expr保留为残余过滤;
  3. 执行效果:获胜计划从COLLSCAN变为FETCH → IXSCANindexBounds按数据类型展开(标量区间、数组双层区间、null/undefined 特殊处理),queryShapeHash保持不变以保证计划稳定性;
  4. 正确性护栏:结果集经assertArrayEq强校验等价;对非数组字段的错误行为差异(IXSCAN 与 COLLSCAN 不对称)是团队认可的既有先例。

这一机制是 MongoDB 查询优化器"以谓词下推换取索引可用性"思路的典型代表,对排查"为什么$expr查询不走索引"类问题具有直接的实践指导价值。建议读者结合 expr_in_rewrite_md.js 与 rewrite_expr.cpp 继续深入阅读底层重写实现。

【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询