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 节),比哈希分片再筛时间范围快一个量级。一个系统里多种分片规则并存是常态,主表用哈希保证均匀、日志表用范围方便归档,两者各取所需。
小结
一致性哈希护得了扩容却复杂了行为;三种算法没有绝对优劣,只有匹配与不匹配。路由层是整个系统最容易出错也最难回滚的地方,上线前一定要灌数据验证、上线后要冻结算法版本。,一致性哈希扩容友好但行为复杂,范围分片适合时间序列但热点重。订单系统选哈希取模加固定分片数,本质上是把扩容问题前移到容量规划里解决。无论选哪种算法,上线前都要做分布均匀性验证、上线后要把算法版本冻结——路由层是整个系统最容易出错也最难回滚的地方。
延伸阅读
- Go 实战:IM 系统:缓存分片与一致性哈希在集群中的实践
- rust 实战:日志管道:日志类数据按时间分片的对照思路
- MySQL 实战:分库分表订单系统:回到本书,2.3 节讲分片数怎么定
