1. 项目背景

业务场景:本地生活电商的分片集群上线半年后,运维发现监控大屏上出现了一个诡异现象——shard-1 的 CPU 使用率 85%,shard-2 只有 12%。打开 sh.status() 一看,shard-1 上有 387 个 Chunk,shard-2 只有 42 个。Balancer 明明在跑,为什么数据没有自动均衡?更严重的是,一个大促活动创建了一个"双11"的订单 Chunk 超过了 128MB,正常分裂失败,变成了 Jumbo Chunk——Balancer 无法移动它,导致 shard-1 的磁盘率先告警。团队讨论是否要紧急重启 Balancer、手动 moveChunk、还是上 MongoDB 5.0 的新特性 reshardCollection。

痛点:分片集群"搭起来"只是第一步,跑起来以后的问题才是真正的考验:Chunk 分裂和迁移的内部机制不透明——为什么有些 Chunk 到了 200MB 还不分裂?Balancer 在窗口期内的行为不可控——大促期间要不要手动暂停?发现热点分片后除了手动 moveChunk 还有没有自动化方案?Jumbo Chunk 阻塞均衡器的时候如何处理不中断服务?

2. 项目设计

小胖(盯着 Grafana 面板上两个分片的天壤之别):大师!我们 shard-1 快炸了,shard-2 闲得在摸鱼!Balancer 是不是坏了?

大师:先别急着怪 Balancer。看看那些没被均衡的 Chunk 是不是 Jumbo Chunk。

小胖:Jumbo Chunk 是啥?跟普通 Chunk 有啥区别?

大师:MongoDB 的 Chunk 默认最大 128MB。一个 Chunk 数据量超过阈值后,mongos 会自动把它"分裂"(split)成两个。但如果分片键的值空间不足——比如你的分片键只有一个值(如 city: "深圳"),那这个 Chunk 全是同一个分片键值,MongoDB 找不到可以从中切开的点,就分裂失败,这个 Chunk 被标记为"Jumbo"——超大且不可分裂。Balancer 看到 Jumbo Chunk 会直接跳过——因为移也移不动,分了分不开。

技术映射:Jumbo Chunk 的根因是分片键基数不够——同一个分片键值的文档数量太多,单个 Chunk 装不下但也不能分。解决之道不是调大 Chunk 大小,而是细化分片键。

小胖:那我怎么处理 Jumbo Chunk?是不是只能 reshardCollection?

大师:在 MongoDB 4.4+ 中可以先用 refineShardKey(微调分片键)——在原来的分片键基础上加一个字段增加基数。比如原先 {city:1},微调为 {city:1, userId:1}——这样同一个城市的文档可以根据 userId 分成多个 Chunk,Jumbo 问题就消失了。

从 MongoDB 5.0 开始直接用 reshardCollection——这是更彻底但更重的手段。它会创建一个新的分片集合,在后台把数据从旧分片键迁移到新分片键,完成后原子切换。整个过程对线上读写几乎无影响,但会耗费大量资源。

技术映射:refineShardKey = 小手术(加字段),reshardCollection = 大手术(换分片键)。前者要求在现有前缀基础上追加,后者可以任意更换。

小白(追问):那 Chunk 迁移(moveChunk)的内部流程是怎样的?迁移期间会影响读写吗?

大师:moveChunk 使用"异步复制 + 追增量"的模式,迁移过程对读写几乎透明。大致流程:

  1. Balancer 选择一个待迁移的 Chunk 和源/目标分片。
  2. 目标分片开始从源分片"克隆"这个 Chunk 的数据。
  3. 克隆期间,源分片上的写操作持续产生增量——目标分片通过追 Oplog 追上增量。
  4. 当增量足够小时,源分片短暂锁定这个 Chunk 的范围(通常几个毫秒),完成最后一次增量同步。
  5. Config Server 更新元数据——把 Chunk 的所有权转给目标分片。
  6. 源分片清理旧数据。

技术映射:Chunk 迁移的"短暂锁定"阶段通过 criticalSection 实现——仅影响正在迁移的 Chunk 对应的分片键范围的写入,不影响其他范围的读写。

小胖:那大促期间,我们应该把 Balancer 关掉吗?

大师:大促期间关掉 Balancer 是标准操作——原因不是 Balancer 本身有问题,而是 Chunk 迁移产生的网络和磁盘 IO 会挤占正常业务的资源。关掉以后,等大促结束、流量平稳再打开。大促前也可以手动做一轮主动均衡——把热点 Chunk 提前迁移到资源充裕的分片上。

大师(总结):分片集群运维三件事——Jumbo Chunk 的本质是分片键基数不足(refineShardKey/reshardCollection 解决);Chunk 迁移是透明的但占资源(大促关 Balancer);手动 moveChunk 是应急手段不要作为常态。

3. 项目实战

3.1 环境准备

需要分片集群环境。如果本地没有完整的分片集群,本章大部分操作可以在单机 mongod 上演示概念(非分片集群环境 sh.status() 不可用,但 Jumbo Chunk 和 refineShardKey 相关 API 可以通过注释/模拟理解)。

3.2 分步实现

步骤一:观察 Chunk 分布与 Balancer 状态

目标:通过 mongos 查看分片集群的 Chunk 分布和均衡状态。

// 连接 mongos
// 1. 全局分片状态概览
sh.status()

// 2. 查看 Balancer 是否运行
sh.isBalancerRunning()
// true = 正在迁移 Chunk,false = 空闲

// 3. 查看 Balancer 窗口配置(允许在什么时间段运行)
use config
db.settings.findOne({ _id: "balancer" })
// 如果 activeWindow 存在,Balancer 只在指定的时间窗口内运行
// 例如:{ start: "02:00", stop: "06:00" } 表示仅凌晨 2-6 点运行

// 4. 设置 Balancer 运行窗口(只在夜间运行)
// sh.setBalancerState(false)   // 先停掉
// db.settings.updateOne(
//   { _id: "balancer" },
//   { $set: { activeWindow: { start: "02:00", stop: "06:00" } } },
//   { upsert: true }
// )
// sh.setBalancerState(true)    // 再开启

// 5. 各分片的 Chunk 数分布
db.chunks.aggregate([
  { $group: { _id: "$shard", chunkCount: { $sum: 1 } } },
  { $sort: { chunkCount: -1 } }
]).toArray().forEach(s => {
  print(`Shard ${s._id}: ${s.chunkCount} chunks`)
})

步骤二:检测和处理 Jumbo Chunk

目标:从 config 库中找出 Jumbo Chunk 并理解处理方法。

use config

// 查找所有 Jumbo Chunk
const jumboChunks = db.chunks.find({ jumbo: true }).toArray()
print("Jumbo Chunk 数量:", jumboChunks.length)
jumboChunks.forEach(c => {
  print(`  集合: ${c.ns}`)
  print(`  分片键范围: ${JSON.stringify(c.min)} → ${JSON.stringify(c.max)}`)
  print(`  所在分片: ${c.shard}`)
  print(`  ---`)
})

// Jumbo Chunk 的处理步骤:

// 方式 A:手动 trigger split(如果数据量已经增长到可分)
// 在 mongos 上执行:
// sh.splitAt("local_life.orders_hashed", { userId: "中间值" })
// 注意:需要知道数据分布——选择一个合理的中间值

// 方式 B:手动 moveChunk(如果能分裂更好先分裂)
// sh.moveChunk("local_life.orders_hashed", { userId: MinKey }, "shard-2")

// 方式 C:refineShardKey(推荐)
// 把分片键 { city: 1 } 微调为 { city: 1, userId: 1 }
// 在 mongos 上执行:
// sh.refineShardKey(
//   "local_life.orders_shard",
//   { city: 1, userId: 1 }   // 新分片键必须在原分片键基础上追加字段
// )
// 注意:refineShardKey 执行期间原分片键的查询仍然有效

// 方式 D:reshardCollection(MongoDB 5.0+)
// sh.reshardCollection(
//   "local_life.orders_shard",
//   { userId: "hashed" }  // 可以完全换新分片键
// )
// reshardCollection 会创建目标分片集合 → 后台拷贝数据 → 原子切换

// 检查 refineShardKey 是否完成
// sh.status() 可看到正在进行的分片键微调状态

步骤三:手动均衡操作

目标:在特殊情况下手动移动 Chunk 来平衡负载。

// 连接 mongos

// 1. 找到热点分片上的 Chunk
use config
const hotShard = "shard-1"
const hotChunks = db.chunks.find({ shard: hotShard }).limit(5).toArray()
print(`热点分片 ${hotShard} 上的 Chunk 数:`,
       db.chunks.countDocuments({ shard: hotShard }))

// 2. 手动移动一个 Chunk 到负载较低的分片
// sh.moveChunk(
//   "local_life.orders_hashed",           // 命名空间
//   { userId: "U_500" },                   // 查找包含该分片键值的 Chunk
//   "shard-2"                              // 目标分片
// )

// 3. 大促前预均衡——把一个热点范围的 Chunk 提前分布开
// 对 hashed 分片集合,可以手动 split 热点范围
// for (let i = 0; i < 100; i++) {
//   sh.splitAt("local_life.orders_hashed", { userId: `U_${i * 100}` })
// }
// 然后让 Balancer 自然均衡这些 Chunk

// 4. 关闭/开启 Balancer
// sh.stopBalancer()        // 大促期间关掉
// sh.startBalancer()       // 大促结束打开

// 查看 Balancer 日志
// 最近的 Chunk 迁移记录
db.changelog.find({ what: "moveChunk.commit" })
  .sort({ time: -1 }).limit(10).toArray()
  .forEach(log => {
    print(`${log.time} | ${log.ns} | ${log.details.from} → ${log.details.to} | ${log.details.duration || '?'}ms`)
  })

步骤四:分片集群备份要点

目标:理解分片集群备份与复制集备份的关键差异。

# 分片集群备份方案对比

# 方案 A:mongodump 通过 mongos(逻辑备份)
# 优点:一个命令备份整个集群
# 缺点:备份速度受限于单 mongos 的吞吐,大集群慢;一致性需依赖 --oplog
mongodump --host=mongos:27017 --oplog --out=/backup/cluster_dump

# 方案 B:单独备份 Config Server + 每个 Shard
# 优点:并行备份,速度快,可单独恢复某个 Shard
# 缺点:需要手动协调各部分的备份时间点一致性
# 备份 Config Server
mongodump --host=config1:27019 --db=config --out=/backup/config
# 备份每个 Shard
mongodump --host=shard1a:27017 --oplog --out=/backup/shard1
mongodump --host=shard2a:27017 --oplog --out=/backup/shard2

# 方案 C:文件系统快照(LVM/ZFS/EBS Snapshot)
# 在所有 Shard 和 Config Server 同一时刻做快照
# 一致性最高、恢复速度最快(TB 级集群的推荐方案)

步骤五:新分片加入 + 扩容实战

目标:为分片集群增加新分片,观察 Chunk 自动重新分布。

// 连接 mongos

// 1. 新分片加入集群
sh.addShard("shard3/shard3a:27017,shard3b:27017,shard3c:27017")

// 2. 观察 Balancer 自动开始均衡
// 旧分片上的 Chunk 会陆续迁移到新分片
// 用 sh.status() 持续观察

// 3. 监控迁移进度
function monitorMigration() {
  const before = db.getSiblingDB("config").chunks.aggregate([
    { $group: { _id: "$shard", count: { $sum: 1 } } }
  ]).toArray()

  sleep(60000)  // 1 分钟后

  const after = db.getSiblingDB("config").chunks.aggregate([
    { $group: { _id: "$shard", count: { $sum: 1 } } }
  ]).toArray()

  print("Chunk 分布变化:")
  after.forEach(s => {
    const prev = before.find(b => b._id === s._id)?.count || 0
    print(`  ${s._id}: ${prev} → ${s.count} (${s.count - prev > 0 ? '+' : ''}${s.count - prev})`)
  })
}
monitorMigration()

3.3 完整代码清单

文件 用途
mongodb-lab/sharding/chunk-monitor.js Chunk 分布与 Balancer 状态监控
mongodb-lab/sharding/jumbo-chunk-detect.js Jumbo Chunk 检测
mongodb-lab/sharding/manual-balance.js 手动 moveChunk 与 split
mongodb-lab/sharding/backup-shard.js 分片集群备份方案

3.4 测试验证

// 在 mongos 中执行

// 1. 确认 Balancer 状态可查询
const balancerConfig = db.getSiblingDB("config").settings.findOne({ _id: "balancer" })
print("Balancer 配置:", balancerConfig ? "PASS" : "N/A")

// 2. 确认 Chunk 分布数据可读
const chunkCount = db.getSiblingDB("config").chunks.countDocuments({})
print("总 Chunk 数:", chunkCount, chunkCount > 0 ? "PASS" : "FAIL")

// 3. 确认 Jumbo Chunk 检测逻辑
const jumboCount = db.getSiblingDB("config").chunks.countDocuments({ jumbo: true })
print("Jumbo Chunk:", jumboCount, jumboCount >= 0 ? "PASS" : "N/A")

// 4. 确认迁移日志可读
const recentLogs = db.getSiblingDB("config").changelog.find({
  what: "moveChunk.commit"
}).count()
print("最近迁移日志:", recentLogs >= 0 ? "PASS" : "N/A")

print("\n=== 分片集群进阶验证完成 ===")

4. 项目总结

4.1 Chunk 管理工具速查

工具 用途 影响范围 风险等级
sh.status() 查看集群整体状态 无 -
Balancer 自动均衡 Chunk 分布 Chunk 迁移期间的 IO 和网络 低(可设窗口期)
sh.moveChunk() 手动移动 Chunk 仅影响被移动的 Chunk 范围 中(迁移期间短暂锁)
sh.splitAt() 手动分裂 Chunk 仅影响被分裂的 Chunk 低
refineShardKey() 追加分片键字段 整个集合,缓慢但非阻塞 低
reshardCollection() 完全换分片键 整个集合,后台拷贝 中(资源消耗大)

4.2 适用场景

本章操作适用:

  1. 分片不均衡的定期巡检——每周查看 Chunk 和文档数分布。
  2. 大促前后的 Balancer 管理——前预均衡、中关 Balancer、后恢复均衡。
  3. 发现 Jumbo Chunk 后的应急处理——refineShardKey 增加分片键基数。
  4. 分片扩容——新分片加入后观察 Chunk 迁移进度。
  5. 分片集群备份策略设计——Config Server + 各 Shard 的一致性快照。

4.3 注意事项

注意事项 说明
moveChunk 不可频繁手动操作 手动 moveChunk 会跳过 Balancer 的阈值判断,可能引发"乒乓效应"(来回迁移)
Jumbo Chunk 的处理不要拖 Jumbo Chunk 持续存在会导致 Balancer 永远无法均衡该集合
reshardCollection 不是轻量级操作 它会占用 2 倍的临时空间(新集合拷贝),并产生大量 Oplog
Balancer 窗口设置得太短 如果窗口内迁不完所有目标 Chunk,下次开启继续——导致迁移永远追不上写入
Config Server 的备份 Config Server 是分片集群的"大脑",Config 库的数据量很小但绝不能丢

4.4 常见踩坑经验

故障案例一:Balancer 窗口设太短导致迁移永远赶不上

某团队把 Balancer 窗口设为凌晨 3-4 点(1 小时),但写入量很大加上白天积累了数百个待迁移 Chunk,1 小时的窗口只能迁完 30-40 个。剩余 Chunk 越来越多,热点分片的问题持续恶化。解决:先把 Balancer 打开跑一整天完成积累的迁移,再把窗口改成 3-6 点(3 小时),并在大促前做一次预均衡预处理。

故障案例二:moveChunk 期间出现了丢失写入

某运维手动 moveChunk 一个正在被大量写入的 Chunk。迁移过程中最后的 criticalSection 阶段短暂锁定了该 Chunk 范围的写入。因为并发太高,MongoDB 多次尝试同步增量均失败,最终 Chunk 迁移被回滚,但应用层已有部分写入被"卡住"超时。解决:不要在 Chunk 活跃写入时手动 moveChunk;最好在大促前、写入低峰期完成均衡。

故障案例三:refineShardKey 执行后原索引仍存在

某团队 refineShardKey 后(从 {city:1} 变为 {city:1, userId:1}),发现查询速度没有改善。原来优化器仍然选用了旧的 {city:1} 分片键索引,而非新的 {city:1, userId:1} 复合前缀。解决:refineShardKey 后 MongoDB 保留了旧索引——手动 hideIndex 掉旧的、仅用新的;同时清除 Plan Cache 让优化器重新为查询竞速。

4.5 思考题

  1. 分片集群中如果删除了一个分片上的大量文档,这个分片的 Chunk 数会自动减少吗?
  2. 如果 reshardCollection 执行到一半 mongos 挂了,重启后 reshard 操作会恢复吗?数据会损坏吗?

(答案将在第 27 章末尾揭晓)

上一章思考题答案:

  1. 新分片加入后旧 Chunk 会自动迁移——Balancer 检测到不同分片的 Chunk 数不平衡(差异超过阈值),自动触发 Chunk 迁移,将部分 Chunk 从旧分片移到新分片。迁移是自动的,无需手动干预。

  2. MongoDB 不允许 updateMany 修改分片键值,因为改了分片键意味着文档所属的分片会改变——而 updateMany 无法保证跨分片的原子性。如果允许修改分片键,更新的一部分文档在分片 A、一部分在分片 B,出现失败时无法回滚。要实现分片键变更,要么用 reshardCollection,要么用事务内 delete + insert 的文档级迁移。

延伸阅读与资源

Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Redis 实战修炼与原理进阶
Python 3实战精进:从脚本到高并发订单引擎
MongoDB 实战进阶与内核修炼
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析

posted on 2026-08-06 15:22  一天不进步,就是退步  阅读(26)  评论(0)    收藏  举报