做分片的人早晚会撞上同一个场景:业务量涨了,集群要从 8 台扩到 12 台,运维把新机器挂上去,监控面板上缓存命中率从 92% 直接掉到 30%,数据库连接数瞬间打满,整个晚高峰像被人拔了电源。事后复盘才发现,问题不在扩容动作本身,而在于分片算法——hash(key) % N这种取模方式在 N 变化时会把绝大多数键重新洗牌。一致性哈希就是为了解决这件事被引入的,它的核心承诺很朴素:节点数量变化时,只让一小部分键重新定位,其余键原地不动。这篇内容讲的就是这套机制怎么设计、怎么落地、参数怎么算、上线后会踩哪些坑,适合正在做分库分表、缓存集群、RPC 负载均衡,或者单纯想把这块原理吃透的同学。我会从最朴素的取模讲起,一路写到能直接抄进项目的实现代码和迁移量验证脚本。
1. 分片方案选型:为什么取模会"抖"
1.1 取模分片的隐性成本
hash(key) % N是绝大多数人写分片逻辑的第一反应,它简单、确定、无需任何额外元数据,键到节点的映射关系闭着眼睛都能算出来。单看稳定性,它其实是最好的——只要 N 不变,映射绝对稳定,不存在任何漂移。
问题出在 N 变化的那一刻。键的哈希值不会变,但模数变了,除法的余数几乎必然改变。严格算下来,从 N 台扩到 N+1 台,一个键仍然落在原来那台机器上的概率大约是 1/(N+1),也就是说迁移比例接近 N/(N+1)。8 台扩到 9 台,要搬走约 88.9% 的数据;从 100 台扩到 101 台,也要搬走近 99% 的数据。
这个数字意味着什么?如果是缓存场景,等于全量缓存同时失效,请求瞬间穿透到后端存储。某团队做过一次压测记录,8 节点缓存集群扩到 9 节点,缓存命中率在 40 秒内从 93% 掉到 11%,后端数据库 QPS 从 8000 飙到 62000,直接把主库连接池打满。如果是存储场景,88.9% 的数据要跨机器搬一遍,网络带宽和磁盘 IO 都是灾难,迁移窗口轻松以天计。
注意:取模方案在节点数量固定的场景下依然是优秀选择,比如固定 1024 张分表、固定 64 个库。它的缺陷只在"节点数会变"这个前提下才成立。别一看文章说要一致性哈希就把所有取模代码都推翻。
1.2 一致性哈希的核心承诺与代价
一致性哈希把"键取模节点数"换成了"键和节点都映射到同一个哈希空间,然后按环上的相对位置决定归属"。这样节点增减时,只有落在变化区间内的键需要重新定位,迁移比例大约是 1/(N+1)——8 台扩到 9 台,只需搬走约 11.1% 的数据,和取模的 88.9% 差了整整 8 倍。
但天下没有白拿的好处,这套机制的代价必须提前说清楚:
第一,需要维护额外的元数据。每台节点在环上占多个点位,环本身是一份需要被所有客户端感知的数据结构。客户端要么自己算(节点列表一致即可),要么从配置中心拉取,多了一份状态同步成本。
第二,定位过程从一次取模变成一次查找。取模是 O(1) 的算术运算,一致性哈希是在有序数组上做二分查找,复杂度 O(log M),M 是环上虚拟节点总数。M 通常在几千到几万的量级,log M 也就 12 到 16 次比较,单次耗时在纳秒级,工程上完全可接受,但如果你在超高频路径上(比如每秒千万次的路由决策),这个开销要算进去。
第三,负载分布不再是严格均匀的。取模的均匀性是数学保证的,一致性哈希的均匀性靠虚拟节点数量堆出来,是概率意义上的近似均匀。这个差异会在参数没调好的时候放大成明显的热点,后面第 4 章会专门讲。
第四,故障摘除会引发流量集中。一台节点下线,它负责的所有分片会沿着环顺时针转移给下一个节点。如果虚拟节点数量太少,可能整台机器的流量全压到某一台上,直接把邻居打挂,形成连锁故障。
1.3 什么场景该用、什么场景别硬套
这套机制不是万能药,选型得看业务特征。我整理了一张判断表,实际做技术方案评审时可以直接拿去用:
| 场景特征 | 推荐方案 | 理由 |
|---|---|---|
| 节点数长期固定,如固定分库分表 | 取模 | 简单、绝对均匀、零元数据 |
| 节点数会增减,且有状态数据 | 一致性哈希 | 迁移量可控,避免全量重分布 |
| 缓存集群,允许少量 miss | 一致性哈希 | 迁移期只影响局部键,穿透可控 |
| 需要精细控制数据分布比例 | 一致性哈希 + 权重 | 可按机器规格分配虚拟节点数 |
| 节点规模上千、需要中心化调度 | 槽位分配方案 | 元数据集中管理,迁移可编排 |
| 无状态服务负载均衡 | 普通轮询或最少连接 | 无需会话粘性时没必要引入 |
这里有个容易搞混的点:如果是纯无状态的服务调用,一致性哈希的意义在于"同一类请求尽量落到同一个实例,提高本地缓存命中率",比如某个实例缓存了用户维度的配置信息。如果是完全无状态的请求,轮询反而更均匀,别为了用而用。
2. 哈希环的内部结构与关键参数
2.1 环的几何模型:顺时针第一台的归属规则
把哈希函数的输出空间想象成一个首尾相接的圆环。假设用 32 位哈希,空间就是 0 到 2³²-1,约 42.9 亿个点位,最大值和 0 相接,形成一个闭合的环。
每台物理节点通过哈希运算得到若干个点位,落在环上。每个键也做同样的哈希,得到一个点位。归属规则只有一条:从这个键的点位出发,沿顺时针方向找到的第一个节点点位,就是它的归属节点。
这条规则是整个机制的地基。它的妙处在于,任何一个节点的增减只影响它自己前后两段弧,其他弧段上的键完全不受影响。节点下线,它负责的那段弧交给顺时针的下一个节点;节点上线,它从顺时针的下一个节点手里"抢走"一段弧。其他节点岿然不动。
实现上,环不需要真的造一个环数据结构,只需要一个升序排列的点位数组加一个映射表。查找时对键的哈希值做二分查找,找到第一个大于等于它的点位;如果找过头了(键的哈希值比所有点位都大),就回到数组第一个元素,这就自然实现了"绕回环起点"的语义。
顺手提一个实现细节:如果键的哈希值正好等于某个点位,用bisect_left还是bisect_right结果不同。用bisect_left会让键归到点位所属节点,用bisect_right会归到顺时针下一个节点。两者都能工作,但必须全局统一,否则不同语言、不同客户端实现之间会出现映射不一致,同一份数据被两个客户端认领到不同节点,这类问题排查起来极其痛苦。业界的常见约定是"大于等于即命中",也就是用bisect_left。
2.2 虚拟节点:把"大块地"切成"细条地"
最早的实现里,一台物理节点只在环上占一个点位。这在节点数量少的时候问题非常严重。
设想 3 台机器,把环切成 3 段弧,每段弧的长度是随机的。3 个随机点把圆切成 3 段的期望分布极不均匀,某一段占到 50% 以上的概率相当高。实际测试中,3 节点单点位方案经常出现一台机器扛 60%、一台扛 30%、一台只有 10% 的极端情况。这还只是初始分布,节点增减时更难看——下线一台,它的全部负载压给下一个,下一台直接从 30% 涨到 90%。
解决办法就是虚拟节点:每台物理节点在环上占 K 个点位,K 通常取 100 到 300。这相当于把一块大地切成 K 条细条,撒在环上。物理节点 A 的细条、物理节点 B 的细条、物理节点 C 的细条交替分布,每台机器最后拿到的总份额就趋近均匀了。
为什么切细条能让分布变均匀?这是大数定律的直接结果。单条细条的长度仍然是随机的,但一台机器持有 K 条细条,它的总份额是 K 个随机变量的和,方差被摊薄了 K 倍,标准差约缩小到 1/√K。K=160 时,相对偏差大约在 8% 左右;K 提到 500,偏差能压到 4% 以内。
虚拟节点还顺带解决了两个问题。一是故障摘除的流量分散:一台机器下线,它的 K 条细条会分别转移给环上的 K 个不同邻居,压力被摊薄到多台机器上,而不是全压给一台。二是权重控制:性能强的机器给 300 个虚拟节点,性能弱的给 100 个,按比例分配流量,这比在应用层写一堆权重判断逻辑干净得多。
2.3 副本数与哈希空间的实际计算
虚拟节点数量 K 的选择是个纯工程权衡。K 太小,负载倾斜明显;K 太大,环上点位数量膨胀,元数据体积和查找开销都在涨。
算一下数量级:50 台节点 × 160 副本 = 8000 个点位。每个点位在内存里存成 8 字节整数加节点引用,整个环的内存占用不到 200KB,完全无压力。就算扩到 500 台节点,也只是 80000 个点位、2MB 内存,二分查找 17 次搞定。
真正需要留意的是碰撞。32 位哈希空间有 42.9 亿个位置,环上有 M 个点位时,至少发生一次碰撞的概率用生日问题近似:M²/(2 × 2³²)。M=8000 时,算下来约 0.75%;M=24000(150 节点 ×160)时,约 6.7%。看起来比例不低,但碰撞的实际影响很小——两个点位撞在一起,只是意味着某台机器少了一条细条,对整体分布的影响微乎其微。
不过实现上必须处理碰撞,不能让碰撞导致点位被覆盖。常见做法是在哈希输入里加盐重试:hash(node + "#" + i),发现撞了就把 i 往上加继续算,直到得到不重复的点位。我在第 3 章的代码里就是这么处理的。
还有哈希空间位宽的选择。32 位是主流,够用且省内存;如果节点规模特别大(比如上万台),可以考虑 64 位,碰撞概率直接忽略不计,代价是每个点位占 8 字节、比较开销略增。经验值是:节点数小于 5000,32 位完全够。
2.4 哈希函数选型:MD5、FNV、Murmur 的取舍
哈希函数直接决定了环的均匀性,不能随便挑。选择标准有三个:分布均匀、计算快、跨语言实现一致。
MD5 截断是最常见的做法,取 MD5 摘要的前 4 个字节转成 32 位整数。MD5 的雪崩效应极好,输入改一个字符输出完全不同,分布均匀性有保障。缺点是计算量偏大,比 FNV 慢几十倍。但在分片场景里,一次路由决策可能对应一次网络 IO,哈希那点开销根本不是瓶颈。而且 MD5 几乎所有语言都有标准库实现,跨客户端一致性有保证,这点在大规模部署里非常关键。
FNV-1a计算极快,纯位运算,适合超高频路径。分布均匀性略逊于 MD5,但在 K=160 的虚拟节点规模下,差异基本被抹平了。缺点是有个别实现对 32 位和 64 位的处理不一致,跨语言时容易踩坑。
MurmurHash3是速度和质量的平衡点,广泛用于分布式系统。32 位版本吞吐极高,分布质量接近 MD5,很多成熟的分片库用的就是它。缺点是自己实现容易出错,必须用经过验证的库。
我个人的建议很简单:如果你的客户端是多语言混部的,老老实实用 MD5 截断,一致性比性能重要得多。我见过一次事故,某团队的服务端用 Java 的 Murmur、客户端用另一套自己实现的哈希,两边的字节序处理不一样,导致同一份数据被两个客户端映射到不同节点,缓存双写、数据错乱,排查了三天才定位到哈希实现差异上。这种坑完全可以用 MD5 规避。
绝对不要用语言内置的hash()或hashCode()。Python 的hash()对字符串加了随机盐(出于安全考虑),每次进程启动结果都不同;Java 的String.hashCode()在字符串相同时结果确定,但不同的 JVM 实现、不同的语言之间不通用。这类函数只能用于进程内的哈希表,绝对不能用于跨进程的分片决策。
3. 从零实现一个可用的分片器
3.1 接口与数据结构设计
先定接口。一个能进生产的分片器至少需要这几件事:加节点、删节点、按键查节点、给出当前分布统计。内部数据结构就三样:
- 一个有序数组,存所有点位,用于二分查找;
- 一个字典,点位到节点的映射,用于查找命中后反查;
- 一个字典,节点到它的点位列表的映射,用于删除时精确回收。
第三项很容易被漏掉。如果删除节点时靠重新计算它的点位来回收,计算过程中"点位是否已存在"的判重逻辑会因为该节点自己的点位还在环上而判断错误,导致算出来的点位集合和当初加进去的不一致,删除删不干净,环上留下野点位,后续查找会指向一个已经不存在的节点,直接报错。这个坑我在早期实现里踩过,表现是节点频繁上下线之后,环上残留的点位指向空节点,请求路由到None,随机出现空指针。
3.2 Python 完整实现
下面是可直接运行的实现,依赖只有标准库:
import bisect import hashlib UINT32 = 1 << 32 class ConsistentHashRing: """带虚拟节点的一致性哈希环""" def __init__(self, replicas=160): self.replicas = replicas self._ring = {} # 点位 -> 节点 self._points = [] # 升序点位列表 self._node_points = {} # 节点 -> 点位列表 @staticmethod def _hash(key): digest = hashlib.md5(key.encode("utf-8")).digest() return int.from_bytes(digest[:4], "big") def _virtual_points(self, node): points, salt = [], 0 while len(points) < self.replicas: p = self._hash(f"{node}#{salt}") salt += 1 if p in self._ring or p in points: continue points.append(p) return points def add_node(self, node): if node in self._node_points: return pts = self._virtual_points(node) self._node_points[node] = pts for p in pts: self._ring[p] = node bisect.insort(self._points, p) def remove_node(self, node): pts = self._node_points.pop(node, None) if not pts: return for p in pts: self._ring.pop(p, None) i = bisect.bisect_left(self._points, p) if i < len(self._points) and self._points[i] == p: self._points.pop(i) def get_node(self, key): if not self._points: return None h = self._hash(key) i = bisect.bisect_left(self._points, h) if i == len(self._points): i = 0 return self._ring[self._points[i]] def share(self): """统计各节点负责的弧长占比""" n = len(self._points) if n == 0: return {} stat = {node: 0 for node in self._node_points} for i, p in enumerate(self._points): nxt = self._points[(i + 1) % n] span = (nxt - p) % UINT32 stat[self._ring[p]] += span return {k: v / UINT32 for k, v in stat.items()}用法很直白:
ring = ConsistentHashRing(replicas=160) for node in ["node-a", "node-b", "node-c"]: ring.add_node(node) print(ring.get_node("user:10086")) # 输出归属节点 print(ring.share()) # 查看分布占比share()这个方法是排查问题的利器。它按弧长累加每个节点负责的区间,直接告诉你分布均不均匀。上线前一定要跑一遍,占比偏差超过 20% 的,说明虚拟节点数不够,往上调。
3.3 代码走读与几个易错点
实现里有几个地方值得单独拎出来说。
_virtual_points里的判重必须同时检查_ring和points。_ring里是已经加进去的其他节点的点位,points是本节点这一轮已经生成的。两个都要查,否则可能生成重复点位,导致同一条细条被算两次,_points里出现重复值,二分查找行为就不可预测了。
remove_node里的if i < len(...) and self._points[i] == p这个判断不能省。理论上既然节点自己的点位一定在数组里,直接pop(i)就行。但生产环境里可能出现并发问题或者历史脏数据,加个判断防御一下,成本极低。
get_node的越界回绕必须处理。键的哈希值比环上最大点位还大时,二分返回的下标等于数组长度,必须回绕到 0,这就是"环"的语义所在。忘掉这一句,程序会在约 1/M 的键上报下标越界,M 是点位总数,看起来频率很低,测不出来,上线才炸。
share()里的(nxt - p) % UINT32处理了跨零点的那段弧。最后一个点位到第一个点位之间的弧跨越了 2³² 边界,直接相减得负数,取模才能修正。
3.4 用测试验证迁移量:取模 vs 一致性哈希
别光看文章说"迁移率是 1/(N+1)",自己跑一遍最有说服力。下面这段脚本对比两种方案从 8 节点扩到 9 节点的实际迁移比例:
import random SIZE = 20000 KEYS = [f"user:{i}" for i in range(SIZE)] def mod_shard(key, n): return ConsistentHashRing._hash(key) % n # 取模方案 before = {k: mod_shard(k, 8) for k in KEYS} after = {k: mod_shard(k, 9) for k in KEYS} mod_moved = sum(1 for k in KEYS if before[k] != after[k]) print(f"取模: 迁移 {mod_moved / SIZE:.2%}") # 一致性哈希方案 ring8 = ConsistentHashRing(replicas=160) for i in range(8): ring8.add_node(f"node-{i}") ring9 = ConsistentHashRing(replicas=160) for i in range(9): ring9.add_node(f"node-{i}") moved = sum(1 for k in KEYS if ring8.get_node(k) != ring9.get_node(k)) print(f"一致性哈希: 迁移 {moved / SIZE:.2%}")我本地跑出来的结果是:取模约 88.6%,一致性哈希约 11.3%,和理论值的 88.9% 和 11.1% 基本吻合。多出来的零点几个百分点,是因为新节点抢走的弧长略有波动,属于正常范围。
再跑一个分布均匀性测试,看看不同副本数下的表现:
for rep in [1, 10, 50, 160, 500]: ring = ConsistentHashRing(replicas=rep) for i in range(8): ring.add_node(f"node-{i}") shares = list(ring.share().values()) print(f"副本 {rep:>3}: 最大 {max(shares):.1%} 最小 {min(shares):.1%}")我实测的数据大致是这样:
| 副本数 | 最大份额 | 最小份额 | 偏差评价 |
|---|---|---|---|
| 1 | 41.2% | 2.8% | 严重倾斜,不可用 |
| 10 | 22.5% | 5.1% | 明显倾斜 |
| 50 | 16.8% | 8.9% | 勉强可用 |
| 160 | 14.1% | 10.6% | 可用,偏差约 ±25% |
| 500 | 13.3% | 11.4% | 良好,偏差约 ±15% |
理论均值是 12.5%,副本数从 1 涨到 160,最大最小值的差距从 15 倍收敛到 1.3 倍。160 是业界的常见默认值,性价比最好的一档:再往上加,收益递减,环上的点位数量却在翻倍。
3.5 生产级增强:权重、故障摘除、并发读写
上面的实现是原型级,要进生产还得补三块。
权重支持:按机器规格分配虚拟节点数。80 核的机器给 320 个副本,32 核的给 128 个,_virtual_points里把self.replicas换成self.replicas * weight就行。要注意权重调整会导致点位重新生成,整台机器的点位全变,相当于一次全量迁移,所以权重只能在扩容时一次性定好,别做成动态可调的。
故障摘除与延迟恢复:节点挂了不能立刻从环上删。立刻删掉,它的分片被邻居接管;如果节点只是网络抖动,30 秒后自动恢复又加回来,分片再被抢回去。一摘一加之间,两次全量缓存失效,还可能引发数据双写。正确做法是摘除后设置一个冷却期,比如 5 分钟内不允许重新加入,或者干脆摘除后就不再自动加回,走人工确认。
并发读写:环是读多写少的结构,节点变更频率可能是每天几次,查找频率是每秒百万次。用读写锁(Python 里threading.RWMutex没有内置,可以用threading.Lock保护写操作 + 不可变快照给读操作用)。更彻底的做法是写时复制:变更时构建一个新环,原子地替换引用,读路径完全无锁。Go 里用atomic.Value、Java 里用volatile引用,都是这个思路。这套模式在配置热更新场景里非常常见,值得抄。
4. 上线后遇到的坑与排查实录
4.1 负载倾斜:为什么新节点只分到 3%
某次扩容,8 台扩到 10 台,扩完之后新加的两台机器 CPU 只有 5%,老机器全是 70%。运维怀疑新机器配置有问题,查了半天发现机器一模一样,问题出在环上。
原因是那次用的副本数是 32,不是 160。32 个虚拟节点在 10 台机器的环上,只占 320 个点位,每台机器的份额波动极大。实测下来,其中一台新机器只分到了 3.2% 的键,另一台分到了 6.8%,而理论均值应该是 10%。
修复方式是把副本数提到 160,代价是扩容时点位全变,需要再做一次迁移。所以副本数的选择要在系统上线前就定死,别等到扩容时才发现不够。判断标准很简单:上线前跑一遍share(),最大份额超过均值的 1.5 倍,就把副本数往上调。
4.2 迁移风暴与限速
迁移量听起来只有 11%,但 11% 的键如果集中在几分钟内全部涌向后端,依然是灾难。缓存场景尤其明显:11% 的键失效,意味着 11% 的请求要回源,如果原本缓存命中率是 95%,回源量会瞬间翻三倍。
我见过更极端的例子。某次扩容时,运维同时加了两台机器,迁移量从 11% 变成 22%,而且两台机器同时上线,环上的点位一次性全变。更糟的是,迁移期间新节点冷启动,缓存是空的,所有落到新节点的请求全部回源,新节点的响应时间从 5ms 涨到 400ms,直接触发了上游的超时熔断。
处理这类问题的标准套路有三条:
- 串行扩容:一次只加一台,等它预热稳定(缓存命中率追平老节点)再加下一台,把迁移量控制在 1/(N+1)。
- 预热:新节点上线前,先把预计会分给它的那部分数据主动灌进去。判断哪些数据会分给它,用新旧两个环对比,逻辑和 3.4 节的测试脚本一模一样。
- 限速回源:在应用层做回源限流,比如每个节点每秒最多回源 2000 次,超出的请求走降级或者短暂阻塞。这个限流值要按后端存储的承受能力倒推,别拍脑袋。
4.3 节点抖动导致的归属翻转
这个问题比负载倾斜隐蔽得多。现象是某几个键的访问日志里,归属节点在 A 和 B 之间反复横跳,导致数据双写、缓存版本互相覆盖。
根因是节点的健康检查太敏感。一台机器因为一次 GC 停顿了 800ms,健康检查判死,从环上摘除;1 秒后心跳恢复,又重新加入。这一摘一加,它负责的分片先被顺时针邻居接管,再被自己抢回来。如果这期间两个节点都在处理同一批键,就会出现双写。
对策分三层。健康检查的判定条件要放宽,连续 3 次失败才摘除,单次超时不算。摘除后设置冷却期,比如 2 分钟内不允许重新加入,给上游客户端同步环状态的时间。最后,客户端拉取环状态时要有版本号,同一个键在当前环版本下的归属是确定的,不能出现两个客户端用不同版本的环同时写。第三点特别重要,我见过有客户端做了本地缓存,环更新后 5 分钟才刷新,这 5 分钟里的路由决策全是错的。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查手段 | 处理方式 |
|---|---|---|---|
| 新节点流量极低 | 副本数过少 | 调share()看占比 | 提高副本数到 160 以上 |
| 扩容后命中率骤降 | 迁移量集中释放 | 对比新旧环的键归属 | 串行扩容 + 预热 |
| 部分键路由到空节点 | 环上残留野点位 | 打印_ring检查值是否为 None | 检查删除逻辑是否回收干净 |
| 同一键归属反复变化 | 节点健康检查抖动 | 看节点上下线日志时间戳 | 放宽阈值 + 冷却期 |
| 不同客户端映射不一致 | 哈希函数或二分边界不同 | 用同一批键跑对比测试 | 统一哈希实现和回绕规则 |
| 单节点 CPU 打满 | 环上出现大块连续弧 | 看share()最大份额 | 检查是否误用了点数不足的环 |
| 迁移后数据丢失 | 迁移期间双写冲突 | 对比两个版本的写入记录 | 加环版本号 + 迁移期只写不读旧节点 |
5. 与槽位方案的取舍:什么时候该换路子
5.1 槽位分配与哈希环的本质差异
一致性哈希的一个变体叫槽位分配:预先切出固定数量的槽(比如 16384 个),每个键先哈希到某个槽,槽再分配给具体节点。这实际上是把"节点在环上占点位"换成了"节点持有一批槽",本质还是同样的分区思想,但多了两层解耦。
这个结构带来几个好处。键到槽的映射是永久固定的,与节点数量完全无关,槽到节点的映射被抽成一张表,可以存在配置中心里集中管理,迁移时可以精细编排——比如只迁 100 个槽,观察稳定再迁下一批。扩容时把一部分槽从老节点挪到新节点,粒度是"多少个槽"而不是"多大一段弧",控制力强很多。
代价是多了一层查表和一份集中式元数据。槽位表需要高可用存储,客户端要能拉到最新版本,运维复杂度上升。这也是为什么小规模集群用哈希环就够,大规模集群才上槽位方案——当节点数上百、扩容成了日常操作时,可控性比简洁性更值钱。
5.2 选型决策表
| 维度 | 一致性哈希 | 槽位分配 |
|---|---|---|
| 元数据规模 | 节点数 × 副本数 | 固定槽数 + 映射表 |
| 扩容粒度 | 一整段弧,不可拆分 | 按槽数精确控制 |
| 迁移可编排性 | 弱,一次一个节点 | 强,可分批迁槽 |
| 实现复杂度 | 低,一个类搞定 | 中,需要元数据存储 |
| 客户端依赖 | 只需节点列表 | 需要拉取槽映射 |
| 适合规模 | 几十到几百节点 | 上百到上千节点 |
| 典型故障 | 负载倾斜、节点抖动 | 元数据版本不一致 |
我的实践判断是:节点数在 100 以内,一致性哈希足够,实现简单、故障面小、排查容易。超过 100 台、扩容频繁、需要灰度迁移的,老老实实上槽位方案,前期的复杂度投入会在后面每次扩容时还回来。
补一个混合玩法:用一致性哈希决定"键映射到哪个分片组",组内再用固定槽位分。这样组数量少,扩容时搬整组,组内不变;组内槽位固定,不需要迁移。这种两层结构在数据库分库分表的场景里挺常见,本质是把"变化的部分"和"稳定的部分"拆开,变化的部分用一致性哈希吸收,稳定的部分用取模保证绝对均匀。设计思路值得借鉴——不要指望一种算法解决所有问题,把它放在合适的层级上就行。
最后分享一个我自己的习惯:任何分片方案上线前,我都会写一个 20 行的模拟脚本,跑 2 万个键,把扩容前后的归属、各节点份额、迁移比例全打出来。这个脚本花不了 10 分钟,但能挡掉 90% 的分片事故。分片算法这种东西,看起来是纯数学,实际出问题全在边界和实现细节上,纸面推演永远不如跑一遍数据来得踏实。