一、为什么需要一致性哈希
你的缓存集群有 10 个节点,用 hash(key) % 10 来分配请求。一切正常,直到某天需要扩容到 11 个节点。问题来了:几乎所有键的分配都会变化——原来在节点 3 的键现在可能跑到节点 7。这意味着大量缓存失效,请求涌向数据库,系统瞬间被打挂。
模哈希的致命缺陷是:节点数 n 一变,几乎 100% 的键都要重新分配。假设集群缓存了 1000 万个键,每个键重建需要 5ms 数据库查询。扩容时 90% 的键失效,就是 900 万次查询同时涌入——即使数据库每秒能扛 10 万次查询,这也是 90 秒的持续冲击,期间响应超时、重试风暴叠加、连接池耗尽,典型的缓存雪崩。
模哈希失效的根因在于取模运算的性质:当 n 从 10 变成 11 时,hash(key) % 10 = 3 的键,在 hash(key) % 11 下几乎均匀地散落到 0-10 的任意位置。只有约 1/11 ≈ 9% 的键能留在原位,超过 90% 的键都会被重新分配。
一致性哈希解决了这个问题。它的核心保证是:添加或移除节点时,只有约 1/n 的键需要重新分配。其他键完全不受影响。这让分布式系统的扩缩容变得平滑——加一台机器不会导致缓存雪崩,减一台机器也不会。
二、现实类比
环形城市地图上分配快递区域。每个快递员负责一段弧线。新快递员加入时,只从相邻同事那里接管一小段区域——其他快递员的辖区几乎不变。有人离职时,只有相邻的快递员分担他的区域。大部分客户不受影响,只有边界处的少量客户需要换人。
注意这个类比隐含了一个假设:每个快递员负责的弧段长度大致相等。一致性哈希同样面临分布不均的问题,后面讲虚拟节点时会展开。
三、核心思想
一致性哈希把节点和键都映射到一个环形空间(0 到 2^32)。每个键顺时针找到的第一个节点就是它的归属。添加节点时,新节点只”偷走”它和前驱之间弧段的键;移除节点时,它的键全部由顺时针下一个节点接管。
哈希环 (0 到 2^32, 首尾相连):
0 Node A ●k1 Node B ●k2 Node C 2^32→0├───────────┼─────────┼───────┼───────────────┼───────┼─────────┤ │ ▲ │ ▲ └───►───┘ └───►───┘ ↑ ↑ k1→Node B k2→Node C
k3(位于 Node C 和 2^32 之间)→ 绕回到 Node Amermaidflowchart TD Key[键: user:42] --> Hash[计算哈希值] Hash --> Search[在环上顺时针查找\n最近的虚拟节点] Search --> Node[映射到物理节点]3.1 虚拟节点:为什么必须要有
如果每个物理节点只在环上占一个位置,分布可能很不均匀——一个节点可能占了 60% 的弧段,而另一个只占 5%。这不是理论上的极端情况,而是小集群中的常态。3 个节点在环上随机放 3 个点,最短弧段和最长弧段的比值可能达到 1<10>10> 甚至更极端。
虚拟节点让每个物理节点在环上映射到 k 个位置(比如 100-200 个),分布就趋向均匀了。背后的数学直觉很简单:如果你随机抛 3 个点,最大间距可能很大;但如果随机抛 300 个点(每个物理节点 100 个),最大间距就会迅速缩小。具体来说,当虚拟节点数为 k 时,每个物理节点负责的弧段长度标准差约为 1/(k√n),其中 n 是物理节点数。k=150 时,10 个物理节点的负载偏差通常能控制在 10% 以内,这对生产环境已经足够。
3.2 有界负载:热键保护
虚拟节点解决了节点间负载不均的问题,但解决不了热键(Hot Key)问题。即使每个节点分到的弧段长度相同,如果某个键的访问量是其他键的 1000 倍,它所在的节点仍然会被打爆。有界负载一致性哈希(Bounded Load Consistent Hashing)就是为这个问题设计的。
核心思路:给每个节点设一个负载上限(比如平均负载的 1.25 倍)。当键按顺时针找到的节点已经满载时,继续顺时针找下一个有容量的节点。这样既保持了一致性,又避免了单点过载。这个方案由 Vimeo 工程团队在 2017 年提出,后来被 HAProxy 和 Nginx 广泛采用。
关键属性:
| 属性 | 值 | 说明 |
|---|---|---|
| 节点增减时键重映射 | 约 1/n | 模哈希为 100% |
| 查找复杂度 | O(log n) | 在排序环上二分搜索 |
| 虚拟节点数 | 每物理节点 k 个 | 越多越均匀,但内存越大 |
| 有界负载额外开销 | O(log n) 顺移 | 最多顺移到下一个有容量的节点 |
四、变体与对比
| 方案 | 节点变化时键重映射 | 均匀性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 模哈希 | ~100% | 完美 | O(1) | 节点数固定的场景 |
| 一致性哈希(无虚拟节点) | ~1/n | 差 | O(log n) | 简单原型 |
| 一致性哈希(带虚拟节点) | ~1/n | 好 | O(log n) | 生产环境标配 |
| 有界负载一致性哈希 | ~1/n | 好 | O(log n) | 需要热点保护 |
| Rendezvous 哈希 | ~1/n | 好 | O(n) | 小集群、无环实现 |
4.1 Redis Cluster 的哈希槽方案
Redis Cluster 用的是另一种方案——固定 16384 个哈希槽(Hash Slot),每个节点负责一段连续的槽位。键通过 CRC16(key) % 16384 计算所属槽位,然后路由到负责该槽的节点。扩缩容时迁移槽位而不是重新哈希。这和一致性哈希思路不同但目标一致:最小化键重映射。
两者的关键区别在于:一致性哈希的”分区”由虚拟节点位置隐式决定,弧段长度不完全可控;而哈希槽是显式划分的固定区间,迁移时可以精确控制数据量。另一个差异是,一致性哈希中一个节点故障只影响后继节点(负载翻倍),而哈希槽方案中故障节点的槽会被分散到多个节点。但哈希槽的缺点是数量固定(16384),集群节点数不能超过这个上限。
4.2 Rendezvous 哈希
Rendezvous 哈希(也叫最高随机权重哈希)走了一条完全不同的路:不需要环,不需要虚拟节点。对每个键,计算它在所有节点上的权重 hash(key, node),选权重最高的那个节点。添加新节点时,只有当新节点对某个键的权重高于当前节点时,该键才会迁移——这自然保证了最小重映射。
它的优势是实现简单,不需要维护排序环和虚拟节点。缺点是查找复杂度是 O(n),每个键都要和所有节点计算一次哈希。小集群(十几个节点)没问题,但上千个节点时性能不如环方案。另外,Rendezvous 哈希无法像一致性哈希那样通过虚拟节点数量来精细调节负载分布。
4.3 数据迁移策略
无论是哪种哈希方案,节点变化时总有一些数据需要迁移。最简单的策略是同步迁移——先迁移数据再切换路由,但迁移期间可能读到过期数据。更实用的方案是双读:迁移期间同时读新旧节点,以新节点为准;写操作直接写入新节点,旧数据等迁移完成后删除。整个过程需要保证幂等性,因为网络抖动可能导致重试。
五、多语言实现
5.1 Go 实现
package consistenthash
import ( "fmt" "sort" "sync")
// HashRing 一致性哈希环type HashRing struct { mu sync.RWMutex replicas int // 每个物理节点的虚拟节点数 keys []int // 排序的哈希值(虚拟节点) hashMap map[int]string // 虚拟节点哈希 → 物理节点名}
// fnv1a FNV-1a 哈希函数func fnv1a(s string) int { h := 2166136261 for i := 0; i < len(s); i++ { h ^= int(s[i]) h *= 16777619 } if h < 0 { h = -h } return h}
func NewHashRing(replicas int) *HashRing { return &HashRing{ replicas: replicas, hashMap: make(map[int]string), }}
// AddNode 添加物理节点(含虚拟节点)func (r *HashRing) AddNode(node string) { r.mu.Lock() defer r.mu.Unlock()
for i := 0; i < r.replicas; i++ { h := fnv1a(fmt.Sprintf("%s:%d", node, i)) r.hashMap[h] = node r.keys = append(r.keys, h) } sort.Ints(r.keys) // 保持有序以支持二分查找}
// RemoveNode 移除物理节点(含虚拟节点)func (r *HashRing) RemoveNode(node string) { r.mu.Lock() defer r.mu.Unlock()
// 删除该节点的所有虚拟节点 for i := 0; i < r.replicas; i++ { h := fnv1a(fmt.Sprintf("%s:%d", node, i)) delete(r.hashMap, h) } // 重建排序键,移除已删除的虚拟节点 r.keys = r.keys[:0] for k := range r.hashMap { r.keys = append(r.keys, k) } sort.Ints(r.keys)}
// GetNode 查找键所属的物理节点func (r *HashRing) GetNode(key string) string { r.mu.RLock() defer r.mu.RUnlock()
if len(r.keys) == 0 { return "" }
h := fnv1a(key) // 二分查找:第一个 >= h 的位置 idx := sort.SearchInts(r.keys, h) if idx >= len(r.keys) { idx = 0 // 绕回环首 } return r.hashMap[r.keys[idx]]}Go 版本选择了 FNV-1a 作为哈希函数。为什么不用 MD5 或 SHA-1?加密哈希函数分布更均匀,但计算开销大得多。FNV-1a 只需一次异或和一次乘法就能处理一个字节,吞吐量是 MD5 的 5-10 倍。一致性哈希需要的是速度快、分布均匀、avalanche effect 好,FNV-1a 恰好满足。
AddNode 为每个物理节点创建 replicas 个虚拟节点,名字格式是 nodename:0、nodename:1 … nodename:k-1。GetNode 用 sort.SearchInts 做二分查找,找到第一个大于等于键哈希值的虚拟节点。如果比环上所有虚拟节点都大,就绕回环首(idx = 0)。查找过程是 O(log n),n 是虚拟节点总数。
RemoveNode 删除一个物理节点的 k 个虚拟节点后,需要重建排序键。这里先清空 r.keys 再从 hashMap 重新构建,比逐个从切片中删除更高效——两者都是 O(n),但重建的常数因子更小。
5.2 TypeScript 实现
class HashRing { private ring = new Map<number, string>(); private sortedKeys: number[] = [];
constructor(private replicas = 100) {}
/** FNV-1a 哈希 */ private hash(key: string): number { let h = 2166136261; for (let i = 0; i < key.length; i++) { h ^= key.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; // 无符号右移,确保非负 }
/** 添加节点(含虚拟节点) */ addNode(node: string): void { for (let i = 0; i < this.replicas; i++) { const h = this.hash(`${node}:${i}`); this.ring.set(h, node); this.sortedKeys.push(h); } this.sortedKeys.sort((a, b) => a - b); }
/** 移除节点(含虚拟节点) */ removeNode(node: string): void { for (let i = 0; i < this.replicas; i++) { this.ring.delete(this.hash(`${node}:${i}`)); } this.sortedKeys = this.sortedKeys.filter((k) => this.ring.has(k)); }
/** 查找键所属节点 */ getNode(key: string): string | undefined { if (this.sortedKeys.length === 0) return undefined;
const h = this.hash(key); // 二分查找第一个 >= h 的位置 let lo = 0, hi = this.sortedKeys.length; while (lo < hi) { const mid = (lo + hi) >>> 1; if (this.sortedKeys[mid] < h) lo = mid + 1; else hi = mid; } // 绕回环首 if (lo >= this.sortedKeys.length) lo = 0; return this.ring.get(this.sortedKeys[lo]); }}TypeScript 版本手动实现二分查找(Array.prototype.find 是线性的)。hash 函数用 >>> 0 确保结果是无符号 32 位整数——JavaScript 位运算先转成有符号整数,负数排序时会排在正数前面,破坏环的顺序。removeNode 用 filter 重建 sortedKeys,和 Go 版本的重建方式复杂度相同。Math.imul 代替普通乘法是为了正确处理 32 位整数溢出——普通乘法在 JavaScript 中是浮点数,大数会丢失精度。
六、生产验证
| 项目 | 源码位置 | 用途 |
|---|---|---|
| Go groupcache | Map 结构体 | 含排序键和 hashMap。Add(L53-L62)插入虚拟节点,Get(L65-L81)用 sort.Search 二分搜索找最近顺时针节点。作者 Brad Fitzpatrick 也是 memcached 的创造者 |
| HAProxy | chash_get_server_hash | 使用弹性二叉树(eb-trees)在一致性哈希环上找最近服务器,O(log n) 查找。支持有界负载均衡和服务器可用性检查 |
| Amazon DynamoDB - 原始论文 | 一致性哈希 + 虚拟节点 | Dynamo 论文首次系统描述了一致性哈希在生产级键值存储中的应用,含虚拟节点的负载均衡分析 |
七、小结
何时使用:
- 分布式缓存——路由键到缓存服务器,扩缩容时最小化缓存失效,memcached 的 ketama 算法是经典实现。缓存本身就是用空间换时间,扩容导致大量失效就白花了
- 负载均衡——后端服务器变化时最小扰动地分发请求,HAProxy 的
balance leastconn配合一致性哈希。相比轮询,一致性哈希保证同一客户端的请求始终落在同一台后端,对有状态服务至关重要 - 分片数据库——将数据分区分配到节点,Cassandra 的 token 环就是一致性哈希。数据库迁移成本远高于缓存,“最小重映射”特性在这里价值更大
- CDN——根据 URL 哈希路由内容到边缘服务器。CDN 边缘节点增减频繁,一致性哈希让大部分内容路由不变
何时不用:
- 静态拓扑——如果节点永远不会变化,模哈希更简单、更均匀。一致性哈希引入虚拟节点和环查找是额外复杂度,没有扩缩容需求时纯属浪费
- 小集群——少于 5 个节点时,随机或轮询可能够用,一致性哈希的均匀性优势不明显
- 严格排序需求——一致性哈希不保留键的顺序,需要范围查询的场景不适用
- 无法接受热点——即使有虚拟节点,单个热键仍然只映射到一个节点。有界负载一致性哈希可以部分缓解,但更根本的方案是在应用层做热键拆分(比如将
user:42的数据复制到user:42:replica:0、user:42:replica:1等多个键),再配合有界负载将请求分散到不同节点
八、参考资料
- Dynamo: Amazon’s Highly Available Key-value Store - Amazon Dynamo 论文,一致性哈希在分布式存储中的奠基性应用
- Consistent Hashing and Random Trees - Karger et al., 1997,一致性哈希的原始论文
- groupcache consistenthash - Go 官方的一致性哈希实现,简洁清晰
- Libketama - memcached 客户端的一致性哈希库(ketama 算法),含虚拟节点权重支持
- HAProxy Consistent Hashing - HAProxy 一致性哈希负载均衡的实践说明
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






