34. MongoDB 分片键设计与热点治理

分片键决定分片集群的写入分布与扩展上限。本文讲基数与频率与单调性准则、范围分片与哈希分片取舍、单调递增键热点治理、chunk 分布诊断、均衡器与 jumbo chunk 处理,以及 reshardCollection 重分片。

分片集群的扩展能力并不取决于分片数量,而取决于分片键能否把读写均匀摊到每个分片上。一个看似自然的 _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.chunksjumbo 标记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 }
})
手段版本是否重写数据适用
refineCollectionShardKey4.4+否原键基础上追加字段
reshardCollection5.0+是(在线)彻底更换分片键
迁移到新集合全版本是(离线或双写)复杂改造

重要:reshardCollection 是后台在线操作,但会占用大量资源,务必在低峰执行并监控 db.currentOp()。重分片期间对目标集合的写入会被缓冲,容量不足时可能失败回滚。

7. 总结与最佳实践

  • 选键准则:高基数、低频率、非单调;三者冲突时优先消解单调性
  • 分片方式:写多读点查用哈希;范围扫描重且写入可控用范围分片
  • 热点治理:哈希打散加复合键保序是通用解法,避免纯时间戳分片键
  • 诊断三板斧:sh.status() 看 chunk 分布、getShardDistribution() 看数据量、config.chunks 看 jumbo
  • 均衡器:设置低峰窗口,关注迁移失败计数而非单次运行状态
  • 演进路径:先 refineCollectionShardKey,再 reshardCollection,最后才考虑迁移新集合

决策铁律:分片键一旦确定,改造成本极高,务必在建模阶段用真实数据分布验证基数、频率与单调性。上线后每季度复查 chunk 分布与写入热点,发现倾斜立即治理,不要等到单分片磁盘打满才动手。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mongodb」更多文章

  1. 数据生命周期、TTL 与冷热归档
  2. $graphLookup 与层次结构建模
  3. GridFS 与大文件存储实践