1. 程式人生 > 實用技巧 >一致性Hash原理與實現

一致性Hash原理與實現

前言

網際網路公司中,絕大部分都沒有馬爸爸系列的公司那樣財大氣粗,他們即沒有強勁的伺服器、也沒有錢去購買昂貴的海量資料庫。那他們是怎麼應對大資料量高併發的業務場景的呢?
這個和當前的開源技術、海量資料架構都有著不可分割的關係。比如通過mysql、nginx等開源軟體,通過架構和低成本的伺服器搭建千萬級別的使用者訪問系統。
怎麼樣搭建一個好的系統架構,這個話題我們能聊上個七天七夜。這裡我主要結合Redis叢集來講一下一致性Hash的相關問題。

Redis叢集的使用

我們在使用Redis的過程中,為了保證Redis的高可用,我們一般會對Redis做主從複製,組成Master-Master或者Master-Slave

的形式,進行資料的讀寫分離,如下圖1-1所示:

圖1-1:Master-Slave模式

當快取資料量超過一定的數量時,我們就要對Redis叢集做分庫分表的操作。

來個栗子,我們有一個電商平臺,需要使用Redis儲存商品的圖片資源,儲存的格式為鍵值對,key值為圖片名稱,Value為該圖片所在的檔案伺服器的路徑,我們需要根據檔名,查詢到檔案所在的檔案伺服器上的路徑,我們的圖片數量大概在3000w左右,按照我們的規則進行分庫,規則就是隨機分配的,我們以每臺伺服器存500w的數量,部署12臺快取伺服器,並且進行主從複製,架構圖如下圖1-2所示: 圖1-2:Redis分庫分表

由於我們定義的規則是隨機的,所以我們的資料有可能儲存在任何一組Redis中,比如我們需要查詢"product.png"的圖片,由於規則的隨機性,我們需要遍歷所有Redis伺服器,才能查詢得到。這樣的結果顯然不是我們所需要的。所以我們會想到按某一個欄位值進行Hash值、取模。所以我們就看看使用Hash的方式是怎麼進行的。

使用Hash的Redis叢集

如果我們使用Hash的方式,每一張圖片在進行分庫的時候都可以定位到特定的伺服器,示意圖如圖1-3所示:


圖1-3:使用Hash方式的命中快取

從上圖中,我們需要查詢的是圖product.png,由於我們有6臺主伺服器,所以計算的公式為:hash(product.png) % 6 = 5, 我們就可以定位到是5號主從,這們就省去了遍歷所有伺服器的時間,從而大大提升了效能。

使用Hash時遇到的問題

在上述hash取模的過程中,我們雖然不需要對所有Redis伺服器進行遍歷而提升了效能。但是,使用Hash演算法快取時會出現一些問題,Redis伺服器變動時,所有快取的位置都會發生改變


比如,現在我們的Redis快取伺服器增加到了8臺,我們計算的公式從hash(product.png) % 6 = 5變成了hash(product.png) % 8 = ? 結果肯定不是原來的5了。
再者,6臺的伺服器叢集中,當某個主從群出現故障時,無法進行快取,那我們需要把故障機器移除,所以取模數又會從6變成了5。我們計算的公式也會變化。

由於上面hash演算法是使用取模來進行快取的,為了規避上述情況,Hash一致性演算法就誕生了~~

一致性Hash演算法原理

一致性Hash演算法也是使用取模的方法,不過,上述的取模方法是對伺服器的數量進行取模,而一致性的Hash演算法是對2的32方取模。即,一致性Hash演算法將整個Hash空間組織成一個虛擬的圓環,Hash函式的值空間為0 ~ 2^32 - 1(一個32位無符號整型),整個雜湊環如下:

圖1-4:Hash圓環
整個圓環以順時針方向組織,圓環正上方的點代表0,0點右側的第一個點代表1,以此類推。
第二步,我們將各個伺服器使用Hash進行一個雜湊,具體可以選擇伺服器的IP或主機名作為關鍵字進行雜湊,這樣每臺伺服器就確定在了雜湊環的一個位置上,比如我們有三臺機器,使用IP地址雜湊後在環空間的位置如圖1-4所示:
圖1-4:伺服器在雜湊環上的位置

現在,我們使用以下演算法定位資料訪問到相應的伺服器:

將資料Key使用相同的函式Hash計算出雜湊值,並確定此資料在環上的位置,從此位置沿環順時針查詢,遇到的伺服器就是其應該定位到的伺服器。

例如,現在有ObjectA,ObjectB,ObjectC三個資料物件,經過雜湊計算後,在環空間上的位置如下:


圖1-5:資料物件在環上的位置

根據一致性演算法,Object -> NodeA,ObjectB -> NodeB, ObjectC -> NodeC

一致性Hash演算法的容錯性和可擴充套件性

現在,假設我們的Node C宕機了,我們從圖中可以看到,A、B不會受到影響,只有Object C物件被重新定位到Node A。所以我們發現,在一致性Hash演算法中,如果一臺伺服器不可用,受影響的資料僅僅是此伺服器到其環空間前一臺伺服器之間的資料(這裡為Node C到Node B之間的資料),其他不會受到影響。如圖1-6所示:


圖1-6:C節點宕機情況,資料移到節點A上

另外一種情況,現在我們系統增加了一臺伺服器Node X,如圖1-7所示:


圖1-7:增加新的伺服器節點X

此時物件ObjectA、ObjectB沒有受到影響,只有Object C重新定位到了新的節點X上。
如上所述:

一致性Hash演算法對於節點的增減都只需重定位環空間中的一小部分資料,有很好的容錯性和可擴充套件性。

資料傾斜問題

在一致性Hash演算法服務節點太少的情況下,容易因為節點分佈不均勻面造成資料傾斜(被快取的物件大部分快取在某一臺伺服器上)問題,如圖1-8特例:

圖1-8:資料傾斜
這時我們發現有大量資料集中在節點A上,而節點B只有少量資料。為了解決資料傾斜問題,一致性Hash演算法引入了虛擬節點機制,即對每一個伺服器節點計算多個雜湊,每個計算結果位置都放置一個此服務節點,稱為虛擬節點。
具體操作可以為伺服器IP或主機名後加入編號來實現,實現如圖1-9所示:
圖1-9:增加虛擬節點情況

資料定位演算法不變,只需要增加一步:虛擬節點到實際點的對映。
所以加入虛擬節點之後,即使在服務節點很少的情況下,也能做到資料的均勻分佈。

具體實現

演算法介面類
public interface IHashService {
    Long hash(String key);
}

演算法介面實現類
public class HashService implements IHashService {

    /**
     * MurMurHash演算法,效能高,碰撞率低
     *
     * @param key String
     * @return Long
     */
    public Long hash(String key) {
        ByteBuffer buf = ByteBuffer.wrap(key.getBytes());
        int seed = 0x1234ABCD;

        ByteOrder byteOrder = buf.order();
        buf.order(ByteOrder.LITTLE_ENDIAN);

        long m = 0xc6a4a7935bd1e995L;
        int r = 47;

        long h = seed ^ (buf.remaining() * m);

        long k;
        while (buf.remaining() >= 8) {
            k = buf.getLong();

            k *= m;
            k ^= k >>> r;
            k *= m;

            h ^= k;
            h *= m;
        }

        if (buf.remaining() > 0) {
            ByteBuffer finish = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN);
            finish.put(buf).rewind();
            h ^= finish.getLong();
            h *= m;
        }

        h ^= h >>> r;
        h *= m;
        h ^= h >>> r;

        buf.order(byteOrder);
        return h;

    }
}

模擬機器節點
public class Node<T> {
    private String ip;
    private String name;

    public Node(String ip, String name) {
        this.ip = ip;
        this.name = name;
    }

    public String getIp() {
        return ip;
    }

    public void setIp(String ip) {
        this.ip = ip;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    /**
     * 使用IP當做hash的Key
     *
     * @return String
     */
    @Override
    public String toString() {
        return ip;
    }
}

一致性Hash操作
public class ConsistentHash<T> {
    // Hash函式介面
    private final IHashService iHashService;
    // 每個機器節點關聯的虛擬節點數量
    private final int          numberOfReplicas;
    // 環形虛擬節點
    private final SortedMap<Long, T> circle = new TreeMap<Long, T>();

    public ConsistentHash(IHashService iHashService, int numberOfReplicas, Collection<T> nodes) {
        this.iHashService = iHashService;
        this.numberOfReplicas = numberOfReplicas;
        for (T node : nodes) {
            add(node);
        }
    }

    /**
     * 增加真實機器節點
     *
     * @param node T
     */
    public void add(T node) {
        for (int i = 0; i < this.numberOfReplicas; i++) {
            circle.put(this.iHashService.hash(node.toString() + i), node);
        }
    }

    /**
     * 刪除真實機器節點
     *
     * @param node T
     */
    public void remove(T node) {
        for (int i = 0; i < this.numberOfReplicas; i++) {
            circle.remove(this.iHashService.hash(node.toString() + i));
        }
    }

    public T get(String key) {
        if (circle.isEmpty()) return null;

        long hash = iHashService.hash(key);

        // 沿環的順時針找到一個虛擬節點
        if (!circle.containsKey(hash)) {
            SortedMap<Long, T> tailMap = circle.tailMap(hash);
            hash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey();
        }
        return circle.get(hash);
    }
}


測試類
public class TestHashCircle {
    // 機器節點IP字首
    private static final String IP_PREFIX = "192.168.0.";

    public static void main(String[] args) {
        // 每臺真實機器節點上儲存的記錄條數
        Map<String, Integer> map = new HashMap<String, Integer>();

        // 真實機器節點, 模擬10臺
        List<Node<String>> nodes = new ArrayList<Node<String>>();
        for (int i = 1; i <= 10; i++) {
            map.put(IP_PREFIX + i, 0); // 初始化記錄
            Node<String> node = new Node<String>(IP_PREFIX + i, "node" + i);
            nodes.add(node);
        }

        IHashService iHashService = new HashService();
        // 每臺真實機器引入100個虛擬節點
        ConsistentHash<Node<String>> consistentHash = new ConsistentHash<Node<String>>(iHashService, 500, nodes);

        // 將5000條記錄儘可能均勻的儲存到10臺機器節點上
        for (int i = 0; i < 5000; i++) {
            // 產生隨機一個字串當做一條記錄,可以是其它更復雜的業務物件,比如隨機字串相當於物件的業務唯一標識
            String data = UUID.randomUUID().toString() + i;
            // 通過記錄找到真實機器節點
            Node<String> node = consistentHash.get(data);
            // 再這裡可以能過其它工具將記錄儲存真實機器節點上,比如MemoryCache等
            // ...
            // 每臺真實機器節點上儲存的記錄條數加1
            map.put(node.getIp(), map.get(node.getIp()) + 1);
        }

        // 列印每臺真實機器節點儲存的記錄條數
        for (int i = 1; i <= 10; i++) {
            System.out.println(IP_PREFIX + i + "節點記錄條數:" + map.get(IP_PREFIX + i));
        }
    }
}

執行結果如下:

一致性hash測試結果

每臺機器對映的虛擬節點越多,則分佈的越均勻~~~
感興趣的同學可以拷貝上面的程式碼執行嘗試一下。