一致哈希(Consistent Hashing)是分布式系统应对节点频繁扩缩容的关键算法。它把节点和数据都映射到同一个哈希环上,让新增或下线一个节点时只影响相邻的一小段数据,而不是像普通取模哈希那样引发全量重分布。本文用可运行的 Python 代码带你吃透原理,并落到 Redis 集群与分库分表的真实场景。
一、普通哈希在分布式下的塌房现场
最常见的路由方式是取模:把 key 的哈希值对节点数 N 取余,得到目标节点下标。单机或固定规模的集群里它简单高效,但一旦节点数变化(扩容、缩容、宕机下线),几乎所有 key 都会映射到不同的节点。
# 普通取模哈希:节点数变化时大量 key 需要迁移
def mod_hash(key, n):
return hash(key) % n
# 3 个节点时的归属
for k in ["user:1", "user:2", "user:3", "user:4", "user:5"]:
print(k, "->", mod_hash(k, 3))
# 扩到 4 个节点:绝大多数 key 落到不同节点
for k in ["user:1", "user:2", "user:3", "user:4", "user:5"]:
print(k, "->", mod_hash(k, 4))
后果很直接:缓存场景里是全量缓存失效,瞬间回源把数据库打挂(缓存雪崩);存储场景里是海量数据在节点间搬移,迁移期间服务质量下降。问题根源在于,取模哈希的映射关系是”全局耦合”的——N 一动,整张映射表就重算。一致哈希要解决的,正是”让 N 变化时只动最少的数据”。
二、一致哈希的核心:哈希环
一致哈希的巧妙之处在于,把”节点”和”数据”放进同一个空间。它把哈希值空间(比如 2 的 32 次方)首尾相连,想象成一个环。所有节点和所有 key 都通过同一个哈希函数散列到这个环上;每个 key 顺时针(或固定方向)走,遇到的第一个节点,就是它的归属节点。
当新增一个节点 D 时,只有原本属于 D 顺时针前一个节点(比如 C)到 D 之间这一小段弧上的 key,会从 C 迁移到 D;环上其余绝大部分 key 的归属完全不变。下线一个节点同理,只把它的数据交给顺时针下一个节点接管。这就是”只迁移 1/N”的来源——N 是节点数。
三、虚拟节点解决数据倾斜
如果每个真实节点只在环上占一个点,节点少的时候,它们的弧段长度极不均匀,数据会严重倾斜到某几个节点上。解决办法是”虚拟节点”:把一个真实节点复制成若干个(比如 100~200 个)虚拟点,打散到环的各个位置。key 先落到虚拟节点,再映射回它背后的真实节点。虚拟节点越多,数据分布越接近均匀。
| 真实节点 | 虚拟节点数 | 环上落点特征 |
|---|---|---|
| nodeA | 150 | 均匀散布 |
| nodeB | 150 | 均匀散布 |
| nodeC | 150 | 均匀散布 |
四、动手实现一个最小一致哈希
下面用 Python 实现一个带虚拟节点、且支持动态增删节点的一致哈希。核心是维护一个有序的虚拟节点哈希列表,用二分查找定位 key 的归属。
import hashlib, bisect
class ConsistentHash:
def __init__(self, nodes=None, replicas=100):
self.replicas = replicas
self.ring = {} # 虚拟节点 hash -> 真实节点
self.sorted_keys = [] # 排序后的虚拟节点 hash
for n in (nodes or []):
self.add_node(n)
def _hash(self, s):
return int(hashlib.md5(s.encode()).hexdigest(), 16)
def add_node(self, node):
for i in range(self.replicas):
h = self._hash(f"{node}#{i}")
self.ring[h] = node
self.sorted_keys.append(h)
self.sorted_keys.sort()
def remove_node(self, node):
for i in range(self.replicas):
h = self._hash(f"{node}#{i}")
self.ring.pop(h, None)
self.sorted_keys.remove(h)
def get_node(self, key):
if not self.sorted_keys:
return None
h = self._hash(key)
idx = bisect.bisect(self.sorted_keys, h) % len(self.sorted_keys)
return self.ring[self.sorted_keys[idx]]
ch = ConsistentHash(["nodeA", "nodeB", "nodeC"], replicas=100)
print(ch.get_node("user:10086")) # 稳定落到一个节点
ch.add_node("nodeD") # 新增节点只迁移约 1/4 的 key
读取归属时,先算 key 的哈希 h,用 bisect 在有序虚拟节点列表中找到第一个大于等于 h 的位置;若越界则回到环首(取模),再从该位置取出真实节点。增删节点只需维护 ring 字典和 sorted_keys 列表,复杂度是 O(log V),V 为虚拟节点总数。
五、真实落地场景
1. 分布式缓存:扩缩容只迁移 1/N
在 Redis 缓存设计 里我们聊过缓存穿透、击穿与雪崩;而一致哈希正是缓解”扩容引发雪崩”的工程解法之一。经典的 Memcached 客户端(如 Spymemcached)就用一致哈希把 key 稳定地路由到某台缓存机;扩容时新机器只接管 1/N 的数据,其余缓存继续命中,避免瞬时回源。Redis Cluster 则采用更进一步的”哈希槽(16384 个 slot)”模型——本质是一致哈希思想的变体:节点负责一段 slot,slot 可在节点间 finely 迁移。
2. 分库分表路由:分片键的归宿
在 分库分表实战 中,分片键(shard key)如何落到具体的库/表是核心问题。当分片节点需要动态增减时,一致哈希能保证只迁移相邻弧段的数据,而不是按取模重算导致全量数据搬移。这也是很多分布式数据库(如 Cassandra)默认采用一致哈希做数据分布的原因。
3. 容器与服务的亲和路由
在 Docker 网络模式 与微服务网格里,一致哈希常用于”粘性路由”:把同一用户的请求稳定地打到同一后端实例,兼顾会话亲和与扩缩容时的平滑迁移。变体 Rendezvous(HRW)哈希还支持带权重,适合异构机器混部。
六、方案对比
| 方案 | 扩缩容迁移量 | 数据倾斜 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 普通取模 | 约 100% | 无 | 极低 | 单节点/固定规模 |
| 一致哈希(无虚拟节点) | 约 1/N | 严重 | 低 | 理论演示/少量节点 |
| 一致哈希 + 虚拟节点 | 约 1/N | 轻微 | 中 | 生产主流 |
| Jump Hash | 约 1/N | 极轻 | 低(无需环) | 内部大规模集群 |
七、局限与改进
一致哈希并非银弹。第一,虚拟节点数需要调优:太少仍倾斜,太多则内存与查找成本上升,生产常用 100~200。第二,异构机器要带权重——给强节点多分配虚拟点。第三,Google 的 Jump Hash、Rendezvous Hash 用更轻量的方式达到类似甚至更好的均衡,且不维护哈希环,适合超大规模。第四,要清醒:一致哈希保证的是”迁移少”,不保证”绝对均衡”,仍需监控各节点 key 数与命中率。
八、生产落地清单
| 检查项 | 建议 |
|---|---|
| 哈希函数 | md5/sha1,分布均匀即可 |
| 虚拟节点数 | 每物理节点 100~200 |
| 节点上下线 | 灰度 + 双读/双写过渡 |
| 客户端 | 本地缓存哈希环,失败重试 |
| 监控 | 各节点 key 数分布、命中率 |
九、总结
一致哈希用”哈希环 + 顺时针归属 + 虚拟节点”三板斧,把分布式系统的扩缩容代价从 100% 降到约 1/N,是缓存、分库分表、服务路由的底层基石。理解了它,你再看 Redis Cluster 的 slot、Cassandra 的 token 环、网关的粘性负载,都会豁然开朗。真正落地时,记得配合灰度迁移与监控,让均衡与稳定兼得。




