一致性 Hash 算法

最近翻到之前的 如何开发一个自己的 RPC 框架的文章,突然对里面提及的“一致性 Hash 算法”很感兴趣,之前面试的时候有了解过,但是时间长易忘记,所以趁今天有时间,详细地了解一下这个算法。

为什么需要一致性 Hash

在分布式系统中,存在这么一个场景:

  • 需要将 10w + 的数据,分别缓存到 3 台服务器上

这个时候,很自然地就想到了使用数据的唯一编号 id 和 服务器的数量 3 进行取模id%3,得到的结果代表缓存的目标服务器。

image-20250904154816882

但是上面的方案在生产环境中是不可用的,因为生产环境会涉及到频繁地增加或者减少机器。如果增加一台机器,那么所有缓存数据的目标服务器都需要重新计算,会导致整个服务出现大面积不可用的情况。

image-20250904155000969

在上面这个背景下,就诞生了一致性 Hash这个算法。

一致性 Hash 的原理

想象一下:

  • 如果将上面的 %3 求模改成和一个固定值求模,那么同一数据的 Hash 是不是都是一致的?

  • 同理,如果将节点的唯一值也对一个固定值求模,那么节点的 Hash 是不是也是固定的?

基于上面的想法,我们可以将所有的节点根据 ip 对一个固定值 2^32 进行求模,得到一个 0~2^32-1 之间的值

然后放在一个 0~2^32-1 的环上

Hash 环

image-20250904161155830

根据服务器的 ip,对 2^32 进行取模,然后分布到 hash 环上

image-20250904161450070

将缓存数据存放到服务器上:

和服务器一样,将数据的唯一编号对 2^32 进行取模,得到的值进行顺时针查找,找到最近的服务器进行存放

image-20250904162035951

服务器缩容

当删掉 node1 机器后,data3 将自动找到 node2服务器,只会影响单台服务器

image-20250904162115576

服务器扩容

当增加 node4 节点后,也只会影响 data3 的数据,不会导致其他数据全部重新缓存

image-20250904162344803

数据倾斜

什么是数据倾斜呢?

![image-20250904162625960](/Users/simon/Library/Application Support/typora-user-images/image-20250904162625960.png)

可以看到数据全部都根据顺时针的查找找到了node1,导致 node1 承担了大部分的缓存任务。为了解决这个问题,引入了虚拟节点的概念

image-20250904162926197

从代码出发,如何实现一致性 Hash

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
/** 一致性哈希环 */
public static class ConsistentHash<T> {
private final NavigableMap<Long, T> ring = new TreeMap<>();
private final int virtualNodes;
private final HashFunction hash;

// 可选:记录物理节点 -> 其虚拟节点哈希,用于快速下线
private final Map<T, List<Long>> nodeToReplicas = new HashMap<>();

public ConsistentHash(int virtualNodes) {
this(virtualNodes, new MD5Hash());
}

public ConsistentHash(int virtualNodes, HashFunction hash) {
if (virtualNodes <= 0) throw new IllegalArgumentException("virtualNodes must > 0");
this.virtualNodes = virtualNodes;
this.hash = hash;
}

/** 扩容:加入一个物理节点(自动生成虚拟节点) */
public synchronized void addNode(T node) {
if (nodeToReplicas.containsKey(node)) return; // 已存在
List<Long> replicas = new ArrayList<>(virtualNodes);
for (int i = 0; i < virtualNodes; i++) {
long h = hash.hash(nodeId(node, i));
ring.put(h, node);
replicas.add(h);
}
nodeToReplicas.put(node, replicas);
}

/** 缩容:移除一个物理节点(移除其所有虚拟节点) */
public synchronized void removeNode(T node) {
List<Long> replicas = nodeToReplicas.remove(node);
if (replicas == null) return;
for (Long h : replicas) {
ring.remove(h);
}
}

/** 根据 key 定位到负责的物理节点(顺时针寻址) */
public synchronized T getNode(Object key) {
if (ring.isEmpty()) return null;
long h = hash.hash(String.valueOf(key));
Map.Entry<Long, T> entry = ring.ceilingEntry(h);
if (entry == null) {
// 超过末尾,回到环首
return ring.firstEntry().getValue();
}
return entry.getValue();
}

/** 返回当前环上的物理节点集合(仅用于演示) */
public synchronized Set<T> nodes() {
return Collections.unmodifiableSet(nodeToReplicas.keySet());
}

/** 返回环上虚拟节点数量(仅用于演示) */
public synchronized int ringSize() {
return ring.size();
}

private String nodeId(T node, int replicaIndex) {
return node.toString() + "##VN" + replicaIndex;
}
}

/** 哈希函数接口(可替换) */
public interface HashFunction {
long hash(String key);
}

/** 使用 MD5 的 32-bit 截断(与一致性哈希足够搭配) */
public static class MD5Hash implements HashFunction {
private final MessageDigest md;

public MD5Hash() {
try {
this.md = MessageDigest.getInstance("MD5");
} catch (Exception e) {
throw new RuntimeException(e);
}
}

@Override
public long hash(String key) {
byte[] digest;
synchronized (md) {
md.reset();
md.update(key.getBytes(StandardCharsets.UTF_8));
digest = md.digest();
}
// 取前 4 字节为无符号 32 位
int val = ByteBuffer.wrap(digest).getInt();
return val & 0xFFFFFFFFL;
}
}

其中使用到了 NavigableMap

  1. 基本概念
  • NavigableMap<K,V> 是 SortedMap<K,V> 的子接口,支持对键 按自然顺序 或者 自定义比较器顺序 排序。
  • 在 SortedMap 的基础上,NavigableMap 提供了更丰富的 导航(navigation)方法,可以根据某个 key 快速获取比它大/小的元素。

常见实现类:

  • TreeMap(最常用,基于红黑树实现,支持有序和高效导航)。
  1. 主要方法

    1. 查找
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    Map.Entry<K,V> lowerEntry(K key);   // 小于 key 的最大 entry
    K lowerKey(K key);

    Map.Entry<K,V> floorEntry(K key); // 小于等于 key 的最大 entry
    K floorKey(K key);

    Map.Entry<K,V> ceilingEntry(K key); // 大于等于 key 的最小 entry
    K ceilingKey(K key);

    Map.Entry<K,V> higherEntry(K key); // 大于 key 的最小 entry
    K higherKey(K key);
    1. 获取首位元素
    1
    2
    3
    4
    5
    Map.Entry<K,V> firstEntry();  // 最小 key
    Map.Entry<K,V> lastEntry(); // 最大 key

    Map.Entry<K,V> pollFirstEntry(); // 获取并删除最小 key
    Map.Entry<K,V> pollLastEntry(); // 获取并删除最大 key