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 使用"异步复制 + 追增量"的模式,迁移过程对读写几乎透明。大致流程:
- Balancer 选择一个待迁移的 Chunk 和源/目标分片。
- 目标分片开始从源分片"克隆"这个 Chunk 的数据。
- 克隆期间,源分片上的写操作持续产生增量——目标分片通过追 Oplog 追上增量。
- 当增量足够小时,源分片短暂锁定这个 Chunk 的范围(通常几个毫秒),完成最后一次增量同步。
- Config Server 更新元数据——把 Chunk 的所有权转给目标分片。
- 源分片清理旧数据。
技术映射: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 适用场景
本章操作适用:
- 分片不均衡的定期巡检——每周查看 Chunk 和文档数分布。
- 大促前后的 Balancer 管理——前预均衡、中关 Balancer、后恢复均衡。
- 发现 Jumbo Chunk 后的应急处理——refineShardKey 增加分片键基数。
- 分片扩容——新分片加入后观察 Chunk 迁移进度。
- 分片集群备份策略设计——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 思考题
- 分片集群中如果删除了一个分片上的大量文档,这个分片的 Chunk 数会自动减少吗?
- 如果
reshardCollection执行到一半 mongos 挂了,重启后 reshard 操作会恢复吗?数据会损坏吗?
(答案将在第 27 章末尾揭晓)
上一章思考题答案:
新分片加入后旧 Chunk 会自动迁移——Balancer 检测到不同分片的 Chunk 数不平衡(差异超过阈值),自动触发 Chunk 迁移,将部分 Chunk 从旧分片移到新分片。迁移是自动的,无需手动干预。
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 实战修炼与源码剖析

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号