1. 程式人生 > 實用技巧 >常見的分散式協議與演算法

常見的分散式協議與演算法

我這裡將主要列舉一致性Hash演算法、Gossip協議、QuorumNWR演算法、PBFT演算法,Paxos會分開單獨講,Raft演算法已經寫好了一篇文章,具體可以參考:從JRaft來看Raft協議實現細節

一致性Hash演算法

一致性Hash演算法是為了解決Hash演算法的遷移成本,以一個10節點的叢集為例,如果向叢集中新增節點時,如果使用了雜湊 演算法,需要遷移高達 90.91% 的資料,使用一致雜湊的話,只需要遷移 6.48% 的資料。

所以使用一致性Hash演算法實現雜湊定址時,可以通過增加節點數降低節點 宕機對整個叢集的影響,以及故障恢復時需要遷移的資料量。後續在需要時,你可以通過增 加節點數來提升系統的容災能力和故障恢復效率。而做資料遷移時,只需要遷移部分資料,就能實現叢集的穩定。

不帶虛擬節點的一致性Hash演算法

我們都知道普通的Hash演算法是通過取模來進行路由定址的,同理一致性Hash用了取模運算,但與雜湊演算法不同的是,雜湊演算法是對節點的數量進行取模 運算,而一致雜湊演算法是對 2^32 進行取模運算。你可以想象下,一致雜湊演算法,將整個 雜湊值空間組織成一個虛擬的圓環,也就是雜湊環:

在一致雜湊中,你可以通過執行雜湊演算法,將節點對映到雜湊環上,從而每個節點就能確定其在雜湊環上的位置了:

然後當要讀取指定key的值的時候,通過對key做一個hash,並確定此 key 在環上的位置,從這個位置沿著雜湊環順時針“行走”,遇到的第一節點就是 key 對應的節點。

這個時候,如果節點C宕機了,那麼節點B和節點A的資料實際上不會受影響,只有原來在節點C的資料會被重新定位到節點A,從而只要節點C的資料做遷移即可。

如果此時叢集不能滿足業務的需求,需要擴容一個節點:

你可以看到,key-01、key-02 不受影響,只有 key-03 的定址被重定位到新節點 D。一般 而言,在一致雜湊演算法中,如果增加一個節點,受影響的資料僅僅是,會定址到新節點和前 一節點之間的資料,其它資料也不會受到影響。

實現程式碼如下:

/**
 * 不帶虛擬節點的一致性Hash演算法 
 */
public class ConsistentHashingWithoutVirtualNode
{
    /**
     * 待新增入Hash環的伺服器列表
     */
    private static String[] servers = {"192.168.0.0:111", "192.168.0.1:111", "192.168.0.2:111",
            "192.168.0.3:111", "192.168.0.4:111"};

    /**
     * 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]);
        }
        System.out.println();
    } 

    /**
     * 得到應當路由到的結點
     */
    private static String getServer(String node)
    {
        // 得到帶路由的結點的Hash值
        int hash = getHash(node);
        // 得到大於該Hash值的所有Map
        SortedMap<Integer, String> subMap =
                sortedMap.tailMap(hash);
        // 第一個Key就是順時針過去離node最近的那個結點
        Integer i = subMap.firstKey();
        // 返回對應的伺服器名稱
        return subMap.get(i);
    }

    public static void main(String[] args)
    {
        String[] nodes = {"127.0.0.1:1111", "221.226.0.1:2222", "10.211.0.1:3333"};
        for (int i = 0; i < nodes.length; i++)
            System.out.println("[" + nodes[i] + "]的hash值為" +
                    getHash(nodes[i]) + ", 被路由到結點[" + getServer(nodes[i]) + "]");
    }
}

帶虛擬節點的一致性Hash演算法

上面的hash演算法可能會造成資料分佈不均勻的情況,也就是 說大多數訪問請求都會集中少量幾個節點上。所以我們可以通過虛擬節點的方式解決資料分佈不均的情況。

其實,就是對每一個伺服器節點計算多個雜湊值,在每個計算結果位置上,都放置一個虛擬 節點,並將虛擬節點對映到實際節點。比如,可以在主機名的後面增加編號,分別計算 “Node-A-01”,“Node-A-02”,“Node-B-01”,“Node-B-02”,“Node-C01”,“Node-C-02”的雜湊值,於是形成 6 個虛擬節點:

增加了節點後,節點在雜湊環上的分佈就相對均勻了。這時,如果有訪 問請求定址到“Node-A-01”這個虛擬節點,將被重定位到節點 A。

具體程式碼實現如下:

/**
 * 帶虛擬節點的一致性Hash演算法
 */
public class ConsistentHashingWithVirtualNode
{
    /**
     * 待新增入Hash環的伺服器列表
     */
    private static String[] servers = {"192.168.0.0:111", "192.168.0.1:111", "192.168.0.2:111",
            "192.168.0.3:111", "192.168.0.4:111"};

    /**
     * 真實結點列表,考慮到伺服器上線、下線的場景,即新增、刪除的場景會比較頻繁,這裡使用LinkedList會更好
     */
    private static List<String> realNodes = new LinkedList<String>();

    /**
     * 虛擬節點,key表示虛擬節點的hash值,value表示虛擬節點的名稱
     */
    private static SortedMap<Integer, String> virtualNodes =
            new TreeMap<Integer, String>();

    /**
     * 虛擬節點的數目,這裡寫死,為了演示需要,一個真實結點對應5個虛擬節點
     */
    private static final int VIRTUAL_NODES = 5;

    static
    {
        // 先把原始的伺服器新增到真實結點列表中
        for (int i = 0; i < servers.length; i++)
            realNodes.add(servers[i]);

        // 再新增虛擬節點,遍歷LinkedList使用foreach迴圈效率會比較高
        for (String str : realNodes)
        {
            for (int i = 0; i < VIRTUAL_NODES; i++)
            {
                String virtualNodeName = str + "&&VN" + String.valueOf(i);
                int hash = getHash(virtualNodeName);
                System.out.println("虛擬節點[" + virtualNodeName + "]被新增, hash值為" + hash);
                virtualNodes.put(hash, virtualNodeName);
            }
        }
        System.out.println();
    }

    /**
     * 得到應當路由到的結點
     */
    private static String getServer(String node)
    {
        // 得到帶路由的結點的Hash值
        int hash = getHash(node);
        // 得到大於該Hash值的所有Map
        SortedMap<Integer, String> subMap =
                virtualNodes.tailMap(hash);
        // 第一個Key就是順時針過去離node最近的那個結點
        Integer i = subMap.firstKey();
        // 返回對應的虛擬節點名稱,這裡字串稍微擷取一下
        String virtualNode = subMap.get(i);
        return virtualNode.substring(0, virtualNode.indexOf("&&"));
    }

    public static void main(String[] args)
    {
        String[] nodes = {"127.0.0.1:1111", "221.226.0.1:2222", "10.211.0.1:3333"};
        for (int i = 0; i < nodes.length; i++)
            System.out.println("[" + nodes[i] + "]的hash值為" +
                    getHash(nodes[i]) + ", 被路由到結點[" + getServer(nodes[i]) + "]");
    }
}

Gossip協議

Gossip 協議,顧名思義,就像流言蜚語一樣,利用一種隨機、帶有傳染性的方式,將資訊 傳播到整個網路中,並在一定時間內,使得系統內的所有節點資料一致。Gossip 協議通過上面的特性,可以保證系統能在極端情況下(比如叢集中只有一個節點在執行)也能執行。

Gossip資料傳播方式

Gossip資料傳播方式分別有:直接郵寄(Direct Mail)、反熵(Anti-entropy)和謠言傳播 (Rumor mongering)。

直接郵寄(Direct Mail):就是直接傳送更新資料,當資料傳送失敗時,將資料快取下來,然後重傳。直接郵寄雖然實現起來比較容易,資料同步也很及時,但可能會因為 快取佇列滿了而丟資料。也就是說,只採用直接郵寄是無法實現最終一致性的。

反熵(Anti-entropy):反熵指的是叢集中的節點,每隔段時間就隨機選擇某個其他節點,然後通過互相交換自己的 所有資料來消除兩者之間的差異,實現資料的最終一致性。

在實現反熵的時候,主要有推、拉和推拉三種方式。推方式,就是將自己的所有副本資料,推給對方,修復對方副本中的熵,拉方式,就是拉取對方的所有副本資料,修復自己副本中的熵。

謠言傳播 (Rumor mongering):指的是當一個節點有了新資料後,這個節點變成活躍狀態,並週期性地聯絡其他節點向其傳送新資料,直到所有的節點都儲存了該新資料。由於謠言傳播非常具有傳染性,它適合動態變化的分散式系統

Quorum NWR演算法

Quorum NWR 中有三個要素,N、W、R。

N 表示副本數,又叫做複製因子(Replication Factor)。也就是說,N 表示叢集中同一份 資料有多少個副本,就像下圖的樣子:

在這個三節點的叢集中,DATA-1 有 2 個副本,DATA-2 有 3 個副 本,DATA-3 有 1 個副本。也就是說,副本數可以不等於節點數,不同的資料可以有不同 的副本數。

W,又稱寫一致性級別(Write Consistency Level),表示成功完成 W 個副本更新。

R,又稱讀一致性級別(Read Consistency Level),表示讀取一個數據物件時需要讀 R 個副本。

通過 Quorum NWR,你可以自定義一致性級別,通過臨時調整寫入或者查詢的方式,當 W + R > N 時,就可以實現強一致性了。

所以假如要讀取節點B,我們再假設W(2) + R(2) > N(3)這個公式,也就是當寫兩個節點,讀的時候也同時讀取兩個節點,那麼讀取資料的時候肯定是讀取返回給客戶端肯定是最新的那份資料。

關於 NWR 需要你注意的是,N、W、R 值的不同組合,會產生不同的一致性效 果,具體來說,有這麼兩種效果:

當 W + R > N 的時候,對於客戶端來講,整個系統能保證強一致性,一定能返回更新後的那份資料。
當 W + R < N 的時候,對於客戶端來講,整個系統只能保證最終一致性,可能會返回舊資料。

PBFT演算法

PBFT 演算法非常實用,是一種能在實際場景中落地的拜占庭容錯演算法。

我們從一個例子入手,看看PBFT 演算法的具體實現:

假設蘇秦再一次帶隊抗秦,這一天,蘇秦和 4 個國家的 4 位將軍趙、魏、韓、楚商量軍機 要事,結果剛商量完沒多久蘇秦就接到了情報,情報上寫道:聯軍中可能存在一個叛徒。這 時,蘇秦要如何下發作戰指令,保證忠將們正確、一致地執行下發的作戰指令,而不是被叛 徒干擾呢?

需要注意的是,所有的訊息都是簽名訊息,也就是說,訊息傳送者的身份和訊息內容都是 無法偽造和篡改的(比如,楚無法偽造一個假裝來自趙的訊息)。

首先,蘇秦聯絡趙,向趙傳送包含作戰指令“進攻”的請求(就像下圖的樣子)。

當趙接收到蘇秦的請求之後,會執行三階段協議(Three-phase protocol)。

趙將進入預準備(Pre-prepare)階段,構造包含作戰指令的預準備訊息,並廣播給其他 將軍(魏、韓、楚)。

因為魏、韓、楚,收到訊息後,不能確認自己接收到指令和其他人接收到的指令是相同的。所以需要進入下一個階段。

接收到預準備訊息之後,魏、韓、楚將進入準備(Prepare)階段,並分別廣播包含作戰 指令的準備訊息給其他將軍。

比如,魏廣播準備訊息給趙、韓、楚(如圖所示)。為了 方便演示,我們假設叛徒楚想通過不傳送訊息,來干擾共識協商(你能看到,圖中的楚 是沒有傳送訊息的)。

因為魏不能確認趙、韓、楚是否收到了 2f(這裡的 2f 包括自己,其中 f 為叛徒數,在我的演示中是 1) 個一致的包含作戰指令的準備消 息。所以需要進入下一個階段Commit。

進入提交階段後,各將軍分別廣播提交訊息給其他將軍,也就是告訴其他將軍,我已經 準備好了,可以執行指令了。

最後,當某個將軍收到 2f + 1 個驗證通過的提交訊息後,大部分的將軍們已經達成共識,這時可以執行作戰指 令了,那麼該將軍將執行蘇秦的作戰指令,執行完畢後傳送執行成功的訊息給蘇秦。

最後,當蘇秦收到 f+1 個相同的響應(Reply)訊息時,說明各位將軍們已經就作戰指令達 成了共識,並執行了作戰指令。

在上面的這個例子中:

可以將趙、魏、韓、楚理解為分散式系統的四個節點,其中趙是主節點(Primary node),魏、韓、楚是從節點(Secondary node);

將蘇秦理解為業務,也就是客戶端;

將訊息理解為網路訊息;

將作戰指令“進攻”,理解成客戶端提議的值,也就是希望被各節點達成共識,並提交 給狀態機的值。

最終的共識是否達成,客戶端是會做判斷的,如果客戶端在指定時間內未 收到請求對應的 f + 1 相同響應,就認為叢集出故障了,共識未達成,客戶端會重新發送請 求。

PBFT 演算法通過檢視變更(View Change)的方式,來處理主節點作 惡,當發現主節點在作惡時,會以“輪流上崗”方式,推舉新的主節點。感興趣的可以自己去查閱。

相比 Raft 演算法完全不適應有人作惡的場景,PBFT 演算法能容忍 (n 1)/3 個惡意節點 (也可以是故障節點)。另外,相比 PoW 演算法,PBFT 的優點是不消耗算 力。PBFT 演算法是O(n ^ 2) 的訊息複雜度的演算法,所以以及隨著訊息數 的增加,網路時延對系統執行的影響也會越大,這些都限制了執行 PBFT 演算法的分散式系統 的規模,也決定了 PBFT 演算法適用於中小型分散式系統。

PoW演算法

工作量證明 (Proof Of Work,簡稱 PoW),就是一份證明,用 來確認你做過一定量的工作。具體來說就是,客戶端需要做一定難度的工作才能得出一個結果,驗 證方卻很容易通過結果來檢查出客戶端是不是做了相應的工作。

具體的工作量證明過程,就像下圖中的樣子:

所以工作量證明通常用於區塊鏈中,區塊鏈通過工作量證明(Proof of Work)增加了壞人作惡的成本,以此防止壞 人作惡。

工作量證明

雜湊函式(Hash Function),也叫雜湊函式。就是說,你輸入一個任意長度的字串,哈 希函式會計算出一個長度相同的雜湊值。

在瞭解了什麼是雜湊函式之後,那麼如何通過雜湊函式進行雜湊運算,從而證明工作量呢?

例如,我們可以給出一個工作量的要求:基於一個基本的字串,你可以在這個字 符串後面新增一個整數值,然後對變更後(新增整數值) 的字串進行 SHA256 雜湊運 算,如果運算後得到的雜湊值(16 進位制形式)是以"0000"開頭的,就驗證通過。

為了達到 這個工作量證明的目標,我們需要不停地遞增整數值,一個一個試,對得到的新字串進行 SHA256 雜湊運算。

通過這個示例你可以看到,工作量證明是通過執行雜湊運算,經過一段時間的計算後,得到 符合條件的雜湊值。也就是說,可以通過這個雜湊值,來證明我們的工作量。

區塊鏈如何實現 PoW 演算法的?

首先看看什麼是區塊鏈:

區塊鏈的區塊,是由區塊頭、區塊體 2 部分組成的:

  • 區塊頭(Block Head):區塊頭主要由上一個區塊的雜湊值、區塊體的雜湊值、4 位元組 的隨機數(nonce)等組成的。

  • 區塊體(Block Body):區塊包含的交易資料,其中的第一筆交易是 Coinbase 交易, 這是一筆激勵礦工的特殊交易。

在區塊鏈中,擁有 80 位元組固定長度的區塊頭,就是用於區塊鏈工作量證明的雜湊運算中輸 入字串,而且通過雙重 SHA256 雜湊運算(也就是對 SHA256 雜湊運算的結果,再執行 一次雜湊運算),計算出的雜湊值,只有小於目標值(target),才是有效的,否則雜湊值 是無效的,必須重算。

所以,在區塊鏈中是通過對區塊頭執行 SHA256 雜湊運算,得到小於目標 值的雜湊值,來證明自己的工作量的。

計算出符合條件的雜湊值後,礦工就會把這個資訊廣播給叢集中所有其他節點,其他節點驗 證通過後,會將這個區塊加入到自己的區塊鏈中,最終形成一串區塊鏈,就像下圖的樣子:

所以,就是攻擊者掌握了較多的算力,能挖掘一條比原鏈更長的攻擊鏈,並將攻擊鏈 向全網廣播,這時呢,按照約定,節點將接受更長的鏈,也就是攻擊鏈,丟棄原鏈。就像下 圖的樣子:

ZAB協議

Zab協議 的全稱是 Zookeeper Atomic Broadcast (Zookeeper原子廣播)。Zookeeper 是通過 Zab 協議來保證分散式事務的最終一致性。ZAB 協議的最核心設計目標就是如何實現操作的順序性。

由於ZAB不基於狀態機,而是基於主備模式的 原子廣播協議(Atomic Broadcast),最終實現了操作的順序性。

主要有以下幾點原因導致了ZAB實現了操作的順序性:

首先,ZAB 實現了主備模式,也就是所有的資料都以主節點為準:

其次,ZAB 實現了 FIFO 佇列,保證訊息處理的順序性。

最後,ZAB 還實現了當主節點崩潰後,只有日誌最完備的節點才能當選主節點,因為日誌 最完備的節點包含了所有已經提交的日誌,所以這樣就能保證提交的日誌不會再改變。