ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MongoDB 的 `$expr: {$in: [常量, “$字段“]}` 重写优化:原理、query knob 与 Golden Test 全解析

MongoDB 的 `$expr: {$in: [常量, “$字段“]}` 重写优化:原理、query knob 与 Golden Test 全解析 MongoDB 的$expr: {$in: [常量, $字段]}重写优化原理、query knob 与 Golden Test 全解析【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo导读本文以 MongoDB 查询优化器中一项具体且实用的改写优化为核心展开当用户写出{$expr: {$in: [常量, $字段路径]}}这类反转 $in查询时优化器默认只能走全表扫描COLLSCAN而借助internalQueryExtraPredicateForReversedIn查询开关MongoDB 会额外生成一个可走索引的等值谓词让这类查询也能利用多键索引完成 IXSCAN 加速。文中将完整梳理该优化的改写规则、适用范围与语义边界并通过仓库中的 Golden Test 期望输出expr_in_rewrite.md逐场景对比开关关闭/打开两种执行计划最后结合源码rewrite_expr.cpp与查询开关定义query_optimization_knobs.idl讲透其底层实现。读完本文你将能理解该优化的触发条件、计划形态变化、多类型取值数字、null、嵌套数组、对象、字符串、正则下的行为差异以及如何通过 Golden Test 复现和验证这类查询优化。背景什么是反转 $in为什么需要改写在 MongoDB 聚合表达式中$in有两种常见形态常规形态{$in: [$字段, [候选值...]]}数组是常量字段是左操作数Match 语言中天然对应{字段: {$in: [...]}}可直接利用索引反转形态{$in: [常量, $字段]}常量出现在左侧字段路径出现在右侧语义是常量是否属于字段数组的成员。后者的执行效率往往很差。由于 Match 语言无法直接表达常量 ∈ 字段数组这种形式查询规划器对反转形态的$expr只能保留原表达式作为过滤条件结果就是执行计划中只有COLLSCAN即使字段上建有索引也无法使用。Golden Test 文档的第一组用例Find filter 为{$expr: {$in: [1, $m]}}清晰展示了这一点开关关闭时Parsed find query 仍是原样{ $expr : { $in : [ { $const : 1 }, $m ] } }winningPlan 为PROJECTION_SIMPLE - COLLSCAN。而开关打开后Parsed find query 变为{ $and : [ { m : { $eq : 1 } }, { $expr : { $in : [ { $const : 1 }, $m ] } } ] }执行计划变为PROJECTION_SIMPLE - FETCH - IXSCAN其中indexBounds为m : [ [1.0, 1.0] ]使用的正是m_1多键索引。可以看到改写后的查询由两部分组成——新增的{ m : { $eq : 1 } }负责索引定位IXSCAN原有的$expr: {$in}保留下来做二次过滤以保证结果正确。文档结构一份可自动校验的 Golden Test 期望输出expr_in_rewrite.md本质上是仓库中 Golden Test 生成的期望输出文件它来自测试脚本 expr_in_rewrite_md.js。该脚本通过import {formatExplainRoot} from jstests/libs/query/analyze_plan.js与tojsonMultiLineSortKeys、tojsonOnelineSortKeys等辅助函数把每条过滤条件在Query knob off / Query knob on两种状态下的结果集、解析后查询和摘要化执行计划输出为 Markdown并通过assertArrayEq断言两组结果完全一致。文档中每个用例都遵循固定模板## N. Find filter展示被测过滤条件### Query knob off开关关闭时的结果### Query knob on开关打开时的结果。每组又包含三块内容Find results实际返回的文档投影_id: 0Parsed find query解析并改写后的匹配条件来自explain.queryPlanner.parsedQuerySummarized explain摘要化执行计划含queryShapeHash、rejectedPlans、winningPlan。对应源码见 expr_in_rewrite_md.js 中的outputPlanAndResults函数。测试环境与数据集测试集合名取自jsTestName()即test.expr_in_rewrite_md数据由insertMany一次性灌入覆盖了尽可能多的 BSON 类型组合expr_in_rewrite_md.js空数组与嵌套数组{m: []}、{m: [[]]}、{m: [[[]]]}含a字段与数组m的文档{a: 1, m: [1]}、{a: 2, m: [1, 2, 3]}普通数值数组{m: [1, 2]}、{m: [2]}、{m: [5, 2, 3, 6]}等含 null 的数组{m: [4, 5, 6, null, 10]}、{m: [null, null, null]}、{m: [[null], null]}嵌套数组元素{m: [[1]]}、{m: [[[1]]]}、{m: [[2]]}、{m: [[1, 2]]}、{m: [[[1, 2]]]}、{m: [[1], [2]]}、{m: [[[1]], [[2]]]}对象元素{m: [{}]}、{m: [[{}]]}、{m: [{a: 1}]}、{m: [{a: [1]}]}、{m: [{a: 1, b: 1}]}字符串与正则元素{m: [a, b, c]}、{m: [abc]}、{m: [/abc/]}、{m: [/a/]}。索引方面建了两个多键索引expr_in_rewrite_md.jsassert.commandWorked(coll.createIndex({m: 1})); assert.commandWorked(coll.createIndex({m.a: 1}));测试脚本的注释特别说明由于索引是多键multikey的无法得到 covered plan若索引不是多键这类查询在任何情况下都会报错——这也是测试特意构造多键索引的原因expr_in_rewrite_md.js。核心开关internalQueryExtraPredicateForReversedInGolden Test 中反复出现的 Query knob off / on 对应的是查询开关internalQueryExtraPredicateForReversedIn其定义位于 query_optimization_knobs.idl描述针对{$expr: {$in: [const, $fieldpath]}}形态查询启用一项优化通过生成额外谓词使$fieldpath上的索引得以使用如果是 FLE客户端字段级加密查询无论开关状态如何都会应用该优化可设置时机set_at: [startup, runtime]即支持启动参数与运行时setParameter两种方式默认值default: false默认关闭这正是文档中 COLLSCAN 基线存在的原因类型Atomicbool对外 wire 名extraPredicateForReversedIn。对应查询 knobs 描述符见 query_knob_descriptors_optimization.h。测试脚本在try/finally中安全地操作该开关expr_in_rewrite_md.jsassert.commandWorked(db.adminCommand({setParameter: 1, [paramName]: false})); // knob off const resOff outputPlanAndResults(filter); assert.commandWorked(db.adminCommand({setParameter: 1, [paramName]: true})); // knob on const resOn outputPlanAndResults(filter); assertArrayEq({expected: resOff, actual: resOn});并在finally中将开关恢复为缓存的原值expr_in_rewrite_md.js。所有用例都通过validateResultsSame断言开关打开后结果集与关闭时完全一致即改写只影响计划形态、不改变语义。逐场景解析39 个用例的执行计划变化场景 A标量常量 vs$m用例 1、2及 17~19 字符串数值1用例 1Knob offCOLLSCAN过滤条件为原$expr: {$in: [{$const: 1}, $m]}Knob onParsed query 变为$and: [{m: {$eq: 1}}, {$expr: {$in: [...]}}]执行FETCH - IXSCAN索引m_1的 bounds 为[1.0, 1.0]。null用例 2行为与数值一致knob on 时生成{m: {$eq: null}}IXSCAN bounds 为[null, null]。字符串用例 17~19a、ab、abc三种取值knob on 时分别生成{m: {$eq: a}}等谓词bounds 为[a, a]等。注意ab无匹配结果但计划同样从 COLLSCAN 变为 IXSCAN——改写与结果是否为空无关。场景 B数组常量 vs$m用例 3~10当左操作数是数组时改写仍然生效但生成的谓词与索引 bounds 会随嵌套层级不同而显著变化[]用例 3生成{m: {$eq: []}}IXSCAN bounds 为两个区间[undefined, undefined]与[[], []][1]用例 4生成{m: {$eq: [1]}}bounds 为[1.0, 1.0]与[[ 1.0 ], [ 1.0 ]]——因为多键索引中元素1与数组[1]都可能被索引故拆成两个区间[2]用例 5同理bounds 为[2.0, 2.0]与[[ 2.0 ], [ 2.0 ]][null]用例 6bounds 为[null, null]与[[ null ], [ null ]][1, 2]用例 7bounds 为[1.0, 1.0]与[[ 1.0, 2.0 ], [ 1.0, 2.0 ]][[1]]用例 8bounds 为[[ 1.0 ], [ 1.0 ]]与[[ [ 1.0 ] ], [ [ 1.0 ] ]][[1, 2]]用例 9bounds 为[[ 1.0, 2.0 ], [ 1.0, 2.0 ]]与[[ [ 1.0, 2.0 ] ], [ [ 1.0, 2.0 ] ]][[[1]]]用例 10结果为[]数据集中无此深层嵌套匹配但计划同样变为 IXSCANbounds 为[[ [ 1.0 ] ], [ [ 1.0 ] ]]与[[ [ [ 1.0 ] ] ], [ [ [ 1.0 ] ] ]]。从源码结构看这种一对 bounds 区间的模式源于 MatchExpression 对数组的隐式遍历语义索引中既可能存了裸值如1也可能存了整个子数组如[1]因此必须同时扫描两处索引条目。场景 C对象常量 vs$m用例 11~15{}用例 11生成{m: {$eq: {}}}bounds 为[{}, {}][{}]用例 12、16bounds 为[{}, {}]与[[ {} ], [ {} ]]{a: 1}用例 13bounds 为[{ a: 1.0 }, { a: 1.0 }][{a: 1}]用例 14bounds 为[{ a: 1.0 }, { a: 1.0 }]与[[ { a: 1.0 } ], [ { a: 1.0 } ]]{a: 1, b: 1}用例 15bounds 为[{ a: 1.0, b: 1.0 }, { a: 1.0, b: 1.0 }]。注意用例 13 与 15 的queryShapeHash相同7905AD4C...用例 12/14/16 的 hash 相同B238149F...说明 query shape 只关心表达式结构、不关心字面量取值——这与计划缓存、查询形状稳定的语义一致。场景 D正则用例 23~25测试刻意把正则放在数组内[[/a/]]、[[/b/]]、[[/abc/]]。三种情况下结果都是[]数组元素中的正则与多键索引中的正则元素按精确值比较不匹配。但计划形态仍是FETCH - IXSCANbounds 为[[ /a/ ], [ /a/ ]]与[/a/, /a/]两组。脚本注释expr_in_rewrite_md.js特别说明不测试裸正则不带数组包裹的$in因为裸正则场景下根本不会做这种改写。原因可从源码找到——重写逻辑遇到BSONType::regEx的常量时会直接返回nullptr放弃改写避免将正则插入InListData触发BadValue错误rewrite_expr.cpp。场景 E$or组合用例 26~28单元素$or: [{$in: [1, $m]}]用例 26knob on 后仍生成{m: {$eq: 1}}IXSCAN bounds[1.0, 1.0]双元素$or: [{$in: [1, $m]}, {$in: [2, $m]}]用例 27knob on 后合并为{m: {$in: [1, 2]}}IXSCAN 出现两个区间[1.0, 1.0]、[2.0, 2.0]且结果文档顺序与 COLLSCAN 时不同IXSCAN 按索引序返回但集合相同混合三元素$or: [{$in: [1, $m]}, {$in: [2, $m]}, {$in: [$a, [1, 2, 10]]}]用例 28改写后 Parsed query 变为{$or: [{a: {$in: [1, 2, 10]}}, {m: {$in: [1, 2]}}]}——即反转 $in分支被改写为{m: {$in: [1, 2]}}同时原有的{$in: [$a, [1, 2, 10]]}因属于常规形态也被转成{a: {$in: [...]}}最终整体成为可下推的普通 Match 谓词。有趣的是该用例即便 knob on 后 winningPlan 仍是COLLSCAN因为$or含$a分支无法仅靠m索引覆盖但过滤器已从纯$expr变成可索引形式。场景 F$and组合用例 29~30用例 29$and: [{$in: [$a, [1, 2]]}, {$or: [{$in: [1, $m]}, {$in: [2, $m]}]}]knob on 后 Parsed query 变为三层$and{a: {$in: [1, 2]}}、{m: {$in: [1, 2]}}、原$expr。执行计划变为FETCH - IXSCANbounds[1.0, 1.0]、[2.0, 2.0]用例 30$and: [{$or: [...]}, {$in: [$a, [null]]}]knob on 后仅提取出{m: {$in: [1, 2]}}作为索引谓词$in: [$a, [null]]分支因含 null 而不参与改写保留在$expr中最终FETCH - IXSCAN。这印证了源码中的一条重要规则$in 候选值数组中含有 array、null、undefined、regEx 时整个表达式不可改写rewrite_expr.cpp。原因注释写得很清楚MatchExpression 的数组隐式遍历语义与 agg 不同——数组元素会被递归展开、null 会额外匹配缺失字段、正则会被求值匹配而非精确比较。场景 G路径字段$m.a用例 31~39当右侧字段是嵌套路径如$m.a时改写同样生效但使用m.a_1索引1用例 31生成{m.a: {$eq: 1}}bounds[1.0, 1.0]multiKeyPaths为[m, m.a][1]用例 32bounds 为[1.0, 1.0]与[[ 1.0 ], [ 1.0 ]][[1]]用例 33bounds 为[[ 1.0 ], [ 1.0 ]]与[[ [ 1.0 ] ], [ [ 1.0 ] ]]结果为空[]用例 34bounds 为[undefined, undefined]与[[], []]命中{m: [{a: []}]}[[]]用例 35bounds 为[[], []]与[[ [] ], [ [] ]]结果为空{}用例 36bounds 为[{}, {}]命中{m: [{a: {}}]}[{}]用例 37bounds 为[{}, {}]与[[ {} ], [ {} ]]结果为空null用例 38bounds 为[null, null]命中{m: [{a: null}]}[null]用例 39bounds 为[null, null]与[[ null ], [ null ]]结果为空。m.a_1索引的multiKeyPaths同时列出m与m.a说明这是一个因m数组产生的多键索引expr_in_rewrite.md。错误行为验证脚本尾部脚本还专门验证了错误场景expr_in_rewrite_md.js插入一条_id: force failure due to non-array $m的坏文档m为标量非数组后{$expr: {$in: [null, $m]}}与包含它的$or在开关开/关两种状态下都会以错误码[40081, 5153700]失败validateError函数使用assert.commandFailedWithCode。同时注释指出{$expr: {$in: [1, $m]}}这类用例不会在 IXSCAN 下失败、但会在 COLLSCAN 下失败由于有先例而选择接受该差异——这是测试设计时对语义边界的有意取舍。源码原理RewriteExpr 的两次改写路径实现集中在 rewrite_expr.cpp 的RewriteExpr类中。rewrite()入口先对整棵表达式树递归改写再对结果调用optimizeMatchExpression禁用了子表达式简化只做整体优化见 rewrite_expr.cpp。_rewriteInExpression负责 $in 改写逻辑分两条路径rewrite_expr.cpp路径 1常规形态{$in: [$lhsFieldPath, rhsConstArray]}左操作数必须是本地字段路径不能是变量引用或$ROOT见validateFieldPathForExprInRewriterewrite_expr.cpp右操作数必须是静态常量数组若候选值中存在 array、null、undefined、regEx 则整体拒绝改写改写为{lhsFieldPath: {$in: rhsConstArray}}wrapConstInArray false。路径 2反转形态{$in: [lhsConst, $rhsFieldPath]}只有当internalQueryExtraPredicateForReversedIn开关打开或当前是 FLE 查询时才允许rewrite_expr.cpp右操作数须为有效字段路径左常量若为正则则放弃避免BadValue: Cannot insert regex into InListData改写为{rhsFieldPath: {$in: [lhsConst]}}wrapConstInArray true即把常量包成一个单元素数组作为等值谓词。两个路径最终都通过模板函数rewriteExprInMatchExpressionwrapConstInArray构造InMatchExpressionrewrite_expr.cppwrapConstInArray true时用BSON_ARRAY(value)包裹否则把常量数组逐元素填入BSONArrayBuilder。函数头注释点明了正确性论证rewrite_expr.cpp改写后的 MatchExpression 是第一层过滤可以利用索引但因为 MatchExpression 存在 agg 所没有的隐式数组遍历语义它可能返回超集结果原始$expr谓词作为第二层过滤被保留以保证结果正确。这正是所有用例中 Parsed query 始终是$and: [新增谓词, 原$expr]的原因——新增谓词负责索引裁剪原表达式负责精确过滤。Golden Test 中assertArrayEq({expected: resOff, actual: resOn})断言两个结果集相等就是对这个双层过滤设计正确性的自动化验证。相关单元测试见 rewrite_expr_test.cpp涵盖常规与反转两种形态的改写断言。如何运行与复现Golden Test 属于jstests/query_golden目录运行方式与普通 jstest 一致需要已构建的 mongod# 方式一直接运行测试脚本 python buildscripts/resmoke.py run --suitesquery_golden --shell jstests/query_golden/expr_in_rewrite_md.js # 方式二用 mongosh 手动加载观察任意用例的开关行为也可以手工在 mongosh 中复现单个用例const coll db.expr_in_rewrite_md; coll.drop(); coll.insertMany([{a: 1, m: [1]}, {a: 2, m: [1, 2, 3]}, {m: [1, 2]}, {m: [5, 2, 1, 3, 6]}]); coll.createIndex({m: 1}); // 开关关闭COLLSCAN db.adminCommand({setParameter: 1, internalQueryExtraPredicateForReversedIn: false}); db.coll.find({$expr: {$in: [1, $m]}}, {_id: 0}).explain(); // 开关打开FETCH - IXSCANm_1bounds [1.0, 1.0] db.adminCommand({setParameter: 1, internalQueryExtraPredicateForReversedIn: true}); db.coll.find({$expr: {$in: [1, $m]}}, {_id: 0}).explain();注意该开关默认falseGolden Test 通过getParameter缓存原值并在finally中恢复避免污染其他用例。实践要点与语义边界总结综合文档与源码可将该优化的边界归纳为一张速查表输入形态是否改写生成的索引谓词说明{$in: [常量, $字段]}是knob on 或 FLE{字段: {$eq: 常量}}裸常量、数值/null/字符串/对象均可{$in: [数组常量, $字段]}是{字段: {$eq: 数组}}bounds 含裸值与子数组两组区间{$in: [正则, $字段]}否—返回nullptr放弃改写{$in: [$字段, 常量数组]}是不依赖 knob{字段: {$in: 常量数组}}常规形态语义等价于 Match 的 $in常量数组含 array/null/undefined/regex否—语义差异过大缺失值匹配、数组遍历、正则求值字段路径为变量引用或$ROOT否—Match 语言无法表达$or/$and包裹的$in是按布尔结构提取/合并如两个$in合并为{m: {$in: [1, 2]}}三个最容易踩坑的语义点结果顺序可能变化IXSCAN 按索引序返回用例 27 中 knob on/off 两组的文档顺序不同但集合内容一致改写不等于一定走索引如用例 28 中$or含$a分支时即使改写成功仍可能选COLLSCAN改写只负责把可索引的谓词暴露给优化器null 的语义差异是刻意的agg 的$in只匹配显式 null而 Match 的$eq: null还会匹配缺失字段因此改写后必须保留原$expr做精确过滤——Golden Test 的结果一致性断言正是针对这类细微语义差异的回归保障。延伸为什么期望输出放在 internalEnableJoinOptimization 目录下该期望输出文件位于 expected_output/internalEnableJoinOptimization/ 目录。仓库中同一测试脚本还对应其他配置目录下的期望输出如 featureFlagSbeFull/expr_in_rewrite.md、sbeFull/expr_in_rewrite.md、expr_in_rewrite.md说明 Golden Test 会按不同 feature flag / 查询优化开关组合生成多份期望输出。internalEnableJoinOptimization与 Join 优化计划plan explainer 中的usedJoinOptimization字段、document_source_join_plan_cache_stats等属于同一批内部查询优化特性家族本文聚焦的$expr: $in反转改写正是其中一项独立的查询优化Golden Test 期望输出即为该优化在特定配置组合下的可验证快照。小结{$expr: {$in: [常量, $字段]}}的索引化改写是理解 MongoDB 查询改写query rewrite机制的一个小而完整的范例优化器通过新增一个可索引谓词 保留原始精确谓词的双层过滤设计在保证语义不变的前提下把原本只能 COLLSCAN 的查询转化为 IXSCAN。internalQueryExtraPredicateForReversedIn开关默认关闭、支持运行时切换、FLE 查询强制开启控制该优化是否生效数组、null、正则、嵌套字段路径各自的索引 bounds 形态则生动展示了多键索引下 Match 语义与聚合语义的差异边界。若想深入验证可直接运行 expr_in_rewrite_md.js 复现本文全部 39 个用例或研读 rewrite_expr.cpp 与 query_optimization_knobs.idl 了解实现全貌。【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表