1. 项目背景
业务场景:本地生活电商上线 18 个月,订单表突破 500GB、日均写入 200 万条。复制集虽然保证了高可用,但单台服务器的磁盘和 IOPS 已经到达物理上限——磁盘使用率 92%、写 IOPS 逼近极限、高峰期 CPU 100%。运维升级服务器配置(加 SSD、加内存)的边际效应越来越小——升级到 64 核 512GB 的服务器,成本翻了 3 倍但性能只提升了 30%。CTO 拍板:做水平扩展。
但团队对分片集群的恐惧感很强——"分片键选错怎么办——数据全在热分片上,其他分片空转""mongos 是不是会成为瓶颈""跨分片聚合会不会比单机还慢""Config Server 挂了整个集群会不会崩"。
痛点:分片集群不是把数据随意撒到多台机器上就完事。分片键的选择直接决定数据分布的均匀度和查询性能;不恰当的分片键会导致 80% 的查询都是 Scatter-Gather(广播到所有分片再合并);单调递增的分片键(如 ObjectId、时间戳)会导致写入全集中在一个分片上,产生 B-Tree 写入热点。
2. 项目设计
小胖(看着架构图发懵):大师!我们订单表 500GB 了,磁盘快炸了。我看 MongoDB 有分片集群——是不是把一张大表拆到 4 台机器上,每台存 125GB?
大师:逻辑上差不多,但实现方式有本质区别。MongoDB 的分片集群由三类进程组成:
| 组件 | 角色 | 部署要求 |
|---|---|---|
| Shard(分片) | 存储数据的子集,每个分片本身就是一个复制集 | 至少 2 个(生产和测试至少 2 个) |
| Config Server(配置服务器) | 存储集群元数据(哪个分片存了哪些 Chunk) | 必须是 3 节点复制集 |
| mongos(查询路由器) | 客户端连接 mongos,mongos 根据分片键把请求路由到正确的 Shard | 通常与应用部署在同一台机器或 Pod 中 |
小胖:分片键又是啥?是不是跟 MySQL 的分库分表键一样?
大师:对,分片键是 MongoDB 用来确定"这条文档应该存在哪个分片上"的字段。分片键的选择要在三个维度上权衡:
| 维度 | 好分片键的特征 | 坏分片键的后果 |
|---|---|---|
| 基数(Cardinality) | 高基数——字段值分布广 | 低基数(如 status 只有几个值)→ 数据挤在少数 Chunk |
| 写入分布(Write Distribution) | 写入均分到所有分片 | 单调递增键 → 写入始终打在同一个分片上 |
| 查询路由(Query Targeting) | 查询能路由到单个分片 | 不带分片键的查询 → 广播到所有分片(Scatter-Gather) |
技术映射:分片键 → Chunk(数据块,默认 128MB)→ Shard。一个 Chunk 是分片键值的一段连续范围。当 Chunk 大于阈值时,自动分裂为两个。Balancer 负责将 Chunk 在各个 Shard 之间迁移以保持数据均衡。
小胖:那订单表用什么分片键?用 _id(ObjectId)可以吗?
大师:ObjectId 有前 4 字节的时间戳,大致按时间单调递增——新写入总是落在键值范围的最大端,也就是始终落在同一个分片上。这会导致 "热分片" ——一个分片忙死,其余分片闲死。
小胖:那用 userId 哈希分片呢?写操作平均分配到所有分片。
大师:不错。{ userId: "hashed" } 是订单表的分片键标准答案之一——哈希把任意 userId 均匀散列到所有分片上,写入分布极好。但代价是——按 userId 的范围查询(如"用户最近一周的订单")会被路由到 1 个分片,性能很好;但按时间范围查询会广播到所有分片(因为不同 time 的文档散落在所有分片上)。
技术映射:哈希分片(Hashed Sharding)= 写入均匀 + 点查高效 + 范围查广播。范围分片(Ranged Sharding)= 区间查询高效 + 可能有热点。
小白:那 zone sharding 又是什么?听起来很高端。
大师:Zone Sharding 是"数据地域化"——你可以把某类数据绑定到特定的分片上。比如"深圳的订单放在华南分片(SSD 快盘),三年前的归档订单放在华北分片(HDD 慢盘)"。这是通过给分片和分片键的值范围打 tag 实现的。
技术映射:Zone Sharding = 人为控制数据在分片上的物理位置。geo-based 业务(按城市/大区分片)、冷热数据分层(近期数据在快盘、历史数据在慢盘)的场景特别适合。
小胖:那 mongos 会不会成为瓶颈?所有请求都经过它。
大师:mongos 本身是无状态的、轻量级的——它只做元数据缓存和请求路由,不存数据,不处理计算。一个 mongos 可以支撑数千 QPS,你也可以部署多个 mongos 实例并在负载均衡器后面分发流量。真正的瓶颈不在 mongos 而在分片键的选择——如果 80% 的查询是 Scatter-Gather,多少个 mongos 也救不了。
大师(总结):分片集群三要素——分片键决定命运(hashed 防热点、ranged 提效率),Config Server 存地图(元数据),mongos 做前台(路由)。选分片键前一定先用 explain 验证查询能否路由到单个分片。
3. 项目实战
3.1 环境准备
最小分片集群需要 2 Shard + 1 Config Server Replica Set + 1 mongos。用 Docker Compose 搭建。
# mongodb-lab/sharding/docker-compose-shard.yml
# 核心服务:
# configsvr(3节点复制集)、shard1(3节点复制集)、
# shard2(3节点复制集)、mongos(1个)
# 详细 YAML 因篇幅省略,通过 docker-compose 启动后按步骤初始化
3.2 分步实现
步骤一:初始化分片集群
目标:通过 mongos 连接分片集群并完成初始化。
# 假设容器已启动,连接 mongos
docker exec -it mongos mongosh
// 在 mongos 中执行
// 1. 添加分片(每个分片是一个复制集)
sh.addShard("shard1/shard1a:27017,shard1b:27017,shard1c:27017")
sh.addShard("shard2/shard2a:27017,shard2b:27017,shard2c:27017")
// 2. 启用分片
sh.enableSharding("local_life")
// 3. 为集合选择分片键并创建分片集合
// hashed:userId 哈希分片(订单表推荐)
sh.shardCollection("local_life.orders_shard", { userId: "hashed" })
// 查看分片状态
sh.status()
步骤二:对比两种分片键——hashed vs ranged
目标:为订单表分别创建两个版本,观察数据分布差异。
// === 版本 A:ranged 分片键(按 createdAt 范围) ===
sh.shardCollection("local_life.orders_ranged", { createdAt: 1 })
// 插入数据(按时间递增写入)
for (let i = 0; i < 50000; i++) {
db.orders_ranged.insertOne({
orderNo: "RNG" + String(i).padStart(8, '0'),
userId: "U" + (i % 1000),
amount: NumberDecimal("99.00"),
createdAt: new Date(2026, 0, 1, 0, 0, 0, i),
status: "已完成"
})
}
print("ranged 分片数据写入完成")
// === 版本 B:hashed 分片键(按 userId 哈希) ===
sh.shardCollection("local_life.orders_hashed", { userId: "hashed" })
for (let i = 0; i < 50000; i++) {
db.orders_hashed.insertOne({
orderNo: "HSH" + String(i).padStart(8, '0'),
userId: "U" + (i % 1000),
amount: NumberDecimal("99.00"),
createdAt: new Date(2026, 0, 1, 0, 0, 0, i),
status: "已完成"
})
}
print("hashed 分片数据写入完成")
步骤三:观察数据分布——Chunk 分布与 Balancer
// 查看 Chunk 分布
sh.status()
// 查看每个分片上的 Chunk 数量
use config
db.chunks.aggregate([
{ $group: { _id: "$shard", count: { $sum: 1 } } }
]).toArray().forEach(s => {
print(`分片 ${s._id}: ${s.count} 个 Chunk`)
})
// 在 ranged 分片中观察 Chunk 分布
// 由于 createdAt 单调递增,所有 Chunk 可能集中在最后一个分片上
// 在 hashed 分片中 Chunk 均匀分布在所有分片
// 手动触发 Balancer 回合(不建议在生产手动操作)
// sh.startBalancer()
// sh.stopBalancer() // 大促期间临时停掉 Balancer
// sh.setBalancerState(false) // 停止数据迁移
步骤四:Scatter-Gather 查询 vs Targeted 查询
目标:演示带分片键和不带分片键的查询差异。
// === Targeted Query(路由到单个分片) ===
const targeted = db.orders_hashed.find({
userId: "U_42" // 分片键包含 userId → 路由到 1 个分片
}).explain("executionStats")
print("Targeted 查询:", targeted.queryPlanner.winningPlan.stage === "SINGLE_SHARD" ?
"单分片 ✓" : "多分片 ✗")
// === Scatter-Gather Query(广播到所有分片) ===
const scatter = db.orders_hashed.find({
status: "已完成" // 不包含分片键 → 广播到 ALL 分片
}).explain("executionStats")
print("Scatter-Gather 查询:",
scatter.executionStats?.executionStages?.shards ?
`广播到 ${scatter.executionStats.executionStages.shards.length} 个分片 ✗` : "检查 explain")
// === 组合分片键中的情况 ===
// 如果分片键是 { userId: "hashed" },但查询中有 userId,mongos 能精确路由
// 如果分片键是 { userId: 1, createdAt: 1 },
// 查询只有 userId 也能精确路由(前缀匹配),只有 createdAt 则广播
// 查看实际路由
const targetedExplain = db.orders_hashed.find({
userId: "U_99", createdAt: { $gte: new Date("2026-01-01") }
}).explain()
print("查询路由到分片数:",
targetedExplain.queryPlanner.winningPlan.shards?.length || 1)
步骤五:分片键选择的压测对比
目标:用不同的分片键设计,观察各种方案的优劣。
// === 三种分片方案对比 ===
// 方案 A:{ userId: "hashed" }
// 优点:写入均匀、点查高效;缺点:按时间范围查广播
// 适用场景:订单表(主要按用户查询)
// 方案 B:{ createdAt: 1, userId: 1 }
// 优点:按时间范围查询可以路由到特定分片;缺点:写入可能不均(新数据集中在新 Chunk)
// 适用场景:日志/事件表(主要按时间范围查询)
// 方案 C:{ city: 1, createdAt: 1 } + Zone Sharding
// 优点:地理分布 + 时间排序双优;缺点:热点城市(如深圳)仍然可能写入集中
// 适用场景:城市本地生活场景,大部分查询限定城市
// === 模拟压测:hashed vs ranged 写入性能 ===
const testInsert = (collection, count) => {
const start = Date.now()
for (let i = 0; i < count; i++) {
db[collection].insertOne({
orderNo: `PERF${count}_${i}`,
userId: "U" + Math.floor(Math.random() * 10000),
createdAt: new Date(),
amount: NumberDecimal("99.00")
})
}
const elapsed = (Date.now() - start) / 1000
return { count, elapsed, qps: (count / elapsed).toFixed(0) }
}
// 对比写入 QPS(需要两个集合分别用 hashed 和 ranged 分片)
const resultHashed = testInsert("orders_hashed", 5000)
print(`hashed 分片: ${resultHashed.qps} 条/秒`)
const resultRanged = testInsert("orders_ranged", 5000)
print(`ranged 分片: ${resultRanged.qps} 条/秒`)
// 期望:hashed 写入均匀,ranged 在插入热分片时可能略差
步骤六:分片集群监控要点
// 1. Balancer 状态
sh.isBalancerRunning()
// true=正在迁移 Chunk, false=空闲
// 2. 分片的数据大小分布
use config
db.chunks.aggregate([
{ $group: { _id: "$shard", totalChunks: { $sum: 1 } } }
])
// 3. jumbo chunk 检测
db.chunks.find({ jumbo: true }).count() > 0
? print("⚠ 存在 Jumbo Chunk (无法分裂的大块)")
: print("✓ 无 Jumbo Chunk")
// 4. 各分片的延迟
sh.status()
// 5. StaleConfig 错误(客户端路由过期)
// 如果客户端缓存的 Chunk 分布过期,会收到 StaleConfig 异常
// mongos 会自动刷新路由元数据并重试,但会有一点延迟
3.3 完整代码清单
| 文件 | 用途 |
|---|---|
mongodb-lab/sharding/docker-compose-shard.yml |
最小分片集群部署 |
mongodb-lab/sharding/init-shard.js |
分片集群初始化脚本 |
mongodb-lab/sharding/compare-shard-keys.js |
分片键对比(hashed vs ranged) |
mongodb-lab/sharding/scatter-gather.js |
Scatter-Gather vs Targeted 演示 |
mongodb-lab/sharding/monitor-shard.js |
分片集群监控脚本 |
3.4 测试验证
// 在 mongos 中执行
// 1. 验证分片已启用
const shardingEnabled = db.runCommand({ isdbgrid: 1 })
print("分片集群:", shardingEnabled.ok === 1 ? "PASS" : "FAIL (非 mongos)")
// 2. 验证分片集合
const colls = db.getSiblingDB("config").collections.find({
_id: /local_life.orders/
}).toArray()
print("分片集合数:", colls.length, colls.length >= 2 ? "PASS" : "FAIL")
// 3. 验证 Chunk 数 > 0
const chunks = db.getSiblingDB("config").chunks.countDocuments({
ns: "local_life.orders_hashed"
})
print("hashed 分片 Chunk 数:", chunks, chunks > 0 ? "PASS" : "FAIL")
// 4. 验证 Targeted 查询
const t = db.orders_hashed.find({ userId: "U_500" }).explain()
const shards = t.queryPlanner.winningPlan.shards?.length || 1
print("单分片路由:", shards === 1 ? "PASS (Targeted)" : "FAIL (Scatter)")
print("\n=== 分片集群验证完成 ===")
4. 项目总结
4.1 分片键选型决策表
| 分片键 | 基数 | 写入分布 | 查询路由 | 热点风险 | 推荐场景 |
|---|---|---|---|---|---|
userId: "hashed" |
高 | 极均匀 | 点查高效,范围查广播 | 低 | 电商订单表、用户表 |
createdAt: 1 |
高 | 极差(最新数据集中) | 时间范围查高效 | 高 | 日志归档(配合 zone) |
city: 1 |
低(10-100 个城市) | 差(大城市集中) | 城市内查询高效 | 高 | 需 zone shard 配合 |
{city:1, userId:1} |
高 | 中 | 城市内查询高效 | 中 | 本地生活电商 |
_id: "hashed" |
高 | 均匀 | 点查高效 | 低 | 无合适分片键时的兜底 |
4.2 适用场景
分片集群适用:
- 单集合数据量超 500GB 或磁盘/IOPS 逼近物理极限。
- 日均写入量 > 100 万条且持续增长。
- 需要 geo-based 数据本地化(zone sharding)。
- 读写混合型——读走 mongos 支持多分片并行读取。
不适用场景:
- 数据量 < 200GB——复制集足够,分片只加复杂度。
- 所有查询都带分片键的场景(如 IoT 设备 ID 唯一查询)——复制集 + 读写分离可能更简易。
4.3 注意事项
| 注意事项 | 说明 |
|---|---|
| 分片键不可更改(4.4 前) | 一旦选择后无法直接修改。MongoDB 4.4+ 支持 refineShardKey(微调),5.0+ reshardCollection(全量重分片) |
| 单调递增的分片键是杀手 | ObjectId、Date 的 ranged 分片 → 写入热点。一定用 hashed 或加前缀打破单调性 |
| 不包含分片键的增删改 | updateOne 不带分片键 → 广播到所有分片逐个找文档(极慢) |
| Balancer 影响性能 | Chunk 迁移期间消耗网络和 IO,大促期间建议关掉 Balancer |
| Config Server 是命脉 | Config Server 挂掉 → mongos 无法路由 → 整个集群不可用 |
4.4 常见踩坑经验
故障案例一:用 ObjectId 做 ranged 分片导致写入热点
某社交媒体系统用 { _id: 1 }(ObjectId)做 ranged 分片,新帖子的 _id 总是落在 Chunk 范围的右端——始终写入最后一个分片。其他 3 个分片的磁盘利用率不到 10%,而热点分片的 IOPS 到极限、CPU 100%。解决:改为 { _id: "hashed" } 或使用 { userId: "hashed" } 做分片键,写入压力均匀分布。
故障案例二:不带分片键的 updateMany 引爆全集群
某 DBA 在分片集群中执行 db.orders.updateMany({}, {$set:{newField: true}}) 为所有订单加字段。分片集群中 updateMany 必须带分片键——不带分片键的 updateMany 被 mongos 广播到所有分片,每个分片做全表扫描更新,整个集群的 IOPS 被吃光。解决:改写为 mongos 上的 forEach + updateOne(带 _id),或者用 bulkWrite 每批带分片键。
故障案例三:Jumbo Chunk 无法分裂导致分片不均衡
某集合按 city 分片,深圳的数据量是其他城市的 10 倍。单个 Chunk 超过 128MB 后多次尝试分裂失败(因为 split vector 无法选择合理的分裂点),成为 Jumbo Chunk——Balancer 无法迁移这个 Chunk,导致深圳分片长期高位。解决:使用 refineShardKey 在分片键中加入 userId 增加基数(如 {city:1, userId:1}),让 Chunk 可以更细粒度分裂。
4.5 思考题
- 分片集群中如果
shardKey包含createdAt,新增分片后,旧分片上的 Chunk 会自动迁移到新分片上吗?为什么? - 为什么 MongoDB 不允许在一个
updateMany中把文档的分片键值改了?(提示:改了分片键意味着文档可能属于不同的分片)
(答案将在第 26 章末尾揭晓)
上一章思考题答案:
Change Stream 消费异常不应"立即重试一整批"——如果异常是永久性的(如数据格式错误),重试会不断失败。正确策略是:① 记录失败的 resumeToken 和异常详情到死信队列(DLQ),② 继续处理后续事件,③ 人工处理死信队列中积压的失败事件。对于瞬态异常(网络抖动)做指数退避重试(最多 3 次)。
resumeAfter基于 resumeToken(每个事件的唯一_id)——精确地从某个事件之后继续。startAtOperationTime基于 Timestamp——从某个时间点之后的事件开始,如果该时间点对应多个事件,会从中恢复。当 resumeToken 已失效(Oplog 覆盖)时,可以用startAtOperationTime作为降级方案——接受少量重复事件(At-Least-Once)来保证不丢失。注意startAtOperationTime只接受 Timestamp 类型,参数的精度比 resumeToken 粗。
延伸阅读与资源
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号