ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MongoDB哈希分片原理与实战:解决写热点和数据倾斜

MongoDB哈希分片原理与实战:解决写热点和数据倾斜 去年处理过一个挺典型的线上问题MongoDB分片集群跑了大半年某天晚高峰写入延迟突然飙到几百毫秒mongos的CPU被打满。查了一圈发现所有新写入的数据都怼到了同一个分片——因为分片键用的是创建时间而业务写入天然是单调递增的范围分片直接把写热点全集中在最后一个chunk上。当时做了两个调整把时间字段的写入路径改造、换用哈希分片键重新部署分片数据分布肉眼可见地趋于均匀。这篇文章就是围绕这次实践展开的。我会把MongoDB哈希分片的原理、它与哈希索引的区别、在分布式环境下的均匀分布策略、以及实操中必然会遇到的那些坑一次讲清楚。适合正在做分片集群设计、或者已经在用分片但发现数据倾斜的同学参考。1. 范围分片是怎么把集群一步步拖垮的1.1 分片集群的核心设计目标分片集群存在的意义本质上就一句话让数据分散到多个节点上用横向扩展换取容量和吞吐。但分散这个词说起来轻巧做起来全是细节。MongoDB把数据按分片键shard key的取值切成若干区间每个区间称为一个chunkchunk再被分配给不同的分片。读写请求到达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本身不会自动合并。结果就是某几个分片上堆了一堆空壳chunkchunk数量很多但数据量不大而另外几个分片上的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副本集端口27019mongod --configsvr --replSet cfgrs --port 27019 \ --dbpath /data/mongo/cfg --bind_ip 127.0.0.1 \ --fork --logpath /data/mongo/cfg.log启动两个分片节点副本集端口27018和27028mongod --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 servermongos --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的处理、均衡器窗口的设置这些内容才是真正决定集群长期稳定运行的关键。
返回列表