1. 程式人生 > >一致性Hash演算法的實現

一致性Hash演算法的實現

一致性hash作為一個負載均衡演算法,可以用在分散式快取、資料庫的分庫分表等場景中,還可以應用在負載均衡器中作為作為負載均衡演算法。在有多臺伺服器時,對於某個請求資源通過hash演算法,對映到某一個臺伺服器,當增加或減少一臺伺服器時,可能會改變這些資源對應的hash值,這樣可能導致一部分快取或資料失效了。一致性hash就是儘可能在將同一個資源請求路由到同一臺伺服器中。

本篇文章將模擬實現一個分散式快取系統來探討在使用了一致性hash以及普通hash在增加、刪除節點之後,對資料分佈、快取命中率的影響

節點&叢集設計

在一個分散式快取系統中,每臺機器可以認為是一個節點,節點作為資料儲存的地方,由一些節點來組成一個叢集。我們先來設計我們的節點和叢集。

節點

@Data
public class Node {

    private String domain;

    private String ip;

    private Map<String, Object> data;

    public <T> void put(String key, T value) {
        data.put(key, value);
    }

    public void remove(String key){
        data.remove(key);
    }

    public
<T> T get(String key) { return (T) data.get(key); } }

一個節點包括domain(域名),ip(IP地址),data(節點儲存資料),節點可以存放、刪除、獲取資料。

叢集

public abstract class Cluster {

    protected List<Node> nodes;

    public Cluster() {
        this.nodes = new ArrayList<>();
    }

    public abstract
void addNode(Node node); public abstract void removeNode(Node node); public abstract Node get(String key); }

在一個叢集中包含多個節點,可以在一個叢集中,增加、刪除節點。還可以通過key獲取資料儲存的節點。

取模

在使用取模的場景中,當一個請求資源,請求某個叢集時,通過對請求資源進行hash得到的值,然後對儲存叢集的節點數取模來得到,該請求資源,應該儲存到哪一個儲存節點。

具體實現如下:

public class NormalHashCluster extends Cluster {

    public NormalHashCluster() {
        super();
    }

    @Override
    public void addNode(Node node) {
        this.nodes.add(node);
    }

    @Override
    public void removeNode(Node node) {
        this.nodes.removeIf(o -> o.getIp().equals(node.getIp()) ||
                o.getDomain().equals(node.getDomain()));
    }

    @Override
    public Node get(String key) {
        long hash = hash(key);
        long index =  hash % nodes.size();
        return nodes.get((int)index);
    }
}

下面我們對該演算法,在資料分佈、增加一個節點、刪除一個節點對快取的命中率影響做一個測試

        Cluster cluster = new NormalHashCluster();
        cluster.addNode(new Node("c1.yywang.info", "192.168.0.1"));
        cluster.addNode(new Node("c2.yywang.info", "192.168.0.2"));
        cluster.addNode(new Node("c3.yywang.info", "192.168.0.3"));
        cluster.addNode(new Node("c4.yywang.info", "192.168.0.4"));

        IntStream.range(0, DATA_COUNT)
                .forEach(index -> {
                    Node node = cluster.get(PRE_KEY + index);
                    node.put(PRE_KEY + index, "Test Data");
                });

        System.out.println("資料分佈情況:");
        cluster.nodes.forEach(node -> {
            System.out.println("IP:" + node.getIp() + ",資料量:" + node.getData().size());
        });

        //快取命中率
        long hitCount = IntStream.range(0, DATA_COUNT)
                .filter(index -> cluster.get(PRE_KEY + index).get(PRE_KEY + index) != null)
                .count();
        System.out.println("快取命中率:" + hitCount * 1f / DATA_COUNT);

在初始狀態下,資料的分佈和快取命中率如下:

資料分佈情況:
IP:192.168.0.1,資料量:12499
IP:192.168.0.2,資料量:12501
IP:192.168.0.3,資料量:12499
IP:192.168.0.4,資料量:12501
快取命中率:1.0

從以上資料可以看出,資料分佈較均勻,在不增不減節點的情況下,快取全部命中

我們新增一個節點

//增加一個節點
cluster.addNode(new Node("c5.yywang.info", "192.168.0.5"));

這時快取命中率

增加一個節點的快取命中率:0.19808

我們來刪除一個節點

cluster.removeNode(new Node("c4.yywang.info", "192.168.0.4"));

這時快取命中率

刪除快取命中率:0.25196

從以上可以看出,通過取模演算法,在增加節點、刪除節點時,將對快取命中率產生極大的影響,所以在該場景中如果使用取模運算將會產生很多的資料遷移量。

一致性hash

為了解決以上取模運算的缺點,我們引入一致性hash演算法,一致性hash演算法的原理如下:

首先我們把2的32次方想象成一個環,比如:

hash1

假如我們有四臺伺服器分佈這個環上,其中Node1,Node2,Node3,Node4就表示這四臺伺服器在環上的位置,一致性hash演算法就是,在快取的Key的值計算後得到的hash值,對映到這個環上的點,然後這些點按照順時針方向找,找到離自己最近的一個物理節點就是自己要儲存的節點。

當我們增加了一個節點如下:

hash2

我們增加了Node5放在Node3和Node4之間,這時我們可以看到增加了一個節點只會影響Node3至Node5之間的資料,其他節點的資料不會受到影響。同時我們還可以看到,Node4和Node5的壓力要小於其他節點,大約是其他節點的一半。這樣就帶了壓力分佈均勻的情況,假定Node4和Node5的機器配置和其它的節點機器配置相同,那麼Node4和Node5的機器資源就浪費了一半,那麼怎麼解決這個問題呢?

我們引入虛擬節點,簡單來說,虛擬節點就是不存在的點,這些虛擬節點儘量的分佈在環上,需要做的就是把這些虛擬節點需要對映到物理節點。

hash3

在引入虛擬節點後,我們把虛擬節點上均勻的分佈到環上,然後把虛擬節點對映到物理節點,當增加了新的機器後,我們只需要把虛擬節點對映到新的機器即可,這樣就解決了機器壓力分佈不均勻的情況

上面我們說了一致性hash的基本演算法,下面我們來看下具體實現

public class ConsistencyHashCluster extends Cluster {

    private SortedMap<Long, Node> virNodes = new TreeMap<Long, Node>();

    private static final int VIR_NODE_COUNT = 512;

    private static final String SPLIT = "#";

    public ConsistencyHashCluster() {
        super();
    }

    @Override
    public void addNode(Node node) {
        this.nodes.add(node);
        IntStream.range(0, VIR_NODE_COUNT)
                .forEach(index -> {
                    long hash = hash(node.getIp() + SPLIT + index);
                    virNodes.put(hash, node);
                });
    }

    @Override
    public void removeNode(Node node) {
        nodes.removeIf(o -> node.getIp().equals(o.getIp()));
        IntStream.range(0, VIR_NODE_COUNT)
                .forEach(index -> {
                    long hash = hash(node.getIp() + SPLIT + index);
                    virNodes.remove(hash);
                });
    }

    @Override
    public Node get(String key) {
        long hash = hash(key);
        SortedMap<Long, Node> subMap = hash >= virNodes.lastKey() ? virNodes.tailMap(0L) : virNodes.tailMap(hash);
        if (subMap.isEmpty()) {
            return null;
        }
        return subMap.get(subMap.firstKey());
    }
}

下面我們同樣對一致性hash演算法,在資料分佈、增加一個節點、刪除一個節點對快取的命中率影響做一個測試

測試程式碼很簡單,我們只需要把以上的程式碼替換成ConsistencyHashCluster實現即可

//        Cluster cluster = new NormalHashCluster();
        Cluster cluster=new ConsistencyHashCluster();

在初始狀態下,資料的分佈和快取命中率如下:

資料分佈情況:
IP:192.168.0.1,資料量:15345
IP:192.168.0.2,資料量:14084
IP:192.168.0.3,資料量:10211
IP:192.168.0.4,資料量:10360
快取命中率:1.0

從以上資料可以看出,資料分佈相對均勻,沒有取模演算法的均勻,在不增不減節點的情況下,快取全部命中

我們增加一個節點

cluster.addNode(new Node("c" + 5 + ".yywang.info", "192.168.0." + 5));

這時快取命中率

增加一個節點的快取命中率:0.82154

可以看出快取命中率明顯高於取模運算的命中率

我們刪除一個節點

cluster.removeNode(new Node("c4.yywang.info", "192.168.0.4"));

這時快取命中率

刪除一個節點的快取命中率:0.7928

從以上可以看出,我們可以看出使用一致性hash演算法,可以極大的提高快取的命中率,減少在增加節點、刪除節點時,資料遷移的成本。

歡迎關注我的公眾號MyArtNote

MyArtNote