MongoDB 分片集群中带 limit/skip 的 find 查询 Explain 实战解析
2026/9/15 1:28:37 网站建设 项目流程

MongoDB 分片集群中带 limit/skip 的 find 查询 Explain 实战解析

【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo

导读:本文基于 MongoDB 开源仓库jstests/query_golden_sharding/sharded_explain_find_with_limit_skip.js及其 golden 期望输出文档,系统讲解分片集群场景下带limitskipsort的 find 查询在 mongos 路由器上的 explain 输出结构。你将掌握SHARD_MERGE_SORTSINGLE_SHARD两种路由器执行形态的区别、limitAmount/skipAmount在分片与路由器两级的正确语义,以及如何用 explain 的nReturned判断"结果少于 limit"的真实返回情况,并理解 golden 测试框架如何固化这类行为。

一、背景与测试目标

在分片集群中执行带limit/skip的排序查询时,mongos(路由器)需要决定把limit/skip下推给各分片多少、在路由器自身保留多少。如果语义处理不当,会出现"返回条数不足""跳过结果错误"等 bug。该 golden 测试的用途正是验证路由器上的分片 explain 能否正确追踪 limit 与 skip 值,并确认nReturned反映的是各分片合并后的总数,而不是各分片返回值的简单加总。

测试用例位于 sharded_explain_find_with_limit_skip.js,其注释明确写到:

Tests that sharded explain for find commands correctly tracks limit and skip values on the router when targeting multiple shards. Also verifies that nReturned reflects the merged total, not the sum across shards.

该测试的另一个价值是同时覆盖 classic 与 SBE(Slot-Based Execution)两种执行引擎:在 sbeRestricted(默认 classic 路径)与 sbeFull(SBE 全量开启)下分别记录期望输出,用于锁定不同引擎下 explain 的稳定行为。

二、测试环境与数据分布

测试通过ShardingTest搭建 1 个 mongos、2 个分片、1 个 config server 的最小分片集群:

const st = new ShardingTest({mongos: 1, shards: 2, config: 1});

关键步骤(对应 sharded_explain_find_with_limit_skip.js):

  1. 启用分片并指定片键:以{x: 1}作为片键调用shardCollection
  2. 写入 100 条数据:文档形如{_id: i, x: i}i从 0 到 99;
  3. x: 50处 split 成两个 chunk
  4. 将低 chunk(x: 45)迁移到 shard0,高 chunk(x: 55)迁移到 shard1

最终数据分布为:shard0 持有x ∈ [0, 49],shard1 持有x ∈ [50, 99]。集合上只有两个索引:_id_x_1(explain 输出中的 "Total indexes on the collection" 一节即来自 golden_test_utils.js 的outputAvailableIndexes辅助函数)。

查询通过统一的辅助函数runFindAndExplain组装游标并输出 golden 结果(sharded_explain_find_with_limit_skip.js):

function runFindAndExplain({query, options = {}, expected = {}}) { let cursor = coll.find(query); if (options.sort) cursor = cursor.sort(options.sort); if (options.skip !== undefined) cursor = cursor.skip(options.skip); if (options.limit !== undefined) cursor = cursor.limit(options.limit); outputFindPlanAndResults(coll, cursor, expected); }

expected参数会断言路由器顶层执行阶段的stageSHARD_MERGE_SORTSINGLE_SHARD)以及limitAmount/skipAmount的取值,这些断言逻辑定义在 golden_test_utils.js 的outputCommonPlanAndResults中:

assert.eq(executionStages.stage, expected.stage); if (expected.limit !== undefined) { assert.eq(executionStages.limitAmount, expected.limit); } if (expected.skip !== undefined) { assert.eq(executionStages.skipAmount, expected.skip); }

三、8 个测试用例速览

该 golden 测试共 8 个用例,覆盖两个维度:多分片定位 vs 单分片定位×limit / skip / limit+skip / 结果不足

#用例名过滤条件limitskip路由器 stage返回文档
1Simple limit targeting multiple shardsx > 305SHARD_MERGE_SORTx = 31..35
2Simple skip targeting multiple shards45 < x < 555SHARD_MERGE_SORTx = 51..54
3Limit + skip targeting multiple shardsx > 3055SHARD_MERGE_SORTx = 36..40
4nReturned lower than limit49 ≤ x ≤ 515SHARD_MERGE_SORTx = 49..51(3 条)
5nReturned lower than skip + limit47 ≤ x ≤ 5555SHARD_MERGE_SORTx = 52..55(4 条)
6Simple limit targeting single shardx > 905SINGLE_SHARDx = 91..95
7Simple skip targeting single shardx > 905SINGLE_SHARDx = 96..99
8Simple limit + skip targeting single shardx > 9055SINGLE_SHARDx = 96..99

完整的查询 JSON、结果与 explain 输出见 sbeRestricted/sharded_explain_find_with_limit_skip.md,下文逐一分析关键用例。

四、多分片定位:SHARD_MERGE_SORT 形态

4.1 用例 1:Simple limit targeting multiple shards

查询x > 30limit: 5,按x升序排序,singleBatch: false(允许分片分批返回):

{ "find": "sharded_explain_find_with_limit_skip", "filter": {"x": {"$gt": 30}}, "limit": 5, "singleBatch": false, "sort": {"x": 1} }

由于x > 30跨越了 shard0(0–49)与 shard1(50–99)两个分片,路由器需要在两级执行 limit:每个分片本地执行limit: 5,路由器在SHARD_MERGE_SORT之上再施加limitAmount: 5。分片内执行计划(classic 引擎):

IXSCAN(5) → SHARDING_FILTER(5) → FETCH(5) → SORT_KEY_GENERATOR(5) → PROJECTION_DEFAULT(5) → LIMIT(limitAmount: 5, nReturned: 5)

路由器顶层:

{ "stage": "SHARD_MERGE_SORT", "limitAmount": 5, "nReturned": 5, "shards": [ ...两个分片各 nReturned: 5... ], "totalDocsExamined": 10, "totalKeysExamined": 10 }

这里有两个值得注意的语义:

  • 分片上的limitAmount: 5不等于路由器上的limitAmount: 5表示同一个东西:分片上的 LIMIT 是"下推的本地上限",路由器上的limitAmount是"最终要返回条数"。两者恰好都是 5,是因为排序字段与片键一致且数据分布均匀,路由器选择了"每分片 5、合并取 5"的下推策略;
  • totalDocsExamined: 10(每分片 5)与totalKeysExamined: 10表明:虽然最终只返回 5 条,但两个分片各扫描并读取了 5 个文档(详见 sbeRestricted/sharded_explain_find_with_limit_skip.md)。

4.2 用例 2:Simple skip targeting multiple shards

查询45 < x < 55skip: 5,按x升序排序。数据分布在 shard0(46–49,4 条)与 shard1(50–54,5 条)。分片内执行计划为:

IXSCAN → SHARDING_FILTER → FETCH → SORT_KEY_GENERATOR → PROJECTION_DEFAULT

注意:分片内没有 SKIP 阶段,skip 完全由路由器承担,路由器顶层为:

{ "stage": "SHARD_MERGE_SORT", "skipAmount": 5, "nReturned": 4, "shards": [ {"nReturned": 4}, {"nReturned": 5} ], "totalDocsExamined": 9, "totalKeysExamined": 9 }

合并后按x排序为 46, 47, 48, 49, 50, 51, 52, 53, 54,跳过前 5 条后剩下 51, 52, 53, 54,nReturned: 4与测试结果的 4 条文档完全一致。这里体现出skip 与 limit 的下推策略不同:skip 只作用于路由器,而 limit 会按需下推给各分片(详见 sbeRestricted/sharded_explain_find_with_limit_skip.md)。

4.3 用例 3:Limit + skip targeting multiple shards

查询x > 30skip: 5, limit: 5。为了让路由器最终能跳过 5 条后再取 5 条,分片本地需要把 limit 放大为limit: 10

IXSCAN(10) → SHARDING_FILTER(10) → FETCH(10) → SORT_KEY_GENERATOR(10) → PROJECTION_DEFAULT(10) → LIMIT(limitAmount: 10, nReturned: 10)

路由器顶层同时出现两个字段:

{ "stage": "SHARD_MERGE_SORT", "limitAmount": 5, "skipAmount": 5, "nReturned": 5, "shards": [ {"nReturned": 10}, {"nReturned": 10} ], "totalDocsExamined": 20, "totalKeysExamined": 20 }

这正是本测试想验证的核心行为之一skip + limit组合时,分片上的 LIMIT 值(10 = skip + limit)与路由器上的 LIMIT 值(5)不同,explain 必须分别正确记录两者的limitAmount(详见 sbeRestricted/sharded_explain_find_with_limit_skip.md)。

4.4 用例 4 与 5:结果不足时 nReturned 反映真实数量

  • 用例 449 ≤ x ≤ 51只有 3 条命中,虽然limit: 5,最终返回 3 条。路由器limitAmount: 5nReturned: 3,两个分片分别返回 1 条与 2 条(详见输出);
  • 用例 547 ≤ x ≤ 55共 9 条命中,skip: 5, limit: 5后返回 4 条(52, 53, 54, 55)。分片本地limitAmount: 10,shard0 返回 3 条、shard1 返回 6 条,路由器合并后跳过 5 条再限 5 条,实际仅剩 4 条(详见输出)。

结论:nReturned永远是"实际返回给客户端的条数",不会因为设置了 limit 就虚报为 limit 值。这也是 golden_test_utils.js 中outputFindPlanAndResults的硬性断言所保证的:

const actualReturned = results.length; assert.eq(actualReturned, explain.executionStats.nReturned); assert.eq(actualReturned, executionStages.nReturned);

五、单分片定位:SINGLE_SHARD 形态

当查询条件(x > 90)使 mongos 可以确定目标数据只存在于单个分片时,路由器不再做合并排序,顶层阶段变为SINGLE_SHARD,分片执行计划原样上抛,不产生limitAmount/skipAmount聚合字段(如果原样计划中没有的话)。

5.1 用例 6:Simple limit targeting single shard

分片内 classic 执行计划:

IXSCAN(5) → SHARDING_FILTER(5) → FETCH(5) → LIMIT(limitAmount: 5)

路由器顶层仅stage: "SINGLE_SHARD"totalDocsExamined: 5totalKeysExamined: 5,与分片一致(详见输出)。

5.2 用例 7:Simple skip targeting single shard

分片内执行计划出现SKIP 阶段

IXSCAN(9) → SHARDING_FILTER(9) → SKIP(skipAmount: 5, nReturned: 4) → FETCH(4)

注意这里totalKeysExamined: 9totalDocsExamined: 4跳过操作只扫描索引键(9 个键),并不读取文档,被跳过的文档不计入 totalDocsExamined。这与多分片用例 2 中 skip 只在路由器执行的策略形成了鲜明对比——单分片定位时 mongos 可以把 skip 完整下推,从而让分片用IXSCAN + SKIP的高效路径直接跳过(详见输出)。

5.3 用例 8:Simple limit + skip targeting single shard

SKIP 与 LIMIT 同时下推:

IXSCAN(9) → SHARDING_FILTER(9) → SKIP(skipAmount: 5, nReturned: 4) → FETCH(4) → LIMIT(limitAmount: 5, nReturned: 4)

最终返回 96, 97, 98, 99 共 4 条(详见输出)。

5.4 单分片 vs 多分片下推策略小结

场景路由器 stageskip 处理limit 处理
多分片 + limitSHARD_MERGE_SORT不下推下推limit(skip+limit 时下推skip+limit
多分片 + skipSHARD_MERGE_SORT仅在路由器
单分片 + limit/skipSINGLE_SHARD完整下推(SKIP阶段)完整下推(LIMIT阶段)

六、classic 与 SBE 引擎的 explain 差异

该 golden 测试在多个 feature flag 变体下记录了期望输出。对比 sbeRestricted(classic)与 sbeFull(SBE),可以看到:

  • classic 引擎:阶段名为大写形式IXSCANSHARDING_FILTERFETCHSORT_KEY_GENERATORPROJECTION_DEFAULTLIMITSKIP
  • SBE 引擎:阶段名变为小写ixseekfilterfetchlimitlimitskip,且执行计划更紧凑。例如单分片 limit 用例在 SBE 下为ixseek → filter → fetch → limit(sbeFull 用例 6),单分片 skip 用例为ixseek → filter → limitskip → fetch,limit+skip 为ixseek → filter → limitskip → fetch → limit
  • 引擎标识:多分片场景下,explain 输出不再单独打印 "Execution Engine" 行(因为各分片可能使用不同引擎),而单分片场景会明确打印Execution Engine: classicExecution Engine: sbe

SBE 的limit_skip阶段实现位于 src/mongo/db/exec/sbe/stages/limit_skip.cpp,classic 的 LIMIT/SKIP 阶段分别位于 src/mongo/db/exec/classic/limit.cpp 与 src/mongo/db/exec/classic/skip.cpp。以 classic 的 LimitStage 为例,其构造参数直接携带 limit 值并写入_specificStats.limit,对应 explain 中的limitAmount;SkipStage 则记录_leftToSkip_skipAmount。从 skip.cpp 的doWork实现可以看到跳过语义:_leftToSkip > 0时调用_ws->free(id)直接丢弃文档而不返回,这正是"跳过只扫键不读文档、totalDocsExamined 不增长"的底层原因。

七、如何阅读与复现:golden 测试的运行机制

7.1 输出文件是如何生成的

.md文件并非手写,而是由测试运行时通过 golden 框架自动生成。pretty_md.js中的 section/subSection/code 等辅助函数 会把## N.(section)、###(subSection)与代码块逐行写入文件,配合printGolden输出;golden 框架的具体机制详见 docs/golden_data_test_framework.md:测试输出与已检入的期望输出比对,任何差异都会导致测试失败,需要同步更新代码或期望输出。

7.2 复现步骤

在编译好的 MongoDB 源码树中,通过 resmoke 运行该 golden 测试:

# 使用 resmoke 运行测试 python buildscripts/resmoke.py run --suite jstests/query_golden_sharding --shell jstests/query_golden_sharding/sharded_explain_find_with_limit_skip.js

或通过 Bazel 运行(BUILD.bazel 已把所有 js 测试文件聚合为all_javascript_files库):

bazel test //jstests/query_golden_sharding:all_javascript_files

注意:该测试带有requires_fcv_82标签,需要 FCV 82 及以上的服务器版本;assumes_read_concern_local表示测试假定默认 read concern 为 local(详见 sharded_explain_find_with_limit_skip.js)。

7.3 期望输出文件的分层结构

期望输出按引擎与 feature flag 变体存放于jstests/query_golden_sharding/expected_output/目录:

  • sbeRestricted/——SBE 受限模式(本关联文档所在目录,classic 为主);
  • sbeFull/——SBE 全量开启;
  • sbeDisabled/——SBE 完全关闭(classic);
  • featureFlagSbeFull/——通过 feature flag 开启 SBE full 的对照变体。

各变体的用例 1–8 查询与结果完全一致,差异仅体现在执行引擎与阶段命名上,便于对照验证不同引擎下 limit/skip 语义的一致性。

八、与 count 命令的横向对照

同目录下还有一个姊妹测试 sharded_explain_count_with_limit_skip.js(期望输出见 sharded_explain_count_with_limit_skip.md),它验证 count 命令带 limit/skip 时的行为:

  • count 场景用nCounted代替nReturnedCOUNT阶段不返回文档,因此totalDocsExamined: 0,但totalKeysExamined与 find 一致(如用例 1 中每分片扫 5 个键);
  • 多分片 count 用SHARD_MERGE(而非SHARD_MERGE_SORT)聚合,路由器的limitAmount/skipAmount语义与 find 相同;
  • 单分片 count 用例 8 中分片COUNT阶段在 skip 后计数 5,验证skip + limit下 count 的取值为 5 而不是 10。

它由 golden_test_utils.js 的outputCountPlanAndResults驱动,并断言actualCount == executionStages.nCounted。通过对照可以发现:limit/skip 的路由器追踪逻辑对 find 与 count 是统一的,只是计数字段不同(nReturned vs nCounted)

九、实践要点总结

  1. 读 explain 先看路由器顶层 stageSHARD_MERGE_SORT表示多分片合并排序,SINGLE_SHARD表示单分片直通;顶层shards数组内才是各分片的真实执行计划。
  2. 区分两个limitAmount:分片内的limitAmount是下推的本地上限(skip + limit组合下会变成skip + limit),路由器顶层的limitAmount才是最终返回条数。
  3. nReturned不等于 limit:结果不足时nReturned反映真实返回数量;用它判断"有没有取满"比猜 limit 更可靠。
  4. skip 的下推策略取决于定位:单分片定位时 skip 会下推为分片内的SKIP/limitskip阶段(只扫键不读文档,totalDocsExamined不增长);多分片定位时 skip 仅在路由器执行。
  5. 引擎差异一眼可辨:classic 用大写阶段名(IXSCAN/FETCH/LIMIT),SBE 用小写(ixseek/fetch/limit);单分片 explain 会打印Execution Engine行,多分片不打印。
  6. golden 文件是行为契约:修改查询执行、下推策略或 explain 输出格式时,必须同步更新 expected_output 下各变体的期望文件,并通过 golden 比对确认行为符合预期。

【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询