当单台机器的存储或写入吞吐到达天花板,副本集就不够了,需要水平扩展——分片(Sharding)。但分片是 MongoDB 里最容易踩坑的特性:片键选错,整个集群退化成单点;chunk 不均衡,迁移把集群拖垮。本文讲清分片集群的三大组件、数据如何分布、以及片键设计这个生死攸关的决定。

场景:分了片反而更慢

团队上了分片集群,本以为写入能线性扩展,结果发现绝大多数写都压在一个 shard 上,其他 shard 闲着。这是片键选了单调递增字段(如时间戳、自增 ID)的经典悲剧。要讲清为什么,先看分片集群长什么样。

机制一:三大组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌─────────────────────────────────────┐
│ Application │
└──────────────┬──────────────────────┘

┌──────▼──────┐
│ mongos │ 路由层(无状态)
└──────┬──────┘

┌───────────┼───────────┐
│ │ │
┌──▼───┐ ┌──▼───┐ ┌───▼──────────┐
│Shard1│ │Shard2│ │Config Servers│
│(RS) │ │(RS) │ │(RS, 存元数据) │
└──────┘ └──────┘ └──────────────┘
  • mongos:无状态路由进程。应用只连 mongos,它根据片键把请求路由到正确的 shard。可以部署多个做负载均衡。
  • config servers:本身是一个副本集,存集群元数据——哪个 chunk 在哪个 shard、片键范围如何划分。元数据丢了集群就瘫,所以它必须高可用。
  • shard:每个 shard 是一个独立副本集,存实际数据的一个子集。

mongos 把 config server 的元数据缓存在本地,据此判断一条查询该发给谁。

机制二:chunk 与数据分布

MongoDB 把分片集合按片键范围切成 chunk(默认逻辑上限 128MB,旧版本 64MB)。每个 chunk 覆盖一段连续的片键区间,归属某个 shard。

1
2
3
chunk A: shardKey ∈ [minKey, "h")    → Shard1
chunk B: shardKey ∈ ["h", "p") → Shard2
chunk C: shardKey ∈ ["p", maxKey) → Shard3

当一个 chunk 超过大小上限,会分裂(split)成两个。当 shard 之间 chunk 数量不均,balancer 后台把 chunk 从多的 shard 迁移到少的 shard(migration)。迁移是有代价的:复制数据 + 更新元数据 + 短暂的路由变更,高峰期可能影响性能,所以可以配置 balancer 只在低峰窗口运行。

机制三:分片策略——范围 vs 哈希

范围分片(ranged):按片键原始值分区。好处是范围查询高效(连续区间落在少数 chunk),坏处是单调递增片键会导致所有新写入都落到"最大值"那个 chunk → 热点 shard。这就是开头悲剧的成因。

哈希分片(hashed):对片键算哈希再分区,把数据均匀打散到所有 shard,写入负载均衡。代价是范围查询失效——查一个区间得 scatter 到所有 shard(broadcast query)。

1
2
3
4
5
// 哈希分片:写入均衡,适合高写入、点查为主的场景
sh.shardCollection("app.events", { userId: "hashed" })

// 范围分片:适合范围查询多的场景,但要选好片键避免热点
sh.shardCollection("app.orders", { region: 1, createdAt: 1 })

机制四:片键设计——分片集群的生死线

片键一旦选定极难更改(虽然新版支持 reshardCollection,但仍是重操作)。好片键要同时满足三个维度:

1
2
3
1. 高基数(Cardinality):取值足够多,否则 chunk 无法细分
2. 低频率(Frequency):单个值不能对应海量文档,否则形成 jumbo chunk
3. 非单调(Non-monotonic):避免写入永远落在同一个 chunk

举几个反例:

  • status(只有几种取值)→ 基数太低,整个集合只能分成几个 chunk,根本分不开。
  • timestamp_id(ObjectId 前缀含时间,单调递增)→ 新写入永远在最右 chunk,热点。
  • userId(若某些大客户数据量极大)→ 单值频率过高,产生无法分裂的 jumbo chunk。

实战常用复合片键兼顾均衡与查询,比如 {region: 1, _id: 1}:region 提供分散,_id 提供高基数,同时支持按 region 的路由查询。或者直接 {userId: "hashed"} 求写入均衡。

机制五:targeted vs scatter-gather 查询

这是分片性能的核心区别:

1
2
查询带片键      → mongos 直接路由到 1 个 shard(targeted,快)
查询不带片键 → mongos 广播到所有 shard 再汇总(scatter-gather,慢)
1
2
3
4
5
# 看一条查询是 targeted 还是 broadcast
mongosh --eval '
db.orders.find({region:"east", createdAt:{$gt: ISODate("2025-01-01")}})
.explain().queryPlanner.winningPlan.shards
'

如果 shards 数组包含所有 shard,说明这是 scatter-gather,随集群规模增大而变慢。理想情况是大部分高频查询都带片键前缀,能被精确路由。带 sort/limit 的 scatter 查询尤其贵,mongos 要从每个 shard 拉够数据再做归并排序。

工程权衡与边界

  • 何时该分片:不要过早分片。副本集 + 索引优化 + 垂直扩容能扛很久。分片引入运维复杂度(更多节点、balancer、元数据一致性),数据量(TB 级)或写吞吐确实超单机才上。
  • jumbo chunk:超过大小上限又因为片键频率太高无法分裂的 chunk,会卡住 balancer,需要人工干预或重选片键。
  • 孤儿文档(orphaned documents):chunk 迁移中断可能在源 shard 留下已迁走的数据副本,新版有 range deleter 清理,老版本需注意。
  • 跨片事务/聚合:分布式事务和跨 shard 聚合代价高,设计 schema 时尽量让一个逻辑单元(如一个用户的所有数据)落在同一 shard(用片键前缀控制)。

常见误区与踩坑

  • 误区:分片就能自动线性扩展。片键选错(单调递增/低基数)直接退化成单点甚至更慢。
  • 踩坑:用 _id 当片键做范围分片。ObjectId 单调,写入全打到一个 shard,要么 hashed 要么换片键。
  • 踩坑:大量 scatter-gather 查询。查询不带片键时被广播,集群越大越慢,设计时让热点查询都能带片键。
  • 误区:config server 不重要。它存全部元数据,必须是高可用副本集,挂了整个集群无法路由。
  • 踩坑:balancer 在业务高峰迁移 chunk 拖慢集群。配置 balancer window 让它只在低峰运行。

小结

分片集群 = mongos 路由 + config server 元数据 + 多个 shard 副本集,数据按片键切成 chunk 分布并由 balancer 动态均衡。所有成败几乎都系于片键设计:高基数、低频率、非单调,且让高频查询能带片键被精确路由。分片是重武器,先把副本集和索引用到极致,真到单机瓶颈再上,并且把片键当成架构级决策慎重对待。