一致性算法Raft詳解
背景
熟悉或了解分布性系統的開發者都知道一致性算法的重要性,Paxos一致性算法從90年提出到現在已經有二十幾年了,而Paxos流程太過於繁雜實現起來也比較復雜,可能也是以為過於復雜 現在我聽說過比較出名使用到Paxos的也就只是Chubby、libpaxos,搜了下發現Keyspace、BerkeleyDB數據庫中也使用了該算法作為數據的一致性同步,雖然現在很廣泛使用的Zookeeper也是基於Paxos算法來實現,但是Zookeeper使用的ZAB(Zookeeper Atomic Broadcast)協議對Paxos進行了很多的改進與優化,算法復雜我想會是制約他發展的一個重要原因;說了這么多只是為了要引出本篇文章的主角Raft一致性算法,沒錯Raft就是在這個背景下誕生的,文章開頭也說到了Paxos最大的問題就是復雜,Raft一致性算法就是比Paxos簡單又能實現Paxos所解決的問題的一致性算法。
Raft是斯坦福的Diego Ongaro、John Ousterhout兩個人以易懂(Understandability)為目標設計的一致性算法,在2013年發布了論文:《In Search of an Understandable Consensus Algorithm》從2013年發布到現在不過只有兩年,到現在已經有了十多種語言的Raft算法實現框架,較為出名的有etcd,Google的Kubernetes也是用了etcd作為他的服務發現框架;由此可見易懂性是多么的重要。
Raft概述
與Paxos不同Raft強調的是易懂(Understandability),Raft和Paxos一樣只要保證n/2+1節點正常就能夠提供服務;眾所周知但問題較為復雜時可以把問題分解為幾個小問題來處理,Raft也使用了分而治之的思想把算法流程分為三個子問題:選舉(Leader election)、日志復制(Log replication)、安全性(Safety)三個子問題;這里先簡單介紹下Raft的流程;
Raft開始時在集群中選舉出Leader負責日志復制的管理,Leader接受來自客戶端的事務請求(日志),並將它們復制給集群的其他節點,然后負責通知集群中其他節點提交日志,Leader負責保證其他節點與他的日志同步,當Leader宕掉后集群其他節點會發起選舉選出新的Leader;
Raft簡介
Raft是一個用於日志復制,同步的一致性算法。它提供了和Paxos一樣的功能和性能,但是它的算法結構與Paxos不同。這使得Raft相比Paxos更好理解,並且更容易構建實際的系統。為了強調可理解性,Raft將一致性算法分解為幾個關鍵流程(模塊),例如選主,安全性,日志復制,通過將分布式一致性這個復雜的問題轉化為一系列的小問題進而各個擊破的方式來解決問題。同時它通過實施一個更強的一致性來減少一些不必要的狀態,進一步降低了復雜性。Raft還包括了一個新機制,允許線上進行動態的集群擴容,利用有交集的大多數機制來保證安全性。
####一些背景知識
#####A. 一致性算法簡單回顧
一致性算法允許一個集群像一個整體一樣工作,即使其中一些機器出現故障也不會影響正常服務。正因為如此,一致性算法在構建可信賴的大型分布式系統中都扮演着重要的角色。Paxos算法在過去10年中統治着這一領域:絕大多數實現都是基於Paxos,同時在教學領域講解一致性問題時Paxos也經常拿來作為范例。但是不幸的是,盡管有很多工作都在試圖降低它的復雜性,但是Paxos仍然難以理解。並且,Paxos自身算法的結構不能直接用於實際系統中,想要使用必須要進行大幅度改變,這些都導致無論在工業界還是教育界,Paxos都讓人很DT。
時勢造英雄,在這樣的背景下Raft應運而生。Raft算法使用了一些特別的技巧使得它易於理解,包括算法分解(Raft主要分為選主,日志復制和安全三個大模塊),同時在不影響功能的情況下,減少復制狀態機的狀態,降低復雜性。Raft算法或多或少的和已經存在的一些一致性算法有着相似之處,但是也具有如下特征:
- 強leader語義:相比其他一致性算法,Raft使用增強形式的leader語義。舉個例子,日志只能由leader復制給其它節點。這簡化了日志復制需要的管理工作,使得Raft易於理解。
- leader的選擇:Raft使用隨機計時器來選擇leader,它的實現只是在心跳機制(任何一致性算法中都必須實現)上多做了一點“文章”,不會增加延遲和復雜性。
- 關系改變:Raft使用了一個新機制joint consensus允許集群動態在線擴容,保障Raft的可持續服務能力。
Raft算法已經被證明是安全正確的,同時也有實驗支撐Raft的效率不比其他一致性算法差。最關鍵是Raft算法要易於理解,在實際系統應用中易於實現的特點使得它成為了解決分布式系統一致性問題上新的“寵兒”。
#####B. 復制狀態機
一致性算法是從復制狀態機中提出的。簡單地講,復制狀態機就是通過彼此之間的通信來將一個集群從一個一致性狀態轉向下一個一致性狀態,它要能容忍很多錯誤情形。經典如GFS,HDFS都是使用了單獨的復制狀態機負責選主,存儲一些能夠在leader crash情況下進行恢復的配置信息等,比如Chubby和ZooKeeper。
復制狀態機的典型實現是基於復制日志,如下圖所示:
每個server上存儲了一個包含一系列指令的日志,這些指令,狀態機要按序執行。每一個日志在相同的位置存放相同的指令(可以理解成一個log包含一堆entry,這些entry組成一個數組,每個entry是一個command,數組相同偏移處的command相同),所以每一個狀態機都執行了相同序列的指令。一致性算法就是基於這樣一個簡單的前提:集群中所有機器當前處於一個一致性狀態,如果它們從該狀態出發執行相同序列的指令走狀態機的話,那么它們的下一個狀態一定一致。但由於分布式系統中存在三態(成功,失敗,無響應),如何來確保每台機器上的日志一致就非常復雜,也是一致性算法需要解決的問題。通常來講,一致性算法需要具有以下特征:
- 安全性:在非拜占庭故障下,包括網絡分區,延遲,丟包,亂序等等情況下都保證正確。
- 絕對可用:只要集群中大多數機器正常,集群就能錯誤容忍,進行正常服務。
- 不依賴時序保證一致性:由於使用邏輯時鍾,所以物理時鍾錯誤或者極端的消息延遲都不影響可用性。
- 通常情況下,一個指令可以在一輪RPC周期內由大多數節點完成,宕機或者運行速度慢的少數派不影響系統整體性能。
####Raft算法
前面對Raft算法進行了簡要的介紹,這里開始對它進行深入分析。Raft實現一致性的機制是這樣的:首先選擇一個leader全權負責管理日志復制,leader從客戶端接收log entries,將它們復制給集群中的其它機器,然后負責告訴其它機器什么時候將日志應用於它們的狀態機。舉個例子,leader可以在無需詢問其它server的情況下決定把新entries放在哪個位置,數據永遠是從leader流向其它機器。一個leader可以fail或者與其他機器失去連接,這種情形下會有新的leader被選舉出來。
通過leader機制,Raft將一致性難題分解為三個相對獨立的子問題:
- Leader選舉:當前leader跪了的情況下,新leader被選舉出來。
- 日志復制:leader必須能夠從客戶端接收log entries,然后將它們復制給其他機器,強制它們與自己一致。
- 安全性:如果任何節點將偏移x的log entry應用於自己的狀態機,那么其他節點改變狀態機時使用的偏移x的指令必須與之相同。
#####A. Raft基本知識
一個Raft集群包含單數個server,5個是一個典型配置,允許該系統最多容忍兩個機器fail。在任何時刻,每個server有三種狀態:leader,follower,candidate。正常運行時,只有一個leader,其余全是follower。follower是被動的:它們不主動提出請求,只是響應leader和candidate的請求。leader負責處理所有客戶端請求(如果客戶端先連接某個follower,該follower要負責把它重定向到leader)。第三種狀態candidate用於選主。下圖展示了這些狀態以及它們之間的轉化:
Raft將時間分解成任意長度的terms,如下圖所示:
terms有連續單調遞增的編號,每個term開始於選舉,這一階段每個candidate都試圖成為leader。如果一個candidate選舉成功,它就在該term剩余周期內履行leader職責。在某種情形下,可能出現選票分散,沒有選出leader的情況,這時新的term立即開始。Raft確保在任何term都只可能存在一個leader。term在Raft用作邏輯時鍾,servers可以利用term判斷一些過時的信息:比如過時的leader。每台server都存儲當前term號,它隨時間單調遞增。term號可以在任何server通信時改變:如果某台server的當前term號小於其它servers,那么這台server必須更新它的term號,與它人保持一致;如果一個candidate或者leader發現自己的term過期,它就必須要“放下身段”變成follower;如果某台server收到一個過時的請求(擁有過時的term號),它會拒絕該請求。Raft servers使用RPC交互,基本的一致性算法只需要兩種RPC。RequestVote RPCs由candidate在選舉階段發起。AppendEntries RPCs在leader復制數據時發起,leader在和其他人做心跳時也用該RPC。servers發起一個RPC,如果在沒得到響應,則需要不斷重試。另外,發起RPC是並行的。
#####B. leader選舉
Raft使用心跳機制來觸發選舉。當server啟動時,初始狀態都是follower。每一個server都有一個定時器,超時時間為election timeout,如果某server沒有超時的情況下收到來自leader或者candidate的任何RPC,定時器重啟,如果超時,它就開始一次選舉。leader給其他人發RPC要么復制日志,要么就是用來告訴followers老子是leader,你們不用選舉的心跳(告訴followers對狀態機應用日志的消息夾雜在心跳中)。如果某個candidate獲得了大多數人的選票,它就贏得了選舉成為新leader。每個server在某個term周期內只能給最多一個人投票,按照先來先給的原則。新leader要給其他人發送心跳,阻止新選舉。
在等待選票過程中,一個candidate,假設為A,可能收到它人的聲稱自己是leader的AppendEntries RPC,如果那家伙的term號大於等於A的,那么A承認他是leader,自己重新變成follower。如果那家伙比自己小,那么A拒絕該RPC,繼續保持candidate狀態。
還有第三種可能性就是candidate既沒選舉成功也沒選舉失敗:如果多個followers同時成為candidate去拉選票,導致選票分散,任何candidate都沒拿到大多數選票,這種情況下Raft使用超時機制來解決。Raft給每一個server都分配一個隨機長度的election timeout(一般是150——300ms),所以同時出現多個candidate的可能性不大,即使機緣巧合同時出現了多個candidate導致選票分散,那么它們就等待自己的election timeout超時,重新開始一次新選舉(再一再二不能再三再四,不可能每次都同時出現多個candidate),實驗也證明這個機制在選舉過程中收斂速度很快。
#####C. 日志復制
一旦選舉出了一個leader,它就開始負責服務客戶端的請求。每個客戶端的請求都包含一個要被復制狀態機執行的指令。leader首先要把這個指令追加到log中形成一個新的entry,然后通過AppendEntries RPCs並行的把該entry發給其他servers,其他server如果發現沒問題,復制成功后會給leader一個表示成功的ACK,leader收到大多數ACK后應用該日志,返回客戶端執行結果。如果followers crash或者丟包,leader會不斷重試AppendEntries RPC。Logs按照下圖組織:
每個log entry都存儲着一條用於狀態機的指令,同時保存從leader收到該entry時的term號。該term號可以用來判斷一些log之間的不一致狀態。每一個entry還有一個index指明自己在log中的位置。
leader需要決定什么時候將日志應用給狀態機是安全的,被應用的entry叫committed。Raft保證committed entries持久化,並且最終被其他狀態機應用。一個log entry一旦復制給了大多數節點就成為committed。同時要注意一種情況,如果當前待提交entry之前有未提交的entry,即使是以前過時的leader創建的,只要滿足已存儲在大多數節點上就一次性按順序都提交。leader要追蹤最新的committed的index,並在每次AppendEntries RPCs(包括心跳)都要捎帶,以使其他server知道一個log entry是已提交的,從而在它們本地的狀態機上也應用。
Raft的日志機制提供兩個保證,統稱為Log Matching Property:
- 不同機器的日志中如果有兩個entry有相同的偏移和term號,那么它們存儲相同的指令。
- 如果不同機器上的日志中有兩個相同偏移和term號的日志,那么日志中這個entry之前的所有entry保持一致。
第一個保證是由於一個leader在指定的偏移和指定的term,只能創建一個entry,log entries不能改變位置。第二個保證通過AppendEntries RPC的一個簡單的一致性檢查機制完成。當發起一個AppendEntries RPC,leader會包含正好排在新entries之前的那個entry的偏移和term號,如果follower發現在相同偏移處沒有相同term號的一個entry,那么它拒絕接受新的entries。這個一致性檢查以一種類似歸納法的方式進行:初始狀態大家都沒有日志,不需要進行Log Matching Property檢查,但是無論何時followers只要日志要追加都要進行此項檢查。因此,只要AppendEntries返回成功,leader就知道這個follower的日志一定和自己的完全一樣。
在正常情形下,leader和follower的日志肯定是一致的,所以AppendEntries一致性檢查從不失敗。然而,如果leader crash,那么它們的日志很可能出現不一致。這種不一致會隨着leader或者followers的crash變得非常復雜。下圖展示了所有日志不一致的情形:
如上圖(a)(b)followers可能丟失日志,(c)(d)有多余的日志,或者是(e)(f)跨越多個terms的又丟失又多余。在Raft里,leader強制followers和自己的日志嚴格一致,這意味着followers的日志很可能被leader的新推送日志所覆蓋。
leader為了強制它人與自己一致,勢必要先找出自己和follower之間存在分歧的點,也就是我的日志與你們的從哪里開始不同。然后令followers刪掉那個分歧點之后的日志,再將自己在那個點之后的日志同步給followers。這個實現也是通過AppendEntries RPCs的一致性檢查來做的。leader會把發給每一個follower的新日志的偏移nextIndex也告訴followers。當新leader剛開始服務時,它把所有follower的nextIndex都初始化為它最新的log entry的偏移+1(如上圖中的11)。如果一個follower的日志和leader的不一致,AppendEntries RPC會失敗,leader就減小nextIndex然后重試,直到找到分歧點,剩下的就好辦了,移除沖突日志entries,同步自己的。當然這里有很大的優化余地,完全不需要一步一步回溯,怎么玩請自己看論文 1,很簡單。
#####D. 安全性
前文講解了Raft如何選主和如何進行日志復制,然而這些還不足以保證不同節點能執行嚴格一致的指令序列,需要額外的一些安全機制。比如,一個follower可能在當前leader commit日志時不可用,然而過會它又被選舉成了新leader,這樣這個新leader可能會用新的entries覆蓋掉剛才那些已經committed的entries。結果不同的復制狀態機可能會執行不同的指令序列,產生不一致的狀況。這里Raft增加了一個可以確保新leader一定包含任何之前commited entries的選舉機制。
(1) 選舉限制
Raft使用了一個投票規則來阻止一個不包含所有commited entries的candidate選舉成功。一個candidate為了選舉成功必須聯系大多數節點,假設它們的集合叫A,而一個entry如果能commit必然存儲在大多數節點,這意味着對於每一個已經committed的entry,A集合中必然有一個節點持有它。如果這個candidate的Log不比A中任何一個節點舊才有機會被選舉為leader,所以這個candidate如果要成為leader一定已經持有了所有commited entries(注意我說的持有指只是儲存,不一定被應用到了復制狀態機中。比如一個老的leader將一個entry發往大多數節點,它們都成功接收,老leader隨即就commit這個entry,然后掛掉,此時這個entry叫commited entry,但是它不一定應用在了集群中所有的復制狀態機上)。這個實現在RequestVote RPC中:該RPC包含了candidate log的信息,選民如果發現被選舉人的log沒有自己新,就拒絕投票。Raft通過比較最近的日志的偏移和term號來決定誰的日志更新。如果兩者最近的日志term號不同,那么越大的越新,如果term號一樣,越長的日志(擁有更多entries)越新。
(2) 提交早期terms的entries
正如前面所述的那樣,一個leader如果知道一個當前term的entry如果儲在大多數節點,就認為它可以commit。但是,一個leader不能也認為一個早於當前term的entry如果存在大多數節點,那么也是可以commit的。下圖展示了這樣一種狀況,一個老的log存儲在大多數節點上,但是仍有可能被新leader覆蓋掉:
要消除上圖中的問題,Raft采取針對老term的日志entries絕不能僅僅通過它在集群中副本的數量滿足大多數,就認為是可以commit的。完整的commit語義也演變成:一個日志entry如果能夠被認為是可以提交的,必須同時滿足兩個條件:
- 這個entry存儲在大多數節點上
- 當前term至少有一個entry存儲在大多數節點。
以上圖為例,這兩個條件確保一旦當前leader將term4的entry復制給大多數節點,那么S5不可能被選舉為新leader了(日志term號過時)。綜合考慮,通過上述的選舉和commit機制,leaders永遠不會覆蓋已提交entries,並且leader的日志永遠絕對是”the truth”。
(3) 調解過期leader
在Raft中有可能同一時刻不只一個server是leader。一個leader突然與集群中其他servers失去連接,導致新leader被選出,這時剛才的老leader又恢復連接,此時集群中就有了兩個leader。這個老leader很可能繼續為客戶端服務,試圖去復制entries給集群中的其它servers。但是Raft的term機制粉碎了這個老leader試圖造成任何不一致的行為。每一個RPC servers都要交換它們的當前term號,新leader一旦被選舉出來,肯定有一個大多數群體包含了最新的term號,老leader的RPC都必須要聯系一個大多數群體,它必然會發現自己的term號過期,從而主動讓賢,退變為follower狀態。
然而有可能是老leader commit一個entry后失去連接,這時新leader必然有那個commit的entry,只是新leader可能還沒commit它,這時新leader會在初次服務客戶端前先把這個entry再commit一次,followers如果已經commit過直接返回成功,沒commit就commit后返回成功而已,不會造成不一致。
#####E. Follower 或者candidate crash
Followers和candidate的crash比起leader來說處理要簡單很多,它們的處理流程是相同的,如果某個follower或者candidate crash了,那么未來發往它的RequestVote和AppendEntries RPCs 都會失敗,Raft采取的策略就是不停的重發,如果crash的機器恢復就會執行成功。另外,如果server crash是在完成一個RPC但在回復之前,那么在它恢復之后仍然會收到相同的RPC(讓它重試一次),Raft的所有RPC都是冪等操作,如果follower已經有了某個entry,但是leader又讓它復制,follower直接忽略即可。
#####F. 時間與可用性
Raft集群的可用性需要滿足一個時間關系,即下面的公式:
broadcastTime代表平均的廣播延遲(從並行發起RPC開始,直到收到大多數的回復為止)。electionTimeout就是前文所述的超時時間間隔,MTBF代表一個機器兩次宕機時間間隔的平均值。broadcastTime必須遠遠小於electionTimeout,否則會頻繁觸發無意義的選舉重啟。electionTimeout遠遠小於MTBF就很好理解了。
####集群擴容
迄今為止的討論都是假設集群配置不變的情況下(參與一致性算法的機器數目不變),但是實際中集群擴容(廣義概念,泛指集群容量變化,可增可減)有時是必須的。比如業務所需,集群需要擴容,或者某機器永久損壞,需要從集群中剔除等等。
擴容最大的挑戰就是保證一致性很困難,因為擴容不是原子的,有可能集群中一部分機器用老配置信息,另一部分用新配置信息,如上圖所示,因此究竟多少算大多數在集群中可能存在分歧。擴容一般都分為兩個階段,有很多實現方法,比較典型的就是第一階段,使所有舊配置信息失效,這段時間不對外提供服務;第二階段允許新配置信息,重新對外服務。在Raft中集群第一階段首先使集群轉向一個被稱作聯合一致性(joint consensus)的狀態;一旦這個聯合一致性(有一個特殊的Cold,new entry表征)被提交,這個系統就開始向新配置遷移。joint consensus既包含老配置信息,又包含新配置信息,它的規則(Cold,new規則)如下:
- log entries復制給集群中所有機器,不管配置信息新舊
- 任何配置的server都有權利當leader
- 達成一致所需要的大多數(不管是選舉還是entry提交),既要包括老配置中的一個大多數也要包括新配置機器中的一個大多數。
joint consensus允許集群配置平滑過渡而不失去安全和一致性,同時集群可正常對外服務。集群配置信息以一個特殊的entry表征,存儲在log中並和其他entry語義一樣。當一個leader收到擴容請求,它就創建一個Cold,new的entry,並復制給集群其它其它機器,一旦有某個follower收到此entry並追加到自己的日志中,它就使用Cold,new規則(不需要committed)。leader按照Cold,new 規則對Cold,new進行commit。如果一個leader crash了,新leader既可能是沒收到Cold,new的又可能是收到的,但是這兩種情況都不會出現leader用Cnew規則做決策。
一旦Cold,new被提交,就開始向新配置轉換。Raft保證只有擁有Cold,new的節點才會被選舉為leader。現在leader可以很安全地創建一個叫Cnew的entry,然后復制給集群中屬於新配置的節點。配置信息還是節點一收到就可以用來做決策。Cnew的commit使用Cnew 規則,舊配置已經與集群無關,新配置中不存在的機器可以着手關閉,Raft通過保證不讓Cold和Cnew的節點有機會同時做決策來保障安全性。配置改變示意圖如下:
有三個要注意的點:
(1) 新server加入進集群時也許沒有存儲任何log entries,它們需要很長時間來追上原有機器的“進度”,為了防止集群性能出現巨大的抖動,Raft在配置改變前又引入了一個特殊的階段,當新機器加入集群時,它們作為無投票權的節點,即leader還是會將log entries復制給它們,但是不會被當做大多數對待。一旦這些“新人”追上了其他機器的進度,動態擴容可以像上述一樣執行。
(2) 當前leader也許不是集群新配置中的一份子,在這種情況下,一旦Cnew被提交,這個leader就主動退出。這意味着當Cnew提交過程中這個leader要管理一個不包括它自己的集群。這時,它在給其他節點復制log entries時不把自己計入大多數。當Cnew提交成功,這個leader的使命就光榮完成了,在這個時間點前,只有Cold的節點才會被選舉為leader。
(3) 第三個問題是被移除的機器可能會影響集群服務。這些機器不會再接收到心跳信息,它們因此可能重啟選舉,結果是它們用一個更新的term號發起RequestVote RPCs,當前leader無奈的退化為follower。新leader(擁有新配置的)還是會被選舉出來,但是那些被移除的機器還是繼續超時,開啟選舉,導致系統可用性降低。為解決這一問題,需要有一個機制讓集群中節點能夠忽略這種RequestVote RPCs,相信當前leader正在正常工作。Raft使用最小超時延遲(前面說過,Raft會隨機為每個節點指定一個隨機的election timeout,典型的是150ms到300ms,最小超時延遲就是它們之間的最小值)來保證,如果一個節點在最小超時延遲前收到一個RequestVote RPCs,那么該節點不會提升term號,也不會為其投票。這個不會影響正常的選舉,因為正常選舉中都是至少等待一個最小超時延遲才開始下一次選舉的。
####Raft新集群擴容方案
為了降低集群擴容的復雜性,Raft還有一個可以備選的方案。它增強了擴容的限制:一次只允許一台機器加入或移除集群。復雜的擴容可以通過一系列的單機增加或刪除變向實現。
當從集群中添加或刪除一台機器時,任何舊配置集群中的大多數和任何新配置集群中的大多數必然存在一個交集,如下圖所示。
注意就是這個交集讓集群不可能存在兩個互相獨立,沒有交集的大多數群體。因此系統中不會同時存在兩個leader(一個由舊配置集群選舉出來,一個由新配置集群選舉出來),因此擴容是安全的,集群可以直接從舊配置轉向新配置。
當leader收到添加或刪除一台機器的請求,它將該Cnew添加到自己的log中,然后使用一樣的復制機制,把這個entry復制給屬於新配置集群的所有節點。只要節點把Cnew添加進log,就馬上使用新配置信息,無需等到該entry提交。leader使用Cnew規則對Cnew進行提交,一旦提交完成,擴容就宣告完成,leader就知道任何沒有Cnew的節點都不可能形成一個大多數,這樣的節點也不可能成為集群的leader。Cnew的提交暗示了以下三件事情:
- leader獲知擴容成功完成
- 如果新配置移除一台機器,那么這台機器可以着手關閉
- 新的擴容可以開始
正如上面所講的那樣,servers永遠使用log中最新的配置,不管這個配置entry是否被提交。這樣leader知道一