一致性協議淺析:從邏輯時鐘到Raft

架構師springboot發表於2019-03-12

前言

春節在家閒著沒事看了幾篇論文,把一致性協議的幾篇論文都過了一遍。在看這些論文之前,我一直有一些疑惑,比如同樣是有Leader和兩階段提交,Zookeeper的ZAB協議和Raft有什麼不同,Paxos協議到底要怎樣才能用在實際工程中,這些問題我都在這些論文中找到了答案。接下來,我將嘗試以自己的語言給大家講講這些協議,使大家能夠理解這些演算法。同時,我自己也有些疑問,我會在我的闡述中提出,也歡迎大家一起討論。水平有限,文中難免會有一些紕漏門也歡迎大家指出。

邏輯時鐘

邏輯時鐘其實算不上是一個一致性協議,它是Lamport大神在1987年就提出來的一個想法,用來解決分散式系統中,不同的機器時鐘不一致可能帶來的問題。在單機系統中,我們用機器的時間來標識事件,就可以非常清晰地知道兩個不同事件的發生次序。但是在分散式系統中,由於每臺機器的時間可能存在誤差,無法通過物理時鐘來準確分辨兩個事件發生的先後順序。但實際上,在分散式系統中,只有兩個發生關聯的事件,我們才會去關心兩者的先來後到關係。比如說兩個事務,一個修改了rowa,一個修改了rowb,他們兩個誰先發生,誰後發生,其實我們並不關心。那所謂邏輯時鐘,就是用來定義兩個關聯事件的發生次序,即‘happens before’。而對於不關聯的事件,邏輯時鐘並不能決定其先後,所以說這種‘happens before’的關係,是一種偏序關係。


一致性協議淺析:從邏輯時鐘到Raft

圖和例子來自於這篇部落格

此圖中,箭頭表示程式間通訊,ABC分別代表分散式系統中的三個程式。

邏輯時鐘的演算法其實很簡單:每個事件對應一個Lamport時間戳,初始值為0

如果事件在節點內發生,時間戳加1

如果事件屬於傳送事件,時間戳加1並在訊息中帶上該時間戳

如果事件屬於接收事件,時間戳 = Max(本地時間戳,訊息中的時間戳) + 1

這樣,所有關聯的傳送接收事件,我們都能保證傳送事件的時間戳小於接收事件。如果兩個事件之間沒有關聯,比如說A3和B5,他們的邏輯時間一樣。正是由於他們沒有關係,我們可以隨意約定他們之間的發生順序。比如說我們規定,當Lamport時間戳一樣時,A程式的事件發生早於B程式早於C程式,這樣我們可以得出A3 ‘happens before’ B5。而實際在物理世界中,明顯B5是要早於A3發生的,但這都沒有關係。

邏輯時鐘貌似目前並沒有被廣泛的應用,除了DynamoDB使用了vector clock來解決多版本的先後問題(如果有其他實際應用的話請指出,可能是我孤陋寡聞了),Google的Spanner 也是採用物理的原子時鐘來解決時鐘問題。但是從Larmport大師的邏輯時鐘演算法上,已經可以看到一些一致性協議的影子。

Replicated State Machine

說到一致性協議,我們通常就會講到複製狀態機。因為通常我們會用複製狀態機加上一致性協議演算法來解決分散式系統中的高可用和容錯。許多分散式系統,都是採用複製狀態機來進行副本之間的資料同步,比如HDFS,Chubby和Zookeeper。

一致性協議淺析:從邏輯時鐘到Raft

所謂複製狀態機,就是在分散式系統的每一個例項副本中,都維持一個持久化的日誌,然後用一定的一致性協議演算法,保證每個例項的這個log都完全保持一致,這樣,例項內部的狀態機按照日誌的順序回放日誌中的每一條命令,這樣客戶端來讀時,在每個副本上都能讀到一樣的資料。複製狀態機的核心就是圖中 的Consensus模組,即今天我們要討論的Paxos,ZAB,Raft等一致性協議演算法。

Paxos

Paxos是Lamport大神在90年代提出的一致性協議演算法,大家一直都覺得難懂,所以Lamport在2001又發表了一篇新的論文《Paxos made simple》,在文中他自己說Paxos是世界上最簡單的一致性演算法,非常容易懂……但是業界還是一致認為Paxos比較難以理解。在我看過Lamport大神的論文後,我覺得,除去複雜的正確性論證過程,Paxos協議本身還是比較好理解的。但是,Paxos協議還是過於理論,離具體的工程實踐還有太遠的距離。我一開始看Paxos協議的時候也是一頭霧水,看來看去發現Paxos協議只是為了單次事件答成一致,而且答成一致後的值無法再被修改,怎麼用Paxos去實現複製狀態機呢?另外,Paxos協議答成一致的值只有Propose和部分follower知道,這協議到底怎麼用……但是,如果你只是把Paxos協議當做一個理論去看,而不是考慮實際工程上會遇到什麼問題的話,會容易理解的多。Lamport的論文中對StateMachine的應用只有一個大概的想法,並沒有具體的實現邏輯,想要直接把Paxos放到複製狀態機裡使用是不可能的,得在Paxos上補充很多的東西。這些是為什麼Paxos有這麼多的變種。

Basic-Paxos

Basic-Paxos即Lamport最初提出的Paxos演算法,其實很簡單,用三言兩語就可以講完,下面我嘗試著用我自己的語言描述下Paxos協議,然後會舉出一個例子。要理解Paxos,只要記住一點就好了,Paxos只能為一個值形成共識,一旦Propose被確定,之後值永遠不會變,也就是說整個Paxos Group只會接受一個提案(或者說接受多個提案,但這些提案的值都一樣)。至於怎麼才能接受多個值來形成複製狀態機,大家可以看下一節Multi-Paxos.

Paxos協議中是沒有Leader這個概念的,除去Learner(只是學習Propose的結果,我們可以不去討論這個角色),只有Proposer和Acceptor。Paxos並且允許多個Proposer同時提案。Proposer要提出一個值讓所有Acceptor答成一個共識。首先是Prepare階段,Proposer會給出一個ProposeID n(注意,此階段Proposer不會把值傳給Acceptor)給每個Acceptor,如果某個Acceptor發現自己從來沒有接收過大於等於n的Proposer,則會回覆Proposer,同時承諾不再接收ProposeID小於等於n的提議的Prepare。如果這個Acceptor已經承諾過比n更大的propose,則不會回覆Proposer。如果Acceptor之前已經Accept了(完成了第二個階段)一個小於n的Propose,則會把這個Propose的值返回給Propose,否則會返回一個null值。當Proposer收到大於半數的Acceptor的回覆後,就可以開始第二階段accept階段。但是這個階段Propose能夠提出的值是受限的,只有它收到的回覆中不含有之前Propose的值,他才能自由提出一個新的value,否則只能是用回覆中Propose最大的值做為提議的值。Proposer用這個值和ProposeID n對每個Acceptor發起Accept請求。也就是說就算Proposer之前已經得到過acceptor的承諾,但是在accept發起之前,Acceptor可能給了proposeID更高的Propose承諾,導致accept失敗。也就是說由於有多個Proposer的存在,雖然第一階段成功,第二階段仍然可能會被拒絕掉。

下面我舉一個例子,這個例子來源於這篇部落格

假設有Server1,Server2, Server3三個伺服器,他們都想通過Paxos協議,讓所有人答成一致他們是leader,這些Server都是Proposer角色,他們的提案的值就是他們自己server的名字。他們要獲取Acceptor1~3這三個成員同意。首先Server2發起一個提案【1】,也就是說ProposeID為1,接下來Server1發起來一個提案【2】,Server3發起一個提案【3】.

首先是Prepare階段:

假設這時Server1傳送的訊息先到達acceptor1和acceptor2,它們都沒有接收過請求,所以接收該請求並返回【2,null】給Server1,同時承諾不再接受編號小於2的請求;

緊接著,Server2的訊息到達acceptor2和acceptor3,acceptor3沒有接受過請求,所以返回proposer2 【1,null】,並承諾不再接受編號小於1的訊息。而acceptor2已經接受Server1的請求並承諾不再接收編號小於2的請求,所以acceptor2拒絕Server2的請求;

最後,Server3的訊息到達acceptor2和acceptor3,它們都接受過提議,但編號3的訊息大於acceptor2已接受的2和acceptor3已接受的1,所以他們都接受該提議,並返回Server3 【3,null】;

此時,Server2沒有收到過半的回覆,所以重新取得編號4,併傳送給acceptor2和acceptor3,此時編號4大於它們已接受的提案編號3,所以接受該提案,並返回Server2 【4,null】。

接下來進入Accept階段,

Server3收到半數以上(2個)的回覆,並且返回的value為null,所以,Server3提交了【3,server3】的提案。

Server1在Prepare階段也收到過半回覆,返回的value為null,所以Server1提交了【2,server1】的提案。

Server2也收到過半回覆,返回的value為null,所以Server2提交了【4,server2】的提案。

Acceptor1和acceptor2接收到Server1的提案【2,server1】,acceptor1通過該請求,acceptor2承諾不再接受編號小於4的提案,所以拒絕;

Acceptor2和acceptor3接收到Server2的提案【4,server2】,都通過該提案;

Acceptor2和acceptor3接收到Server3的提案【3,server3】,它們都承諾不再接受編號小於4的提案,所以都拒絕。

此時,過半的acceptor(acceptor2和acceptor3)都接受了提案【4,server2】,learner感知到提案的通過,learner開始學習提案,所以server2成為最終的leader。

Multi-Paxos

剛才我講了,Paxos還過於理論,無法直接用到複製狀態機中,總的來說,有以下幾個原因

  • Paxos只能確定一個值,無法用做Log的連續複製
  • 由於有多個Proposer,可能會出現活鎖,如我在上面舉的例子中,Server2的一共提了兩次Propose才最終讓提案通過,極端情況下,次數可能會更多
  • 提案的最終結果可能只有部分Acceptor知曉,沒法達到複製狀態機每個instance都必須有完全一致log的需求。

那麼其實Multi-Paxos,其實就是為了解決上述三個問題,使Paxos協議能夠實際使用在狀態機中。解決第一個問題其實很簡單。為Log Entry每個index的值都是用一個獨立的Paxos instance。解決第二個問題也很簡答,讓一個Paxos group中不要有多個Proposer,在寫入時先用Paxos協議選出一個leader(如我上面的例子),然後之後只由這個leader做寫入,就可以避免活鎖問題。並且,有了單一的leader之後,我們還可以省略掉大部分的prepare過程。只需要在leader當選後做一次prepare,所有Acceptor都沒有接受過其他Leader的prepare請求,那每次寫入,都可以直接進行Accept,除非有Acceptor拒絕,這說明有新的leader在寫入。為了解決第三個問題,Multi-Paxos給每個Server引入了一個firstUnchosenIndex,讓leader能夠向向每個Acceptor同步被選中的值。解決這些問題之後Paxos就可以用於實際工程了。

Paxos到目前已經有了很多的補充和變種,實際上,之後我要討論的ZAB也好,Raft也好,都可以看做是對Paxos的修改和變種,另外還有一句流傳甚廣的話,“世上只有一種一致性演算法,那就是Paxos”。

ZAB

ZAB即Zookeeper Atomic BoardCast,是Zookeeper中使用的一致性協議。ZAB是Zookeeper的專用協議,與Zookeeper強繫結,並沒有抽離成獨立的庫,因此它的應用也不是很廣泛,僅限於Zookeeper。但ZAB協議的論文中對ZAB協議進行了詳細的證明,證明ZAB協議是能夠嚴格滿足一致性要求的。

ZAB隨著Zookeeper誕生於2007年,此時Raft協議還沒有發明,根據ZAB的論文,之所以Zookeeper沒有直接使用Paxos而是自己造輪子,是因為他們認為Paxos並不能滿足他們的要求。比如Paxos允許多個proposer,可能會造成客戶端提交的多個命令沒法按照FIFO次序執行。同時在恢復過程中,有一些follower的資料不全。這些斷論都是基於最原始的Paxos協議的,實際上後來一些Paxos的變種,比如Multi-Paxos已經解決了這些問題。當然我們只能站在歷史的角度去看待這個問題,由於當時的Paxos並不能很好的解決這些問題,因此Zookeeper的開發者創造了一個新的一致性協議ZAB。


一致性協議淺析:從邏輯時鐘到Raft

ZAB其實和後來的Raft非常像,有選主過程,有恢復過程,寫入也是兩階段提交,先從leader發起一輪投票,獲得超過半數同意後,再發起一次commit。ZAB中每個主的epoch number其實就相當於我接下來要講的Raft中的term。只不過ZAB中把這個epoch number和transition number組成了一個zxid存在了每個entry中。

ZAB在做log複製時,兩階段提交時,一個階段是投票階段,只要收到過半數的同意票就可以,這個階段並不會真正把資料傳輸給follower,實際作用是保證當時有超過半數的機器是沒有掛掉,或者在同一個網路分割槽裡的。第二個階段commit,才會把資料傳輸給每個follower,每個follower(包括leader)再把資料追加到log裡,這次寫操作就算完成。如果第一個階段投票成功,第二個階段有follower掛掉,也沒有關係,重啟後leader也會保證follower資料和leader對其。如果commit階段leader掛掉,如果這次寫操作已經在至少一個follower上commit了,那這個follower一定會被選為leader,因為他的zxid是最大的,那麼他選為leader後,會讓所有follower都commit這條訊息。如果leader掛時沒有follower commit這條訊息,那麼這個寫入就當做沒寫完。

由於只有在commit的時候才需要追加寫日誌,因此ZAB的log,只需要append-only的能力,就可以了。

另外,ZAB支援在從replica裡做stale read,如果要做強一致的讀,可以用sync read,原理也是先發起一次虛擬的寫操作,到不做任何寫入,等這個操作完成後,本地也commit了這次sync操作,再在本地replica上讀,能夠保證讀到sync這個時間點前所有的正確資料,而Raft所有的讀和寫都是經過主節點的

Raft

Raft是史丹佛大學在2014年提出的一種新的一致性協議。作者表示之所以要設計一種全新的一致性協議,是因為Paxos實在太難理解,而且Paxos只是一個理論,離實際的工程實現還有很遠的路。因此作者狠狠地吐槽了Paxos一把:

  1. Paxos協議中,是不需要Leader的,每個Proposer都可以提出一個propose。相比Raft這種一開始設計時就把選主和協議達成一致分開相比,Paxos等於是把選主和propose階段雜糅在了一起,造成Paxos比較難以理解。

  2. 最原始的Paxos協議只是對單一的一次事件答成一致,一旦這個值被確定,就無法被更改,而在我們的現實生活中,包括我們資料庫的一致性,都需要連續地對log entry的值答成一致,所以單單理解Paxos協議本身是不夠的,我們還需要對Paxos協議進行改進和補充,才能真正把Paxos協議應用到工程中。而對Paxos協議的補充本身又非常複雜,而且雖然Paxos協議被Lamport證明過,而新增了這些補充後,這些基於Paxos的改進演算法,如Multi-Paxos,又是未經證明的。

  3. 第三個槽點是Paxos協議只提供了一個非常粗略的描述,導致後續每一個對Paxos的改進,以及使用Paxos的工程,如Google的Chubby,都是自己實現了一套工程來解決Paxos中的一些具體問題。而像Chubby的實現細節其實並沒有公開。也就是說要想在自己的工程中使用Paxos,基本上每個人都需要自己定製和實現一套適合自己的Paxos協議。

因此,Raft的作者在設計Raft的時候,有一個非常明確的目標,就是讓這個協議能夠更好的理解,在設計Raft的過程中,如果遇到有多種方案可以選擇的,就選擇更加容易理解的那個。作者舉了一個例子。在Raft的選主階段,本來可以給每個server附上一個id,大家都去投id最大的那個server做leader,會更快地達成一致(類似ZAB協議),但這個方案又增加了一個serverid的概念,同時在高id的server掛掉時,低id的server要想成為主必須有一個等待時間,影響可用性。因此Raft的選主使用了一個非常簡單的方案:每個server都隨機sleep一段時間,最早醒過來的server來發起一次投票,獲取了大多數投票即可為主。在通常的網路環境下,最早發起投票的server也會最早收到其他server的贊成票,因此基本上只需要一輪投票就可以決出leader。整個選主過程非常簡單明瞭。

一致性協議淺析:從邏輯時鐘到Raft

除了選主,整個Raft協議的設計都非常簡單。leader和follower之間的互動(如果不考慮snapshot和改變成員數量)一共只有2個RPC call。其中一個還是選主時才需要的RequestVote。也就是說所有的資料互動,都只由AppendEntries 這一個RPC完成。

理解Raft演算法,首先要理解Term這個概念。每個leader都有自己的Term,而且這個term會帶到log的每個entry中去,來代表這個entry是哪個leader term時期寫入的。另外Term相當於一個lease。如果在規定的時間內leader沒有傳送心跳(心跳也是AppendEntries這個RPC call),Follower就會認為leader已經掛掉,會把自己收到過的最高的Term加上1做為新的term去發起一輪選舉。如果參選人的term還沒自己的高的話,follower會投反對票,保證選出來的新leader的term是最高的。如果在time out週期內沒人獲得足夠的選票(這是有可能的),則follower會在term上再加上1去做新的投票請求,直到選出leader為止。最初的raft是用c語言實現的,這個timeout時間可以設定的非常短,通常在幾十ms,因此在raft協議中,leader掛掉之後基本在幾十ms就能夠被檢測發現,故障恢復時間可以做到非常短。而像用Java實現的Raft庫,如Ratis,考慮到GC時間,我估計這個超時時間沒法設定這麼短。

一致性協議淺析:從邏輯時鐘到Raft

在Leader做寫入時也是一個兩階段提交的過程。首先leader會把在自己的log中找到第一個空位index寫入,並通過AppendEntries這個RPC把這個entry的值發給每個follower,如果收到半數以上的follower(包括自己)回覆true,則再下一個AppendEntries中,leader會把committedIndex加1,代表寫入的這個entry已經被提交。如在下圖中,leader將x=4寫入index=8的這個entry中,並把他傳送給了所有follower,在收到第一臺(自己),第三臺,第五臺(圖中沒有畫index=8的entry,但因為這臺伺服器之前所有的entry都和leader保持了一致,因此它一定會投同意),那麼leader就獲得了多數票,再下一個rpc中,會將Committed index往前挪一位,代表index<=8的所有entry都已經提交。至於第二臺和第四臺伺服器,log內容已經明顯落後,這要麼是因為前幾次rpc沒有成功。leader會無限重試直到這些follower和leader的日誌追平。另外一個可能是這兩臺伺服器重啟過,處於恢復狀態。那麼這兩臺伺服器在收到寫入index=8的RPC時,follower也會把上一個entry的term和index發給他們。也就是說prevLogIndex=7,prevLogTerm=3這個資訊會發給第二臺伺服器,那麼對於第二臺伺服器,index=7的entry是空的,也就是log和leader不一致,他會返回一個false給leader,leader會不停地從後往前遍歷,直到找到一個entry與第二臺伺服器一致的,從這個點開始重新把leader的log內容傳送給該follower,即可完成恢復。raft協議保證了所有成員的replicated log中每個index位置,如果他們的term一致,內容也一定一致。如果不一致,leader一定會把這個index的內容改寫成和leader一致。

一致性協議淺析:從邏輯時鐘到Raft

其實經過剛才我的一些描述,基本上就已經把Raft的選主,寫入流程和恢復基本上都講完了。從這裡,我們可以看出Raft一些非常有意思的地方。

第一個有意思的地方是Raft的log的entry是可能被修改的,比如一個follower接收了一個leader的prepare請求,把值寫入了一個index,而這個leader掛掉,新選出的leader可能會重新使用這個index,那麼這個follower的相應index的內容,會被改寫成新的內容。這樣就造成了兩個問題,首先第一個,raft的log無法在append-only的檔案或者檔案系統上去實現,而像ZAB,Paxos協議,log只會追加,只要求檔案系統有append的能力即可,不需要隨機訪問修改能力。

第二個有意思的地方是,為了簡單,Raft中只維護了一個Committed index,也就是任何小於等於這個committedIndex的entry,都是被認為是commit過的。這樣就會造成在寫入過程中,在leader獲得大多數選票之前掛掉(或者leader在寫完自己的log之後還沒來得及通知到任何follower就掛掉),重啟後如果這個server繼續被選為leader,這個值仍然會被commit永久生效。因為leader的log中有這個值,leader一定會保證所有的follower的log都和自己保持一致。而後續的寫入在增長committedIndex後,這個值也預設被commit了。

舉例來說,現在有5臺伺服器,其中S2為leader,但是當他在為index=1的entry執行寫入時,先寫到了自己的log中,還沒來得及通知其他server append entry就當機了。

一致性協議淺析:從邏輯時鐘到Raft

當S2重啟後,任然有可能被重新當選leader,當S2重新當選leader後,仍然會把index=1的這個entry複製給每臺伺服器(但是不會往前移動Committed index)

一致性協議淺析:從邏輯時鐘到Raft

此時S2又發生一次寫入,這次寫入完成後,會把Committed index移動到2的位置,因此index=1的entry也被認為已經commit了。

一致性協議淺析:從邏輯時鐘到Raft

這個行為有點奇怪,因為這樣等於raft會讓一個沒有獲得大多數人同意的值最終commit。這個行為取決於leader,如果上面的例子中S2重啟後沒有被選為leader,index=1的entry內容會被新leader的內容覆蓋,從而不會提交未經過表決的內容。

雖然說這個行為是有點奇怪,但是不會造成任何問題,因為leader和follower還是會保持一致,而且在寫入過程中leader掛掉,對客戶端來說是本來就是一個未決語義,raft論文中也指出,如果使用者想要exactly once的語義,可以在寫入的時候加入一個類似uuid的東西,在寫入之前leader查下這個uuid是否已經寫入。那麼在一定程度上,可以保證exactly once的語義。

Raft的論文中也比較了ZAB的演算法,文中說ZAB協議的一個缺點是在恢復階段需要leader和follower來回交換資料,這裡我沒太明白,據我理解,ZAB在重新選主的過程中,會選擇Zxid最大的那個從成為主,而其他follower會從leader這裡補全資料,並不會出現leader從follower節點補資料這一說。


感興趣的可以自己來我的Java架構群,可以獲取免費的學習資料,群號:855801563對Java技術,架構技術感興趣的同學,歡迎加群,一起學習,相互討論。


相關文章