2.2 路由算法:哈希取模与一致性

选完分片键,下一个决定是用什么算法把分片键映射到分片号。最常见的两个候选是「哈希取模」与「一致性哈希」,再加一个按范围分片的选择。三种算法的性能、均匀性与扩容成本差异很大,这一节把三者放在同一张表里对比,并给出我们的选择。

场景:三种算法各自适合什么

哈希取模是 shard = hash(key) % N,最简单、分布最均匀,缺点是 N 一变,几乎所有数据都要搬家。一致性哈希把 Key 和节点都映射到同一个哈希环上,节点增减时只影响相邻区间的数据,扩容友好,但默认分布不均需要引入虚拟节点。范围分片按 Key 的大小区间划分,天然支持范围查询,但热点严重(最新区间的写入永远最热)。

算法 均匀性 扩容迁移量 范围查询支持 实现复杂度 适用场景
哈希取模 极好 约 100% 重分布 不支持 低 分片数固定、Key 随机
一致性哈希 好(虚拟节点) 约 1/N 迁移 不支持 中 频繁扩容的集群
范围分片 差(热尾) 新增区间不迁移 支持 低 时间序列数据
哈希加范围混合 好 中 部分支持 高 复杂业务

方案:主表哈希取模,日志表按月范围分

我们的选择是主链路用哈希取模,分片数 16 固定,靠「一次规划到位」规避扩容迁移(见 2.3 节的分片数规划);但如果未来要扩容,采用「翻倍迁移」策略:16 到 32 片时每个旧分片拆成两半,迁移量虽然大但路由规则简单。订单操作日志表 t_order_log 则用范围分片(按月),因为它天然按时间追加,冷热分离时直接整月归档,见 4.2 节。

一致性哈希我们没有选,根本原因是订单系统是强路由场景:ShardingSphere 内置的取模算法开箱即用、行为可预期,而一致性哈希的虚拟节点数需要调参,路由结果肉眼不可验证。对一个只有半个 DBA 的团队,可验证性比理论优雅更重要。集群版的存储(比如 Redis 集群的槽位迁移)更适合一致性哈希,相关讨论可以参考 Go 实战:IM 系统 的缓存分片章节。

迁移方式 迁移量 路由规则变化 停写窗口 我们的选择
16 片扩容到 32 片(翻倍) 100% 数据换位 每片一分为二 需双写切换 备选方案
一致性哈希加节点 约 1/16 部分区间重映射 可在线 未采用
离线搬迁加停服 100% 全部重算 停服数小时 上线初版方案
不扩容(容量预留) 0 不变 无 最终选择

配置:取模算法的哈希函数选择

哈希取模的隐藏变量是哈希函数本身。订单号是纯数字字符串,如果直接 order_no % 16,某些业务号段会呈现出规律性,分布不均。规范做法是先做一次 MD5 或者 Murmur3 摘要再取模:

// ShardingSphere MOD 算法内部对字符串列会先做 hash 再取模
// 自定义算法时注意:Long 类型列直接取模,String 类型列需先摘要
int shard = Math.abs(murmur3(orderNo.getBytes())) % 16;

我们用一段灌数据脚本验证过分布均匀性:100 万条连续订单号,Murmur3 加取模后每片约 62500 条,最大偏差 0.3%。而直接对数字取模,某些号段(比如以 8 结尾的批次)会出现 3% 以上的偏差。验证脚本同时检查了「订单号前缀为某渠道号」的子集分布——渠道号是业务批次号,前几位相同,如果哈希函数对此敏感会造成渠道级热点。

翻倍扩容时的路由推演

把翻倍扩容的路由变化推演清楚,能帮助理解为什么坚持 2 的幂。16 片时路由是 hash % 16,扩到 32 片后是 hash % 32。奇妙之处在于:每片旧数据恰好被一分为二,ds_0 的数据只会去 ds_0 或 ds_16,不会去 ds_7。这样迁移时只要把每片的一半数据搬到新片,逻辑清晰、可验证。

扩容方式 数据搬迁路径 验证方式 停写窗口需求
16 到 32(翻倍) 每片拆一半到新片 每片行数减半校验 双写切换,分钟级
16 到 24(非幂次) 全部重新哈希分布 只能逐条计数校验 需要较长时间停写
一致性哈希加片 只搬相邻区间 槽位区间对比 可在线进行

推演出这个结论后,团队对「2 的幂」的执念有了具体依据,不再只是惯例。一致的分片数规划让扩容从一次「大手术」变成一套可重复执行的流水线。五年后回看,2 的幂是这本书里最便宜的一条规定。

踩坑:自定义算法忘了绝对值

一个低级但真实出过的故障:自定义路由算法里写了 hash % 16 没有取绝对值,负的哈希值直接 % 得到负的分片号,ShardingSphere 找不到 ds_-3 数据源,整批写入失败。凡是手写路由算法,第一行应该是 Math.abs(),而且要用单元测试覆盖负数输入。

第二个坑是路由结果不能随算法版本变化。我们中途升级过一次哈希实现(从 MD5 换 Murmur3 本来是想优化性能),上线后新写入的数据按新算法路由,老数据按旧算法的位置查询——直接乱套。路由算法一经上线即冻结,要换只能走完整的数据重分布流程。任何手写路由算法的人,建议先读一遍 ShardingSphere 的 MOD 算法实现再动手——中间件帮你踩过的坑,没必要自己再踩一遍。

第二个坑背后的故事值得完整讲。那次 MD5 换 Murmur3 的起因是性能压测:路由计算占 CPU 的 3%,换算法能省一半。改完灰度上线,新写入的数据按新算法落库,查询却按旧算法的位置找——前 10 分钟所有新订单都查不到,还好灰度只放了 1% 流量。这次事故的直接教训是路由计算结果必须可复现:我们随后把路由算法的版本号写进了每一行的元数据(写入时记录使用的算法版本),查询路径根据版本选算法,彻底避免新旧混用。想换算法只能走完整的重分布流程,没有捷径。

算法验证:三组必做的灌数测试

路由算法选好后,上线前必做三组灌数验证。三组测试的目的分别是验证均匀性、验证稳定性和验证业务特征子集的分布:

测试组 灌入数据 检查指标 通过标准
均匀性 100 万条连续订单号 各片行数 最大偏差小于 1%
稳定性 相同数据重跑两次 路由结果 完全一致
子集分布 单一渠道号下 10 万条 各片行数 最大偏差小于 2%
热点模拟 前缀相同的 1 万条 各片行数 不出现单片聚集
边界值 极小与极大的订单号 路由结果 不报错不越界

第三组子集分布测试最容易被漏掉。订单号虽然是雪花 ID,但业务上会有「渠道批次」这种前缀规则(比如某个渠道一天的订单共享前缀),如果哈希函数对前缀敏感,就会形成渠道级热点。我们用 Murmur3 与 MD5 各跑了一遍子集测试,两者都通过,最终选 Murmur3 是因为它的耗时比 MD5 低约 4 倍,每秒路由调用量在百万级时差距明显。

范围分片我们用在了另一张表上。订单操作日志 t_order_log 天然按时间追加、查询都是「最近 N 天」的范围条件,按月分片后冷热分离时直接整月归档(详见 4.2 节),比哈希分片再筛时间范围快一个量级。一个系统里多种分片规则并存是常态,主表用哈希保证均匀、日志表用范围方便归档,两者各取所需。

小结

一致性哈希护得了扩容却复杂了行为;三种算法没有绝对优劣,只有匹配与不匹配。路由层是整个系统最容易出错也最难回滚的地方,上线前一定要灌数据验证、上线后要冻结算法版本。,一致性哈希扩容友好但行为复杂,范围分片适合时间序列但热点重。订单系统选哈希取模加固定分片数,本质上是把扩容问题前移到容量规划里解决。无论选哪种算法,上线前都要做分布均匀性验证、上线后要把算法版本冻结——路由层是整个系统最容易出错也最难回滚的地方。

延伸阅读