去年处理过一个挺典型的线上问题:MongoDB分片集群跑了大半年,某天晚高峰写入延迟突然飙到几百毫秒,mongos的CPU被打满。查了一圈,发现所有新写入的数据都怼到了同一个分片——因为分片键用的是创建时间,而业务写入天然是单调递增的,范围分片直接把写热点全集中在最后一个chunk上。当时做了两个调整:把时间字段的写入路径改造、换用哈希分片键重新部署分片,数据分布肉眼可见地趋于均匀。
这篇文章就是围绕这次实践展开的。我会把MongoDB哈希分片的原理、它与哈希索引的区别、在分布式环境下的均匀分布策略、以及实操中必然会遇到的那些坑,一次讲清楚。适合正在做分片集群设计、或者已经在用分片但发现数据倾斜的同学参考。
1. 范围分片是怎么把集群一步步拖垮的
1.1 分片集群的核心设计目标
分片集群存在的意义,本质上就一句话:让数据分散到多个节点上,用横向扩展换取容量和吞吐。但"分散"这个词说起来轻巧,做起来全是细节。MongoDB把数据按分片键(shard key)的取值切成若干区间,每个区间称为一个chunk,chunk再被分配给不同的分片。读写请求到达mongos后,根据分片键值定位到对应chunk,然后把请求路由到目标分片。
这里的关键是:chunk的边界和分布,直接决定了每个分片上的数据量、写入流量和查询压力。如果chunk本身在物理分布上是均衡的,集群整体表现就均衡;如果chunk分布天然倾斜,那么无论底层的存储引擎多快、副本集配置多高,都扛不住单点流量。
1.2 单调递增键引发的写热点
范围分片(Ranged Sharding)是很多团队的第一选择,因为它对范围查询太友好了——按时间、按数字区间去查数据,mongos可以直接裁剪到最小的chunk范围。但它有一个非常致命的问题:对单调递增的字段做分片键时,新写入的数据永远落在键空间的最大值或最小值附近。
拿日志场景举例,分片键是ts时间戳。所有新日志的ts都在持续变大,它们会滑向同一个方向。最开始chunk可能还跨越几个时间段,但一旦写入量增加,chunk不断分裂,新的chunk卡在键空间的右端,而右端chunk所在的分片就会持续承接全部的新写入流量。
均衡器理论上会把多余的chunk迁移到其他分片,但迁移只能把已经产生的新chunk搬走,下一分钟新分裂的chunk又出现在同一个分片。迁移速度永远赶不上分裂速度,时间一长,那个分片就会同时拥有"最新的chunk"和"持续增长的流量",最终成为整个集群的单点瓶颈。
1.3 数据倾斜不只是"分布不均"
倾斜的后果不只是某台机器磁盘用得多一点,它往往是一连串问题的起点。我曾经接到一个工单,业务反馈某些查询突然变慢,结果发现慢查询全部集中在同一个分片。原因是范围分片下,某个时间段的数据请求特别密集,该分片的CPU打满,而其他分片的负载却很低。所有跨分片聚合操作也被mongos广播,最终导致整个集群的延迟被单一热分片拖住。
还有一个容易被忽略的坑:数据删除也会制造倾斜。如果业务按时间清理历史数据,删除操作集中在某些chunk上,这些chunk的数据量大幅缩水但chunk本身不会自动合并。结果就是某几个分片上堆了一堆"空壳chunk",chunk数量很多但数据量不大;而另外几个分片上的chunk又大又重。这时候只看chunk数量会觉得集群好像均衡,实际上数据分布早就歪了。
2. 哈希分片的原理:把均匀分布这件事交给数学
2.1 MongoDB的哈希索引到底"算"了什么
在讲分片之前,先搞清楚哈希索引是怎么工作的。MongoDB里可以通过下面这条命令给某个字段创建哈希索引:
db.deviceData.createIndex({ deviceId: "hashed" })这条命令的作用是:对deviceId字段的值做MD5计算,然后从结果中取前4字节转换成一个整数值,作为索引键。也就是说,你在B+树里看到的键不再是原始的deviceId字符串,而是经过哈希映射后的数字。
哈希索引最大的特点是:即使原始字段值的分布是连续的、有规律的,映射后的哈希值也会被打散到整个键空间中。正因如此,MongoDB能够通过哈希值快速做等值查找:计算查询条件的哈希值,然后直接跳到对应的索引位置。
但要注意,哈希索引在本质上只是一个"辅助查询索引"。它擅长等值查询,却不擅长范围查询。因为数据在物理存储顺序上已经完全被打乱,你无法利用哈希索引对原始字段做高效的顺序扫描或区间比较。这一点后续会反复提到。
2.2 哈希分片和哈希索引的关系:两个容易被混在一起的概念
这里必须把两个概念掰开说清楚,因为我在实际交流中发现,有不少人误以为"建了哈希索引就等于哈希分片了"。
哈希索引:解决的是查询路径问题。你创建了 { deviceId: "hashed" } 索引,查询 deviceId 的等值条件时,可以直接走索引快速定位,但数据仍然存储在一个节点上,分布没有任何变化。
哈希分片:解决的是数据分布问题。你需要执行下面的命令,让MongoDB使用哈希值作为分片键,从而将数据按哈希值范围切分成chunk分布到各分片:
sh.shardCollection("iot.deviceData", { deviceId: "hashed" })关键点是:shardCollection 在执行时会自动为 deviceId 创建哈希索引。所以如果你打算做哈希分片,其实不需要手动先建索引。反过来,如果你只是想让某个字段的等值查询更快,直接建哈希索引就好,完全不需要启用分片。
还有一个容易忽略的细节:哈希索引只能建在单个字段上,不支持复合索引。你没办法写 { deviceId: "hashed", ts: 1 } 这种组合。这就意味着,哈希分片键永远只能是一个字段,想让"等值定位+范围过滤"同时走同一个索引是不现实的。后面我会讲这个限制的应对方式。
2.3 为什么哈希能让数据"大致均匀"
理解哈希分片,可以想象一个仓库分箱的场景。范围分片是按时间顺序把零件依次放进箱子,新到的零件总是被扔进最后一个箱子;哈希分片则是先用一套固定的随机算法给零件打上散列编号,然后按编号区间分箱。由于编号是伪随机且均匀分布的,每一批新写入的零件都会大致均匀地落进各个区间,而不是集中在同一个箱子。
MongoDB具体的做法是:对分片键的每个值算出哈希整数,然后根据这个整数所在的哈希区间,决定文档属于哪个chunk。默认情况下,哈希键空间会被均匀地切成若干区间,均衡器负责把这些chunk分散到不同分片上。因为每个chunk覆盖的哈希区间是大致相同的宽度,且业务字段的哈希值在键空间里近似均匀分布,所以从长期来看,各分片上的数据量、写入量和查询压力都能保持在一个相对均衡的状态。
需要注意,这里的"均匀"是统计意义上的。如果某些键值出现频率极高——比如一台设备上报频率是其他设备的100倍——那这台设备对应的哈希区间就会相对更热。好在chunk是可以分裂的,热点chunk分裂后,均衡器可以把新chunk迁移到其他分片,从而分散压力。哈希分片没有彻底消灭热点,但它给了均衡器足够的工作空间,让热点能够被"搬走",这是范围分片做不到的。
3. 实战:给IoT设备数据做哈希分片并验证分布效果
3.1 搭建一个最小分片集群
我不建议直接在生成环境做实验。先在本地把分片集群跑起来,把整个流程观察明白,再考虑线上迁移。一个最小集群需要三种角色:配置服务器(config server)、分片节点(shard)、路由节点(mongos)。
本地实验我一般用单机多进程方式,简单直接。先准备三个目录分别存配置、分片和路由日志:
mkdir -p /data/mongo/{cfg,shard1,shard2,mongos}启动config server(副本集,端口27019):
mongod --configsvr --replSet cfgrs --port 27019 \ --dbpath /data/mongo/cfg --bind_ip 127.0.0.1 \ --fork --logpath /data/mongo/cfg.log启动两个分片节点(副本集,端口27018和27028):
mongod --shardsvr --replSet shard1rs --port 27018 \ --dbpath /data/mongo/shard1 --bind_ip 127.0.0.1 \ --fork --logpath /data/mongo/shard1.log mongod --shardsvr --replSet shard2rs --port 27028 \ --dbpath /data/mongo/shard2 --bind_ip 127.0.0.1 \ --fork --logpath /data/mongo/shard2.log启动mongos(端口27017),注意要指向config server:
mongos --configdb cfgrs/127.0.0.1:27019 --port 27017 \ --bind_ip 127.0.0.1 --fork --logpath /data/mongo/mongos.log然后用mongosh连接mongos,初始化config副本集并把分片节点加进来:
rs.initiate() sh.addShard("shard1rs/127.0.0.1:27018") sh.addShard("shard2rs/127.0.0.1:27028")3.2 创建哈希索引并启用哈希分片
假设业务表 iot.deviceData 存储设备上报数据,字段结构大致是:
{ deviceId: "dev_001", ts: ISODate("..."), payload: { voltage: 3.7 } }启用数据库分片和集合分片:
sh.enableSharding("iot") sh.shardCollection("iot.deviceData", { deviceId: "hashed" })前面提到过,shardCollection会自动创建deviceId的哈希索引。但是在这里我还是建议,如果你的集合已经有一定数据量,最好提前手动创建索引,避免shardCollection阶段同步建索引导致耗时偏长:
db.deviceData.createIndex({ deviceId: "hashed" })注意,创建哈希索引在数据量大时也可能花很长时间。MongoDB 4.2以上版本的索引创建默认是在后台做的,不会完全阻塞读写,但还是会占用IO,我一般会选择在低峰期执行。
接下来模拟一批写入,模拟100台设备、每台设备上报1000条记录:
for (let i = 0; i < 100000; i++) { let deviceId = "dev_" + (i % 100); db.deviceData.insertOne({ deviceId, ts: new Date(Date.now() - i * 1000), payload: { value: i } }); }3.3 用getShardDistribution验证数据分布
写入完成之后,用下面命令查看数据分布在两个分片上的实际情况:
db.deviceData.getShardDistribution()执行结果大概是这样的对比:
Shard shard1rs at 127.0.0.1:27018 data : 5.1MiB docs : 50123 chunks : 3 Shard shard2rs at 127.0.0.1:27028 data : 5.0MiB docs : 49877 chunks : 3这个结果说明两个分片上的文档数和数据量基本五五开。你还可以用sh.status()查看chunk分布:
sh.status()会看到类似下面的信息:
chunks: shard1rs 3 shard2rs 3作为对比,如果用ts做范围分片再执行同样的写入,你会发现所有新数据几乎都落到最后一个chunk,其中一个分片可能只有几十KB数据,另一个分片却有近10MB。差异非常直观。
乳白色茶汤、时间戳递增、设备ID哈希,这两组对比放一起,任何人看完都能明白为什么选择哈希分片。
4. 分片键选型:哈希分片不是万能药
4.1 分片键的三个硬标准
哈希分片虽然能解决单调递增写热点,但它不能解决所有"坏分片键"问题。选分片键我在项目里主要看三个指标:基数(cardinality)、出现频率(frequency)、查询模式(query pattern)。
- 基数要高:分片键要有足够多的不同取值。如果只有三五个取值,比如status字段,哈希之后也顶多制造三五个热点区域,chunk分裂和均衡器发挥不了作用。
- 频率要分散:不能出现少数键值占绝对主导。极端情况下,如果某个设备ID对应的数据占全表80%,哈希分片也救不了它,因为这个哈希区间会持续膨胀并产生热点chunk。
- 查询模式要匹配:最好所有查询都包含分片键的等值条件,这样mongos可以精确路由到目标chunk,避免广播。
我用一张表总结常见场景的推荐做法:
| 业务场景 | 推荐分片键 | 理由 |
|---|---|---|
| IoT设备历史数据 | deviceId + hashed | 设备ID基数高,写入分散,按设备查询天然匹配等值路由 |
| 电商订单表 | userId + hashed | 用户维度查询为主,同一用户订单固定在同一个分片,事务和聚合更友好 |
| 日志流水表 | 时间字段做范围分片 + 租户维度 | 日志常按时间范围扫,哈希分片对范围查询不友好;可考虑复合策略 |
| 状态字段分表 | 不适合分片 | 基数太低,任何分片策略都难均匀 |
4.2 这几个场景千万别用哈希分片
第一个场景是纯范围查询。如果你的核心查询是"查最近一小时的所有设备日志",没有设备ID做等值条件,那哈希分片键完全没有用武之地。因为查询条件无法定位到具体哈希值,mongos只能把请求广播到所有分片,每个分片各自扫描。分片越多,读放大越严重。
第二个场景是高频更新同一个范围的文档。哈希分片会把同一时间段的文档打散,早先设计的"按时间局部性批量更新"可能失效。例如按天批量更新所有设备的某些字段,本来在一个chunk上就能完成,哈希分片后需要触及所有分片。
第三个场景是索引字段本身是数组。MongoDB不支持对数组字段创建哈希索引,如果你把集合中可能为数组的字段设为分片键,shardCollection会直接报错。这类字段最好先做数据清洗,或者换一个稳定的标量字段。
4.3 哈希分片键下的复合查询补偿方案
既然哈希索引不支持复合,那实际的"等值+范围"查询怎么做?这是项目里最常被问的问题。
答案是用二级索引兜底。比如按deviceId哈希分片,但业务经常查某个设备某段时间内的数据:
db.deviceData.find({ deviceId: "dev_001", ts: { $gte: ISODate("..."), $lt: ISODate("...") } })这条查询携带了deviceId的等值条件,mongos会先根据deviceId算出哈希值,路由到对应的分片和chunk。然后在这个分片内部,我们需要一个普通二级索引来加速ts范围过滤:
db.deviceData.createIndex({ deviceId: 1, ts: 1 })问题来了:分片集合的二级索引是每个分片自己维护的。上面的索引在分片内部可以正常工作,配合哈希分片键的等值路由,能够把范围查询精准地限定在一个分片内执行。
如果查询完全不带deviceId等值条件,比如"查询所有设备最近5分钟的数据",那无论有没有二级索引,请求都会广播到所有分片。这种查询在任意分片策略下都会放大IO,只能通过业务层做汇总或预聚合来缓解,而不是期待分片键解决一切。
5. 均衡器、Jumbo Chunk与运维避坑
5.1 数据分布不均时的排查链路
生产环境的MongoDB分片集群,数据分布不可能是完美的五五开。当分布偏差大到一定程度,就需要主动排查。
我一般按下面几步排查:
// 第一步:看chunk分布 sh.status() // 第二步:看数据量和文档数分布 db.collection.getShardDistribution() // 第三步:看是否存在jumbo标记 sh.status("db.collection") // 第四步:看当前是否有chunk迁移或大查询 db.currentOp()先看chunk数量是否明显偏离平均值。再看getShardDistribution输出的百分比,如果某个分片的数据量占比超过三分之一(三分片集群),基本可以判定倾斜。最后要看迁移状态:如果chunk一直在迁移但从未完成,可能是均衡器被jumbo chunk卡住了。
5.2 Jumbo Chunk:当"均匀"遇到"膨胀"
Jumbo Chunk是指无法被继续分裂的chunk,通常是因为某个chunk内部的单个文档过大(超过chunkSize),或者文档过于紧密导致split没有合适的边界。哈希分片虽然从概率上让数据分布均匀,但挡不住某几个设备的数据特别大——比如某台设备上报的payload里嵌套了长达几千个元素的数组。
当一个chunk变成jumbo之后,均衡器不会迁移它。原因很简单,chunk迁移意味着要把整个数据块挪到另一个分片,而jumbo chunk的体积已经超过了迁移阈值,强行搬只会拖垮网络和IO。于是这个chunk永远钉在某个分片上,形成一个无法消除的局部热点。
处理jumbo chunk,我建议按严重程度分三个方案:
- 如果chunk还能手动分裂,先用sh.splitAt或者sh.splitFind把它拆开,拆开后jumbo标记会自动清除,均衡器就可以继续工作。
- 如果无法分裂,考虑调整chunkSize。chunkSize的允许范围是1到1024MB,可以把128MB的默认值调大。但这是"退而求其次",因为更大的chunk意味着更粗的均衡粒度,也会让热点问题更加尖锐。
- 最坏情况下,把jumbo chunk里的超大文档迁移到单独集合,或者对表做数据清理。虽然麻烦,但能根治。
5.3 均衡器的窗口与迁移控制
均衡器(Balancer)默认全天运行,它会在各个分片间移动chunk。移动过程会占用带宽和磁盘IO,特别是大chunk迁移时可能影响业务写入。
我比较推荐给均衡器设置一个窗口期,把它限制在业务低峰。不同版本的MongoDB对均衡器窗口的设置命令不太一样,老版本用配置文件参数,新版本在mongosh里也提供了更便捷的接口。操作之前先查一下你对应版本的文档,避免命令不兼容。核心思路是:
// 查看均衡器状态 sh.getBalancerState() // 暂停均衡器 sh.stopBalancer() // 恢复均衡器 sh.startBalancer()设置窗口期之后,需要注意一点:如果窗口期过短而chunk数量又很大,均衡器可能来不及迁移完,分布会持续偏斜。所以线上我会在窗口期结束后观察sh.status()的chunk分布,确认没有遗留大量待迁移chunk。
5.4 字段缺失的坑:所有null都堆到同一个chunk
这个坑比较隐蔽,也是我后来才意识到的。哈希分片键如果是一个可选字段,大量文档可能没有该字段。MongoDB对缺失字段的处理方式是把字段值视为null,所有null值的哈希结果完全一致。
也就是说,如果一个集合里30%的文档都缺失了分片键字段,这30%的数据会全部落在同一个哈希值上,直接导致一个chunk异常膨胀。而且这个chunk可能很难分裂,因为它内部的文档在业务逻辑上本应分布到各处。
避免方法很简单:设计分片键时,必须确保该字段在所有文档里都存在,并且没有大量文档使用默认空值。如果业务上无法控制,可以在写入时填充业务唯一值作为兜底。
6. 哈希分片在版本迭代中的变化和我的建议
6.1 分片键不可变限制的缓解
在MongoDB 4.4之前,分片键字段值写入后就不能修改,想改只能删文档重插。4.4版本开始,官方支持在特定条件下修改分片键的值——前提是修改后的值不会导致文档跨分片迁移。
哈希分片比较尴尬的一点是:哈希值几乎对原始值的所有bit都敏感,哪怕只改一个字符,哈希结果都可能跳到键空间的另一个区域。因此,哈希分片键的值在实际项目中仍然要按"不可变"来设计。选键的时候,尽量选那些稳定、不随业务变化而改变的字段,比如设备ID、用户ID、订单ID。
如果你的业务确实存在"后续需要修改分片键字段值"的需求,我建议在选型阶段直接放弃哈希分片,改成其他分片策略,否则后面会被这个限制折磨。
6.2 哈希分片与分布式事务的取舍
哈希分片有一个隐藏的优点:同一个分片键值对应的所有文档,永远会落在同一个分片。这一点在分布式事务和多文档聚合里非常关键。
比如订单系统按userId哈希分片,一个用户的所有订单、支付记录、优惠券记录都集中在同一个分片。对这个用户发起的跨集合查询和事务,完全可以在单分片内完成,不需要跨分片协调,性能开销小得多。如果选了一个业务上无法保证相关文档聚集的分片键,那么分布式事务的成本会急剧上升。
所以在做哈希分片设计前,先问自己一个问题:我的业务里,哪些数据天然需要"聚在一起"?把最能代表这种聚集关系的字段作为哈希分片键,往往比单纯追求写入均匀更符合业务需要。
6.3 如果要更极致的均匀,还能做什么
哈希分片已经把分布均匀性做到比较理想的水平,但在某些场景下还不够。比如新集合刚启用时,chunk数量很少,数据可能暂时集中在少数chunk上;均衡器需要一定时间才能把chunk铺平。
如果你非常在意上线初期的均匀性,可以考虑在空集合上预先规划chunk数量,通过切分命令把哈希空间提前分成多个区间,再通过均衡器分散到各分片。这个操作对命令的版本兼容性要求较高,而且不同版本的行为差异明显,不建议新手直接在生产环境尝试。
我更推荐的做法是:先用小规模压测观察chunk分裂速度和均衡器收敛时间。如果发现chunk前期分裂太慢,再调整预分配策略。哈希分片本身是自动化的,给它一点时间,均衡器会逐渐把分布拉平。比起手动干预,让它自己工作是更省心的选择。
从目前的版本演进来看,MongoDB对哈希分片的核心机制一直保持稳定,没有出现颠覆性的变化。即使你用的是最新版本,之前积累的哈希分片经验和踩坑教训也完全适用。分片键的选择、null值的坑、jumbo chunk的处理、均衡器窗口的设置,这些内容才是真正决定集群长期稳定运行的关键。