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 ChunkconstjumboChunks=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. 找到热点分片上的 Chunkuse configconsthotShard="shard-1"consthotChunks=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 的吞吐,大集群慢;一致性需依赖 --oplogmongodump--host=mongos:27017--oplog--out=/backup/cluster_dump# 方案 B:单独备份 Config Server + 每个 Shard# 优点:并行备份,速度快,可单独恢复某个 Shard# 缺点:需要手动协调各部分的备份时间点一致性# 备份 Config Servermongodump--host=config1:27019--db=config--out=/backup/config# 备份每个 Shardmongodump--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. 监控迁移进度functionmonitorMigration(){constbefore=db.getSiblingDB("config").chunks.aggregate([{$group:{_id:"$shard",count:{$sum:1}}}]).toArray()sleep(60000)// 1 分钟后constafter=db.getSiblingDB("config").chunks.aggregate([{$group:{_id:"$shard",count:{$sum:1}}}]).toArray()print("Chunk 分布变化:")after.forEach(s=>{constprev=before.find(b=>b._id===s._id)?.count||0print(`${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 状态可查询constbalancerConfig=db.getSiblingDB("config").settings.findOne({_id:"balancer"})print("Balancer 配置:",balancerConfig?"PASS":"N/A")// 2. 确认 Chunk 分布数据可读constchunkCount=db.getSiblingDB("config").chunks.countDocuments({})print("总 Chunk 数:",chunkCount,chunkCount>0?"PASS":"FAIL")// 3. 确认 Jumbo Chunk 检测逻辑constjumboCount=db.getSiblingDB("config").chunks.countDocuments({jumbo:true})print("Jumbo Chunk:",jumboCount,jumboCount>=0?"PASS":"N/A")// 4. 确认迁移日志可读constrecentLogs=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的文档级迁移。
延伸阅读与资源
MongoDB 实战进阶与内核修炼
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析