尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MongoDB DISTINCT_SCAN 索引合格性判定详解:从 `distinct_index_eligibility` Golden Test 看查询计划器的优化边界

MongoDB DISTINCT_SCAN 索引合格性判定详解:从 `distinct_index_eligibility` Golden Test 看查询计划器的优化边界 MongoDB DISTINCT_SCAN 索引合格性判定详解从distinct_index_eligibilityGolden Test 看查询计划器的优化边界【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo导读本文以 MongoDB 仓库中的 golden 测试输出文档 distinct_index_eligibility.md 及其测试源文件 distinct_index_eligibility_md.js 为骨架系统讲解查询计划器query planner在为distinct命令与$group聚合生成DISTINCT_SCAN时的合格性eligibility判定规则哪些索引可以被选中、哪些会被拒绝以及 multikey、sparse、wildcard、覆盖投影covered projection、$sort翻转等条件如何影响最终执行计划。读完本文你将掌握 DISTINCT_SCAN 的启用前提、从 explain 输出中识别与验证 DISTINCT_SCAN 的方法以及如何用 golden 测试机制回归验证查询计划的变化。一、背景什么是 DISTINCT_SCANDISTINCT_SCAN是 MongoDB 查询计划器为distinct()命令以及可重写为 distinct 语义的$group聚合以单一字段作为_id、配合$first/$last/$top/$bottom等累积器生成的一种索引扫描执行阶段。它利用索引键本身的有序性只扫描键值发生变化的边界点从而跳过大量重复键避免为每个文档逐一取数去重。在 explain 输出中它的stage字段为DISTINCT_SCAN并携带indexName、indexBounds、isMultiKey、isSparse、isPartial、isUnique、keyPattern与multiKeyPaths等元信息聚合场景下还会在其上层出现PROJECTION_COVERED与$groupByDistinctScan阶段实现覆盖式去重聚合。该优化并非总是可用的。查询计划器必须满足一组严格的合格性条件才会为某个索引生成 DISTINCT_SCAN。本文档正是把哪些情况下合格、哪些情况下不合格以可执行、可复现的方式固化了下来。二、文档定位它是一份 Golden Test 期望输出需要先说明本文件的身份它位于 jstests/query_golden/expected_output/internalEnableJoinOptimization/ 目录下是 golden 测试快照测试的期望输出基线expected output由测试源文件 jstests/query_golden/distinct_index_eligibility_md.js 驱动生成。该测试的注释明确写道Tests that we generate DISTINCT_SCANs just for eligible indexes.测试我们只为合格的索引生成 DISTINCT_SCAN。它的tags声明了运行前提// tags: [ // featureFlagShardFilteringDistinctScan, // requires_fcv_82 // ]对应地query_feature_flags.idl 中定义了该特性开关featureFlagShardFilteringDistinctScan: description: Feature flag to support shard filtering in distinct scan optimization cpp_varname: gFeatureFlagShardFilteringDistinctScan default: true version: 8.2 fcv_gated: true也就是说默认开启、FCV 8.2 及以上生效测试集合的库名为test.distinct_index_eligibility_md见 explain 中的nss字段。文档内部的结构约定如下### Pipeline被执行的聚合管道或### Distinct on a, with filter: {...}形式的 distinct 命令描述### Results实际返回结果可用于校验语义正确性### Total indexes on the collection集合上现存的所有索引用于理解可用索引集合### Summarized explain经过 golden_test_utils.js 中outputAggregationPlanAndResults/outputDistinctPlanAndResults归一化后的 explain 输出顶部标注Execution Engine: sbe或Execution Engine: classic。整个文档围绕两大主题组织Distinct Field part of the Index Key Pattern去重字段在索引键内与Distinct Field not part of the Index Key Pattern去重字段不在索引键内下面分别展开。三、规则一去重字段必须在索引键内但 multikey 位置决定成败本节对应文档的第一大节 Distinct Field part of the Index Key Pattern共 8 组用例核心变量是flip是否翻转扫描方向、strict是否严格模式与multikey索引在哪个字段上是多键。3.1 flip multikey on distinct field 无 DISTINCT_SCAN测试源distinct_index_eligibility_md.js先插入一条文档{a: [1, 2, 3], b: 5}——a是数组因此在{a: 1, b: 1}索引上a字段成为 multikey path。执行带$sort的$group管道[ { $sort : { a : 1, b : 1 } }, { $group : { _id : $a, accum : { $last : $b } } } ]结果{ _id : [ 1, 2, 3 ], accum : 5 }表明聚合语义正确但 explain 显示并未使用 DISTINCT_SCAN而是Execution Engine: sbe顶层GROUP阶段下层为FETCHIXSCAN索引a_1_b_1isMultiKey: truemultiKeyPaths.a [a]indexBounds全开[MinKey, MaxKey]。原因flip表示排序被翻转$last需要逆序扫描explain 中对应direction : forward与后文 3.2 的backward相对而此时distinct 字段a本身是 multikey。对 multikey 索引同一文档会在索引中展开为多个键条目distinct 语义要求每个文档只贡献一个_id值当既需要翻转方向为$last取序又需要处理 distinct 字段的数组展开时DISTINCT_SCAN 无法保证正确性故被拒绝。3.2 flip !multikey DISTINCT_SCAN同样的管道但数据改为{a: 1, b: 5}a为标量测试源码 L28-L35。此时Execution Engine: classicDISTINCT_SCAN阶段direction: backwardindexName: a_1_b_1isMultiKey: falseisFetching: false上层为PROJECTION_COVERED_id:0, a:1, b:1再上层为$groupByDistinctScan阶段newRoot为{_id: $a, accum: $b}。原因a非 multikey翻转扫描方向$last需要取每个分组内按b序的最后一条故反向扫索引是安全的。isFetching: false说明整个执行被覆盖索引包住无需回表。注意两处 explain 的usedJoinOptimization: false——这是 join 优化internalEnableJoinOptimization关闭时的标志本组用例主要验证 DISTINCT_SCAN 本身。3.3 !flip strict multikey on distinct field 无 DISTINCT_SCAN不用$sort改用$top累积器sortBy内联在$group中测试源码 L37-L43[ { $group : { _id : $a, accum : { $top : { output : $b, sortBy : { a : 1, b : 1 } } } } } ]数据仍是{a: [1, 2, 3], b: 5}a为 multikey结果{ _id : [ 1, 2, 3 ], accum : 5 }。explain 为 sbe 引擎GROUP阶段直接吃一个带空filter的COLLSCAN——整表扫描。原因strict指严格模式——即_id必须能由索引键直接推导、不能有歧义。当 distinct 字段a是 multikey 时索引中同一个文档为a展开出多个键无法确定哪一个a值代表该文档的分组键因此即便不翻转方向DISTINCT_SCAN 也不合格。3.4 !flip strict 仅非 distinct 字段是 multikey DISTINCT_SCAN对照实验测试源码 L45-L53数据换成{a: 1, b: [1, 2, 3]}——b是数组a是标量。同样使用$top管道[ { $group : { _id : $a, accum : { $top : { output : $b, sortBy : { a : 1, b : 1 } } } } } ]结果{ _id : 1, accum : [ 1, 2, 3 ] }explainclassic 引擎显示DISTINCT_SCANindexName: a_1_b_1isMultiKey: trueisFetching: truemultiKeyPaths: { a: [], b: [b] }——multikey 只落在非 distinct 字段b上上层$groupByDistinctScan的newRoot为{_id: $a, accum: $b}。原因distinct 字段a不是 multikey分组键是确定的b虽是数组但b只作为$top的输出字段DISTINCT_SCAN 只需保证每组a值只访问一次索引条目数组展开不会破坏分组语义。注意isFetching: true——因为需要从文档中取回完整的b数组索引条目中b展开成了单个元素这是 multikey 场景下无法完全覆盖的必要回表。小结规则一当去重字段在复合索引键内时该字段不能是 multikey非 distinct 字段是否为 multikey 只影响是否需要FETCH不影响 DISTINCT_SCAN 的合格性。四、规则二distinct 命令 过滤条件非严格模式4.1 !flip !strict distinct 字段非 multikey DISTINCT_SCAN非严格模式对应distinct()命令其内部会把{field: {$gt: ...}}作为谓词。测试测试源码 L55-L59在{a: 1, b: [1,2,3]}的数据上对a执行 distinct过滤器{a: {$gt: 3}}### Distinct on a, with filter: { a : { $gt : 3 } } ### Distinct results [ ]explainclassic显示DISTINCT_SCANindexBounds.a [(3.0, inf]]——过滤器被下推为索引边界只扫a 3的键区间b的边界全开isMultiKey: true但multiKeyPaths.a []multikey 仅在bisFetching: false上层PROJECTION_COVERED仅投影_id:0, a:1。这里的非严格体现在distinct命令语义就是对满足过滤器的文档返回不重复的字段值只要 distinct 字段本身不是 multikey、索引能覆盖过滤与投影就可以直接扫索引键去重。4.2 非严格 sparse 索引 DISTINCT_SCAN本节末尾还有一个关键用例数据只有{b: 5}根本没有a字段在稀疏索引{a: 1} (sparse: true)上执行distinct(a)测试源码 L107-L111### Distinct on a, with filter: { } ### Distinct results [ ]explain 显示DISTINCT_SCANindexName: a_1isSparse: trueisFetching: false。原因distinct命令本身不会返回缺失字段的 null 值因此稀疏索引跳过不含a的文档恰好符合 distinct 语义——这里稀疏性不但不碍事反而省去了扫描。这与下一节严格模式 sparse形成鲜明对照同样的索引属性在 distinct 命令非严格下合格在$group严格下不合格。五、规则三严格模式$group下 sparse 索引的合格性反转严格模式指$group聚合_id必须对集合中的每个文档都产生分组缺失字段会聚合成null。这带来一个重要推论——sparse 索引会看不见缺失索引键的文档从而漏掉应产出null的分组因此在严格模式下不合格。本节数据固定为{b: 5}无a字段索引为 sparse 的{a: 1}。以下 3 个用例全部得到 sbe 引擎的COLLSCAN顶层GROUP 空filter的全表扫描而非 DISTINCT_SCAN用例PipelineResultsexplain纯$group[{ $group : { _id : $a } }]{ _id : null }sbe / GROUP → COLLSCAN带累积器[{ $group : { _id : $a, accum : { $last : $b } } }]{ _id : null, accum : 5 }sbe / GROUP → COLLSCAN带$sort[{ $sort : { a : 1 } }, { $group : { _id : $a } }]{ _id : null }sbe / GROUP → SORT → PROJECTION_SIMPLE → COLLSCAN第三个用例尤其值得注意explain 中出现了SORT阶段sortPattern: {a: 1}memLimit: 104857600即 100MB 内存排序上限与PROJECTION_SIMPLE_id: false, a: true说明排序无法用 sparse 索引满足只能全表扫描 内存排序 分组。原因结果{ _id : null }表明$group必须把没有a字段的文档归入null分组。sparse 索引不索引缺失字段的文档若使用它做 DISTINCT_SCAN 就会漏掉null分组产生错误结果——所以计划器宁可用全表扫描也不用 DISTINCT_SCAN。5.1 补救提供一个非 sparse 的复合索引DISTINCT_SCAN 立即恢复测试随后在集合上追加普通索引{a: 1, b: 1}测试源码 L76-L77同一批管道立刻切回 classic 引擎的DISTINCT_SCAN纯$groupDISTINCT_SCANona_1_b_1isMultiKey: falseisFetching: falsePROJECTION_COVERED投影_id:0, a:1带$last: $bDISTINCT_SCAN方向backward$last需要取序末isFetching: falsePROJECTION_COVERED投影_id:0, a:1, b:1上层$groupByDistinctScan带$sort: {a: 1}DISTINCT_SCAN方向forward同样覆盖执行。原因a_1_b_1是非 sparse 的普通索引集合中所有文档含缺失a的文档都有完整索引条目因此可以从索引中看到文档无a这一事实对应null分组DISTINCT_SCAN 恢复合格。这也解释了为何计划器在索引选择上偏好能看见全部文档的索引。5.2 带 sortaccum 的最严格用例测试最后覆盖最严格组合测试源码 L89-L105[ { $sort : { a : 1, b : 1 } }, { $group : { _id : $a, accum : { $last : $b } } } ]仅有 sparse 的{a: 1, b: 1}时sbe 引擎GROUP → SORT → PROJECTION_SIMPLE → COLLSCAN无 DISTINCT_SCANsparse 索引无法满足排序与严格分组语义追加非 sparse 的{a: 1, b: 1, c: 1}后classic 引擎在三键复合索引a_1_b_1_c_1上生成DISTINCT_SCAN方向backwardindexBounds三个键全部[MaxKey, MinKey]isFetching: false$groupByDistinctScan的newRoot为{_id: $a, accum: $b}。这个对照再次印证计划器会遍历可用索引只要存在一个合格非 sparse、distinct 字段非 multikey、前缀匹配去重字段与排序的索引就会为它生成 DISTINCT_SCAN。六、规则四去重字段不在索引键内——wildcard 索引的特殊性第二大节 Distinct Field not part of the Index Key Pattern 讨论当 distinct 字段不是任何索引的第一个键时只有wildcard 索引$**且配合覆盖投影才有机会。该节共 3 组用例数据为[{a: 1}, {a: 2}, {a: 3}, {b: 5}, {b: 7}]等注意a的取值包含数字与字符串用于验证 distinct 的类型语义。6.1 wildcard covered projection DISTINCT_SCAN对a执行 distinct过滤器{a: {$lt: 3}}索引为{$**: 1}测试源码 L115-L119### Distinct on a, with filter: { a : { $lt : 3 } } ### Distinct results [ 1, 2 ]explainclassic显示DISTINCT_SCANon$**_1keyPattern: { $_path: 1, a: 1 }——wildcard 索引在内部把路径编码为$_path键真正的字段值放在后续键indexBounds$_path区间[a, a]只扫路径为a的键a区间[-inf, 3.0)过滤器下推isMultiKey: falseisFetching: falsePROJECTION_COVERED投影_id:0, a:1。原因wildcard 索引把字段名编码进键路径$_path恰好定位到a字段从而字段不在索引第一个键的限制被绕过又因为_id:0, a:1的投影被索引完全覆盖isFetching: false无需回表即可去重。6.2 !wildcard 无 DISTINCT_SCAN同样的 distinct 与过滤器但索引换成普通单键{b: 1}测试源码 L121-L125。结果相同[ 1, 2 ]但 explain 退化为COLLSCANfilter: {a: {$lt: 3}}。原因b索引根本不包含a的键既无法用索引边界满足a上的过滤器也无法覆盖a的投影走索引反而要回表且无去重收益因此计划器选择全表扫描。6.3 wildcard !covered projection 无 DISTINCT_SCAN数据为[{a: 1}, {a: 2}, {a: 3}, {a: 4, b: 3}, {b: 7}]仍用$**索引但过滤器改到b上测试源码 L127-L131### Distinct on a, with filter: { b : { $lt : 5 } } ### Distinct results [ 4 ]explain 显示FETCH → IXSCANindexName: $**_1$_path区间[b, b]b区间[-inf, 5.0)没有 DISTINCT_SCAN。原因过滤器在b上索引只覆盖b的路径与值要返回 distinct 的a值必须回表取文档FETCH投影不再被覆盖。非覆盖投影 非 distinct 字段过滤器的组合让 DISTINCT_SCAN 失去意义。小结规则四去重字段不在索引键内时唯一可能的路径是 wildcard 索引 覆盖投影distinct 字段与过滤器字段都被索引覆盖否则只能全表扫描。七、源码层面的支撑DISTINCT_SCAN 的生成与校验上述合格性规则在源码中有对应实现。查询计划器在 query_planner.cpp 中调用constructCoveredDistinctScan第 1201 行附近构造覆盖式 DISTINCT_SCAN并在特定条件下拒绝该路径// query_planner.cpp auto soln constructCoveredDistinctScan(query, params, *query.getDistinct());而distinct专属的索引选择逻辑集中在 distinct_access.cpp其中getDistinctNodeIndex明确处理了multikey 的约束——源码注释指出Multikey indices are not suitable for DistinctNode when the projection is on an array ...当投影落在数组上时multikey 索引不适合 DistinctNode这正对应本文档 3.1/3.3 的distinct 字段是 multikey 无 DISTINCT_SCAN结论。此外distinct_access.cpp 还包含对$sort $group翻转场景的校验$last/$bottom通过反转扫描方向实现对应 explain 中的direction: backward以及$unwind $group重写为 distinct scan 时的严格性检查tassert断言unexpected sort for the $unwind$group distinct scan rewrite。特性开关featureFlagShardFilteringDistinctScan的默认值与版本约束见 query_feature_flags.idl默认trueversion: 8.2fcv_gated: true与测试文件tags中的requires_fcv_82呼应——低于 FCV 8.2 的环境不会启用本优化也不应运行本测试。八、如何运行与复现golden 测试的执行方式本文件不是手工维护的文档而是由 resmoke 驱动的 golden 测试自动生成并校验的期望输出。运行方式与仓库中的其他 golden 测试一致详见 README.plan_stability.md 的 Running 一节buildscripts/resmoke.py run \ --suitesquery_golden_join_optimization \ jstests/query_golden/distinct_index_eligibility_md.js其中query_golden_join_optimization套件定义于 buildscripts/resmokeconfig/suites/query_golden_join_optimization.yml它通过beginGoldenTest(jstests/query_golden/expected_output)指定期望输出目录并设置了一组与 Join Optimization 变体对齐的 mongod 参数set_parameters: enableTestCommands: 1 internalEnableJoinOptimization: true internalEnableJoinPlanCache: true featureFlagCostBasedRanker: true featureFlagPathArrayness: true featureFlagPersistentStats: true internalQuerySamplingByStrides: true这也是本文件为何位于expected_output/internalEnableJoinOptimization/子目录的原因——它属于internalEnableJoinOptimization打开这一配置变体下的期望输出。若计划器行为发生变化例如 DISTINCT_SCAN 合格性判断被放宽或收紧测试将失败并产出 diff开发者审阅 diff 后可用buildscripts/golden_test.py accept接受新计划作为新的基线见 README.plan_stability.md 的 Accepting the modified query plans 一节。测试使用的两个核心输出辅助函数定义在 golden_test_utils.jsoutputAggregationPlanAndResults(coll, pipeline, ...)执行聚合管道断言返回行数与 explain 的nReturned一致再输出管道、结果、索引清单与归一化 explainoutputDistinctPlanAndResults(coll, field, filter)等价地输出 distinct 命令的计划与结果。两者都借助getEngine/getStableExecutionStats等工具把 explain 收敛为稳定字段去除执行时间等不稳定量保证 golden 对比可复现、可 diff。九、总结DISTINCT_SCAN 合格性速查表综合本文件全部 15 组用例可将 DISTINCT_SCAN 的合格性判定归纳如下条件distinct 字段或$group._id字段其他因素是否生成 DISTINCT_SCAN$sort$lastflip非 multikey索引方向可翻转✅方向 backward$sort$lastflipmultikey—❌退化为 IXSCANFETCH$top/$groupstrict非 multikey非 distinct 字段 multikey 亦可✅可能需 FETCH$top/$groupstrictmultikey—❌退化为 COLLSCANdistinct()命令!strict非 multikey过滤器下推为索引边界✅$groupstrict sparse 索引无该字段的文档稀疏索引看不见缺键文档会漏null分组❌退化为 COLLSCAN$groupstrict sparse 索引同上存在非 sparse 复合索引✅切到复合索引distinct()!strict sparse 索引无该字段的文档命令不返回缺失字段的 null✅distinct 字段不在索引键内wildcard 索引 覆盖投影$_path定位路径✅distinct 字段不在索引键内普通索引 / 非覆盖投影—❌退化为 COLLSCAN贯穿始终的三条主线语义正确性优先只要 DISTINCT_SCAN 可能漏掉分组如 multikey 展开破坏分组键、sparse 索引漏掉null分组计划器宁可退化为 COLLSCAN 也不用它覆盖投影是加分项isFetching: false的覆盖式 DISTINCT_SCAN 是理想形态但 multikey 场景允许isFetching: true回表索引候选集的权衡计划器在全部可用索引中挑选合格者a_1_b_1_c_1优于 sparse 的a_1_b_1、wildcard 的$**_1优于普通b_1。对于应用开发者这意味着想让distinct与$group聚合走 DISTINCT_SCAN应确保去重字段是索引前缀且字段值非数组若数据可能缺失该字段请避免让唯一候选索引是 sparse。而查询计划器的任何细微改动都能通过本文介绍的 golden 测试机制被自动捕捉、审阅与固化。【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表