☰
一致性哈希与虚拟节点:缓存分片扩容迁移与热点处理实战
2026/11/2 4:47:11 网站建设 项目流程

做缓存分片或者存储分片的朋友,大概都经历过这样的场面:集群本来跑得好好的,业务量涨了要扩两台机器,运维把节点加进配置重启,结果监控大盘上缓存命中率从 98% 直接掉到 40%,后面那台存储的 QPS 瞬间翻了几倍,接口超时告警刷满屏。问题不在新机器,而在于hash(key) % N这个看起来人畜无害的取模运算——N 一变,几乎所有 key 的归属都变了。一致性哈希(Consistent Hashing)就是为这件事而生的:它让节点数量变化时,只有一小部分 key 需要重新搬家。这篇内容会从取模分片的硬伤讲起,把哈希环的数据结构、虚拟节点的作用、加权与热点处理、以及上线后才会碰到的坑一条条拆开,附一份可以直接跑起来的 Python 实现和模拟数据。适合正在做分片设计、正在被扩容迁移折磨、或者面试前想把这块真正搞懂的读者。

1. 从一次缓存扩容说起:取模分片为什么会把命中率打穿

1.1 8 个节点扩到 10 个节点的那十分钟

先把这个场景还原清楚。假设有 8 台缓存节点,编号 0 到 7,客户端拿到一个 key 之后算slot = hash(key) % 8,然后去对应编号的节点读写。这套逻辑简单、O(1)、无状态,客户端不需要维护任何额外的路由表,所以绝大多数早期系统都是这么干的。

现在加两台机器,编号变成 0 到 9,N 从 8 变成 10。问题来了:对于同一个 key,hash(key)的值没变,但模数变了,余数自然会变。举个具体的例子,某个 key 的哈希值是 12345,12345 % 8 = 1,落到 1 号节点;扩容后12345 % 10 = 5,跑到 5 号节点去了。原来 1 号节点上缓存的数据,新节点根本不知道,只能回源查询。

这种回源不是只发生一次。如果后端存储扛得住,最多是一次短暂的压力尖峰,缓存预热完就恢复了。但现实往往是:大量 key 同时失效,回源请求把后端存储压垮,存储响应变慢导致缓存回填变慢,缓存没数据又继续回源,形成一个正反馈,最后就是雪崩。

我在模拟环境里复现过这个过程,8 台机器扩到 10 台,100 万个 key,扩完之后仍然落在原来那台机器上的 key 只占20%,也就是 80% 的 key 全部换了归属。这个数字很多人第一次听到会觉得夸张,但算一下就明白了。

1.2 迁移比例的量化对照表

一个 key 扩容后还留在原节点,条件是hash(key) % 8 == hash(key) % 10。把哈希值按最小公倍数 40 分组,在这个区间里能满足等式的只有 0 到 7 这 8 个值(因为余数必须同时小于 8 和 10,且两个模运算结果相同,等价于x ≡ y (mod 8)且x ≡ y (mod 10),即x ≡ y (mod 40),在 0 到 39 内只有x = y且y < 8的解),所以保留率是 8/40 = 20%,迁移率 80%。

换成几组不同的扩容方案,算出来的结果差异很大:

扩容方案保留在原节点的比例需要迁移的比例说明
8 → 98/72 ≈ 11.1%约 88.9%8 和 9 互质,最惨的情况
8 → 108/40 = 20%约 80%有公约数 2,稍好一点
8 → 168/16 = 50%约 50%翻倍扩容,恰好保住一半
16 → 1716/272 ≈ 5.9%约 94.1%节点越多,加一台越疼

这张表最有意思的一行是「8 → 16」。翻倍扩容之所以能保住一半,是因为 16 是 8 的倍数,原来落在偶数编号位置的 key 有一部分刚好对得上。这也解释了一个流传很广的"经验":取模分片要按倍数扩容。这个说法不能算错,但它只在"节点数恰好翻倍"时成立,而且即使翻倍,也还有一半的 key 要迁移。业务上你很难保证每次扩容都是翻倍,8 台扩到 16 台,下次就得 32 台,机器数量和成本根本对不上。

换成一致性哈希,加一台节点,受影响的只有原本属于"新节点顺时针覆盖的那一段环"的 key,理论迁移量是1/(n+1)。8 台加 1 台,迁移比例约 11.1%,跟取模分片最乐观的翻倍扩容差不多,但它对任意节点数都成立,这才是关键区别。

1.3 这里的"一致性"指的是哈希结果的单调性,不是分布式事务那套

很多人在这个概念上栽过跟头。"一致性哈希"里的"一致性",跟 CAP 里的 C(一致性)、跟分布式事务的一致性、跟缓存与数据库的一致性,完全是两回事。

它指的是哈希结果在节点集合变化时的单调性:当一个新的节点加入时,原本已经分配到各节点的 key,要么留在原来的节点上,要么被移动到新加入的节点上,绝不会被移动到其他任何旧节点。这个性质保证了每次扩容只产生"新增的搬迁",不产生"无谓的搬迁"。

除了单调性,评价一个一致性哈希实现好坏还有三个指标,这几个词在面试和文档里经常出现,值得记住:

  • 平衡性(Balance):所有物理节点分到的 key 数量应该大致相当,不能有节点闲着、有节点爆掉。
  • 分散性(Spread):同一个 key 被不同客户端看到时,应该尽量落到同一个节点,否则会出现多份副本。
  • 负载(Load):不同 key 落到同一台机器的总量要可控,避免某台机器因为几个大 key 而过载。

后面讲的虚拟节点,解决的主要是平衡性;加权和热点拆分,解决的是负载。

2. 哈希环的两个关键构件:首尾相接的取值域与虚拟节点

2.1 顺时针第一个节点:环上查找的真实含义

一致性哈希把整个哈希值空间想象成一个首尾相接的圆环。如果用 32 位哈希,环上就有 0 到 2³²-1 这么多个位置;我在下面的实现里用的是 64 位(取 MD5 前 8 字节),空间更大,碰撞概率更低。

每个物理节点通过hash(节点标识)算出一个值,落在环上的某个位置。每个 key 也通过hash(key)算出一个值落在环上。查找规则只有一句话:从这个 key 的位置出发,沿着环顺时针走,遇到的第一个节点就是它的归属。如果走到环的尽头还没遇到,就绕回 0 继续找。

这个规则的美妙之处在于:新增一个节点时,它只"抢走"它逆时针方向到上一个节点之间那一小段环上的 key,其他所有 key 的归属完全不变。删除节点时同理,被删节点覆盖的那一段 key 顺延给顺时针方向的下一个节点。

实现上,环就是一个升序排列的哈希值数组,加一个与之平行的"节点名"数组。查找时对 key 的哈希值做一次二分查找:找到第一个大于等于该值的位置,取对应的节点;如果找不到(key 的哈希值比所有节点都大),就取数组第 0 个,也就是绕回环的起点。

这里有个细节值得说清楚:为什么在 Python 里用bisect_left而不是bisect_right?因为当 key 的哈希值恰好等于某个虚拟节点的哈希值时(概率极低但不是零),bisect_left会返回这个位置本身,也就是让这个 key 归到这个虚拟节点所属的物理节点上,语义上更自然。用哪个其实都行,但一旦选定,所有客户端必须统一,否则边界 key 的路由会不一致。

2.2 只有 8 个点的环有多歪

把 8 个物理节点直接放到环上,问题立刻就来了:这 8 个点是随机分布的,它们切出来的 8 段弧长不可能相等。

举一个极端的例子:假设 8 个节点的哈希值恰好挤在环的某一段,那么其中某两个相邻节点之间的弧可能占了整个环的 40%,而另外几个节点之间的弧只有 1%。结果就是一台机器扛了四成的流量,另外几台几乎空转。节点数越少,这种歪斜越明显,3 台机器的时候,最坏情况下可能一台扛掉一半。

这不是理论上的担心。我见过有人用 3 个节点的裸一致性哈希上线,跑了一周发现其中一台机器 CPU 一直是另外两台的三倍,查了半天怀疑是某个 key 特别大,最后把环打出来一看,就是节点分布不均。

解决办法就是虚拟节点:每个物理节点不再只对应环上的一个位置,而是对应几十上百个位置。比如节点 A 映射成A#0、A#1、A#2……A#159,每个虚拟标识各自算一个哈希值放到环上,但这 160 个位置全都归属于物理节点 A。这样环上就有了 8 × 160 = 1280 个点,把环切得非常碎,每个物理节点分到的总弧长就会趋近于平均值。

2.3 虚拟节点数量的取值实验与经验区间

虚拟节点数不是越多越好,也不是随便定个 100 就行,它是可以用公式估算的。

假设物理节点数为 N,每节点虚拟节点数为 K,那么单个物理节点分到的份额期望是1/N。由于虚拟节点在环上近似均匀随机分布,实际份额的标准差可以近似为sqrt(p(1-p)/K),其中p = 1/N。以 N=8、p=0.125 为例:

每节点虚拟节点数 K份额标准差(绝对)相对偏差(σ / 12.5%)实际观感
1约 33%约 265%完全不可用,一台顶三台
10约 10.4%约 83%明显倾斜,能看出快慢机器
50约 4.7%约 37%仍然偏斜,但能接受
100约 3.3%约 26%一般业务够用
160约 2.6%约 21%我常用的默认值
500约 1.5%约 12%均匀性很好,内存开销上升
1000约 1.0%约 8%收益递减明显

从这张表能看出来,K 从 1 涨到 100,相对偏差降了十倍;从 100 涨到 1000,只再降三倍多,但环上的点数翻了十倍,内存和构建时间同步翻十倍。

我的经验取值是这样的:物理节点数少于 10 台时用 150 到 200,节点数在 10 到 50 之间用 100 到 150,超过 50 台可以用 50 到 100。理由是节点数多的时候,即使单个节点份额有偏差,整体上大数定律已经在起作用了,没必要堆太多虚拟节点。另外,虚拟节点数一旦定下来,上线之后就不要再改,因为改这个值等价于重建整个环,会引发一次全量数据重分布,这个坑在第 5 节还会展开说。

还有一个容易忽略的细节:虚拟节点的命名必须保证全局唯一且可复现。用节点名#序号是最简单的做法,但如果你有两个集群共用一套节点名规范,就可能导致不同集群的环撞在一起。加个集群前缀更稳妥。

3. 一份能直接跑的 Python 实现,以及每一行为什么这么写

3.1 数据结构选型:有序数组 + 平行数组比字典更快

讲实现之前先讲选型,因为选错了后面全是补丁。

环的存储:可选方案有三种。第一种是纯字典,key 是哈希值、value 是节点名,查找时得把字典的 key 排序后二分,或者遍历,前者每次查找都要 O(V log V) 排序,后者是 O(V) 遍历,都不合适。第二种是平衡树(Java 的 TreeMap、C++ 的 std::map),天然有序,查找 O(log V),插入删除 O(log V),是最正统的选择。第三种是 Python 里的做法:一个升序 list 存哈希值,一个平行 list 存节点名,查找用bisect做二分,O(log V);插入用list.insert,O(V)。

Python 没有内置的平衡树,用 list + bisect 是最实用的权衡。插入是 O(V) 看起来吓人,但环的变更频率极低——一台节点一年可能就加一次,而查找是每毫秒几千次。把成本放在变更路径上、让查询路径最短,这个取舍是明确的。

平行数组而不是元组数组:存成[(hash, node), ...]也能跑,但 bisect 的时候要给key参数传一个只比较第一个元素的可调用对象,或者写成bisect.bisect_left(ring, (h, ""))这种别扭的写法。拆成两个 list,bisect 直接对整数数组做二分,更快也更干净。

并发安全:环的读远多于写,所以不要给每次查找都加锁。正确做法是写时复制:变更时先在新对象上构建好整个环,构建完成后一次性替换引用。读线程拿到的要么是旧环、要么是新环,永远不会拿到半成品。Python 里引用赋值是原子的,天然支持这个模式;Java 里用一个volatile修饰的不可变环对象即可。

3.2 完整代码

下面这份实现我用了很久,接口设计上刻意保留了"取多个副本节点"的能力,后面讲写扩散时会用到。

import bisect import hashlib class ConsistentHashRing: """一致性哈希环:有序哈希数组 + 平行节点数组""" def __init__(self, vnodes=160): self._vnodes = vnodes self._hashes = [] # 升序排列的虚拟节点哈希值 self._owners = [] # 与 _hashes 一一对应的物理节点名 self._counts = {} # 物理节点 -> 虚拟节点数量 # ---------------- 基础 ---------------- @staticmethod def _hash(key: str) -> int: # 取 MD5 前 8 字节,得到 0 ~ 2^64-1 的整数,避免负数 digest = hashlib.md5(key.encode("utf-8")).digest() return int.from_bytes(digest[:8], "big") @staticmethod def _vkey(node: str, idx: int) -> str: return "{}#{}".format(node, idx) def _insert(self, h: int, node: str) -> None: pos = bisect.bisect_left(self._hashes, h) self._hashes.insert(pos, h) self._owners.insert(pos, node) # ---------------- 增删节点 ---------------- def add_node(self, node: str, weight: int = 1) -> None: if node in self._counts: return replicas = self._vnodes * max(1, weight) self._counts[node] = replicas for i in range(replicas): self._insert(self._hash(self._vkey(node, i)), node) def remove_node(self, node: str) -> None: if node not in self._counts: return self._counts.pop(node) # 重建比逐个删除更省心,也避免删除过程中的顺序错乱 pairs = [(h, o) for h, o in zip(self._hashes, self._owners) if o != node] self._hashes = [p[0] for p in pairs] self._owners = [p[1] for p in pairs] # ---------------- 查找 ---------------- def get_node(self, key: str) -> str: if not self._hashes: raise RuntimeError("ring is empty") pos = bisect.bisect_left(self._hashes, self._hash(key)) if pos == len(self._hashes): pos = 0 # 走到环尾,绕回起点 return self._owners[pos] def get_nodes(self, key: str, n: int = 3): """取 key 顺时针往下的 n 个不同物理节点,用于多副本""" total = len(self._hashes) if total == 0: return [] start = bisect.bisect_left(self._hashes, self._hash(key)) if start == total: start = 0 picked, seen = [], set() for step in range(total): owner = self._owners[(start + step) % total] if owner not in seen: seen.add(owner) picked.append(owner) if len(picked) == n: break return picked

几个地方值得单独解释。

为什么自己写_hash而不用 Python 的hash():Python 3 对字符串的hash()默认带随机化种子(受PYTHONHASHSEED控制),同一个字符串在不同进程里算出来的值不一样。如果你的环是在服务端构建、把结果下发给客户端,或者多个客户端各自构建环,用内置hash()会导致每个进程的环完全不同,同一个 key 被路由到不同节点,缓存直接双份。这个坑非常隐蔽——单机测试完全正常,一上多副本就出问题。

为什么用 MD5 而不是crc32:MD5 在这里不是为了安全,纯粹是为了雪崩效应好、分布均匀,而且实现随处可得、跨语言一致。截取前 8 字节已经足够,性能上对分片场景完全不是瓶颈(一次查找一次哈希,微秒级)。如果对性能极度敏感,murmur3也是好选择,但要注意不同语言的 murmur3 实现在编码处理上可能有差异。

为什么remove_node用重建而不是逐个删:逐个删需要在_hashes里二分定位再pop,逻辑上没问题,但删除 160 个虚拟节点就意味着 160 次 O(V) 的移动,而且中途如果有人读到了半删除状态的环,会看到部分虚拟节点消失的中间态。整体重建一遍更简单,也更容易配合写时复制的并发模型。

加权是怎么实现的:add_node(node, weight=3)就是给这个节点分配 3 倍的虚拟节点数。这个做法简单粗暴但有效,第 4 节会详细说它的副作用。

3.3 模拟实测:迁移比例、均匀度与虚拟节点数的关系

代码写完,一定要跑一遍模拟,不然你永远不知道自己的参数选得对不对。下面是两个我常用的验证脚本。

第一个验证迁移比例:

keys = ["user:%d" % i for i in range(100000)] ring = ConsistentHashRing(vnodes=160) for i in range(8): ring.add_node("node%d" % i) before = {k: ring.get_node(k) for k in keys} ring.add_node("node8") moved = sum(1 for k in keys if ring.get_node(k) != before[k]) print("迁移比例 %.3f" % (moved / len(keys))) # 期望 0.11 ~ 0.12

跑出来的结果在 0.11 附近浮动,跟理论值1/9 ≈ 0.111相当吻合。如果你跑出来是 0.8 往上,那就说明实现有问题,比如虚拟节点没加进去、或者哈希函数有严重碰撞。

第二个验证均匀度,用同样的 key 集合统计每个节点分到的数量:

from collections import Counter c = Counter(ring.get_node(k) for k in keys) for node in sorted(c): print(node, c[node], "%.2f%%" % (100.0 * c[node] / len(keys)))

8 个节点、160 个虚拟节点的情况下,各节点份额大概落在 10% 到 15% 之间,最大的和最小的差三四个百分点。把vnodes改成 10 再跑一遍,你会看到有的节点拿到 25% 以上,有的只有 5%——这个对比非常直观,建议自己动手跑一次,比看十篇文章都记得牢。

还有一个容易被忽略的指标:最大节点与最小节点的比值。份额标准差是统计量,但在生产环境里,爆掉的是那个份额最大的节点。我一般要求这个比值控制在 1.5 以内,超过就加虚拟节点数或者调整节点权重。

3.4 取多个副本节点与写扩散的接法

get_nodes(key, n)这个接口在单副本的缓存场景用不上,但在需要多副本的场景里是必需的。它的逻辑是:从 key 的位置顺时针往下扫,跳过已经选过的物理节点,凑够 n 个不同的物理节点为止。

这里有个必须注意的点:不能简单地取"接下来的 n 个虚拟节点",因为相邻的几个虚拟节点很可能属于同一个物理节点(虚拟节点是打散分布的,但偶尔会相邻)。所以必须按物理节点去重。上面代码里用seen集合做去重就是这个原因。

多副本的典型用法是"写扩散 + 读随机":写入时把数据写到 n 个副本节点,读取时从 n 个里随机挑一个。这样做的好处是某个节点挂掉时数据不丢,缺点是写放大了 n 倍,而且一致性维护变复杂——如果写某个副本失败,是回滚还是补偿?这块要结合具体业务的一致性要求来定,不能无脑上多副本。

另一个用法是"热点 key 打散":对于某几个特别热的 key,把它们同时放到 3 个节点上,读请求按客户端 IP 或者随机数取模选一个节点。这个方案能显著降低单节点压力,但只适合读多写少的场景,而且写的时候要么写全部副本,要么接受短暂的不一致。

4. 倾斜、热点与"再多的虚拟节点也没用"的场景

4.1 加权一致性哈希:按机器规格分配虚拟节点

真实机房的机器很少是同规格的。老机器 16 核 64G,新机器 32 核 128G,如果按相同的虚拟节点数分配,新机器的资源就浪费了。这时候用加权:add_node("node3", weight=2),虚拟节点数翻倍,分到的 key 也大致翻倍。

权重的取值我一般按内存容量或者 CPU 核数来定,取整数倍关系。比如基准机器权重 1,两倍规格的机器权重 2,四倍规格的权重 4。虚拟节点数就是150 × weight。

但加权有两个副作用要注意。

副作用一:权重太细会影响均匀度。如果权重出现 1.3、1.7 这种小数,虚拟节点数就不是整数,四舍五入之后实际权重和预期权重有偏差,节点数少的时候这个偏差会放大。所以我一般只用整数权重,而且只取 1、2、4 这种整数倍。

副作用二:权重变更等价于数据重分布。把某台机器的权重从 1 调到 2,它会新增 150 个虚拟节点,从其他所有节点那里抢走一部分 key。这个过程对业务来说就是一次大规模缓存失效。所以权重尽量在集群规划阶段就定好,上线后少动。

还有一种做法是有限负载一致性哈希(Consistent Hashing with Bounded Loads)。思路是:先用环算出候选节点,但给每个节点设一个容量上限(比如平均负载的 1.25 倍),如果算出来的节点已经满了,就顺时针找下一个没满的。这个方案能严格保证不会出现某台机器过载,代价是破坏了单调性——同一个 key 的归属会随着全局负载变化而漂移,实现和排查都更复杂。我的看法是:如果真的出现过载,优先考虑拆分热点 key 或者加机器,而不是上这套机制。

4.2 热点 key 的三种拆解思路与代价

虚拟节点解决的是"节点分布不均",解决不了"key 本身太热"。一个爆款商品的详情缓存,不管环上放了 1000 个虚拟节点,它还是只会落到一台机器上,那台机器的网卡该打满还是打满。

这个区别一定要分清楚:虚拟节点治的是节点维度的倾斜,不治 key 维度的热点。

针对热点 key,常见有三条路,各有代价:

路径一:key 加后缀拆分。把一个热 key 拆成hotkey#0到hotkey#9十个 key,分散到十个节点上。读取时随机选一个后缀,写的时候写十份。代价是写放大十倍、存储放大十倍,而且数据一致性需要额外维护。适用于读极高、写极少的场景,比如商品详情、配置下发。

路径二:本地缓存兜底。在应用进程内加一层本地缓存,热点数据直接命中内存,根本不走分片。代价是数据有延迟、多实例之间不一致、内存占用不可控。适用于对实时性要求不高的展示类数据,配合短 TTL(比如 1 秒到 5 秒)效果很好。我个人认为这是性价比最高的方案,大部分所谓"缓存扛不住"的问题,加一层本地缓存就解决了。

路径三:副本加随机读。就是 3.4 节讲的get_nodes,把热 key 放到 3 个节点上,读请求随机选一个。这个方案不用改 key,也不用维护十个副本,代价是写放大三倍。

三条路怎么选?我的判断顺序是:先看能不能用本地缓存(改动最小),不行再看副本随机读(放大倍数可控),最后才考虑 key 拆分(放大最狠但扩容最彻底)。

4.3 主流分片策略横向对比

一致性哈希不是分片的唯一答案,把它和几个常见方案放在一起比较,能更清楚它适合什么场景。

方案增删节点时的迁移量均匀度实现复杂度热点处理典型适用场景
取模分片hash % N几乎全量,约1 - 1/N好极低无节点数固定、几乎不扩容的场景
一致性哈希(无虚拟节点)约1/(n+1)差低无基本不推荐单独使用
一致性哈希 + 虚拟节点约1/(n+1)好中无缓存集群、客户端分片路由
加权一致性哈希约按权重比例好(权重整数时)中无机器规格不一致的集群
Rendezvous(HRW)约1/(n+1)好极低无节点数不多、追求实现简单
有限负载一致性哈希约1/(n+1),有少量额外漂移严格可控高部分缓解对过载零容忍的在线服务
范围分片(按区间切)局部迁移,可实现只搬一段取决于切分点中需手工拆分有序数据、范围查询多的存储
目录式(中心化路由表)可控,可以按需搬可控高可迁移需要精细调度的大规模存储

表格里有个容易被忽视的选手:Rendezvous 哈希(也叫 HRW,Highest Random Weight)。它的做法是每个 key 对每个节点算一次hash(key, node),取权重最大的那个节点。查找复杂度是 O(N),看起来比一致性哈希的 O(log V) 差,但 N 是物理节点数,几十台以内完全不是问题。它的优势是:节点增删时同样只迁移1/(n+1),而且不存在分布不均的问题,不需要虚拟节点,权重天然支持。节点数少于 50 台的场景,我个人更倾向用 Rendezvous,代码只有十几行,不容易写错。

一致性哈希真正的优势场景是节点数很多、或者客户端要和服务端各自独立计算路由的情况。因为环可以被缓存、被增量更新,而 Rendezvous 每次查找都要算 N 次哈希。

5. 上线之后才会遇到的四个问题

5.1 慢节点被摘除引发的连环雪崩

一致性哈希的故障转移逻辑很直接:节点挂了,它那段环上的 key 顺延给顺时针方向的下一个节点。这个机制在"节点真的挂了"的时候很好用,但在"节点变慢"的时候是灾难。

设想这样的连锁反应:某台节点因为 GC 或者磁盘抖动,响应变慢,客户端的健康检查超时,把它从环里摘掉。它原本承担的 12% 流量瞬间压到下一台机器上,下一台机器的负载从 100% 变成 112%,如果它本来就在高位运行,很可能跟着变慢、跟着被摘除。一台接一台,最后整个集群被摘空。

我在模拟环境里推演过这个过程:8 个节点,负载水位 80%,摘掉一台之后剩下的节点平均水位到 91%,再摘一台到 103%,第三台必挂。也就是说,只需要一个抖动,就能引发连锁崩溃。

对策有三条,我是组合使用的:

  • 健康检查加延迟确认。不要一次心跳失败就摘除,连续 3 次失败、或者 30 秒内失败率超过阈值才摘。慢节点和死节点要区别对待。
  • 摘除要限速。摘除一个节点后,观察 1 到 2 分钟,确认集群水位稳定再做下一次摘除操作。自动化流程里也要加这个冷却期。
  • 加熔断和隔离。即使节点被摘除,客户端也应该对它的请求做超时和并发限制,不要让一个慢节点拖住整个线程池。这个属于基础防护,但很多故障都是因为缺了它才被放大。

还有一种更温和的做法:只摘一半虚拟节点。节点变慢时,先把它的虚拟节点从 160 个减到 80 个,负载减半,看看能不能自己恢复。这个操作比全摘影响小得多,适合用来处理"疑似抖动"的情况。

5.2 新节点入环的数据空窗期

扩容流程里有个顺序问题,很多人第一次做的时候会搞错。

正确的心智模型是:新节点一旦加入环,客户端立刻就会把属于它的 key 路由过去,但那些 key 的数据还在旧节点上。对缓存来说,这意味着大量的 miss 和回源;对持久化存储来说,这意味着读到一个不存在的数据。

对缓存集群,我的做法是分三步:

  1. 新节点先启动,不加入环,只做预热(如果有关联的冷启动逻辑)。
  2. 加入环,但客户端做限流:对路由到新节点但 miss 的请求,限制回源并发,避免把后端打垮。这个限流通常是几百 QPS 级别的令牌桶,具体值要看后端承受能力。
  3. 观察命中率爬升,一般十几分钟内能回到正常水位。如果后端比较脆弱,把这个过程拉长到半小时也是值得的。

对持久化存储,顺序要反过来:先把数据迁过去,再把节点加入环。迁移工具按照环的区间扫描旧节点上属于新节点的 key,全量搬完之后做增量追赶,追平之后再切换路由。这个流程需要迁移工具支持"按一致性哈希区间扫描",不是所有现成工具都支持,自己写的话注意加限速和断点续传。

迁移期间一定要保留旧数据,至少保留到新节点稳定运行一周。我见过迁移完立刻删旧数据的,结果新节点磁盘故障,数据全丢,只能从更上一层的备份恢复。

5.3 多客户端路由表不一致导致的"一 key 两份"

这个问题的表现很迷惑:同一个 key,有时读到 A 值,有时读到 B 值,但两个值单独看都是合法的。排查的时候先怀疑缓存脏了、再怀疑主从延迟,最后才发现是两个客户端进程看到的是不同版本的环。

原因是成员变更的通知有延迟。服务端在 T 时刻把新节点加入环并广播,但某个客户端因为网络抖动没收到通知,它还按旧环路由,于是同一个 key 被写到了两个不同的节点。

对策是给环加版本号:

  • 服务端维护一个单调递增的epoch,每次成员变更epoch + 1。
  • 路由表下发时带上epoch,客户端定期(比如每 30 秒)全量拉取一次,做兜底校准。
  • 客户端在请求里带上自己使用的epoch,服务端发现epoch落后就拒绝或者重定向,逼迫客户端刷新。

这套机制的代价是增加了一次版本比对,收益是彻底消除了"环版本漂移"。如果系统规模不大,还有一个更土但有效的办法:给环设一个较短的缓存 TTL(比如 10 秒),到期强制重新拉取,同时保留变更推送做加速。多一层兜底,出问题的概率低很多。

5.4 一些我踩过的细节

几个零散但很实用的点,都是被坑过之后才记住的。

第一,不要在环上做业务逻辑判断。有时候会想"这个 key 是 VIP 用户的,我要把它路由到高性能节点",于是在get_node里加分支。千万别这么干。一旦路由函数依赖了业务状态,环就不再是一个纯函数,测试、复现、迁移全部变难。要区分服务质量,用加权或者拆独立集群。

第二,监控里一定要有"每节点 key 数量"这个指标。一致性哈希的倾斜是渐进的,不会一下子爆掉,它会慢慢地把某台机器推到 90% 水位。等告警响的时候已经很难处理了。我一般设两级阈值:某节点份额超过平均值 1.3 倍时预警,超过 1.5 倍时告警并人工介入。

第三,压测要用真实的 key 分布。用key-1到key-100000这种顺序字符串压测,哈希出来的分布往往比真实 key 均匀得多,因为真实 key 带业务前缀,前缀重复度高,某些哈希函数对前缀敏感,实际分布会更歪。压测数据最好从线上采样脱敏后使用。

第四,虚拟节点数写进配置,但不要暴露成可随时改的参数。前面说过,改虚拟节点数等价于重建环。如果做成配置项还支持热更新,某天有人手滑改了一下,整个集群会做一次全量数据重分布。我的做法是把它写成启动参数,改动需要重启,并且加一条启动校验:如果检测到虚拟节点数跟上次不一致,打一条醒目的告警日志。

第五,小规模集群可以偷懒。如果总共就 3 台机器,而且未来一年不打算扩,说实话取模分片就够了,上一致性哈希是过度设计。技术选型要看真实约束,不要因为一个方案"更优雅"就上。

这份实现我在几个项目里用过,后来做了一次重构,把环的构建抽成了独立的构建器,查找和构建彻底分离,好处是可以把构建好的环序列化成一份紧凑的二进制(哈希值数组用array('Q')存,节点名存一份索引表),下发给客户端,客户端启动时直接反序列化,省掉了几百次 MD5 的计算。如果节点规模到了几百台、虚拟节点上万,这个优化还是值得做的。再往前一步想,如果环本身成了瓶颈,那就说明该考虑中心化的路由服务了,把路由表的维护从客户端挪到服务端,客户端只保留一层短期缓存。至于什么规模该做这个切换,我的判断是:客户端实例数超过 500 个、且环的变更频率高于每天一次的时候,中心化路由的管理成本就低于分布式下发的成本了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询