1. 程式人生 > >分散式演算法(一致性Hash演算法)

分散式演算法(一致性Hash演算法)

一、分散式演算法

    在做伺服器負載均衡時候可供選擇的負載均衡的演算法有很多,包括: 輪循演算法(Round Robin)、雜湊演算法(HASH)、最少連線演算法(Least Connection)、響應速度演算法(Response Time)、加權法(Weighted )等。其中雜湊演算法是最為常用的演算法.

  典型的應用場景是: 有N臺伺服器提供快取服務,需要對伺服器進行負載均衡,將請求平均分發到每臺伺服器上,每臺機器負責1/N的服務。

  常用的演算法是對hash結果取餘數 (hash() mod N ):對機器編號從0到N-1,按照自定義的 hash()演算法,對每個請求的hash()值按N取模,得到餘數i,然後將請求分發到編號為i的機器。但這樣的演算法方法存在致命問題,如果某一臺機器宕機,那麼應該落在該機器的請求就無法得到正確的處理,這時需要將當掉的伺服器從演算法從去除,此時候會有(N-1)/N的伺服器的快取資料需要重新進行計算;如果新增一臺機器,會有N /(N+1)的伺服器的快取資料需要進行重新計算。對於系統而言,這通常是不可接受的顛簸(因為這意味著大量快取的失效或者資料需要轉移)。那麼,如何設計一個負載均衡策略,使得受到影響的請求儘可能的少呢?

  在Memcached、Key-Value Store 、Bittorrent DHT、LVS中都採用了Consistent Hashing演算法,可以說Consistent Hashing 是分散式系統負載均衡的首選演算法。

二、分散式快取問題

   在大型web應用中,快取可算是當今的一個標準開發配置了。在大規模的快取應用中,應運而生了分散式快取系統。分散式快取系統的基本原理,大家也有所耳聞。key-value如何均勻的分散到叢集中?說到此,最常規的方式莫過於hash取模的方式。比如叢集中可用機器適量為N,那麼key值為K的的資料請求很簡單的應該路由到hash(K) mod N對應的機器。的確,這種結構是簡單的,也是實用的。但是在一些高速發展的web系統中,這樣的解決方案仍有些缺陷。隨著系統訪問壓力的增長,快取系統不得不通過增加機器節點的方式提高叢集的相應速度和資料承載量。增加機器意味著按照hash取模的方式,在增加機器節點的這一時刻,大量的快取命不中,快取資料需要重新建立,甚至是進行整體的快取資料遷移,瞬間會給DB帶來極高的系統負載,設定導致DB伺服器宕機。 那麼就沒有辦法解決hash取模的方式帶來的詬病嗎?

 假設我們有一個網站,最近發現隨著流量增加,伺服器壓力越來越大,之前直接讀寫資料庫的方式不太給力了,於是我們想引入Memcached作為快取機制。現在我們一共有三臺機器可以作為Memcached伺服器,如下圖所示:

    很顯然,最簡單的策略是將每一次Memcached請求隨機發送到一臺Memcached伺服器,但是這種策略可能會帶來兩個問題:一是同一份資料可能被存在不同的機器上而造成資料冗餘,二是有可能某資料已經被快取但是訪問卻沒有命中,因為無法保證對相同key的所有訪問都被髮送到相同的伺服器。因此,隨機策略無論是時間效率還是空間效率都非常不好。

    要解決上述問題只需做到如下一點:保證對相同key的訪問會被髮送到相同的伺服器。很多方法可以實現這一點,最常用的方法是計算雜湊。例如對於每次訪問,可以按如下演算法計算其雜湊值:

h = Hash(key) % 3

    其中Hash是一個從字串到正整數的雜湊對映函式。這樣,如果我們將Memcached Server分別編號為0、1、2,那麼就可以根據上式和key計算出伺服器編號h,然後去訪問。

這個方法雖然解決了上面提到的兩個問題,但是存在一些其它的問題。如果將上述方法抽象,可以認為通過:

h = Hash(key) % N

    這個算式計算每個key的請求應該被髮送到哪臺伺服器,其中N為伺服器的臺數,並且伺服器按照0 – (N-1)編號。

    這個演算法的問題在於容錯性和擴充套件性不好。所謂容錯性是指當系統中某一個或幾個伺服器變得不可用時,整個系統是否可以正確高效執行;而擴充套件性是指當加入新的伺服器後,整個系統是否可以正確高效執行。

   現假設有一臺伺服器宕機了,那麼為了填補空缺,要將宕機的伺服器從編號列表中移除,後面的伺服器按順序前移一位並將其編號值減一,此時每個key就要按h = Hash(key) % (N-1)重新計算;同樣,如果新增了一臺伺服器,雖然原有伺服器編號不用改變,但是要按h = Hash(key) % (N+1)重新計算雜湊值。因此係統中一旦有伺服器變更,大量的key會被重定位到不同的伺服器從而造成大量的快取不命中。而這種情況在分散式系統中是非常糟糕的。

一個設計良好的分散式雜湊方案應該具有良好的單調性,即服務節點的增減不會造成大量雜湊重定位。一致性雜湊演算法就是這樣一種雜湊方案。

Hash 演算法的一個衡量指標是單調性( Monotonicity ),定義如下:單調性是指如果已經有一些內容通過雜湊分派到了相應的緩衝中,又有新的緩衝加入到系統中。雜湊的結果應能夠保證原有已分配的內容可以被對映到新的緩衝中去,而不會被對映到舊的緩衝集合中的其他緩衝區。

容易看到,上面的簡單 hash 演算法 hash(object)%N 難以滿足單調性要求

三、一致性雜湊演算法的理解

1、演算法簡述

     一致性雜湊演算法(Consistent Hashing Algorithm)是一種分散式演算法,常用於負載均衡。Memcached client也選擇這種演算法,解決將key-value均勻分配到眾多Memcached server上的問題。它可以取代傳統的取模操作,解決了取模操作無法應對增刪Memcached Server的問題(增刪server會導致同一個key,在get操作時分配不到資料真正儲存的server,命中率會急劇下降)。

  簡單來說,一致性雜湊將整個雜湊值空間組織成一個虛擬的圓環,如假設某雜湊函式H的值空間為0 - (2^32)-1(即雜湊值是一個32位無符號整形),整個雜湊空間環如下:

 

    整個空間按順時針方向組織。0和(2^32)-1在零點中方向重合。

    下一步將各個伺服器使用H進行一個雜湊,具體可以選擇伺服器的ip或主機名作為關鍵字進行雜湊,這樣每臺機器就能確定其在雜湊環上的位置,這裡假設將上文中三臺伺服器使用ip地址雜湊後在環空間的位置如下:

     接下來使用如下演算法定位資料訪問到相應伺服器:將資料key使用相同的函式H計算出雜湊值h,通根據h確定此資料在環上的位置,從此位置沿環順時針“行走”,第一臺遇到的伺服器就是其應該定位到的伺服器。

    例如我們有A、B、C、D四個資料物件,經過雜湊計算後,在環空間上的位置如下:

 根據一致性雜湊演算法,資料A會被定為到Server 1上,D被定為到Server 3上,而B、C分別被定為到Server 2上。

2、容錯性與可擴充套件性分析

   下面分析一致性雜湊演算法的容錯性和可擴充套件性。現假設Server 3宕機了:

 

 

  可以看到此時A、C、B不會受到影響,只有D節點被重定位到Server 2。一般的,在一致性雜湊演算法中,如果一臺伺服器不可用,則受影響的資料僅僅是此伺服器到其環空間中前一臺伺服器(即順著逆時針方向行走遇到的第一臺伺服器)之間資料,其它不會受到影響。

  下面考慮另外一種情況,如果我們在系統中增加一臺伺服器Memcached Server 4:

     此時A、D、C不受影響,只有B需要重定位到新的Server 4。一般的,在一致性雜湊演算法中,如果增加一臺伺服器,則受影響的資料僅僅是新伺服器到其環空間中前一臺伺服器(即順著逆時針方向行走遇到的第一臺伺服器)之間資料,其它不會受到影響。

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

3、虛擬節點

    一致性雜湊演算法在服務節點太少時,容易因為節點分部不均勻而造成資料傾斜問題。例如我們的系統中有兩臺伺服器,其環分佈如下:

   此時必然造成大量資料集中到Server 1上,而只有極少量會定位到Server 2上。為了解決這種資料傾斜問題,一致性雜湊演算法引入了虛擬節點機制,即對每一個服務節點計算多個雜湊,每個計算結果位置都放置一個此服務節點,稱為虛擬節點。具體做法可以在伺服器ip或主機名的後面增加編號來實現。例如上面的情況,我們決定為每臺伺服器計算三個虛擬節點,於是可以分別計算“Memcached Server 1#1”、“Memcached Server 1#2”、“Memcached Server 1#3”、“Memcached Server 2#1”、“Memcached Server 2#2”、“Memcached Server 2#3”的雜湊值,於是形成六個虛擬節點:

 4、JAVA實現

 

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Collection;
import java.util.SortedMap;
import java.util.TreeMap;

/**
 * 一致性Hash演算法
 *
 * @param <T> 節點型別
 */
public class ConsistentHash<T> {
    /**
     * Hash計算物件,用於自定義hash演算法
     */
    HashFunc hashFunc;
    /**
     * 複製的節點個數
     */
    private final int numberOfReplicas;
    /**
     * 一致性Hash環
     */
    private final SortedMap<Long, T> circle = new TreeMap<>();

    /**
     * 構造,使用Java預設的Hash演算法
     * @param numberOfReplicas 複製的節點個數,增加每個節點的複製節點有利於負載均衡
     * @param nodes            節點物件
     */
    public ConsistentHash(int numberOfReplicas, Collection<T> nodes) {
        this.numberOfReplicas = numberOfReplicas;
        this.hashFunc = new HashFunc() {

            @Override
            public Long hash(Object key) {
//                return fnv1HashingAlg(key.toString());
                return md5HashingAlg(key.toString());
            }
        };
        //初始化節點
        for (T node : nodes) {
            add(node);
        }
    }

    /**
     * 構造
     * @param hashFunc         hash演算法物件
     * @param numberOfReplicas 複製的節點個數,增加每個節點的複製節點有利於負載均衡
     * @param nodes            節點物件
     */
    public ConsistentHash(HashFunc hashFunc, int numberOfReplicas, Collection<T> nodes) {
        this.numberOfReplicas = numberOfReplicas;
        this.hashFunc = hashFunc;
        //初始化節點
        for (T node : nodes) {
            add(node);
        }
    }

    /**
     * 增加節點<br>
     * 每增加一個節點,就會在閉環上增加給定複製節點數<br>
     * 例如複製節點數是2,則每呼叫此方法一次,增加兩個虛擬節點,這兩個節點指向同一Node
     * 由於hash演算法會呼叫node的toString方法,故按照toString去重
     *
     * @param node 節點物件
     */
    public void add(T node) {
        for (int i = 0; i < numberOfReplicas; i++) {
            circle.put(hashFunc.hash(node.toString() + i), node);
        }
    }

    /**
     * 移除節點的同時移除相應的虛擬節點
     *
     * @param node 節點物件
     */
    public void remove(T node) {
        for (int i = 0; i < numberOfReplicas; i++) {
            circle.remove(hashFunc.hash(node.toString() + i));
        }
    }

    /**
     * 獲得一個最近的順時針節點
     *
     * @param key 為給定鍵取Hash,取得順時針方向上最近的一個虛擬節點對應的實際節點
     * @return 節點物件
     */
    public T get(Object key) {
        if (circle.isEmpty()) {
            return null;
        }
        long hash = hashFunc.hash(key);
        if (!circle.containsKey(hash)) {
            SortedMap<Long, T> tailMap = circle.tailMap(hash); //返回此對映的部分檢視,其鍵大於等於 hash
            hash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey();
        }
        //正好命中
        return circle.get(hash);
    }

    /**
     * 使用MD5演算法
     * @param key
     * @return
     */
    private static long md5HashingAlg(String key) {
        MessageDigest md5 = null;
        try {
            md5 = MessageDigest.getInstance("MD5");
            md5.reset();
            md5.update(key.getBytes());
            byte[] bKey = md5.digest();
            long res = ((long) (bKey[3] & 0xFF) << 24) | ((long) (bKey[2] & 0xFF) << 16) | ((long) (bKey[1] & 0xFF) << 8)| (long) (bKey[0] & 0xFF);
            return res;
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }
        return 0l;
    }

    /**
     * 使用FNV1hash演算法
     * @param key
     * @return
     */
    private static long fnv1HashingAlg(String key) {
        final int p = 16777619;
        int hash = (int) 2166136261L;
        for (int i = 0; i < key.length(); i++)
            hash = (hash ^ key.charAt(i)) * p;
        hash += hash << 13;
        hash ^= hash >> 7;
        hash += hash << 3;
        hash ^= hash >> 17;
        hash += hash << 5;
        return hash;
    }

    /**
     * Hash演算法物件,用於自定義hash演算法
     */
    public interface HashFunc {
        public Long hash(Object key);
    }
}

 

     Consistent Hashing最大限度地抑制了hash鍵的重新分佈。另外要取得比較好的負載均衡的效果,往往在伺服器數量比較少的時候需要增加虛擬節點來保證伺服器能均勻的分佈在圓環上。因為使用一般的hash方法,伺服器的對映地點的分佈非常不均勻。使用虛擬節點的思想,為每個物理節點(伺服器)在圓上分配100~200個點。這樣就能抑制分佈不均勻,最大限度地減小伺服器增減時的快取重新分佈。使用者資料對映在虛擬節點上,就表示使用者資料真正儲存位置是在該虛擬節點代表的實際物理伺服器上。