我這里將主要列舉一致性Hash算法、Gossip協議、QuorumNWR算法、PBFT算法、PoW算法、ZAB協議,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 還實現了當主節點崩潰后,只有日志最完備的節點才能當選主節點,因為日志 最完備的節點包含了所有已經提交的日志,所以這樣就能保證提交的日志不會再改變。