
在分布式数据库架构中,数据自动均衡(Balance)机制是分片集群良好运行的基石,它确保分片的优势——性能、可扩展性和可用得到充分发挥。腾讯云NoSQL和内核团队在内核性能基准测试中发现:MongoDB 6.0.3 及后续版本,其 Balance 数据迁移效率相比5.0版本获得了30%至45%的显著提升,这一性能的提升意味着 MongoDB 在面对数据不均时具备更强的自我调节能力,更好地防止某些分片上出现热点,从而提升整个集群的吞吐量和查询速度,为了更好地了解 MongoDB 性能提升背后的技术原理,腾讯云 NoSQL 和内核团队对 MongoDB 内核发起了源码级深度剖析,本文将系统性阐释balance的触发机制、迁移流程与性能调优方法,并对 7.0、8.0 版本 balance 的调优进行剖析。 希望本文能为各位同事在架构设计、性能优化和客户业务问题排查上提供有力的理论支持与实践指导。
作者:杨亚洲、尹超
在 MongoDB 分片集群中,均衡器(Balance)的重要性在于通过自动在分片间移动数据(块)来确保数据均匀分布。这个过程对于维持最佳性能和可扩展性至关重要,因为它能防止某些分片上出现热点,从而提升整个集群的吞吐量和查询速度。若禁用均衡器会导致分片数据不均,进而降低集群性能,因此持续运行均衡器对于维护健康高效的分片系统至关重要,确保分片的性能、可扩展性和可用性得到充分发挥。
腾讯云MongoDB和内核团队对 MongoDB 内核 balance 性能测试时,发现6.0.3及其以后版本的 balance 数据迁移性能整体比 5.0 好大约 30% - 45% 。为什么 6.0 及其以上版本 balance 性能提升这么大?为何高版本 chunk 分布不均衡?为何高版本 split 对业务无影响?带着这些疑惑,腾讯云 MongoDB 和内核团队结合 MongoDB 内核 (包括 6.0、7.0、8.0 )源码对 balance 原理做了一次深入的研究分析。本文主要以 MongoDB 8.0.10 内核代码进行原理分析。
balance 触发条件及其实现
当 config server 选出主节点后会创建一个“Balancer”线程,该线程负责判断集群中所有分片表数据是否均衡,是否需要触发做数据迁移操作
1.1 数据不均衡判断

config server会向分片集群的所有shard广播发送 _shardsvrGetStatsForBalancing 命令来获取表的 stats 统计。
● 单分片表场景
假设只有一个分片表 collection1,则 _shardsvrGetStatsForBalancing 广播到所有分片:

各个 shard 收到后,本地获取该表的数据大小返回给 config server ,内容如下:

● 多分片表场景
如果该分片集群有多个分片表,“Blancer”线程按照100个分片表一组在一个 _shardsvrGetStatsForBalancing 请求中携带所有分片表广播给所有shard,各个 shard 收到该请求后,会一次性把本节点上这一批表的 stats 信息返回。

一次批量获取100个表 stats 信息,内容如下:

获取到各个 shard 的 stats 数据后,config server 在以下场景会触发数据迁移:
迁移场景
1. draining 分片迁移:
该表在某些 shard 有数据,如果该 shard 从分片集群中移除,这个场景比较好理解。
2. zone 约束违规:
约束违规,主要在 addTagRange 调整的时候容易触发,具体举例详见附录详情文档。
3.普通数据表不均衡场景:
普通分片表各个 shard 数据差异大,这是最常见的 balance 迁移场景。
以上三种场景有优先级限制,draining 分片优先级最高,zone 约束违规次之,普通数据不均衡场景优先级最低。只有在高优先级场景的 chunk 数据迁移完成后,才会执行低优先级场景的 chunk 数据迁移。
Config server 获取到分片表在每个分片上的 stats (表数据量)信息后,会选出表数据量最大的分片和最小的分片,下面举例说明确定源和目标分片的步骤:
● 确定各分片数据分布
假设某 collection 在四个分片的数据分布如下:

集群4个 shard,数据分布从1G – 4G,分片间数据存在不均衡现象。
● 计算理想数据分布
根据各个分片的 stats 信息计算理想数据分布,以计算过程如下:

numShardsInZone:代表分片数,4个分片
totalDataSizeOfShardsWithZone:代表数据总和 = 4G + 1G+ 2G + 3G = 10G
idealDataSizePerShardForZone:代表各个shard理想分布 = totalDataSizeOfShardsWithZone / numShardsInZone = 10G / 4 = 2.5G
● 源分片和目标分片选择
获取到每个 shard 的数据量,从而可以计算出 shard0001 数据量最多,所以选择 shard0001 作为源分片。
同理,因为 shard0002 数据量最少,因此被选为目标分片。选择算法举例参考附录详情。
● 迁移条件判断
选出源分片和目标分片后,还需要满足以下3个条件才会生成迁移任务:
1.源分片表数据量 > idealDataSizePerShardForZone。
2.目标分片表数据量 < idealDataSizePerShardForZone
3.目标分片表数据量 > 源分片表数据量 + 3 * maxChunkSizeBytes(默认128M)
当源分片的数据量比目标分片数据量多3个 chunk(3*128=484M) 的时候,会生成迁移任务。
● Chunk 选择及迁移任务生成
chunk 选择主要通过 ChunkManager 的 forEachOverlappingChunk 接口完成,通过遍历该表路由信息属于源分片的第一个 chunk ,然后生成 migrations 任务。遍历过程会跳过 jumbo chunk ,只会选择非 jumbo chunk 。
注意这里构造的迁移任务的 max 字段设置为 boost::none ,这个很关键,因为在源分片会识别 max 是否有值,如果没有的话就会进入 split chunk 自动拆分逻辑,请参考后面的S(T2)章节。
如果该shard全是 jumbo chunk ,则会给出 warning 信息,详见附录。
● 迁移任务入队
迁移任务生成后,config server 根据迁移任务构建 _shardsvrMoveRange 请求对象。
构建好请求对象后,会为该对象分配一个唯一“requesid”,然后把该re questid 和请求对象作为 kv 加入到 _requests(unordered_map) 中,同时把 requestid 加入到一个 fifo 先进先出队列_unsubmittedRequestIds中。入队完成后通过_stateUpdatedCV条件变量唤醒消费者线程 (BalancerCommandsScheduler) ,消费者线程被唤醒后开始执行迁移任务。
_requests 数据结构:
requestid 作为 key ,请求对象作为 value 存储到该 unordered_map 中。
_unsubmittedRequestIds 队列:
一个 fifo 结构的队列,只存储“requested”,主要保障迁移任务的顺序执行。
如果有多个分片表数据不均衡,则每个分片表都会生成一个迁移任务入队:

● 迁移任务异步处理
Config server的“BalancerCommandsScheduler”线程检测到_unsubmittedRequestIds队列有待执行的迁移任务,最终会通过“Sharding-Fixed”线程池调度把构造的 _ShardsvrMoveRange 命令发送给源分片,通知源分片开始数据迁移,如果候选迁移任务对象只有一个分片表,其交互流程如下:

如果有多个表数据不均衡,则“BalancerCommandsScheduler”线程会把这些表对应的迁移任务对象一起发送给源分片,通知源分片对这些表进行并行迁移,然后异步等待 chunk 数据迁移完成,如下图:

“Balancer”和“BalancerCommandsScheduler”线程不会阻塞等待,当源分片迁移 chunk 数据完成后,激活条件变量 _stateUpdatedCV 。由于“Balancer”和“BalancerCommandsScheduler”线程都会等待这个条件变量,因此这两个线程会被激活唤醒,然后开始下一轮的任务处理,这样就可以实现多表的并发迁移。
为了更好的理解,请参考附录中的异步并发调度举例。
1. Config server 选主成功后,会创建一个“Balancer”线程,该线程会获取所有分片表在所有 shard 上 stats 数据量统计信息,然后为不均衡的分片表生成对应的迁移任务入队到队列。
2. “BalancerCommandsScheduler”线程从队列中获取任务,然后交由“Sharding-Fixed”线程池异步调度。
3. “Sharding-Fixed”线程池获取到源分片的应答后,会通过条件变量 _stateUpdatedCV激活“Balancer”和“BalancerCommandsScheduler”线程进行下一轮处理,继续选择和生成新的 chunk 迁移任务。
4. 所有分片表迁移任务批量生成和提交,因此可以实现多个不均衡分片表的并行迁移。
5. 一个表数据迁移慢不会影响其他表迁移任务的执行。
数据迁移过程及其实现
源分片收到 config server 发送过来的 _ShardsvrMoveRange 请求后,会标记该接受请求的线程为"MoveChunk"线程,该线程会负责整个源分片数据迁移管理。源分片把迁移过程拆分为6个过程,每个过程都通过 _moveTimingHelper 来跟踪每个过程中的耗时。
目标分片数据迁移流程主要由 MigrationDestinationManager 类实现,目标分片把整个迁移拆分为8个阶段( _migrateDriver 中的 timing->done(x) ,x取值1-8 ),并记录每个阶段耗时。
为了更好地理解迁移过程,下面主要以时间线结合代码进行说明,例如:
S(T1):代表源分片第一阶段的代码实现。
S(Tn): 代表源分片第n阶段的代码实现
D(T1):代表目标分片第一阶段的代码实现。
D(Tn):代表目标分片第n阶段的代码实现。
源分片T1阶段主要完成 MigrationSourceManager 类构造函数的基础初始化和锁验证,该阶段几乎不耗时,理论上耗时为0ms。
该阶段是分片迁移的准备阶段,主要是从 config server 获取最新路由信息、 chunk 边界检查计算、冲突检测、 chunk 版本获取等。此外,该阶段有个很重要的功能逻辑是做 chunk 数据 split 拆分,下面重点分析该阶段 split 拆分及其原理:

前面2.4章节生成迁移任务的时候max字段设置为空,因此 balance 会进入上面的流程,最终在 autoSplitVector 中触发 chunk 拆分。
由于迁移 chunk 可能是一个大 chunk (大于128M),因此需要对该 chunk 进行拆分,其核心实现如下:
● 预估一个目标 chunk 的文档数

源分片首先获取该分片表在该分片的数据总行数( totalLocalCollDocuments ),同时获取该分片上该表的总数据大小( dataSize ),就可以得出单个文档的平均大小( avgDocSize ), 最后用一个目标chunk的大小( maxChunkSizeBytes )除以单个文档平均大小( avgDocSize )即可得到一个目标chunk内预期的文档行数( maxDocsPerChunk )。
● 确定 chunk 是否可分裂
根据片键索引生成扫描器,然后获取该 chunk[min, max>范围内的第一个最小的数据 key ,再获取该范围内的最后一个最大的数据 key ,如果两个 key 相等,说明这个范围只有一个键值,也就是该 chunk 不可分裂。
● 确定 chunk 分裂点
确定分裂点借助扫描器,通过扫描器的 getNext 接口从该 chunk 的 min 边界开始一行一行获取索引数据,当获取到的数据行数达到 maxDocsPerChunk 时即可确认分界点和一个子 chunk 。假设某源chunk 有1000条数据,maxDocsPerChunk 为100,从该 chunk 获取3个分裂点,其核心算法如下图所示:

从上面的代码中可以看出分裂点获取采用“均匀间隔采样策略”,这样可以确保每个每个拆分后的 chunk 大小均匀。
● 优化:拆分后避免小 chunk
一个大 chunk 拆分为多个 maxChunkSizeBytes(128M) 大小的 chunk 时,会优先保证前面的多个子 chunk 达到128M,这样就可能引起一个问题:例如某个 chunk 有10001条数据,每100条数据可以组成一个128M的子 chunk ,前面的1000条数据刚好可以拆分为10个128M的子chunk,最后的第11个 chunk 就只会有1条数据,这样就会引起 chunk 不均衡。
MongoDB 为了避免小 chunk 的产生,如果发现某个chunk的文档数小于maxDocsPerChunk*0.8 ,则会对拆分出来的最后面的3个分裂点(也就是最后的4个子chunk)进行重新均衡,最终保证后面4个 chunk 的数据均衡。为了让最后4个 chunk 均衡,这里有两种策略:简单策略和公平策略。最终选择哪种策略由以下算法决定:

对该算法的举例说明请参考附录详情。
● 自动 balance 引起的 chunk 拆分总结
当源分片进入S(T2)阶段发现 chunk 没有 max 信息,因此会调用 autoSplitVector 函数对接受到的 chunk 进行拆分,自动 balance 引起的 split 限制 limit 限制为1,也就是只中拆分出1个子 chunk,这个子 chunk 的拆分点就是子 chunk 的 max ,这样 chunk 的[min, max>范围也就确认了。
说明:为了避免拆分点后的 chunk 过小,自动 balance 引起的 chunk split 会扫描1+1=2个子 chunk 。
该阶段主要确定要迁移的 chunk 对应的所有数据 RecordId 和通知目标分片开始数据迁移。
● 获取迁移 chunk 的所属数据 RecordId 列表
为了确定迁移 chunk 有哪些 RecordId ,首先在 _storeCurrentRecordId 函数中通过分片键索引生成执行器,然后通过执行器 getNext 接口获取该属于该 chunk 范围的所有 RecordId 存入 _cloneList类的_recordIds 克隆列表中(std::set容器管理),存入 _recordIds 的 RecordId 将在后续的D(T4)中使用,获取RecordId克隆列表核心代码详见附录文档。
如果迁移 chunk 的大小超过了2倍 maxChunk 的大小,则会把该 chunk 标记为 jumbo chunk 。如果我们配置中不允许迁移 jumbo chunk 则会迁移失败,并且日志中会有 ChunkTooBig 相关报错日志,可以通过如下配置启用 jumbo chunk 迁移:

● 通知目标分片开始数据迁移
注册 _coordinator 迁移协调器( MigrationCoordinator )和 _cloneDriver 源克隆驱动器( MigrationChunkClonerSource ),然后源克隆驱动器通知目标集群可以开始数据迁移了,通知过程如下:

目标分片接收到 _recvChunkStart 请求后解析请求中的各种参数信息然后赋值给 MigrationDestinationManager (目标分片负责接收数据和状态管理的类)的各个成员, 同时创建"migrateThread"线程,该线程负责接收 chunk 数据。
"migrateThread"线程创建完成后,进入目标分片数据迁移8阶段的第1阶段。该阶段获取迁移表的创建参数、uuid、表索引信息,然后检查当前分片是否有影响本次迁移的待删除任务,如果有则删除该任务中对应的脏数据。
该阶段主要根据上面D(T1)获取到的集合参数信息、索引信息,然后在目标分片创建一致的集合及其索引等,确保集合结构( options + indexes )与源分片的完全一致。
该阶段初始化构造 recipientDeletionTask 任务,然后把该删除任务写入 config.rangeDeletions 集合中,后续如果 chunk 迁移失败或者异常,就可以通过持久化到 rangeDeletions 集合中的删除任务来删除已经写入到目标分片的数据。
如果 chunk 迁移中途失败,则后台 delete 线程会根据该集合中的这条 chunk 范围记录删除已经迁移到该目标 shard 的脏数据。
目标分片从源分片获取数据写入本分片采用生产者-消费者双线程池模式实现并发数据获取及并发写入。生产者负责从源分片获取数据,由"ChunkMigrationFetchers"线程池管理,消费者负责把生产者获取的数据写入本地分片,消费逻辑由"ChunkMigrationInserters"线程池管理,通过该架构实现了一个分片表的并发数据迁移,架构图如下:

● 线程池大小
生产者线程池和消费者线程池大小可由同一个配置( chunkMigrationConcurrency )调整,该配置默认为1,调整命令如下:

说明:最新 MongoDB 内核已经取消了该配置,只会由一个线程来 fetcher 和 insert 数据。
● 缓冲队列大小
缓冲队列中的每一个 inserter 中记录了一个 fetcher 线程从源分片获取到的一批数据,如果 inserter 线程池写入比 fetcher 线程池慢,那么缓冲队列就会一直增长,这样就会引起内存的持续增加。为了限制内存的过度消耗,MongoDB 通过 chunkMigrationFetcherMaxBufferedSizeBytesPerThread 配置来限制每个 fetcher 线程在 chunk 迁移过程中缓冲到队列中的文档字节数,该配置默认值为约64.06MB,计算方法如下。

一批数据大约16M,也就是一个 fetcher 线程大约可以拉取4批数据存到队列中。如果一个 insert 线程池消费数据写入目标分片过慢,则队列中的 inserter 就会积压,当每个 fetcher 线程挤压大约4批数据后,fetcher 线程就会阻塞等待。
● Fetcher 线程从源分片拉数据核心实现原理
Fetcher 线程构造“_migrateClone”请求发送给源分片,源分片收到“_migrateClone”请求后,根据S(T3)预记录的 _recordIds 克隆列表中的 recordId 从存储引擎获取真实 doc 文档,其核心代码如下:

为了解决多线程并发获取的问题,MongoDB 通过互斥锁来实现,保证不同线程获取不同的 recordId , 最终实现多线程并发分配 recordId ,其核心代码及举例如下:

此外,MongoDB 内核批量数据传输有 BSONObjMaxUserSize(16M) 限制,为了尽量填充满16M空间到 arrBuilder 中,因此每次获取到真实 doc 后需要进行判断,如果最新获取的一条 doc 填充后长度超过16M,则最后一条 doc 不会填充到arrBuilder,而是临时存入 _cloneList._overflowDocs 队列中,在下一批数据获取的时候会优先把 _cloneList._overflowDocs 队列中的数据填充到 arrBuilder ,核心代码如下:

目标分片收到源分片应答后,会把接收到的一批数据封装成 inserter 入队。
● Inserter 线程获取队列数据写入本地节点
目标分片从队列获取 inserter 分批写入本地主节点,单批次写入的文档数可配置,假设配置的单批次文档数为100条,则分批次写入本地如下图:

单批次写入文档数由 migrateCloneInsertionBatchSize 限制,该值默认为0,也就是把 fetcher 从源分片获取到的 inserter 数据一次性写入主分片,该配置修改方法如下:

从源分片获取到的数据在客户端 driver 写入源分片的时候已经做了 schema 校验,为了提升目标分片批量写入性能,performInserts 批量写入数据之前目标端提前禁用了 SchemaValidation 功能。
此外,如果长时间高负载迁移数据到目标分片,可能引起目标分片负载高(例如 dirty 持续性增加)。为了降低持续迁移写入对目标分片的影响,一批数据写入完成后可以延迟一段时间,延迟时间到才继续下一批数据写入。延迟时间可通过 migrateCloneInsertionBatchDelayMS 参数配置,该配置参数默认为0,也就是默认一批数据写入后不会 sleep ,修改方法如下:

此外,由于迁移期间,迁移 chunk 数据还没正式归属于目标分片,因此这部分数据属于孤儿文档,需要对孤儿文档计数。每应用一批数据到本地成功,就通过persistUpdatedNumOrphans函数对 config.rangeDeletions 表中的 numOrphanDocs 字段做“$inc”累加操作,例如这批数据写入了100条,则会对表中的字段增加100。
● Chunk 迁移过程中增量数据收集
Chunk 迁移过程中,客户端可能写或者修改该 chunk 中的数据,这部分写除了会应用到源分片外,还会记录到源分片的内存队列中,队列中只会保存迁移期间的增量写 oplog 的 _id 信息,其中 delete 删除操作记录到 MigrationChunkClonerSource 类的 _deleted 队列,insert 和 update 则记录在 _reload 队列中。
写入源分片内存队列中的 oplog 增量数据将在后面的D(T5)阶段由源分片消费后发送给目标分片, _reload 队列生产和消费逻辑如下图所示(说明:下图中的 oplog 实际上是 oplog 中的_id内容):

同理,_deleted 队列生产和消费逻辑如下图所示:

本阶段D(T4)只负责生产 oplog 入队,队列中 oplog 消费流程在下面的D(T5)阶段实现。
D(T4)获取到一个 chunk 的数据写入目标分片完成后进入该阶段,进入该阶段后状态会更新为 kCatchup ,该阶段首先发送"_transferMods"请求到源分片。
源分片收到该请求后,通过 TransferModsCommand::run 处理请求,run 中调用nextModsBatch接口获取上面D(T4)阶段 _reload 和 _deleted 队列中的 oplog 。
● 原子性接管队列 oplog
在接管两个队列前通过加锁保证,核心代码如下:

如上代码可以看出,使用 splice 操作可以避免拷贝开销,最终 _deleted 队列中的 oplog(delete) 由 deleteList 接管, _reload 队列中的 oplog(insert/update) 由 updateLis t接管。
● delete 操作封包
删除操作封包流程主要是从 deleteList 列表中获取 delete 操作的 oplog ,由于删除操作只需要确定 _id 即可定位对应数据,因此 delete 操作只提取其中的 _id 字段添加到封包中
封包后的内容如下:

● insert/update 操作封包
当 deleteList 列表中的 oplog 全部填充到 bson 包完成后,deleteList 列表为空,这时候才会进行 updateList 列表中的 insert/update 相关 oplog 的填充。Insert/update 操作首先通过 oplog 中的 _id 来查询完整文档,然后把完整文档填充到封包中。
xferModes 遍历 updateList 列表填充 bson 包的时候,会根据 _id 去重,避免同一个 _id 的整文档多次添加到 bson 包中。Insert/update 相关 oplog 封包后的内容如下:

● 因果顺序一致性保障机制
上面 delete、insert/update 封包流程可以看出,增量数据会优先对delete相关操作进行封包处理。业务写入数据生成 oplog 到 oplog.rs 表后是有序的,但是这里却投递到了两个队列,oplog 到两个队列如何保证数据的一致性?下面以两个场景举例:
场景1: 先删后改
鉴于篇幅,详解附录文档查看详细场景举例。
场景2: 先改后删
鉴于篇幅,详解附录文档查看详细场景举例。
● 批量传输限制
由于内核单次请求应答的 bson 包体大小最大16M, 如果增量数据过多,则需要分批次返回给目标分片。如果一批数据封装完成后,剩余的数据重新还给 _deleted 和 _reload 队列,下次继续从队列消费剩余增量数据封装。
假设一批数据中既有 delete 数据也有 reload 数据,则传输的数据内容如下:

目标分片"migrateThread"线程接收到增量数据后,在 fetchAndApplyBatch 中采用生产者-消费者双线程并发协作模式把增量数据应用到本地。生产者线程负责从源分片获取增量数据写入队列,消费者线程从队列消费数据应用到本地,队列大小固定为1。整体架构如下:

● 生产者线程循环从源分片获取增量数据入队
生产者线程主要通过“_transferMods”从源分片获取增量数据,收到源分片返回的内容后,把返回的一批 batch 数据入队到 batches 队列,然后继续从源分片获取数据,直到源分片返回的 size 为0,说明增量数据拉取完毕。
● 消费者线程从队列消费数据应用到本地
消费者线程从队列中获取 batch 数据后,通过 applyModsFn 回掉接口把数据应用到本地,先处理源分片返回的 deleted 相关增量 oplog 数据,deleted 增量数据应用原理: 根据返回的 deleted 列表中的 _id 获取整个文档,然后结合 shardkey 检查该数据是否属于这个 chunk ,如果检查通过则根据 _id 执行删除操作。
Deleted 列表中的_id数据处理完毕后,开始处理 reload 列表( insert/update )中的数据,应用 reload 列表中的数据原理和 deleted 类似,先根据 reload 列表中获取的整文档检查是否属于这个 chunk,如果属于迁移的 chunk 则对整文档执行 upsert 操作。
为了提升应用增量数据的性能,和D(T4)章节全量 chunk 回放一样禁用 schemaValidate 检查。此外,在应用增量数据流程中还会对孤儿文档进行计数,每应用一条 delete 对应 oplog 删除一条数据则孤儿文档数 changeInOrphans 减1,从 reload 队列每 upsert 写一条数据成功则孤儿文档数加1。
源分片S(T4)和目标分片D(T1)–D(T5)处于同一时间段,因此这里用S(T4)[ D(T1)–D(T5)]表示。
在目标分片从源分片迁移增量数据期间[ D(T1)–D(T5)],源分片在这期间进行第4阶段的功能处理,源分片第4阶段主要功能是通过远程调用,周期性检查目标分片当前的克隆进度和状态,判断是否可以安全进入提交阶段 startCommit 。
● 进度查询交互过程
源分片向目标分片发送“_recvChunkStatus”命令请求向目标分片获取增量数据克隆迁移进度,请求内容如下:

请求中的 waitForSteadyOrDone 字段内容为 true ,因此目标分片会在条件变量上等待,条件满足或者1秒超时后才会返回源分片。
“_recvChunkStatus”获取进度交互过程如下:

● 进入关键临界区(阻止用户写)判断条件
如果源分片收到目标分片应答的 stat 状态为 catchup (也就是增量数据获取中)或者 steady (增量数据已获取完成),并且未传输的增量数据占 chunk 最大值的百分比低于 maxCatchUpPercentageBeforeBlockingWrites (默认10%,即128*10%=12.8M)配置时将进入关键阶段。未传输的增量数据计算方法详解附录文档。
调整配置方法如下:

● 源分片进入临界区并阻塞用户写
进入临界区,也就是数据迁移的关键阶段,在 MigrationSourceManager::enterCriticalSection() 中会进入临界区,对应代码如下:

通过 _critSec.emplace() 最终会创建 ShardingMigrationCriticalSection 对象,该对象包含:
_critSecCtx:关键区域上下文。
critSecSignal:一个SharedPromise<void> 用于阻塞操作。
reason:阻塞原因(迁移相关信息)。
进入临界区后,当用户写迁移 chunk 数据到源分片如果发现当前处于临界区则需要阻塞等待,对应阻塞等待伪代码如下:

● 向目标分片发出提交信号,驱动目标分片进入提交收尾流程
进入临界区后,源分片向目标分片发送"_recvChunkCommit"请求,同时阻塞等待目标分片返回进度内容,请求内容如下:

目标分片收到该请求后,需要等待 “migrateThread”迁移线程完成增量数据迁移及一些收尾工作,然后进入 kCommitStart 状态,同时唤醒目标分片的“migrateThread”线程进入下一阶段处理,对应代码如下:

唤醒目标分片 migrateThread 线程后,处理"_recvChunkCommit"请求的线程会阻塞等待,等待“migrateThread”数据迁移线程进入临界关键区,等待该迁移线程把状态推进到 kEnteredCritSec,等待超时时间 defaultConfigCommandTimeoutMS(默认30秒) 可配置,配置方式如下:

当 migrateThread 线程第8阶段进入 kEnteredCritSec 状态后,会唤醒处理"_recvChunkCommit"请求的线程,该请求线程返回目标分片迁移状态。源分片收到返回的迁移状态内容后会进入S(T6)阶段。
到这里目标分片已经进入 kCommitStart 状态,同时源已经禁写,但是D(T5)-D(T6)期间源可能还有写入,因此 migrateThread 需要再次从源分片 fetch 一次数据,如果源分片返回的响应内容为空 (size=0) 则说明禁写期间的增量数据也获取完成。
该阶段主要确保所有与迁移 chunk 相关的会话数据(如事务、可重试写等)都已从源分片同步到本地, 这一步保证了迁移后目标分片能够正确处理与该 chunk 相关的分布式事务和会话操作。只有在会话数据全部迁移完成后,目标分片才会进入迁移流程的最终阶段(如关键区域解除、迁移完成),防止事务丢失或会话状态不一致,核心代码实现参考附录。
第8阶段是临界区关键阶段,主要工作如下:
● 持久化恢复文档
在目标分片进入关键区域前,将迁移的关键信息(迁移ID、命名空间、会话ID、chunk范围、源分片等)持久化到 config.migrationRecipients 集合。
如果目标分片的主节点在临界关键区域期间发生故障,新主节点可以根据持久化的文档恢复迁移状态, 确保迁移流程不会因故障而中断或丢失。
● 目标分片通知源分片数据迁移完成
前面的工作处理完成后,目标分片 migrateThread 唤醒S(T5)中的处理 _recvChunkCommit 请求的线程,该线程被唤醒后返回源分片目标分片已进入临界区状态,返回内容参考前面S(T4)章节。唤醒代码如下:

● 目标分片阻塞等待源分片通知自己退出临界区
到这里,目标分片阻塞等待源分片通知自己退出临界区的通知, 该通知由下面的S(T6)阶段实现,对应代码如下:

目标分片第8阶段进入 kEnteredCritSec 状态后,源分片进入S(T6)阶段,该阶段主要作用如下:
● 进入关键临界区提交阶段
源分片将集合临界区域从只阻塞chunk数据写操作升级为阻塞 chunk 读写操作,进入迁移的最终提交阶段。核心代码如下:

● 修改 config server 的 chunk 元数据
Chunk 已经从源分片迁移到目标分片了,修改 config.chunks 表中对应已迁移 chunk 元数据属于目标分片。
● 通知目标分片释放临界区
修改 chunk 元数据完成后,源分片发送 _recvChunkReleaseCritSec 请求给目标分片。目标分片处理该请求的线程通过下面代码通知D(T8)阶段阻塞的 migrateThread 线程释放临界区:

目标分片 migrateThread 线程退出临界区后,会从 config server 获取最新的 chunk 元数据。至此,目标分片正式接管新 chunk,写入该 chunk 的数据将由新分片接收。
migrateThread 线程退出临界区后,处理 _recvChunkReleaseCritSec 请求的线程返回成功应答给源分片,如下:

● 源分片退出临界区
源分片收到目标分片退出临界区完成的应答后,源分片退出临界区,核心代码如下:

调用 reset() 方法会清理关键临界区状态,从而解除对迁移集合的读写操作阻塞。此外,清除临界区状态后,同时向 config.changelog 表中记录一条“moveChunk.commit”日志。
Chunk 数据迁移过程按照时间线总结如下:

迁移异常处理及诊断统计
如果迁移异常,例如迁移一部分数据写入目标分片后,因为某种原因迁移失败,就需要对目标分片的脏数据做清理操作。此外,如果 chunk 数据迁移成功,源分片需要清理该 chunk 的数据,这部分数据也是脏数据。
鉴于篇幅,本文不再做脏数据清理相关分析,脏数据清理原理以后单独分享。
MongoDB 会对迁移过程中重要环节进行统计,可通过 db.serverStatus().shardingStatistics
命令查看:

各统计的含义总结如下:
● 源分片迁移相关统计

● 目标分片迁移统计

● 关键临界区域相关统计

● 数据克隆相关统计

● 范围删除相关统计

● 迁移失败相关统计

● 其他统计

性能调优
为了适应不同场景,balance 过程支持配置参数在线调整,调优总结如下:
● chunkMigrationConcurrency
该参数主要负责单个表迁移的并发度调整,默认为1。如果迁移过程中源分片和目标分片负载都不高,可以适当的调大该值;如果迁移过程中,系统负载高,不建议调大该值。该配置 5.0.15, 6.0.5 及其以上版本支持调整。
说明: 由于调大该值会增加系统负载,并且可能增加孤儿文档的产生,因此从 8.0.5, 7.0.23 开始的版本不再支持调整该参数。
● chunkMigrationFetcherMaxBufferedSizeBytesPerThread
每个迁移线程缓冲文档的最大字节数,0表示无限制,默认值4 BSONObjMaxInternalSize (约等于464M),具体作用请参考前面D(T4)。不建议修改该值。
● migrateCloneInsertionBatchSize
克隆阶段目标分片每批插入的最大文档数,该配置默认值0,也就是不限制一批写入的文档数。如果目标分片压力很大影响业务,建议适当调整该值,例如调整为100,一次批量写入100条数据。
● migrateCloneInsertionBatchDelayMS
克隆阶段目标分片批次插入之间的等待时间,也就是每写一批数据后都延迟一段时间,这样就可以减少目标分片负载。该配置默认值为0,也就是不 sleep 延迟。如果目标分片因为数据迁移压力很大,可以调大该值减少对业务的影响。
● migrationLockAcquisitionMaxWaitMS
迁移操作获取集合锁的最大等待时间,默认值500ms,不建议修改该值。
● maxCatchUpPercentageBeforeBlockingWrites
进入关键临界区时的判断条件,也就是允许未传输的增量数据占 chunk 最大值的百分比,默认值10%,也就是当未传输的增量数据大约为12.8M( chunk 默认128M * 10%)时,可以参考S(T4)章节获取更多细节。
如果存在热点 chunk ,例如迁移该 chunk 过程中业务持续在修改该 chunk ,这时候可能迁移速度没有业务写入速度快,这时候可能增量数据长时间超过12.8M,这种场景可以适当调大该值。
● rangeDeleterBatchSize
清理数据时每批删除的最大文档数,默认值 INT_MAX ,也就是不限制。如果发现有大量删除操作引起负载高或者影响业务,建议调小该值,例如100。
● rangeDeleterBatchDelayMS
一批数据删除后,是否做 sleep ,默认值20ms。如果系统负载很低,建议调小该值,如果系统负载高,建议调大该值。
● rangeDeleterHighPriority
是否优先执行范围删除操作(高于用户操作),默认值 false ,不建议修改该值。
● orphanCleanupDelaySecs
延迟多久后开始正式删除数据,默认值900秒。不建议修改该值。
● receiveChunkWaitForRangeDeleterTimeoutMS
该参数控制目标分片在接收新的 chunk 迁移请求时,等待与迁移范围重叠的范围删除任务完成的最大时间。默认值10000ms,如果重叠范围太多可以适当调大该值。
● persistedChunkCacheUpdateMaxBatchSize
控制分片本地持久化 chunk 缓存更新时的批处理大小,即每次更新操作最多处理的 chunk 元数据记录数,默认值1000,不建议修改该值。
不同内核版本性能及功能总结
由于 MongoDB 内核 6.0~8.0 代码 balance 整体原理差异不大,鉴于篇幅,这里重点总结高版本相比 5.0 及以下版本的几个核心差异:
● 性能相关
实测 5.0 和 8.0 的整体迁移速度,8.0 性能相比 5.0 好大约 30% - 45%。性能提升原理总结如下:
1. 6.0开始的版本 chunksize 大小由默认64M增加到128M, 网络IO处理开销更小,整体网络传输效率、读写吞吐更高。
2. Chunk 路由总量更少,路由查询处理效率更高,网络交互更少。
3. 高版本批量写性能的提升进一步提升迁移速度。
4. 相比低版本 split 由用户线程实现,6.0.3 高版本 split 由源分片 MoveChun 后台线程完成,因此 split 对业务无影响,参考S(T2)章节。
● Chunk 和 split 差异
5.0及以下版本,当 chunk 大小达到 chunksize 配置大小后会触发 split 。6.0.3 开始的高版本 split 在做 balance 的过程中由源分片“moveChunk”后台线程进行拆分,具体请参考S(T2)章节,因此高版本一个 chunk 可以很大,甚至到T级别。
基于上面的原因,6.0.3 开始版本不能再通过每个分片的 chunk 数量来判断数据是否均衡,而是通过 db.xxxx.getShardDistribution() 命令获取每个分片的实际数据量来判断数据是否均衡。
● Chunksize 默认大小调整
6.0开始版本 chunksize 大小默认由64M调整到128M。
● 新功能相关
从MongoDB 7.0 开始,新增 automerge、更详细的诊断统计、 Defragmentation 碎片整理等功能。
附录
完整文章见下链接:
https://doc.weixin.qq.com/doc/w3_ARYA9wYhADkCNsc4kMXrESMWGvE38?scode=AJEAIQdfAAoKgZv5DTARYA9wYhADk
TencentDB