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扁平化后的获胜计划,含queryShapeHash、rejectedPlans、winningPlan的 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 explain:
winningPlan为PROJECTION_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 explain:
winningPlan变为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" }关键观察:
queryShapeHash在开关前后完全一致(CAB0CA88...),说明两条额外谓词的追加不改变查询形状哈希——这是刻意设计,避免影响计划缓存与查询形状统计;- 结果集完全一致:开关前后 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); - FETCH 阶段仍保留
$expr过滤:追加的谓词只是"预筛"(pre-filter),真正的$in语义仍由原始$expr保证,因此不会因多键(multikey)索引的路径语义差异而返回错误结果; - 由于
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_1的multiKeyPaths仅为["m"]。
五、按数据类型逐一拆解($m系列,用例 1-25)
5.1 数字与 null(用例 1-2)
| 用例 | Filter 常量 | Find results | Knob on 追加谓词 | Index bounds |
|---|---|---|---|---|
| 1 | 1 | 4 条(含a:1/a:2文档) | {"m": {"$eq": 1}} | [1.0, 1.0] |
| 2 | null | 3 条([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 results | Knob 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]三个不同常量共享同一个queryShapeHash(6061C7642A45F0C552A2B27C2D0D95DEC4103AF55835DD9E44FDBBD56734483B),[[1]]、[[1,2]]、[[[1]]]共享90114C24...。这说明queryShapeHash只反映查询的结构形状(如"数组常量套字段路径"),与具体常量值无关。
5.3 对象(用例 11-16)
| 用例 | Filter 常量 | Find results | Knob 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}共享queryShapeHash(7905AD4C...),再次印证哈希与字段内容无关。
5.4 字符串(用例 17-22)
| 用例 | Filter 常量 | Find results | Knob 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 依然执行。所有字符串用例共享queryShapeHash(C60A7B3F...或7D05D020...)。
5.5 正则(用例 23-25)
正则常量只以数组形式([/a/]、[/b/]、[/abc/])出现在测试中,因为源码注释明确指出:正则不在数组中时不会触发该重写(expr_in_rewrite_md.js)。
| 用例 | Filter 常量 | Find results | Index 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/]。这三个用例共享queryShapeHash(4E434920...),且均返回空结果集(数据集中没有包含正则的数组条目)。
六、复合谓词场景(用例 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(无单一索引可同时服务a与m两条路径),说明重写只负责"生成额外谓词",最终是否使用索引仍由规划器决定。这也再次印证:追加谓词是语义等价的——a字段在测试数据中只有1、2两个值,$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 → IXSCAN(m_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 results | Index bounds |
|---|---|---|---|
| 31 | 1 | 2 条(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 条 | [{}, {}]、[[ {} ], [ {} ]] |
| 38 | null | 1 条(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"]}})的优化管线:
- 触发条件:
$in第一操作数为常量(数字、null、数组、嵌套数组、对象、字符串均可),第二操作数为字段路径(可为多级路径);正则常量仅在数组内触发;FLE 查询无条件触发; - 重写动作:在
$and顶层(或与既有$and/$or组合)追加一条形如{"fieldpath": {"$eq": <const>}}的可索引谓词,多个$in可合并为$in/$or谓词;原始$expr保留为残余过滤; - 执行效果:获胜计划从
COLLSCAN变为FETCH → IXSCAN,indexBounds按数据类型展开(标量区间、数组双层区间、null/undefined 特殊处理),queryShapeHash保持不变以保证计划稳定性; - 正确性护栏:结果集经
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),仅供参考