一致性哈希算法(Consistent Hashing )
一、问题
分布式架构中,无论是数据缓存还是持久化存储,都需要考虑负载均衡的问题,期望将对后台的访问或者数据存储能够尽可能地“平均”分发给每一个服务器节点。
为满足上述需求,最容易想到的一个办法就是hash,假设有N个节点,根据hash(obj)%N的结果来判断应该访问哪一个节点。理论上,如果能保证hash函数的随机性,则能够实现负载均衡的需求。然而,在实际情况中,节点具有不可预测的运行故障,如果集群中的某个节点宕机,那么节点数量就从N变成了N-1,若使用hash(obj)%N的方法实现负载均衡,此时将有(N-1)/N的数据发生迁移(即有(N-1)/N的数据hash(obj)%N != hash(obj)%(N-1),这个结论的推导文末会说明)。这将是一次规模很大的数据迁移,几乎之前所有的结果都将重新计算。同时,若集群规模不足以满足需求,需要扩充服务器节点时(节点数量从N变到N+1),也将发生类似问题。此时,基本的散列取余的方法非常低效。
注:上述问题其实是不满足hash函数的单调性,这是衡量hash函数好坏的一个指标。具体请见一致性hash(consistent hashing)。
二、算法原理
2.1 算法简析
一致性哈希算法在1997年由麻省理工学院的Karger等人提出,用以解决分布式Cache的负载均衡问题。这是个能够保证单调性的hash算法。对于有N个节点的集群,最常见的hash(obj)%N算法首先计算出数据对象的hashcode,然后映射到0~N-1的N点环形空间中。而Consistent Hashing则将对象映射到0~232-1的环形空间中,同时注意,这里的对象包括服务器节点对象和数据对象。如下图所示:
为什么选择232这个数呢?因为hash算法所得结果一般是一个unsigned int类型。
因此,算法的步骤如下:
- 计算节点的hashcode,并将其映射在[0, 232-1]环形空间中(hash(node)%232)。
- 计算数据对象的hashcode,并映射到[0, 232-1]环形空间中(hash(obj)%232)。
- 在映射空间中,根据顺时针方向,数据对象找到离其最近的节点,即为该数据的存储(缓存)位置。
下图形象地描述了算法的原理。
CacheA, CacheB, CacheC表示三个节点,object1, object2, object3, object4表示四个数据对象。根据图中的映射结果,我们可以看出存储的分布为:{CacheA: [object1]}, {CacheB: [object4]}, {CacheC: [object2, object3]}。
那么回到一开始的问题,当节点增加或者减少时,Consistent Hashing算法是如何保证单调性的?
2.2 移除节点
假如此时CacheB节点宕机,从映射空间中移除。则根据顺时针查找的原理,object4将发现离它最近的节点变成了了CacheC,此时数据分布变成了:{CacheA: [object1]}, {CacheC: [object2, object3, object4]}。
可以看出,只有object4发生了数据迁移,或者说只有CacheB存储的数据发生了迁移,而原本存储在其他节点上的数据依然维持原来的分布。
2.3 增加节点
当扩容时,增加一个节点CacheD,根据计算,映射在如下的位置上,则object2发现离他最近的节点变成了CacheD,则数据分布发生变化:{CacheA: [object1]}, {CacheB: [object4]}, {CacheC: [object3]}, {CacheD: [object2]}。
此时,只有object2从CacheC迁移到CacheD上,而其他数据依然维持原来的分布关系。
这就满足了hash算法的单调性:
内容通过hash算法分布到相应的节点中,若此时增加一个新节点,则旧节点中的内容要不就保持原来的分布关系,要不就分布到新增的节点中,而不会分布到其他的旧节点中。
2.4 虚拟节点(Virtual nodes)
为了衡量算法的稳定性,我们常常考虑一下极端场景。比如上面那张“移除节点”的图,当节点的数量比较少时,只有CacheA和CacheC,CacheA节点只存储一个数据,而CacheC却存储了3个数据,此时数据分布不均匀。
虽然是极端情况,但却很具备现实意义,毕竟常见的集群规模与232比都是微不足道了,极有可能发生上述的数据分布不均匀的情况,或者说平衡性问题。
为此引入虚拟节点。
如上图所示,CacheA2和CacheC2分别是CacheA1和CacheC1的虚拟节点,同样映射到了[0, 232-1]圆环空间。此时,逻辑上的数据分布为:{CacheA1: [object2]}, {CacheA2: [object1]}, {CacheC1: [object3]}, {CacheC2: [object4]}。
要注意的是,虚拟节点之所以是虚拟的,是因为它们仅仅是逻辑上存在的,数据存储的物理位置是其对应的真实节点。所以,物理上的数据分布为:{CacheA1: [object1, object2]}, {CacheC1: [object3, object4]}。
对比引入虚拟节点之前的数据分布:{CacheA1: [object1, object2, object4]}, {CacheC1: [object3]}。可以发现,虚拟节点的引入改善了consistent hashing算法的平衡性。
如果要引入虚拟节点,则引入的数量应当如何选择呢?
上图横轴为虚拟节点的倍数,纵轴为真实节点的个数。蓝线表示为保证hash算法的平衡性,节点数量与虚拟节点副本倍数的关系。仅供参考。
除了解决平衡性问题,节点的异构性也能通过引入虚拟节点加以解决。异构性通俗的讲就是不同服务器节点的存储、算力等性能是不一样的,一个完美的hash算法是连节点的异构性都能考虑进去的。根据每个节点的性能算出一个量化的权重,根据这个权重来计算这个节点引入虚拟节点的数量。性能差的,虚拟节点少点,分布的数据就少点,让他放轻松,别紧张。性能好的,就多整点,对着他干就对了。
三、代码实现
使用Java实现Consistent hashing。
package cn.edu.njupt.qyz;
import java.util.Collection;
import java.util.SortedMap;
import java.util.TreeMap;
public class ConsistentHashing<T> {
// hash函数
private final HashFunction hashFunction;
// 虚拟节点副本数
private final int numberOfReplicas;
// 映射圆环
private final SortedMap<Integer, T> circle = new TreeMap<Integer, T>();
// 构造方法
public ConsistentHashing(HashFunction hashFunction, int numberOfReplicas, Collection<T> nodes) {
this.hashFunction = hashFunction;
this.numberOfReplicas = numberOfReplicas;
for (T node : nodes) {
add(node);
}
}
// 将服务器节点映射到圆环上,同时加入对应的虚拟节点
public void add(T node) {
for (int i = 0; i < numberOfReplicas; i++) {
circle.put(hashFunction.hash(node.toString() + i), node);
}
}
// 移除映射,同时移除虚拟节点
public void remove(T node) {
for (int i = 0; i < numberOfReplicas; i++) {
circle.remove(hashFunction.hash(node.toString() + i));
}
}
// 根据传入的obj找到对应的节点
public T get(Object key) {
if (circle.isEmpty()) {
return null;
}
int hash = hashFunction.hash(key);
if (!circle.containsKey(hash)) {
// 从比obj的hashcode更大的Map中找,即顺时针方向找node
SortedMap<Integer, T> tailMap = circle.tailMap(hash);
// 环形空间,找到顺时针方向第一个node,并将其hashcode赋值
hash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey();
}
// 返回找到的节点
return circle.get(hash);
}
}
代码使用具有排序能力的的TreeMap实现环形映射结构,首先将节点加入到TreeMap中,然后定义一个get方法,根据传入的key对象来查找对应的节点。
四、总结
为了解决分布式架构的负载均衡问题,并保证hash算法的单调性,Consistent Hashing算法应运而生。同时,为了保证算法的平衡性,引入虚拟节点以达到令数据均匀分布在各个节点的目的。本文介绍了Consistent Hashing算法的基本原理,并进行了代码实现。
五、参考资料
推导
对hash(obj)%N的结果呈[0, N-1]循环,hash(obj)%(N-1)的结果为[0, N-2]的循环。N和N-1的最小公倍数为N(N-1),则二者的结果每隔N(N-1)个数据发生一次重叠,而每次重叠有N-1个结果是相等的,且有(N-1)2个结果不相等,因此全部的数据中有(N-1)/N的不相等,将发生数据迁移。可以参考下表。
hashCode | N=2 | N=3 | N=4 | N=5 |
---|---|---|---|---|
0 | 0 | 0 | 0 | 0 |
1 | 1 | 1 | 1 | 1 |
2 | 0 | 2 | 2 | 2 |
3 | 1 | 0 | 3 | 3 |
4 | 0 | 1 | 0 | 4 |
5 | 1 | 2 | 1 | 0 |
6 | 0 | 0 | 2 | 1 |
7 | 1 | 1 | 3 | 2 |
8 | 0 | 2 | 0 | 3 |
9 | 1 | 0 | 1 | 4 |
10 | 0 | 1 | 2 | 0 |
11 | 1 | 2 | 3 | 1 |
12 | 0 | 0 | 0 | 2 |
13 | 1 | 1 | 1 | 3 |
14 | 0 | 2 | 2 | 4 |