一致性哈希完全指南:从哈希环到 Jump Hash 与 Redis Cluster

"加一台机器,数据怎么重新分配?" —— 这是分布式存储和分布式缓存绕不开的核心问题。普通取模(node = hash(key) % N)在节点变化时会让所有数据重新映射,代价巨大。一致性哈希给出了优雅的答案:节点增减只影响相邻节点的数据。这篇文章把一致性哈希讲透,从原始算法到虚拟节点、到 Jump Hash、再到现代系统(Redis Cluster、DynamoDB、CDN)的实际应用。

问题:为什么不能简单取模

三台节点,key 取模分配:

node 0:keys hash % 3 == 0
node 1:keys hash % 3 == 1
node 2:keys hash % 3 == 2

现在加一台节点变 4 个:

node 0:keys hash % 4 == 0
node 1:keys hash % 4 == 1
node 2:keys hash % 4 == 2
node 3:keys hash % 4 == 3

所有 key 的归属几乎都变了 —— 因为 mod 3 和 mod 4 的结果几乎完全无关。3 节点扩到 4 节点,理想只需要迁移 1/4 数据,但取模会迁移 3/4。集群越大问题越严重 —— 缓存层这样做等于"瞬间全部失效",数据库这样做等于"所有数据搬家"。

一致性哈希的核心思想

Karger 等人 1997 年提出。基本思想:把 key 和节点都映射到同一个哈希环上,key 顺时针找到第一个节点就归该节点

哈希环:0 ~ 2^32 - 1,首尾相连成环

节点哈希:
  hash("node-A") -> 1000
  hash("node-B") -> 4000
  hash("node-C") -> 7000

key 哈希:
  hash("key1") -> 1500  -> 顺时针找到 node-B
  hash("key2") -> 6000  -> 找到 node-C
  hash("key3") -> 8000  -> 找到 node-A (绕回开头)

关键性质:加一个节点 D(hash=5000),只有 4001~5000 这段 key 从 C 转到 D,其他不变。删一个节点也类似 —— 只有它负责的 key 顺时针交给下一个节点。变更只影响 1/N 数据(N 是节点数)。

简单实现

import bisect
import hashlib

class ConsistentHash:
    def __init__(self, nodes=None):
        self.ring = []           # 排序的哈希值
        self.ring_map = {}       # 哈希值 -> 节点

        for node in (nodes or []):
            self.add_node(node)

    def _hash(self, key):
        return int(hashlib.md5(key.encode()).hexdigest(), 16)

    def add_node(self, node):
        h = self._hash(node)
        bisect.insort(self.ring, h)
        self.ring_map[h] = node

    def remove_node(self, node):
        h = self._hash(node)
        self.ring.remove(h)
        del self.ring_map[h]

    def get_node(self, key):
        if not self.ring:
            return None
        h = self._hash(key)
        # 找到第一个 >= h 的位置;没有就绕回 0
        idx = bisect.bisect(self.ring, h) % len(self.ring)
        return self.ring_map[self.ring[idx]]

# 用法
ch = ConsistentHash(["node-A", "node-B", "node-C"])
print(ch.get_node("user:1234"))   # node-X
print(ch.get_node("user:5678"))   # node-Y

# 加节点
ch.add_node("node-D")
print(ch.get_node("user:1234"))   # 可能还是 X,也可能变成 D

问题:数据倾斜

3 个节点随机散在环上,如果它们位置不均(运气不好),某个节点可能负责环的一大段,而另一个只负责一小段。结果:有的节点压力大,有的节点闲

节点位置:A=100, B=200, C=3 000 000 000
负责区间:
  A:200~100         (绕环一圈)— 极大!
  B:100~200         — 极小
  C:200~3000000000  — 中等

这就是数据倾斜问题,简单一致性哈希在节点少时严重。

虚拟节点(Virtual Nodes / Vnodes)

解决倾斜的标准方案:把每个物理节点映射成 N 个虚拟节点,虚拟节点散到环的不同位置

def add_node(self, node, vnodes=150):
    for i in range(vnodes):
        vkey = f"{node}#{i}"
        h = self._hash(vkey)
        bisect.insort(self.ring, h)
        self.ring_map[h] = node     # 都指向同一个物理节点

虚拟节点数 150-1000 个能让分布接近均匀。Memcache、Redis 客户端的 ketama 算法、Dynamo 都用虚拟节点。

实战 1:Memcached 客户端的 Ketama

Last.fm 2007 年开源的 ketama,几乎成了一致性哈希在缓存层的事实标准。每个 Memcached 节点 160 个虚拟节点,用 MD5 哈希,客户端直接根据 ketama 算法找节点 —— 服务端无需协调,加节点只是改客户端配置。

# Python pylibmc / mc 库都支持 ketama
import memcache
mc = memcache.Client(
    ["10.0.0.1:11211", "10.0.0.2:11211", "10.0.0.3:11211"],
    behaviors={"ketama": True}   # 启用一致性哈希
)
mc.set("foo", "bar")

这样设计的好处:加节点时大部分 key 的去向不变 —— 缓存命中率不会瞬间塌方。

实战 2:Redis Cluster 的"哈希槽"

Redis Cluster 没用经典一致性哈希,而是用固定 16384 个槽(slots):

slot = CRC16(key) % 16384

每个节点负责一段连续的槽:
  node 0:slot 0 ~ 5460
  node 1:slot 5461 ~ 10922
  node 2:slot 10923 ~ 16383

加节点时:把现有节点的部分槽迁给新节点,只迁移涉及的槽对应的 key。这其实是"预分片 + 显式槽分配",效果类似一致性哈希,但简化了实现 —— 客户端只要拿一份"槽 → 节点"映射表即可。

设计权衡:固定 16384 槽 = 易实现、易调试、迁移粒度合适;经典一致性哈希 = 更"动态",但实现复杂。Redis 选择更工程化的方案,大多数互联网级系统也这么做(Codis、Aerospike、Kafka 也是预分片)。

实战 3:Cassandra / DynamoDB 的环

Cassandra 用经典一致性哈希 + 虚拟节点。每个物理节点默认 256 个 vnode。一份数据存 N 个副本(replication factor),按"顺时针的下 N 个不同物理节点"放。

RF=3 时:
  key 落在 node-A
  副本存到 node-A、node-B、node-C(顺时针下两个物理节点)

读时配合 Quorum:
  R+W > N 保证强一致
  R=W=2,N=3 -> 强一致(慢)
  R=1,W=1 -> 最终一致(快)

DynamoDB 论文里的 Dynamo 模型也是这套思路。一致性哈希 + 多副本 + Quorum 是 NoSQL 数据库的经典设计。

Jump Consistent Hash:Google 的简化版

Google 2014 年提出 Jump Hash,只用几行代码、无需虚拟节点、分布均匀:

int jump_hash(uint64_t key, int num_buckets) {
    int64_t b = -1, j = 0;
    while (j < num_buckets) {
        b = j;
        key = key * 2862933555777941757ULL + 1;
        j = (b + 1) * (double)(1LL << 31) / (double)((key >> 33) + 1);
    }
    return b;
}

# 用法
node_idx = jump_hash(hash(key), num_nodes)

性质:

  • 不需要存任何环结构 —— 给定 key 和 N 直接算节点。
  • 分布天然均匀,无需虚拟节点。
  • N 从 K 变到 K+1 时,只有 1/(K+1) 的 key 改归属。

限制:节点必须用 0~N-1 编号,不能随意删除中间节点(只能在尾部加 / 减)。对"有序追加节点"场景特别合适。GFS / Bigtable / 某些 CDN 用。

Rendezvous Hashing(HRW)

另一种思路:对每个 (key, node) 对算 hash,key 归 hash 最大的那个 node:

def get_node(key, nodes):
    return max(nodes, key=lambda n: hash(f"{key}:{n}"))

性质和一致性哈希类似(节点变化只影响 1/N 数据),但实现极简,且支持"按权重分配"(节点 hash 乘权重)。CDN 的源站选择常用这个。

实战 4:CDN 边缘节点选择

CDN 用一致性哈希决定"哪个边缘节点缓存哪个 URL"。同一个 URL 总落到同一个节点 → 缓存命中率高;加边缘节点 → 只迁移 1/N 数据。

def select_edge(url, edges):
    return hrw_hash(url, edges)

# 客户端总把同一 URL 请求同一个边缘,提升 cache hit

Akamai、Cloudflare、Fastly 内部都有类似机制。

实战 5:分布式锁的归属

多个 Redis 节点做分布式锁,锁 key 要稳定地落到同一节点,否则锁不上。一致性哈希让 lock("order:1001") 永远去同一节点,自然保证。

"权重"支持

节点性能不一致(老机器 8 核,新机器 32 核)时,希望强节点承担更多 key。两种实现:

1. 多重虚拟节点

强节点给更多虚拟节点。8 核给 100 vnode,32 核给 400 vnode。

2. 加权 Rendezvous

def get_node(key, weighted_nodes):
    return max(weighted_nodes, key=lambda n: -math.log(rand_from_hash(key, n.id)) / n.weight)

常见坑

坑 1:虚拟节点数不够导致倾斜。 50 个 vnode 在 3 节点集群可能仍倾斜 20%+。生产场景至少 150-300 个。

坑 2:哈希函数选错。 用了分布不均的哈希(如简单的 key.hashCode())会导致 key 集中在某段环。MD5 / MurmurHash3 是常见选择。

坑 3:删节点不下线数据。 节点从环上移除后,它"本该负责"的 key 全转给顺时针下一个节点。但物理节点上的数据并没自动迁过去 —— 要么有后台同步,要么读侧实现"找不到就去下一个节点"的兜底。

坑 4:加节点时缓存命中率短暂下降。 新节点刚加入,它负责的 key 在新节点上还没缓存,要回源。这种"热点漂移"在大流量场景明显。常见缓解:预热(从邻居节点同步数据再上线)。

坑 5:哈希环序列化跨语言不一致。 Java 的 String.hashCode 和 Python 的 hash 算出不同值。多语言项目要统一用 MD5 / MurmurHash 等跨语言一致的哈希。

什么时候用一致性哈希

  1. 分布式缓存(Memcached、Redis Cluster、CDN 边缘)。
  2. 分布式数据库分片(Cassandra、DynamoDB)。
  3. 消息队列分区(Kafka 的 partitioner 默认就是这套思路)。
  4. 分布式锁的节点选择。
  5. 对象存储的副本分布(Ceph 的 CRUSH 是一致性哈希的进化版)。

什么时候不用

  • 数据量小 / 节点固定:简单取模或哈希分片就够。
  • 需要范围查询:一致性哈希让 key 完全随机分布,"查询 user_id 1000~2000"要扫所有节点。这类场景要用范围分片。
  • 需要精细控制(把特定 key 强制放某节点):一致性哈希是"哈希决定",不支持。

Maglev Hashing:Google 的负载均衡器内核

Google 内部负载均衡器 Maglev 用的哈希算法,2016 年开源论文。它的核心是"预计算查找表":每个 backend 用伪随机偏好序列填一张大小为 M(素数)的表,key hash 后查表得后端。

构建查找表的简化逻辑:
  N 个后端,每个有 (offset_n, skip_n)
  for slot in 0..M-1:
    选下一个后端 n(轮流给每个后端机会)
    target = (offset_n + i * skip_n) % M
    如果 lookup[target] 空:lookup[target] = n
    n 的 i++

查找时只要 lookup[hash(key) % M] 一次数组访问,极快。后端增减时 disrupt 率 ≈ 1/N,接近最优。Maglev 是 Google Cloud Load Balancer、Envoy 的 ringhash 算法的灵感来源。

分片 + 副本的组合

一致性哈希解决"每个 key 归哪个节点"。但实际生产数据还需要副本。常见组合:

1. 一致性哈希决定 primary 节点
2. primary 之后顺时针的 N-1 个不同物理节点是 replica

加节点 D 后:
  受影响的 key:落在 D 负责的环段
  这些 key 的 primary 从 C 变成 D
  D 把这些 key 的副本同步到自己 + 下两个节点

读时配合 Quorum,读多数副本 -> 强一致

这就是 Dynamo / Cassandra 的"preference list" —— 每个 key 有一个有序的副本列表,从环上顺序生成。读写按列表顺序尝试,失败就 fallback 到下一个。

热点处理

一致性哈希不能避免"单个 hot key 把节点压垮" —— 一个超热的 key 永远落到同一节点。常见缓解:

  • 客户端分散:把热 key 复制成 key#0key#1...key#9,客户端随机读其中一个。写要更新所有副本。
  • 本地缓存:客户端 / 应用层缓存,减少对后端节点的请求。
  • 动态识别 + 多副本:监控发现 hot key 后,临时给它在多个节点放副本。Redis Cluster 的"读 slot replication"思路。

Ceph 的 CRUSH:一致性哈希的进化版

Ceph 存储用 CRUSH 算法决定数据放在哪些 OSD(存储节点),它是一致性哈希的复杂演化:

  • 支持层级结构:数据中心 → 机柜 → 主机 → OSD,可以"同一份数据 3 个副本必须分在 3 个不同机柜"。
  • 支持权重:不同 OSD 容量不同,自动分配多 / 少。
  • 确定性:给定 key 和 cluster map,任何节点都能算出"这个 key 该在哪些 OSD",不用查中心。

这种"分布式哈希 + 拓扑感知 + 副本约束"是大规模存储系统的标配设计。

真实迁移成本

"一致性哈希加节点只迁移 1/N 数据" 是数学性质。但工程上"迁移"本身有成本:

  • 数据量大时,1/N 也可能是 TB 级,迁移耗几小时。
  • 迁移期间,该数据的读可能 fallback 到旧节点(双写、影子读)。
  • 必须限速避免迁移流量打挂网络。

所以"加机器"在生产系统里通常是计划性活动,有运行手册、限速策略、回滚机制。"挂掉一台立刻加新机器" 的设想多数情况下不现实 —— 你能挂的机器都是有冗余的。

写在最后

一致性哈希是"动态扩缩容"问题的优雅解。它的存在让"分布式系统加机器"从"瞬间全部失效"变成"1/N 影响",直接催生了 CDN、Memcached 集群、NoSQL 大规模化的可能。这是分布式系统设计里少数"原理简单 + 应用广泛 + 工程价值巨大"的算法。

给一个工程心得:一致性哈希的真正价值不在"哈希环",而在"变化局部化"。任何"节点数会变,但 key 到节点的映射希望稳定"的场景都该想到它。当你设计一个会扩缩容的分布式服务时,默认就选一致性哈希(或 Jump Hash / Rendezvous)分配,而不是取模 —— 这点投入在第一次扩容时就会给你巨大回报。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Raft 与 Paxos 共识算法完全指南:从原理到 etcd 实战

2026-5-15 16:09:29

技术教程

限流熔断降级完全指南:Sentinel 与 Resilience4j 的高可用三件套

2026-5-15 16:19:19

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索