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 适用场景

分片集群适用

  1. 单集合数据量超 500GB 或磁盘/IOPS 逼近物理极限。
  2. 日均写入量 > 100 万条且持续增长。
  3. 需要 geo-based 数据本地化(zone sharding)。
  4. 读写混合型——读走 mongos 支持多分片并行读取。

不适用场景

  1. 数据量 < 200GB——复制集足够,分片只加复杂度。
  2. 所有查询都带分片键的场景(如 IoT 设备 ID 唯一查询)——复制集 + 读写分离可能更简易。

4.3 注意事项

注意事项 说明
分片键不可更改(4.4 前) 一旦选择后无法直接修改。MongoDB 4.4+ 支持 refineShardKey(微调),5.0+ reshardCollection(全量重分片)
单调递增的分片键是杀手 ObjectIdDate 的 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 思考题

  1. 分片集群中如果 shardKey 包含 createdAt,新增分片后,旧分片上的 Chunk 会自动迁移到新分片上吗?为什么?
  2. 为什么 MongoDB 不允许在一个 updateMany 中把文档的分片键值改了?(提示:改了分片键意味着文档可能属于不同的分片)

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

上一章思考题答案

  1. Change Stream 消费异常不应"立即重试一整批"——如果异常是永久性的(如数据格式错误),重试会不断失败。正确策略是:① 记录失败的 resumeToken 和异常详情到死信队列(DLQ),② 继续处理后续事件,③ 人工处理死信队列中积压的失败事件。对于瞬态异常(网络抖动)做指数退避重试(最多 3 次)。

  2. 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 实战修炼与源码剖析

posted on 2026-08-05 18:45  一天不进步,就是退步  阅读(5)  评论(0)    收藏  举报