跳到主要内容
约 14 分钟7 张卡

一致性哈希

一致性哈希是一类在节点集合变化时,尽量保留已有键归属的映射方法。它适用于需要按键选择缓存节点、存储节点或服务实例的场景,可以减少扩缩容造成的重新分配。

面试中,画出哈希环只是第一步。还要能说明哪些键会移动、虚拟节点改善了什么,以及为什么“算出了新节点”不代表数据已经迁移完成,也不代表各节点负载相同。本文用顺时针寻找后继节点的环实现讲解。

1. 面试核心​

先记住这几条,再读正文
  • 键和节点位置映射到同一哈希空间,查询选择顺时针遇到的第一个位置,再找到它所属的物理节点。环空间固定,不随物理节点数量重新取模。卡 · 节点数量取模的问题
  • 保留旧节点位置时,新增节点只接走自己的区间,删除节点只重新分配它原来负责的键。使用虚拟节点后,一个物理节点涉及多个区间,可能与多台机器交换数据。卡 · 节点增删与映射变化
  • 虚拟节点让一台机器承担多个分散区间,改善有限节点下的分布并表达容量权重;数量比例不等于实际键数、字节数或请求量的精确比例。卡 · 虚拟节点与权重
  • 同一个键仍选中同一个所有者,增加虚拟节点不能拆开单键热点。需要按读取、可合并写入或严格顺序写入的语义处理。卡 · 为什么单键热点仍然存在
  • 映射变化不负责复制历史数据、接住增量或协调写权,存储迁移需要单独保证数据完整和旧路由隔离。卡 · 负载均衡和存储迁移有什么不同
  • 虚拟节点不是数据副本,“一致性”也不表示强一致读写;副本放置、复制和共识是另外的职责。卡 · 虚拟节点不是副本,一致性哈希不是共识
  • 各参与方必须使用相同的节点身份、编码、哈希规则、权重和成员版本。规则不一致时,即使输入业务键相同,也可能得到不同归属。卡 · 环上的位置不是数据本身

2. 节点数量取模的问题​

一种直接的分配方式是 hash(key) % N,把余数当作节点编号。问题在于,节点数量从 N 变成 N+1 时,所有键都改用新的除数,许多原本属于健康节点的键也会改变归属。

例如用哈希值 0 到 11 分别对 3 和 4 取模,只保留编号 0、1、2 对应的原节点,再新增编号 3:

哈希值% 3% 4归属是否变化
0、1、20、1、20、1、2不变
3、4、50、1、23、0、1改变
6、7、80、1、22、3、0改变
9、10、110、1、21、2、3改变

这个小例子中,12 个键有 9 个换了节点。用于缓存时,大量旧缓存可能暂时用不上;用于存储时,需要重新定位数据。实际系统也可以使用固定逻辑分片再映射到机器,避免直接按机器数量取模,见数据拆分中的分片方式。一致性哈希是另一种控制变化范围的办法。

3. 哈希环与后继节点​

哈希环把一个固定整数空间首尾相接。节点位置和键位置都落在这个空间内;超过最大位置后,继续从最小位置寻找。

先假设每个物理节点只有一个位置。为了方便推导,用 0 到 99 的空间,三个节点分别位于 A=10、B=50、C=80。查询从键位置出发,按数值增大的方向,选择第一个不小于它的节点位置;没有找到时回到最小节点位置。

键位置负责节点原因
5A第一个不小于 5 的位置是 10
10A恰好命中 A 的位置
30、50B第一个后继位置是 50
70C第一个后继位置是 80
90A超过 80 后绕回 10

因此,B 负责 (10, 50],C 负责 (50, 80],A 负责跨过环尾的 (80, 99] 与 [0, 10]。这里采用左开右闭的边界约定;实现可以采用其他约定,但构建、查询和迁移必须一致。

3.1 环上的位置不是数据本身​

路由只决定请求交给哪台机器,机器内部仍需根据完整键存取记录。两个业务键哈希到同一位置,会去同一个节点,但不应因此被当成同一条记录;精确区分键仍遵守哈希表的相等关系。

节点位置碰撞则影响环的构建。不能用 位置 → 节点 的字典静默覆盖另一个节点。可以在构建时拒绝碰撞,也可以约定稳定的重新取点或排序规则;所有参与方必须得到同一结果。后面的示例选择检测并拒绝,避免用随机重试悄悄改变其他进程的环。

4. 节点增删与映射变化​

4.1 新增节点​

在刚才的环上增加 D=40,已有 A、B、C 的位置不动。D 接管 (10, 40],这些键原本由 B 负责;B 继续负责 (40, 50],其他区间不变。

由此可以推出一个检查条件:只新增 D 的位置时,改变归属的键,新所有者一定是 D。如果发现一个键从 A 变到 C,就要检查是否同时移动了旧节点、改变了哈希规则或调整了其他权重。

4.2 删除节点​

回到只有 A、B、C 的原环,删除 B。B 原来的 (10, 50] 被顺时针下一个节点 C 接管;原来属于 A 或 C 的键不需要改变归属。

对应检查条件是:只删除 B 的位置时,改变归属的键,旧所有者一定是 B。如果 B 已经宕机且数据没有副本,算法只能算出新的归属,无法凭空恢复 B 上的数据。

4.3 迁移比例的条件​

当各节点等权、位置分布合适,且大量键近似均匀分布时,从 N 台增加到 N+1 台,新增节点预期承担约 1/(N+1) 的键;从 N 台删除一台,原来约 1/N 的键需要重新分配。

这不是每次操作必然满足的百分比。一台节点只占一个随机位置时,它前面的区间可能很大,也可能很小;键数量有限、记录大小不同或访问集中时,实际迁移键数、迁移字节数和转移请求量更不能混为一谈。上述变化范围还假设旧位置、哈希规则和其他节点权重保持不变,不包括副本集合的额外调整。

5. 虚拟节点与权重​

虚拟节点是物理节点在环上的一个位置及其所属关系。一台机器可以对应 A:0、A:1 等多个位置,查询先找到某个虚拟节点,再回到所属机器 A。它不必对应独立进程,也不表示多保存了一份数据。

多个分散的小区间,可以降低单个大区间偶然落在某台机器上的影响。一台机器退出时,它的多个区间也有机会交给不同机器,避免全部交给一个物理邻居。这个效果依赖位置分布,不能保证每台机器获得完全相同的负载。

5.1 用位置数量表达容量权重​

节点性能不同时,可以让容量更大的节点拥有更多位置。例如 A、B、C 权重为 1:2:1,每份权重对应 32 个位置,则分别生成 32、64、32 个位置。位置分布合适时,B 预期承担更多键。

位置数量是配置量,实际负责的环区间长度才直接影响均匀键的分配。两个位置可能非常接近,所以 32:64:32 不意味着实际请求严格按 1:2:1 分配。Envoy 1.31.0 的 ring hash 文档也明确说明,少量位置不足以表达目标权重,需要观察实际环分布。

调整权重同样会触发迁移。如果只给 B 增加稳定编号的新位置,改变归属的键只会转给 B;如果整个实现重新计算所有节点的位置数量或身份,就不能继续套用这个局部变化条件。

5.2 虚拟节点的代价​

更多虚拟节点需要保存更多路由条目,增加构建、发布和迁移管理的成本。若环上有 V 个位置,排序数组需要 O(V) 空间,构建排序为 O(V log V),一次后继查找为 O(log V),另加键本身的哈希成本。

虚拟节点数量没有脱离规模和负载的万能值。需要同时观察键数、字节数、请求量、队列与延迟,并考虑迁移时的带宽和磁盘压力。单靠增加位置数量,不能修复错误的分片键或消除大对象带来的容量倾斜。

6. 热点、迁移与一致性边界​

6.1 为什么单键热点仍然存在​

同一个键采用同一份环配置,总是找到同一个首选所有者。即使其他键分配得很均匀,一个热门键的请求也可能压满这台机器。增加虚拟节点最多改变它由谁负责,不会把这个键自动拆成多个独立键。

读取热点可以结合缓存副本和请求合并;可合并的计数可以拆成子计数;严格顺序写入则需要保留所有者并限制入口。这些方案改变了存储或访问语义,不能仅修改路由就假定正确,具体取舍见数据拆分中的热点。

6.2 负载均衡和存储迁移有什么不同​

一致性哈希用于请求分发时,计算结果是本次请求的候选后端。按用户或资源键保持亲和性有利于利用本地缓存;但这不等于根据实时忙闲选择后端,也不会把已经建立的连接自动搬到新机器。健康检查和重试策略可能改变最终处理节点,应由具体负载均衡实现说明。

用于持久化存储时,目标节点必须有正确的数据,或者能够按迁移协议找到旧数据。修改路由以后再慢慢复制,会让部分读请求找不到记录,也可能让新旧节点同时接受写入。

因此需要在切换前后处理历史数据、并发增量、校验、路由版本和旧端写入隔离。完整流程由数据拆分的扩容迁移解释。缓存有时可以回源重建,但仍需评估集中失效造成的回源压力,不能把缓存的恢复办法直接用于唯一的数据副本。

6.3 虚拟节点不是副本,一致性哈希不是共识​

分片回答“这个键由谁负责”,副本回答“还在哪些节点保存副本”。如果沿环选择多个位置作为副本候选,必须跳过同一物理节点的其他位置,否则选中三个虚拟节点,可能实际只有一台机器;故障域隔离还要进一步考虑机架或可用区。

确定副本集合以后,仍要设计数据传输、成功确认、冲突和故障恢复。Dynamo 2007 论文分别讨论了分区、复制、版本管理和成员发现,不能把它们都归结为一个哈希算法。

一致性哈希也不会让两个持有不同成员列表的客户端立即达成一致。它控制的是映射变化的范围;Raft等协议负责的日志共识,以及一致性模型描述的读写结果,是其他问题。

6.4 固定哈希槽与哈希环是不是一回事​

不是。Redis 6、7 的 Cluster 先把键映射到固定的 16384 个槽,再维护槽到节点的归属。扩缩容主要改变槽的所有者,不把机器位置放入本文的后继环重新查找。两者都能通过一层映射减少不必要的重新分配,但路由与迁移机制不同,见Cluster 与哈希槽。

7. 可复现的环实现​

下面使用 Python 3 的排序数组和 bisect_left 构建环快照,节点变化时创建新环,不原地修改旧环。节点 ID 和虚拟节点编号共同生成位置,键使用另一种固定前缀;SHA-256 的前 8 个字节按大端转换为 64 位整数。代码只负责映射,不包含发现服务、网络请求、副本或数据迁移。

from bisect import bisect_left
from hashlib import sha256


def position(text: str) -> int:
return int.from_bytes(sha256(text.encode("utf-8")).digest()[:8], "big")


class Ring:
def __init__(self, weights: dict[str, int], vnodes: int = 32):
if not weights or type(vnodes) is not int or vnodes <= 0:
raise ValueError("nonempty nodes and positive vnodes required")
entries = []
for node, weight in weights.items():
if not isinstance(node, str) or not node:
raise ValueError("node ID must be a nonempty string")
if type(weight) is not int or weight <= 0:
raise ValueError("weight must be a positive integer")
for i in range(vnodes * weight):
entries.append((position(f"node:{node}:{i}"), node))
entries.sort()
points = tuple(point for point, _ in entries)
if len(set(points)) != len(points):
raise ValueError("token collision; ring was not installed")
self.points = points
self.nodes = tuple(node for _, node in entries)

def owner_at(self, point: int) -> str:
if not 0 <= point < 2**64:
raise ValueError("point is outside the ring")
i = bisect_left(self.points, point)
return self.nodes[i % len(self.points)]

def owner(self, key: str) -> str:
return self.owner_at(position("key:" + key))


old = Ring({"A": 1, "B": 1, "C": 1})
added = Ring({"A": 1, "B": 1, "C": 1, "D": 1})
removed = Ring({"A": 1, "C": 1})
keys = [f"key:{i}" for i in range(1000)]

moved_add = [key for key in keys if old.owner(key) != added.owner(key)]
moved_remove = [key for key in keys if old.owner(key) != removed.owner(key)]
assert all(added.owner(key) == "D" for key in moved_add)
assert all(old.owner(key) == "B" for key in moved_remove)
assert all(removed.owner(key) != "B" for key in keys)
print("add D:", len(moved_add))
print("remove B:", len(moved_remove))

输出为 add D: 265 和 remove B: 312。这些数字是当前节点名、位置规则和 1000 个固定键的结果,不是迁移比例保证。断言检查的是变化方向:增加 D 没有让键在旧节点之间重新分配,删除 B 没有影响原先属于其他节点的键。

bisect_left 保证位置恰好等于某个节点位置时选中该节点;超过环上最大位置后,取模让查询回到第一个位置。节点条目排序后才查找,所以输入字典的遍历顺序不决定环。构建新快照不会修改旧快照,调用者仍需自行发布版本,并决定旧请求如何处理。

实现没有使用进程间可能变化的 hash(str)。即使选择稳定摘要,也必须固定 UTF-8 编码、取哪些字节、字节序、节点 ID、虚拟节点编号和权重。不能把节点在当前列表里的序号当作长期身份:删除中间一项后重新编号,会改变后续节点的位置,破坏“其余位置保持不变”的前提。

8 · 面试题卡片​

本页题目与参考回答

8.1 一致性哈希怎样定位节点,与直接按节点数量取模有什么区别?​

考察重点:固定哈希空间、后继查找、回绕和成员变化。

相关内容:节点数量取模的问题、哈希环与后继节点。

参考回答

把键和节点位置放入固定哈希空间,选择顺时针第一个后继位置,再找到对应物理节点。 超过最大位置时绕回最小位置,精确命中时按实现约定处理边界。

直接对节点数取模,在节点数变化后会让大量键重新计算归属。环实现保持其他节点位置不动,只改变新增或移除位置相关的区间,所以能保留更多旧映射。

8.2 增加或删除一个节点,会影响哪些键,迁移比例是否固定?​

考察重点:单所有者映射的不变量、虚拟节点区间和均衡假设。

相关内容:节点增删与映射变化。

参考回答

保留其他位置不变时,新增节点只接走新归它负责的键,删除节点只让原来属于它的键重新分配。 物理节点有多个虚拟位置时,会涉及多个区间和可能多台相邻机器。

等权且分布合适时,从 N 台增加一台,预期转移约 1/(N+1) 的键;删除一台,约 1/N。这是带分布假设的估计,实际键数、字节数和请求量不一定相同,副本集合变化还可能带来额外工作。算出归属也不等于完成数据迁移。

8.3 虚拟节点解决什么问题,权重为 1:2 是否意味着负载严格为 1:2?​

考察重点:位置分布、容量权重、有限样本和路由成本。

相关内容:虚拟节点与权重。

参考回答

虚拟节点让每台机器拥有多个分散区间,改善少量随机位置造成的倾斜,并用位置数量表达容量差异。 更高权重可以分配更多位置,但不同位置前面的区间长度不一定相同。

即使环区间近似按权重分配,有限键数、记录大小和访问频率也可能不同,所以不能保证实际负载严格为 1:2。增加位置还会增加路由表、构建和迁移管理成本,需要结合观测选择数量。

8.4 虚拟节点很多、键数分布均匀,为什么仍可能有一台机器过载?​

考察重点:键数量、数据大小与访问频率的区别。

相关内容:为什么单键热点仍然存在。

参考回答

键数均匀不等于请求量或存储量均匀,同一个热门键仍集中到一个首选所有者。 大对象、热门租户或请求处理成本差异,也会造成节点负载不同。

读取热点可结合副本与请求合并,可合并写入可拆成子键,严格顺序写则保留所有者并限流。方案必须符合业务语义,不能靠增加虚拟节点自动得到。

8.5 扩容后发布新哈希环,为什么可能读不到数据或出现双写?​

考察重点:路由与数据状态的区别,增量复制及写入隔离。

相关内容:负载均衡和存储迁移有什么不同、路由版本与写入隔离。

参考回答

新路由只改变请求目的地,没有自动复制旧数据或撤销旧端写权。 目标还没追上数据时,读可能缺失;客户端看到不同版本时,新旧节点可能同时接收写入。

需要复制历史数据并追上增量、校验结果,再协调路由和写所有权切换。最终提交位置必须拒绝过期所有权版本,不能只靠客户端刷新配置。缓存若允许回源,还应控制切换带来的回源压力。

8.6 一致性哈希、虚拟节点、副本复制与 Raft 分别负责什么?​

考察重点:分片映射、物理故障域、数据副本和日志共识。

相关内容:虚拟节点不是副本,一致性哈希不是共识。

参考回答

一致性哈希负责按键定位,虚拟节点是物理机器在映射中的多个位置;两者本身不复制数据。 选择副本时必须区分物理节点和故障域,不能把同一机器的多个位置当成多个独立副本。

复制负责把数据保存在其他节点;Raft 等协议解决相应假设下的日志共识。数据如何确认成功、故障后读到什么结果,需要另外设计,一致性哈希的名称不提供强一致保证。

8.7 相同业务键在两个客户端得到不同节点,应检查哪些映射条件?​

考察重点:可复现哈希、稳定节点身份和配置版本。

相关内容:环上的位置不是数据本身、可复现的环实现。

参考回答

先确认两端看到相同的成员版本、节点身份、权重和虚拟节点编号,再比较编码与哈希规则。 哈希种子、摘要截取、字节序或碰撞处理不同,都可能产生不同的环。

不要用可变的列表序号作为节点长期身份,也不要让不同进程随机解决位置碰撞。映射条件统一以后,仍需处理旧版本在途请求;算法不会自行协调配置发布。

9. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明