一致性哈希算法(Consistent Hashing)

一致性哈希算法(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的环形空间中,同时注意,这里的对象包括服务器节点对象和数据对象。如下图所示:

consistent hashing的映射空间

为什么选择232这个数呢?因为hash算法所得结果一般是一个unsigned int类型。

因此,算法的步骤如下:

  1. 计算节点的hashcode,并将其映射在[0, 232-1]环形空间中(hash(node)%232)。
  2. 计算数据对象的hashcode,并映射到[0, 232-1]环形空间中(hash(obj)%232)。
  3. 在映射空间中,根据顺时针方向,数据对象找到离其最近的节点,即为该数据的存储(缓存)位置。

下图形象地描述了算法的原理。


consistent hashing 算法原理

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 {

    // hash函数
    private final HashFunction hashFunction;
    // 虚拟节点副本数
    private final int numberOfReplicas;
    // 映射圆环
    private final SortedMap circle = new TreeMap();

    // 构造方法
    public ConsistentHashing(HashFunction hashFunction, int numberOfReplicas, Collection 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 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算法的基本原理,并进行了代码实现。

五、参考资料

  1. Consistent Hashing算法
  2. 一致性hash算法:jump Consistent hash(零内存消耗,均匀,快速,简洁)
  3. 一致性hash(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

你可能感兴趣的:(一致性哈希算法(Consistent Hashing))