分片集群的扩展能力并不取决于分片数量,而取决于分片键能否把读写均匀摊到每个分片上。一个看似自然的 _id 或时间戳分片键,会让所有写入涌向同一个分片,让集群退化成单机。本文围绕分片键的选择准则、范围与哈希分片的取舍、写入热点的成因与治理展开,并给出 sh.status() 诊断、均衡器调优、jumbo chunk 处理以及重分片的完整路径。
1. 分片键的核心准则
分片键是文档在集群中的路由地址。它不仅决定文档落在哪个分片,也决定查询能否被精准路由到单个分片(目标查询)还是必须广播到所有分片(分散查询)。选键之前,先把三条准则刻进脑子:基数、频率、单调性。
1.1 基数
基数指分片键可能取值的数量。基数太低,chunk 无法继续切分,数据只能堆在少数几个分片上。
// 低基数:status 只有少数取值,最多分出几个 chunk
// 分片键 { status: 1 },值域 { "active", "inactive" },基数 = 2
// 高基数:用户 ID 或订单号,基数可达百万级
// 分片键 { userId: 1 },值域 = 全部用户数
1.2 频率
频率指某个分片键值出现的文档比例。如果某个值占据了大量文档(例如 tenantId: "default" 承载了 80% 数据),那么无论基数多高,这个值所在的 chunk 都会成为巨型 chunk,无法被均衡。
// 反例:默认租户承载绝大多数数据
db.orders.aggregate([
{ $group: { _id: "$tenantId", count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 5 }
])
// { _id: "default", count: 8200000 } ← 单值占比过高,必然热点
// { _id: "tenant-42", count: 3100 }
1.3 单调性
单调递增或递减的分片键(时间戳、自增 ID、ObjectId)会把新写入永远导向同一个 chunk,即"最后一段"。这是最常见的写入热点来源。
// 反例:以创建时间做分片键
// 所有新文档的 createdAt 都大于历史文档,全部落进最大 chunk
{ createdAt: 1 }
| 准则 | 含义 | 反例 | 后果 |
|---|---|---|---|
| 基数 | 取值数量 | status 字段 | chunk 无法分裂 |
| 频率 | 单值文档占比 | 默认租户 | 巨型 chunk 无法均衡 |
| 单调性 | 是否单向递增 | 时间戳、自增 ID | 写入热点集中在尾 chunk |
决策铁律:理想分片键应"高基数、低频率、非单调"。三者很难同时完美满足,实践中通常用哈希来消解单调性,用复合键来提升基数与打散频率。
2. 范围分片与哈希分片
MongoDB 提供两种分片方式:范围分片(ranged)与哈希分片(hashed)。它们决定了 chunk 的边界如何划分,也决定了读写分布的特征。
2.1 范围分片
范围分片按分片键的取值区间切分 chunk,{ x: 1 } 表示升序范围,{ x: -1 } 表示降序范围。范围分片对范围查询友好:查询 x 在某个区间内时,mongos 可以只路由到包含该区间的分片。
sh.enableSharding("shop")
sh.shardCollection("shop.orders", { customerId: 1 })
范围查询命中的是连续 chunk:
// 只路由到包含 [1000, 2000] 区间的分片
db.orders.find({ customerId: { $gte: 1000, $lte: 2000 } })
2.2 哈希分片
哈希分片对分片键值先做哈希再按哈希值范围切分,{ x: "hashed" }。哈希打散了单调性,写入均匀,但代价是范围查询会变成分散查询。
sh.shardCollection("shop.orders", { customerId: "hashed" })
// 范围查询必须广播到所有分片
db.orders.find({ customerId: { $gte: 1000, $lte: 2000 } })
// mongos 无法从哈希值推断范围,SHARD_MERGE 到全部目标分片
2.3 取舍
| 维度 | 范围分片 | 哈希分片 |
|---|---|---|
| 写入分布 | 单调键易热点 | 天然均匀 |
| 范围查询 | 精准路由 | 分散到全部分片 |
| 等值查询 | 精准路由 | 精准路由 |
| 典型键 | 时间戳 + 打散字段 | 高基数 ID |
| chunk 迁移 | 数据增长后迁移 | 初始分布均匀 |
注意:哈希分片无法用"分片键唯一"来做全局唯一(除非该唯一索引只含分片键),且哈希值不保序,
$sort与范围查询会退化。选择哈希前,先确认业务是否重度依赖范围扫描。
3. 单调递增键的写入热点与治理
3.1 热点如何形成
以 { createdAt: 1 } 为分片键时,chunk 边界按时间递增。所有新写入的 createdAt 都落在最后一个 chunk 上,于是只有持有该 chunk 的分片在承接写入,其余分片闲置。
// 写入全部落到同一个分片的监控信号
db.serverStatus().opcounters.insert // 在某个分片上明显偏高
db.serverStatus().opcounters.query
诊断时对比各分片的写入计数,若长期单分片独高,即是热点:
// 在三个分片上分别执行,比较 insert 计数
db.serverStatus().opcounters
// { insert: 4120000, query: 210000, update: 0, delete: 0, ... }
3.2 哈希分片解法
最简单的手段是把分片键改成哈希:
sh.shardCollection("shop.events", { _id: "hashed" })
// 或对时间字段的伴生高基数字段哈希
sh.shardCollection("shop.events", { deviceId: "hashed" })
哈希把写入打散到所有分片,代价是时间范围查询退化为分散查询。如果业务主要按时间查最近数据,纯哈希并不划算。
3.3 复合分片键解法
折中方案是复合键:把"打散字段"放前面做哈希或高基数前缀,把"查询字段"放后面保序。MongoDB 的复合分片键支持前缀哈希加后缀升序,但只有第一位可以是 hashed。
// 组合键:tenantId 哈希打散 + createdAt 升序保序
// 注意:hashed 键必须位于分片键的第一位
sh.shardCollection("shop.events", { tenantId: "hashed", createdAt: 1 })
// 等值加范围查询可精准路由到目标分片
db.events.find({ tenantId: "t-42", createdAt: { $gte: ISODate("2026-10-01") } })
另一种常见模式是"业务主键加时间":
sh.shardCollection("shop.orders", { customerId: 1, createdAt: 1 })
// 同一客户的订单集中在少数 chunk,跨客户均匀分布
| 方案 | 写入分布 | 范围查询 | 适用 |
|---|---|---|---|
| 单调键范围分片 | 差 | 好 | 几乎不推荐 |
| 纯哈希 | 好 | 差 | 纯 KV 点查 |
| 哈希加升序复合 | 好 | 前缀等值好 | 多租户加时间 |
| 高基数业务键加时间 | 好 | 单主体范围好 | 订单、消息 |
重要:复合分片键一旦确定,其前缀顺序不可调整。把高频等值过滤字段放在最前,把范围字段放在最后,这是让目标查询尽可能命中的关键。
4. sh.status 与 chunk 分布诊断
4.1 sh.status 输出精读
sh.status()
--- Sharding Status ---
sharding version: {
"_id": 1,
"clusterId": ObjectId("6521a0f1c3b4e5d6a7f80001"),
"minCompatibleVersion": 5,
"currentVersion": 6
}
shards:
{ "_id": "shard01", "host": "shard01/rs-shard01:27018", "state": 1, "tags": [] }
{ "_id": "shard02", "host": "shard02/rs-shard02:27018", "state": 1, "tags": [] }
{ "_id": "shard03", "host": "shard03/rs-shard03:27018", "state": 1, "tags": [] }
active mongoses:
"6.0.5": 2
balancer:
Currently enabled: yes
Currently running: no
Failed balancer rounds in last 5 attempts: 0
Migration Results for the last 24 hours:
No recent migrations
databases:
{ "_id": "shop", "primary": "shard01", "partitioned": true }
shop.orders
shard key: { "tenantId": "hashed", "createdAt": 1 }
chunks:
shard01 8
shard02 8
shard03 7
too many chunks to print, use verbose if you want to force print
关注三件事:每个分片的 chunk 数是否接近、balancer 是否卡在 yes 但 Currently running: no(可能被窗口限制)、Migration Results 是否持续失败。
4.2 chunk 分布诊断
use shop
db.orders.getShardDistribution()
Shard shard01 at shard01/rs-shard01:27018
data : 1.9GiB docs : 6100000 chunks : 8
estimated data per chunk : 243MiB
estimated docs per chunk : 762500
Shard shard02 at shard02/rs-shard02:27018
data : 1.9GiB docs : 6080000 chunks : 8
estimated data per chunk : 243MiB
estimated docs per chunk : 760000
Shard shard03 at shard03/rs-shard03:27018
data : 512MiB docs : 1600000 chunks : 7
estimated data per chunk : 73MiB
estimated docs per chunk : 228571
Totals
data : 4.3GiB docs : 13780000 chunks : 23
Shard shard01 contains 44.18% data, 44.26% docs in cluster, avg obj size on shard : 334B
Shard shard02 contains 44.18% data, 44.12% docs in cluster, avg obj size on shard : 334B
Shard shard03 contains 11.62% data, 11.60% docs in cluster, avg obj size on shard : 334B
shard03 明显偏小,说明该分片上的 chunk 分裂不足,或近期新增分片尚未完成均衡。可进一步查询 config.chunks:
use config
db.chunks.aggregate([
{ $match: { ns: "shop.orders" } },
{ $group: {
_id: "$shard",
chunks: { $sum: 1 },
jumbo: { $sum: { $cond: ["$jumbo", 1, 0] } }
} }
])
| 诊断命令 | 观察点 | 异常信号 |
|---|---|---|
| sh.status() | chunk 数分布 | 某分片 chunk 数远低于均值 |
| getShardDistribution() | 数据量与文档量 | 某分片占比明显偏低 |
| config.chunks | jumbo 标记 | jumbo chunk 数大于零 |
| config.migrationCoordinators | 迁移状态 | 长期停留在 in-progress |
5. 均衡器行为与窗口
5.1 均衡器工作原理
均衡器(balancer)是后台进程,负责在分片间迁移 chunk,使各分片的 chunk 数量趋于均衡。它只在 chunk 数差超过阈值时触发迁移,默认迁移的是整个 chunk(默认 128MB)。
sh.getBalancerState() // true 表示均衡器已启用
sh.isBalancerRunning() // true 表示当前正在迁移
sh.getBalancerWindow() // 查看均衡窗口,未设置返回 undefined
阈值规则:当某分片上的 chunk 数比最少的分片多出迁移阈值(由 chunk 总数决定,通常为 2 或 4)时,均衡器开始迁移。注意均衡器均衡的是 chunk 数量而非数据量,chunk 大小不均时数量均衡并不等于数据均衡。
5.2 均衡窗口
生产环境常把均衡限制在业务低峰,避免迁移占用 I/O:
// 设置均衡窗口为凌晨 02:00 到 06:00
db.settings.updateOne(
{ _id: "balancer" },
{ $set: { activeWindow: { start: "02:00", stop: "06:00" } } },
{ upsert: true }
)
// 临时关闭与开启
sh.stopBalancer()
sh.startBalancer()
注意:设置窗口后,
sh.isBalancerRunning()在窗口外会返回false,但sh.getBalancerState()仍为true。不要把"窗口外未运行"误判为均衡器故障。
5.3 迁移限流与 chunk 大小调整
迁移会消耗网络与磁盘 I/O,可通过并发与限流参数控制。MongoDB 6.0 起支持按集合调整 chunk 大小与碎片整理:
db.adminCommand({
configureCollectionBalancing: "shop.orders",
chunkSizeMB: 256, // 提高 chunk 大小以减少迁移次数
defragmentCollection: true // 触发集合碎片整理
})
// 查看当前集合的均衡配置
db.adminCommand({ configureCollectionBalancing: "shop.orders" })
决策铁律:chunk 越大,迁移次数越少但单次迁移越重;chunk 越小,均衡越细但迁移越频繁。写入密集的集合建议用 128MB 以上,读多写少的集合可用默认值。
6. jumbo chunk 与分片键不可变的应对
6.1 jumbo chunk 成因与识别
当一个 chunk 内的文档数或数据量超过阈值且无法分裂时,会被标记为 jumbo。常见成因:分片键基数低(无法找到分裂点)、单值频率过高(大量文档共享同一键值)。
use config
db.chunks.find({ ns: "shop.orders", jumbo: true })
// { _id: "shop.orders-tenantId_\"default\"", jumbo: true, lastmod: ..., ... }
6.2 治理手段
- 提高基数:改用复合分片键或哈希分片
- 降低单值频率:把热点值再拆分,例如
tenantId + 哈希(userId) - 手动分裂(仅当键值确实可切分):
sh.splitAt()
// 在指定键值处手动分裂 chunk
sh.splitAt("shop.orders", { tenantId: "default", createdAt: ISODate("2026-06-01") })
但若 jumbo 的根因是低基数,手动分裂无效,因为根本找不到可用的分裂点。此时唯一出路是重分片或迁移到新集合。
6.3 重分片 reshardCollection
分片键在 5.0 之前完全不可改,5.0 起提供 reshardCollection(在线重分片),4.4 起提供 refineCollectionShardKey(在原键基础上追加后缀字段)。
// 在线重分片:把分片键换成新的复合键
db.adminCommand({
reshardCollection: "shop.orders",
key: { tenantId: "hashed", createdAt: 1 },
numInitialChunks: 64
})
// 仅追加后缀字段,无需重写全部数据,开销更小
db.adminCommand({
refineCollectionShardKey: "shop.orders",
key: { customerId: 1, createdAt: 1 } // 原键为 { customerId: 1 }
})
| 手段 | 版本 | 是否重写数据 | 适用 |
|---|---|---|---|
| refineCollectionShardKey | 4.4+ | 否 | 原键基础上追加字段 |
| reshardCollection | 5.0+ | 是(在线) | 彻底更换分片键 |
| 迁移到新集合 | 全版本 | 是(离线或双写) | 复杂改造 |
重要:
reshardCollection是后台在线操作,但会占用大量资源,务必在低峰执行并监控db.currentOp()。重分片期间对目标集合的写入会被缓冲,容量不足时可能失败回滚。
7. 总结与最佳实践
- 选键准则:高基数、低频率、非单调;三者冲突时优先消解单调性
- 分片方式:写多读点查用哈希;范围扫描重且写入可控用范围分片
- 热点治理:哈希打散加复合键保序是通用解法,避免纯时间戳分片键
- 诊断三板斧:
sh.status()看 chunk 分布、getShardDistribution()看数据量、config.chunks看 jumbo - 均衡器:设置低峰窗口,关注迁移失败计数而非单次运行状态
- 演进路径:先
refineCollectionShardKey,再reshardCollection,最后才考虑迁移新集合
决策铁律:分片键一旦确定,改造成本极高,务必在建模阶段用真实数据分布验证基数、频率与单调性。上线后每季度复查 chunk 分布与写入热点,发现倾斜立即治理,不要等到单分片磁盘打满才动手。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。