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_SCAN
DISTINCT_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阶段,下层为FETCH+IXSCAN(索引a_1_b_1,isMultiKey: true,multiKeyPaths.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: classic;DISTINCT_SCAN阶段:direction: "backward",indexName: "a_1_b_1",isMultiKey: false,isFetching: 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 ] },explain(classic 引擎)显示DISTINCT_SCAN:
indexName: "a_1_b_1",isMultiKey: true,isFetching: true;multiKeyPaths: { "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 [ ]explain(classic)显示DISTINCT_SCAN:
indexBounds.a = ["(3.0, inf]"]——过滤器被下推为索引边界,只扫a > 3的键区间;b的边界全开;isMultiKey: true但multiKeyPaths.a = [](multikey 仅在b),isFetching: 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_SCAN,indexName: "a_1",isSparse: true,isFetching: 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:
| 用例 | Pipeline | Results | explain |
|---|---|---|---|
纯$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:
- 纯
$group:DISTINCT_SCANona_1_b_1,isMultiKey: false,isFetching: false,PROJECTION_COVERED投影_id:0, a:1; - 带
$last: "$b":DISTINCT_SCAN方向backward($last需要取序末),isFetching: false,PROJECTION_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 带 sort+accum 的最严格用例
测试最后覆盖"最严格"组合(测试源码 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_SCAN(sparse 索引无法满足排序与严格分组语义); - 追加非 sparse 的
{a: 1, b: 1, c: 1}后:classic 引擎在三键复合索引a_1_b_1_c_1上生成DISTINCT_SCAN,方向backward,indexBounds三个键全部[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 ]explain(classic)显示DISTINCT_SCANon$**_1:
keyPattern: { "$_path": 1, "a": 1 }——wildcard 索引在内部把"路径"编码为$_path键,真正的字段值放在后续键;indexBounds:$_path区间["a", "a"](只扫路径为a的键),a区间[-inf, 3.0)(过滤器下推);isMultiKey: false,isFetching: false,PROJECTION_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 退化为COLLSCAN(filter: {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 → IXSCAN(indexName: "$**_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(默认true,version: 8.2,fcv_gated: true),与测试文件@tags中的requires_fcv_82呼应——低于 FCV 8.2 的环境不会启用本优化,也不应运行本测试。
八、如何运行与复现:golden 测试的执行方式
本文件不是手工维护的文档,而是由 resmoke 驱动的 golden 测试自动生成并校验的期望输出。运行方式与仓库中的其他 golden 测试一致(详见 README.plan_stability.md 的 "Running" 一节):
buildscripts/resmoke.py run \ --suites=query_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.js:
outputAggregationPlanAndResults(coll, pipeline, ...):执行聚合管道,断言返回行数与 explain 的nReturned一致,再输出管道、结果、索引清单与归一化 explain;outputDistinctPlanAndResults(coll, field, filter):等价地输出 distinct 命令的计划与结果。
两者都借助getEngine/getStableExecutionStats等工具把 explain 收敛为稳定字段(去除执行时间等不稳定量),保证 golden 对比可复现、可 diff。
九、总结:DISTINCT_SCAN 合格性速查表
综合本文件全部 15 组用例,可将 DISTINCT_SCAN 的合格性判定归纳如下:
| 条件 | distinct 字段(或$group._id字段) | 其他因素 | 是否生成 DISTINCT_SCAN |
|---|---|---|---|
$sort+$last(flip) | 非 multikey | 索引方向可翻转 | ✅(方向 backward) |
$sort+$last(flip) | multikey | — | ❌(退化为 IXSCAN+FETCH) |
$top/$group(strict) | 非 multikey | 非 distinct 字段 multikey 亦可 | ✅(可能需 FETCH) |
$top/$group(strict) | multikey | — | ❌(退化为 COLLSCAN) |
distinct()命令(!strict) | 非 multikey | 过滤器下推为索引边界 | ✅ |
$group(strict)+ sparse 索引 | 无该字段的文档 | 稀疏索引看不见缺键文档,会漏null分组 | ❌(退化为 COLLSCAN) |
$group(strict)+ 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),仅供参考