圖解一致性哈希算法

要了解一致性哈希,首先咱們必須瞭解傳統的哈希及其在大規模分佈式系統中的侷限性。簡單地說,哈希就是一個鍵值對存儲,在給定鍵的狀況下,能夠很是高效地找到所關聯的值。假設咱們要根據其郵政編碼查找城市中的街道名稱。一種最簡單的實現方式是將此信息以哈希字典的形式進行存儲 <Zip Code,Street Name>java

當數據太大而沒法存儲在一個節點或機器上時,問題變得更加有趣,系統中須要多個這樣的節點或機器來存儲它。好比,使用多個 Web 緩存中間件的系統。那如何肯定哪一個 key 存儲在哪一個節點上?針對該問題,最簡單的解決方案是使用哈希取模來肯定。 給定一個 key,先對 key 進行哈希運算,將其除以系統中的節點數,而後將該 key 放入該節點。一樣,在獲取 key 時,對 key 進行哈希運算,再除以節點數,而後轉到該節點並獲取值。上述過程對應的哈希算法定義以下:node

node_number = hash(key) % N # 其中 N 爲節點數。

下圖描繪了多節點系統中的傳統的哈希取模算法,基於該算法能夠實現簡單的負載均衡。面試

traditional-hashing.png

閱讀更多關於 Angular、TypeScript、Node.js/Java 、Spring 等技術文章,歡迎訪問個人我的博客 ——全棧修仙之路

1、傳統哈希取模算法的侷限性

下面咱們來分析一下傳統的哈希及其在大規模分佈式系統中的侷限性。這裏咱們直接使用我以前所寫文章 布隆過濾器你值得擁有的開發利器 中定義的 SimpleHash 類,而後分別對 semlinker、kakuqo 和 test 3 個鍵進行哈希運算並取餘,具體代碼以下:算法

public class SimpleHash {
    private int cap;
    private int seed;

    public SimpleHash(int cap, int seed) {
        this.cap = cap;
        this.seed = seed;
    }

    public int hash(String value) {
        int result = 0;
        int len = value.length();
        for (int i = 0; i < len; i++) {
            result = seed * result + value.charAt(i);
        }
        return (cap - 1) & result;
    }

    public static void main(String[] args) {
        SimpleHash simpleHash = new SimpleHash(2 << 12, 8);
        System.out.println("node_number=hash(\"semlinker\") % 3 -> " + 
          simpleHash.hash("semlinker") % 3);
        System.out.println("node_number=hash(\"kakuqo\") % 3 -> " + 
          simpleHash.hash("kakuqo") % 3);
        System.out.println("node_number=hash(\"test\") % 3 -> " + 
          simpleHash.hash("test") % 3);
    }
}

以上代碼成功運行後,在控制檯會輸出如下結果:shell

node_number=hash("semlinker") % 3 -> 1
node_number=hash("kakuqo") % 3 -> 2
node_number=hash("test") % 3 -> 0

基於以上的輸出結果,咱們能夠建立如下表格:segmentfault

ch-three-nodes-hash.jpg

1.1 節點減小的場景

在分佈式多節點系統中,出現故障很常見。任何節點均可能在沒有任何事先通知的狀況下掛掉,針對這種狀況咱們指望系統只是出現性能下降,正常的功能不會受到影響。 對於原始示例,當節點出現故障時會發生什麼?原始示例中有的 3 個節點,假設其中 1 個節點出現故障,這時節點數發生了變化,節點個數從 3 減小爲 2,此時表格的狀態發生了變化:緩存

ch-two-nodes-hash.jpg

很明顯節點的減小會致使鍵與節點的映射關係發生變化,這個變化對於新的鍵來講並不會產生任何影響,但對於已有的鍵來講,將致使節點映射錯誤,以 「semlinker」 爲例,變化前系統有 3 個節點,該鍵對應的節點編號爲 1,當出現故障時,節點數減小爲 2 個,此時該鍵對應的節點編號爲 0。服務器

1.2 節點增長的場景

在分佈式多節點系統中,對於某些場景好比節日大促,就須要對服務節點進行擴容,以應對突發的流量。 對於原始示例,當增長節點會發生什麼?原始示例中有的 3 個節點,假設進行擴容臨時增長了 1 個節點,這時節點數發生了變化,節點個數從 3 增長爲 4 個,此時表格的狀態發生了變化:數據結構

ch-four-nodes-hash.jpg

很明顯節點的增長也會致使鍵與節點的映射關係發生變化,這個變化對於新的鍵來講並不會產生任何影響,但對於已有的鍵來講,將致使節點映射錯誤,一樣以 「semlinker」 爲例,變化前系統有 3 個節點,該鍵對應的節點編號爲 1,當增長節點時,節點數增長爲 4 個,此時該鍵對應的節點編號爲 2。負載均衡

當集羣中節點的數量發生變化時,以前的映射規則就可能發生變化。若是集羣中每一個機器提供的服務沒有差異,這不會有什麼影響。但對於分佈式緩存這種的系統而言,映射規則失效就意味着以前緩存的失效,若同一時刻出現大量的緩存失效,則可能會出現 「緩存雪崩」,這將會形成災難性的後果。

要解決此問題,咱們必須在其他節點上從新分配全部現有鍵,這多是很是昂貴的操做,而且可能對正在運行的系統產生不利影響。固然除了從新分配全部現有鍵的方案以外,還有另外一種更好的方案即便用一致性哈希算法。

2、一致性哈希算法

一致性哈希算法在 1997 年由麻省理工學院提出,是一種特殊的哈希算法,在移除或者添加一個服務器時,可以儘量小地改變已存在的服務請求與處理請求服務器之間的映射關係。一致性哈希解決了簡單哈希算法在分佈式哈希表(Distributed Hash Table,DHT)中存在的動態伸縮等問題 。

2.1 一致性哈希算法優勢

  • 可擴展性。一致性哈希算法保證了增長或減小服務器時,數據存儲的改變最少,相比傳統哈希算法大大節省了數據移動的開銷 。
  • 更好地適應數據的快速增加。採用一致性哈希算法分佈數據,當數據不斷增加時,部分虛擬節點中可能包含不少數據、形成數據在虛擬節點上分佈不均衡,此時能夠將包含數據多的虛擬節點分裂,這種分裂僅僅是將原有的虛擬節點一分爲2、不須要對所有的數據進行從新哈希和劃分。

    虛擬節點分裂後,若是物理服務器的負載仍然不均衡,只需在服務器之間調整部分虛擬節點的存儲分佈。這樣能夠隨數據的增加而動態的擴展物理服務器的數量,且代價遠比傳統哈希算法從新分佈全部數據要小不少。

2.2 一致性哈希算法與哈希算法的關係

一致性哈希算法是在哈希算法基礎上提出的,在動態變化的分佈式環境中,哈希算法應該知足的幾個條件:平衡性、單調性和分散性。

  • 平衡性:是指 hash 的結果應該平均分配到各個節點,這樣從算法上解決了負載均衡問題。
  • 單調性:是指在新增或者刪減節點時,不影響系統正常運行。
  • 分散性:是指數據應該分散地存放在分佈式集羣中的各個節點(節點本身能夠有備份),沒必要每一個節點都存儲全部的數據。

3、一致性哈希算法原理

一致性哈希算法經過一個叫做一致性哈希環的數據結構實現。這個環的起點是 0,終點是 2^32 - 1,而且起點與終點鏈接,故這個環的整數分佈範圍是 [0, 2^32-1],以下圖所示:

hash-ring.jpg

3.1 將對象放置到哈希環

假設咱們有 "semlinker"、"kakuqo"、"lolo"、"fer" 四個對象,分別簡寫爲 o一、o二、o3 和 o4,而後使用哈希函數計算這個對象的 hash 值,值的範圍是 [0, 2^32-1]:

hash-ring-hash-objects.jpg

圖中對象的映射關係以下:

hash(o1) = k1; hash(o2) = k2;
hash(o3) = k3; hash(o4) = k4;

3.2 將服務器放置到哈希環

接着使用一樣的哈希函數,咱們將服務器也放置到哈希環上,能夠選擇服務器的 IP 或主機名做爲鍵進行哈希,這樣每臺服務器就能肯定其在哈希環上的位置。這裏假設咱們有 3 臺緩存服務器,分別爲 cs一、cs2 和 cs3:

hash-ring-hash-servers.jpg

圖中服務器的映射關係以下:

hash(cs1) = t1; hash(cs2) = t2; hash(cs3) = t3; # Cache Server

3.3 爲對象選擇服務器

將對象和服務器都放置到同一個哈希環後,在哈希環上順時針查找距離這個對象的 hash 值最近的機器,便是這個對象所屬的機器。 以 o2 對象爲例,順序針找到最近的機器是 cs2,故服務器 cs2 會緩存 o2 對象。而服務器 cs1 則緩存 o1,o3 對象,服務器 cs3 則緩存 o4 對象。

hash-ring-objects-servers.jpg

3.4 服務器增長的狀況

假設因爲業務須要,咱們須要增長一臺服務器 cs4,通過一樣的 hash 運算,該服務器最終落於 t1 和 t2 服務器之間,具體以下圖所示:

hash-ring-add-server.jpg

對於上述的狀況,只有 t1 和 t2 服務器之間的對象須要從新分配。在以上示例中只有 o3 對象須要從新分配,即它被從新到 cs4 服務器。在前面咱們已經分析過,若是使用簡單的取模方法,當新添加服務器時可能會致使大部分緩存失效,而使用一致性哈希算法後,這種狀況獲得了較大的改善,由於只有少部分對象須要從新分配。

3.5 服務器減小的狀況

假設 cs3 服務器出現故障致使服務下線,這時本來存儲於 cs3 服務器的對象 o4,須要被從新分配至 cs2 服務器,其它對象仍存儲在原有的機器上。

hash-ring-remove-server.jpg

3.6 虛擬節點

到這裏一致性哈希的基本原理已經介紹完了,但對於新增服務器的狀況還存在一些問題。新增的服務器 cs4 只分擔了 cs1 服務器的負載,服務器 cs2 和 cs3 並無由於 cs4 服務器的加入而減小負載壓力。若是 cs4 服務器的性能與原有服務器的性能一致甚至可能更高,那麼這種結果並非咱們所指望的。

針對這個問題,咱們能夠經過引入虛擬節點來解決負載不均衡的問題。即將每臺物理服務器虛擬爲一組虛擬服務器,將虛擬服務器放置到哈希環上,若是要肯定對象的服務器,需先肯定對象的虛擬服務器,再由虛擬服務器肯定物理服務器。

ch-virtual-nodes.jpg

圖中 o1 和 o2 表示對象,v1 ~ v6 表示虛擬服務器,s1 ~ s3 表示物理服務器。

4、一致性哈希算法實現

這裏咱們只介紹不帶虛擬節點的一致性哈希算法實現:

import java.util.SortedMap;
import java.util.TreeMap;

public class ConsistentHashingWithoutVirtualNode {
    //待添加入Hash環的服務器列表
    private static String[] servers = {"192.168.0.1:8888", "192.168.0.2:8888", 
      "192.168.0.3:8888"};

    //key表示服務器的hash值,value表示服務器
    private static SortedMap<Integer, String> sortedMap = new TreeMap<Integer, String>();

    //程序初始化,將全部的服務器放入sortedMap中
    static {
        for (int i = 0; i < servers.length; i++) {
            int hash = getHash(servers[i]);
            System.out.println("[" + servers[i] + "]加入集合中, 其Hash值爲" + hash);
            sortedMap.put(hash, servers[i]);
        }
    }

    //獲得應當路由到的結點
    private static String getServer(String key) {
        //獲得該key的hash值
        int hash = getHash(key);
        //獲得大於該Hash值的全部Map
        SortedMap<Integer, String> subMap = sortedMap.tailMap(hash);
        if (subMap.isEmpty()) {
            //若是沒有比該key的hash值大的,則從第一個node開始
            Integer i = sortedMap.firstKey();
            //返回對應的服務器
            return sortedMap.get(i);
        } else {
            //第一個Key就是順時針過去離node最近的那個結點
            Integer i = subMap.firstKey();
            //返回對應的服務器
            return subMap.get(i);
        }
    }

    //使用FNV1_32_HASH算法計算服務器的Hash值
    private static int getHash(String str) {
        final int p = 16777619;
        int hash = (int) 2166136261L;
        for (int i = 0; i < str.length(); i++)
            hash = (hash ^ str.charAt(i)) * p;
        hash += hash << 13;
        hash ^= hash >> 7;
        hash += hash << 3;
        hash ^= hash >> 17;
        hash += hash << 5;

        // 若是算出來的值爲負數則取其絕對值
        if (hash < 0)
            hash = Math.abs(hash);
        return hash;
    }

    public static void main(String[] args) {
        String[] keys = {"semlinker", "kakuqo", "fer"};
        for (int i = 0; i < keys.length; i++)
            System.out.println("[" + keys[i] + "]的hash值爲" + getHash(keys[i])
                    + ", 被路由到結點[" + getServer(keys[i]) + "]");
    }

}

以上代碼成功運行後,在控制檯會輸出如下結果:

[192.168.0.1:8888]加入集合中, 其Hash值爲1326271016
[192.168.0.2:8888]加入集合中, 其Hash值爲1132535844
[192.168.0.3:8888]加入集合中, 其Hash值爲115798597

[semlinker]的hash值爲1549041406, 被路由到結點[192.168.0.3:8888]
[kakuqo]的hash值爲463104755, 被路由到結點[192.168.0.2:8888]
[fer]的hash值爲1677150790, 被路由到結點[192.168.0.3:8888]

上面咱們只介紹了不帶虛擬節點的一致性哈希算法實現,若是有的小夥伴對帶虛擬節點的一致性哈希算法感興趣,能夠參考 一致性Hash(Consistent Hashing)原理剖析及Java實現 這篇文章。

5、總結

本文經過示例介紹了傳統的哈希取模算法在分佈式系統中的侷限性,進而在針對該問題的解決方案中引出了一致性哈希算法。一致性哈希算法在 1997 年由麻省理工學院提出,是一種特殊的哈希算法,在移除或者添加一個服務器時,可以儘量小地改變已存在的服務請求與處理請求服務器之間的映射關係。在介紹完一致性哈希算法的做用和優勢等相關知識後,咱們以圖解的形式生動介紹了一致性哈希算法的原理,最後給出了不帶虛擬節點的一致性哈希算法的 Java 實現。

6、參考資源

本人的全棧修仙之路訂閱號,會按期分享 Angular、TypeScript、Node.js/Java 、Spring 相關文章,歡迎感興趣的小夥伴訂閱哈!

full-stack-logo

相關文章
相關標籤/搜索
本站公眾號
   歡迎關注本站公眾號,獲取更多信息