mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
3226 字
9 分钟
一致性哈希(Consistent Hashing)
2026-06-13

一、为什么需要一致性哈希#

你的缓存集群有 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 A
mermaid
flowchart TD
Key[键: user:42] --> Hash[计算哈希值]
Hash --> Search[在环上顺时针查找\n最近的虚拟节点]
Search --> Node[映射到物理节点]

3.1 虚拟节点:为什么必须要有#

如果每个物理节点只在环上占一个位置,分布可能很不均匀——一个节点可能占了 60% 的弧段,而另一个只占 5%。这不是理论上的极端情况,而是小集群中的常态。3 个节点在环上随机放 3 个点,最短弧段和最长弧段的比值可能达到 1<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/nO(log n)简单原型
一致性哈希(带虚拟节点)~1/nO(log n)生产环境标配
有界负载一致性哈希~1/nO(log n)需要热点保护
Rendezvous 哈希~1/nO(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:0nodename:1nodename:k-1GetNodesort.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 位运算先转成有符号整数,负数排序时会排在正数前面,破坏环的顺序。removeNodefilter 重建 sortedKeys,和 Go 版本的重建方式复杂度相同。Math.imul 代替普通乘法是为了正确处理 32 位整数溢出——普通乘法在 JavaScript 中是浮点数,大数会丢失精度。

六、生产验证#

项目源码位置用途
Go groupcacheMap 结构体含排序键和 hashMapAdd(L53-L62)插入虚拟节点,Get(L65-L81)用 sort.Search 二分搜索找最近顺时针节点。作者 Brad Fitzpatrick 也是 memcached 的创造者
HAProxychash_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:0user:42:replica:1 等多个键),再配合有界负载将请求分散到不同节点

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

一致性哈希(Consistent Hashing)
https://blog.souloss.com/posts/programming/system-patterns/system-patterns-consistent-hashing/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时