一致性哈希限制成员变化时的数据放置变化,但它本身不搬运数据、不让副本达成一致,也不把单个热键的请求自动分散。经典顺时针哈希环把键交给哈希位置之后遇到的第一个节点点位;如果位置恰好相等,就归该点,超过最大点位则绕回最小点位。理解这个查找方向,是判断迁移范围的前提。
先把归属区间画对
一个点拥有的是前驱之后到自身为止的区间,即 (前驱, 当前点]。假设 A 位于 20,B 位于 50,C 位于 80,那么 B 负责大于 20 且不大于 50 的位置;超过 80 的键绕回 A。这不是从 B 到其后继 C 的弧段,左右端点是否包含也不是可以随意改变的细节。
在 35 加入 D,它从顺时针后继 B 手中取得大于 20 到 35 的位置;移除 B,则将 B 原来负责的区间交给 C。未受影响的点位保持原位置及归属,这才是算法有用的性质。仅说“移动一小段弧”还不够,因为原来某段弧可能就占据整个环的很大一部分。
作为对照,直接用 hash(key) 对节点数量取模来选择数组下标,新增节点会改变除数,很多键随之选到别的下标。四个等容量且平衡的节点扩到五个时,理想哈希环大约需要迁移五分之一的键,但真实迁移比例取决于点位与键分布,不能把这个比例当成所有部署都成立的保证。
虚拟节点分散区间,不拆分单个键
为每台物理节点分配多个环上点位,通常能减轻少量随机点造成的不平衡。每台机器持有多个较小区间,新节点可以从不同邻居取得一部分归属;不同容量还可以通过权重分配不同数量的点。但点越多,路由表的内存和维护工作也会增加,并非只有收益没有成本。
在本文的主归属模型中,一个键仍然只选择一台物理机器。如果绝大部分请求都针对这个键,增加虚拟节点不会让这些请求自动跨机器分担。即使每台机器的键数量接近,也不意味着字节数、请求频率或处理成本接近。复制、读流量分配和热键处理需要另外的策略。
稳定性也不等于平衡。所有参与方需要对成员集合、持久节点身份、键的字节编码、哈希规则和点数达成一致。稳定摘要可以让大家重现位置,却无法弥补成员视图不同或工作负载倾斜。如果每次成员变化都重新生成全部旧节点的点,保持既有归属的性质也就被破坏了。
放置结果不是迁移协议或一致性协议
路由算出了新归属,不代表缓存值或持久数据已经到达新节点。缓存可能经历未命中和源站负载上升,持久存储则需要数据迁移与所有权切换协议。发布期间仍使用旧环和已经使用新环的客户端,可能对同一个键作出不同选择;恢复与同步语义要由外围系统解决。
实际产品还使用不同算法和元数据。Redis Cluster 重新分配固定哈希槽的归属,并不是本文这种直接映射节点点位的环;Cassandra 还叠加副本放置和 token 管理规则,Envoy 则用哈希选择上游服务。不能从一个产品的放置示意图,推导另一个产品的故障安全保证。
坏掉的样子
每台机器只有一个点时可能严重失衡,重命名节点也可能意外迁移其点位。改变字节编码或哈希配置,即使没有任何机器加入,也能让大量键重新分配。应把这些映射输入视为部署配置,并在发布前比较新旧归属,而不是只检查服务进程是否正常运行。
还应把迁移键数、迁移字节数和真实请求流量分开测量。虚拟节点改善的是统计上的放置分布,不保证最大负载上界。成员频繁变化时,应为数据迁移或冷缓存回源预留资源,并检查客户端如何学习新视图。放置计算实验不能代替可用性与数据完整性验收。
快速验证
把以下代码保存为 ring_check.py,使用 Python 3.10 以上版本执行 python3 ring_check.py。本例已用 Python 3.14.4 运行,只依赖标准库,不启动服务,也不进行网络请求或文件写入。固定的 SHA-256 和明确的 UTF-8 输入,让生成的测试数据可以复现。
from bisect import bisect_left
from collections import Counter
from hashlib import sha256
def token(value):
return int.from_bytes(sha256(value.encode("utf-8")).digest(), "big")
def ring(nodes, copies):
entries = sorted(
(token(f"node:{node}:{copy}"), node)
for node in nodes for copy in range(copies)
)
positions, owners = zip(*entries)
assert len(set(positions)) == len(positions), "fixture token collision"
return positions, owners
def lookup(layout, position):
positions, owners = layout
return owners[bisect_left(positions, position) % len(positions)]
simple = ((20, 50, 80), ("A", "B", "C"))
joined = ((20, 35, 50, 80), ("A", "D", "B", "C"))
removed = ((20, 80), ("A", "C"))
assert [lookup(simple, value) for value in (0, 20, 21, 50, 51, 80, 81)] == [
"A", "A", "B", "B", "C", "C", "A",
]
for value in range(100):
assert lookup(joined, value) == ("D" if 20 < value <= 35 else lookup(simple, value))
assert lookup(removed, value) == ("C" if 20 < value <= 50 else lookup(simple, value))
assert lookup(joined, 35) == "D"
assert lookup(removed, 50) == "C"
print("a point owns (predecessor, point]; lookups wrap clockwise")
nodes = ("A", "B", "C", "D")
positions = [token(f"key:{index}") for index in range(20000)]
base = ring(nodes, 256)
expanded = ring(nodes + ("E",), 256)
contracted = ring(tuple(node for node in nodes if node != "B"), 256)
assert base == ring(tuple(reversed(nodes)), 256)
before = [lookup(base, position) for position in positions]
after_add = [lookup(expanded, position) for position in positions]
after_remove = [lookup(contracted, position) for position in positions]
add_count = sum(old != new for old, new in zip(before, after_add))
remove_count = sum(old != new for old, new in zip(before, after_remove))
assert 0 < add_count < len(positions)
assert add_count == after_add.count("E")
assert all(old == new or new == "E" for old, new in zip(before, after_add))
assert remove_count == before.count("B")
assert all(old == new or old == "B" for old, new in zip(before, after_remove))
modulo_count = sum(
nodes[position % 4] != (nodes + ("E",))[position % 5]
for position in positions
)
print(f"add E: {add_count}/20000 keys move ({add_count / 200:.2f}%)")
print(f"remove B: {remove_count}/20000 keys move ({remove_count / 200:.2f}%)")
print(f"modulo 4 -> 5: {modulo_count}/20000 keys move ({modulo_count / 200:.2f}%)")
single = ring(nodes, 1)
for label, layout in (("one point", single), ("256 points", base)):
counts = Counter(lookup(layout, position) for position in positions)
print(label, {node: counts[node] for node in nodes})
hot_owner = lookup(base, token("key:hot"))
synthetic_requests = Counter(before)
synthetic_requests[hot_owner] += 100000
assert synthetic_requests[hot_owner] == before.count(hot_owner) + 100000
print("100000 extra requests for one hot key all target", hot_owner)
print("synthetic request counts", dict(sorted(synthetic_requests.items())))
整数例子检查了从零到 99 的每个位置,覆盖区间端点和绕环情况。对 20,000 个确定生成的键,每台节点配置 256 个点时,新增 E 迁移 4,023 个键,移除 B 迁移 4,895 个,而取模方式扩容迁移 16,013 个。断言还要求新增造成的所有迁移都流向 E,删除造成的所有迁移都来自 B,验证的不只是数量看起来变少。
输出中的数量展示了这组数据使用 256 个点后的平衡改善,不是普遍的负载上界。再给一个热键增加 100,000 次合成请求,它们仍全部指向 D。这些是计算出来的归属和需求计数,并非实际发送的生产请求,也没有测量网络搬运字节数或故障恢复时间。
延伸阅读
来源
- Apache Cassandra 的 Dynamo 架构,顺时针归属、token 区间和虚拟节点,已核验 2026-09-08。
- Envoy 负载均衡算法,环哈希、稳定节点身份、权重和分布,已核验 2026-09-08。
- Python hashlib,可移植的 SHA-256 摘要构建,已核验 2026-09-08。
- Redis Cluster 规范,采用固定槽映射的另一种放置设计,已核验 2026-09-08。