ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/9/13 9:37:11 拓冰建站 浏览量
MongoDB `$expr: {$in: [常量, “$字段“]}` 反向重写与索引利用优化:`internalQueryExtraPredicateForReversedIn` 金标测试深度解析 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.parsedQuerySummarized explain由formatExplainRoot扁平化后的获胜计划含queryShapeHash、rejectedPlans、winningPlan的 stage 链。二、优化开关internalQueryExtraPredicateForReversedIn开关在 IDL 文件中定义见 query_optimization_knobs.idl关键属性如下属性值参数名internalQueryExtraPredicateForReversedInwire 名对外暴露名extraPredicateForReversedIn默认值false默认关闭设置时机startup启动参数、runtime运行期setParameter类型Atomicbool官方描述对{$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 offParsed find query仅将常量包装为$const不做任何改写{ $expr : { $in : [ { $const : 1 }, $m ] } }Summarized explainwinningPlan为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 onParsed 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 }关键观察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.jsFETCH 阶段仍保留$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-255.1 数字与 null用例 1-2用例Filter 常量Find resultsKnob on 追加谓词Index bounds114 条含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 bounds3[]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 bounds11{}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 bounds17a1 条m:[a,b,c]{m: {$eq: a}}[a, a]18ab0 条{m: {$eq: ab}}[ab, ab]19abc2 条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 bounds23[/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无单一索引可同时服务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 → 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_1indexBounds : { 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 bounds3112 条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 --suitesquery_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 → IXSCANindexBounds按数据类型展开标量区间、数组双层区间、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),仅供参考