Raft協議詳解

說明

分佈式存儲系統通常通過維護多個副本來進行容錯,提高系統的可用性。要實現此目標,就必須要解決分佈式存儲系統的最核心問題:維護多個副本的一致性。

首先需要解釋一下什麼是一致性(consensus),它是構建具有容錯性(fault-tolerant)的分佈式系統的基礎。 在一個具有一致性的性質的集羣裏面,同一時刻所有的結點對存儲在其中的某個值都有相同的結果,即對其共享的存儲保持一致。集羣具有自動恢復的性質,當少數結點失效的時候不影響集羣的正常工作,當大多數集羣中的結點失效的時候,集羣則會停止服務(不會返回一個錯誤的結果)。

一致性協議就是用來幹這事的,用來保證即使在部分(確切地說是小部分)副本宕機的情況下,系統仍然能正常對外提供服務。一致性協議通常基於replicated state machines,即所有結點都從同一個state出發,都經過同樣的一些操作序列(log),最後到達同樣的state。


架構

系統中每個結點有三個組件:

狀態機: 當我們說一致性的時候,實際就是在說要保證這個狀態機的一致性。狀態機會從log裏面取出所有的命令,然後執行一遍,得到的結果就是我們對外提供的保證了一致性的數據

Log: 保存了所有修改記錄

一致性模塊: 一致性模塊算法就是用來保證寫入的log的命令的一致性,這也是raft算法核心內容

協議內容

Raft協議將一致性協議的核心內容分拆成爲幾個關鍵階段,以簡化流程,提高協議的可理解性。

Leader election

Raft協議的每個副本都會處於三種狀態之一:Leader、Follower、Candidate。

Leader:所有請求的處理者,Leader副本接受client的更新請求,本地處理後再同步至多個其他副本;

Follower:請求的被動更新者,從Leader接受更新請求,然後寫入本地日誌文件

Candidate:如果Follower副本在一段時間內沒有收到Leader副本的心跳,則判斷Leader可能已經故障,此時啓動選主過程,此時副本會變成Candidate狀態,直到選主結束。

時間被分爲很多連續的隨機長度的term,term有唯一的id。每個term一開始就進行選主:

  1. Follower將自己維護的current_term_id加1。
  2. 然後將自己的狀態轉成Candidate
  3. 發送RequestVoteRPC消息(帶上current_term_id) 給 其它所有server

這個過程會有三種結果:

  • 自己被選成了主。當收到了majority的投票後,狀態切成Leader,並且定期給其它的所有server發心跳消息(不帶log的AppendEntriesRPC)以告訴對方自己是current_term_id所標識的term的leader。每個term最多隻有一個leader,term id作爲logical clock,在每個RPC消息中都會帶上,用於檢測過期的消息。當一個server收到的RPC消息中的rpc_term_id比本地的current_term_id更大時,就更新current_term_id爲rpc_term_id,並且如果當前state爲leader或者candidate時,將自己的狀態切成follower。如果rpc_term_id比本地的current_term_id更小,則拒絕這個RPC消息。
  • 別人成爲了主。如1所述,當Candidator在等待投票的過程中,收到了大於或者等於本地的current_term_id的聲明對方是leader的AppendEntriesRPC時,則將自己的state切成follower,並且更新本地的current_term_id。
  • 沒有選出主。當投票被瓜分,沒有任何一個candidate收到了majority的vote時,沒有leader被選出。這種情況下,每個candidate等待的投票的過程就超時了,接着candidates都會將本地的current_term_id再加1,發起RequestVoteRPC進行新一輪的leader election。

投票策略:

  • 每個節點只會給每個term投一票,具體的是否同意和後續的Safety有關。
  • 當投票被瓜分後,所有的candidate同時超時,然後有可能進入新一輪的票數被瓜分,爲了避免這個問題,Raft採用一種很簡單的方法:每個Candidate的election timeout從150ms-300ms之間隨機取,那麼第一個超時的Candidate就可以發起新一輪的leader election,帶着最大的term_id給其它所有server發送RequestVoteRPC消息,從而自己成爲leader,然後給他們發送心跳消息以告訴他們自己是主。

Log Replication

當Leader被選出來後,就可以接受客戶端發來的請求了,每個請求包含一條需要被replicated state machines執行的命令。leader會把它作爲一個log entry append到日誌中,然後給其它的server發AppendEntriesRPC請求。當Leader確定一個log entry被safely replicated了(大多數副本已經將該命令寫入日誌當中),就apply這條log entry到狀態機中然後返回結果給客戶端。如果某個Follower宕機了或者運行的很慢,或者網絡丟包了,則會一直給這個Follower發AppendEntriesRPC直到日誌一致。

當一條日誌是commited時,Leader纔可以將它應用到狀態機中。Raft保證一條commited的log entry已經持久化了並且會被所有的節點執行。

當一個新的Leader被選出來時,它的日誌和其它的Follower的日誌可能不一樣,這個時候,就需要一個機制來保證日誌的一致性。一個新leader產生時,集羣狀態可能如下:


最上面這個是新Leader,a~f是Follower,每個格子代表一條log entry,格子內的數字代表這個log entry是在哪個term上產生的。

新Leader產生後,就以Leader上的log爲準。其它的follower要麼少了數據比如b,要麼多了數據,比如d,要麼既少了又多了數據,比如f。

因此,需要有一種機制來讓leader和follower對log達成一致,leader會爲每個follower維護一個nextIndex,表示leader給各個follower發送的下一條log entry在log中的index,初始化爲leader的最後一條log entry的下一個位置。leader給follower發送AppendEntriesRPC消息,帶着(term_id, (nextIndex-1)), term_id即(nextIndex-1)這個槽位的log entry的term_id,follower接收到AppendEntriesRPC後,會從自己的log中找是不是存在這樣的log entry,如果不存在,就給leader回覆拒絕消息,然後leader則將nextIndex減1,再重複,知道AppendEntriesRPC消息被接收。

以leader和b爲例:

初始化,nextIndex爲11,leader給b發送AppendEntriesRPC(6,10),b在自己log的10號槽位中沒有找到term_id爲6的log entry。則給leader迴應一個拒絕消息。接着,leader將nextIndex減一,變成10,然後給b發送AppendEntriesRPC(6, 9),b在自己log的9號槽位中同樣沒有找到term_id爲6的log entry。循環下去,直到leader發送了AppendEntriesRPC(4,4),b在自己log的槽位4中找到了term_id爲4的log entry。接收了消息。隨後,leader就可以從槽位5開始給b推送日誌了。

Safety

  • 哪些follower有資格成爲leader?

Raft保證被選爲新leader的節點擁有所有已提交的log entry,這與ViewStamped Replication不同,後者不需要這個保證,而是通過其他機制從follower拉取自己沒有的提交的日誌記錄

這個保證是在RequestVoteRPC階段做的,candidate在發送RequestVoteRPC時,會帶上自己的最後一條日誌記錄的term_id和index,其他節點收到消息時,如果發現自己的日誌比RPC請求中攜帶的更新,拒絕投票。日誌比較的原則是,如果本地的最後一條log entry的term id更大,則更新,如果term id一樣大,則日誌更多的更大(index更大)。


  • 哪些日誌記錄被認爲是commited?

  1. leader正在replicate當前term(即term 2)的日誌記錄給其它Follower,一旦leader確認了這條log entry被majority寫盤了,這條log entry就被認爲是committed。如圖a,S1作爲當前term即term2的leader,log index爲2的日誌被majority寫盤了,這條log entry被認爲是commited
  2. leader正在replicate更早的term的log entry給其它follower。圖b的狀態是這麼出來的。

對協議的一點修正

在實際的協議中,需要進行一些微調,這是因爲可能會出現下面這種情況:


  1. 在階段a,term爲2,S1是Leader,且S1寫入日誌(term, index)爲(2, 2),並且日誌被同步寫入了S2;
  2. 在階段b,S1離線,觸發一次新的選主,此時S5被選爲新的Leader,此時系統term爲3,且寫入了日誌(term, index)爲(3, 2);
  3. S5尚未將日誌推送到Followers變離線了,進而觸發了一次新的選主,而之前離線的S1經過重新上線後被選中變成Leader,此時系統term爲4,此時S1會將自己的日誌同步到Followers,按照上圖就是將日誌(2, 2)同步到了S3,而此時由於該日誌已經被同步到了多數節點(S1, S2, S3),因此,此時日誌(2,2)可以被commit了(即更新到狀態機);
  4. 在階段d,S1又很不幸地下線了,系統觸發一次選主,而S5有可能被選爲新的Leader(這是因爲S5可以滿足作爲主的一切條件:1. term = 5 > 4,2. 最新的日誌爲(3,2),比大多數節點(如S2/S3/S4的日誌都新),然後S5會將自己的日誌更新到Followers,於是S2、S3中已經被提交的日誌(2,2)被截斷了,這是致命性的錯誤,因爲一致性協議中不允許出現已經應用到狀態機中的日誌被截斷。

爲了避免這種致命錯誤,需要對協議進行一個微調:

只允許主節點提交包含當前term的日誌

針對上述情況就是:即使日誌(2,2)已經被大多數節點(S1、S2、S3)確認了,但是它不能被Commit,因爲它是來自之前term(2)的日誌,直到S1在當前term(4)產生的日誌(4, 4)被大多數Follower確認,S1方可Commit(4,4)這條日誌,當然,根據Raft定義,(4,4)之前的所有日誌也會被Commit。此時即使S1再下線,重新選主時S5不可能成爲Leader,因爲它沒有包含大多數節點已經擁有的日誌(4,4)。

Log Compaction

在實際的系統中,不能讓日誌無限增長,否則系統重啓時需要花很長的時間進行回放,從而影響availability。Raft採用對整個系統進行snapshot來處理,snapshot之前的日誌都可以丟棄。Snapshot技術在Chubby和ZooKeeper系統中都有采用。

Raft使用的方案是:每個副本獨立的對自己的系統狀態進行Snapshot,並且只能對已經提交的日誌記錄(已經應用到狀態機)進行snapshot。

Snapshot中包含以下內容:

  • 日誌元數據,最後一條commited log entry的 (log index, last_included_term)。這兩個值在Snapshot之後的第一條log entry的AppendEntriesRPC的consistency check的時候會被用上,之前講過。一旦這個server做完了snapshot,就可以把這條記錄的最後一條log index及其之前的所有的log entry都刪掉。
  • 系統狀態機:存儲系統當前狀態(這是怎麼生成的呢?)

snapshot的缺點就是不是增量的,即使內存中某個值沒有變,下次做snapshot的時候同樣會被dump到磁盤。當leader需要發給某個follower的log entry被丟棄了(因爲leader做了snapshot),leader會將snapshot發給落後太多的follower。或者當新加進一臺機器時,也會發送snapshot給它。發送snapshot使用新的RPC,InstalledSnapshot。

做snapshot有一些需要注意的性能點,1. 不要做太頻繁,否則消耗磁盤帶寬。 2. 不要做的太不頻繁,否則一旦節點重啓需要回放大量日誌,影響可用性。系統推薦當日志達到某個固定的大小做一次snapshot。3. 做一次snapshot可能耗時過長,會影響正常log entry的replicate。這個可以通過使用copy-on-write的技術來避免snapshot過程影響正常log entry的replicate。

集羣拓撲變化

集羣拓撲變化的意思是在運行過程中多副本集羣的結構性變化,如增加/減少副本數、節點替換等。

Raft協議定義時也考慮了這種情況,從而避免由於下線老集羣上線新集羣而引起的系統不可用。Raft也是利用上面的Log Entry和一致性協議來實現該功能。

假設在Raft中,老集羣配置用Cold表示,新集羣配置用Cnew表示,整個集羣拓撲變化的流程如下:

  1. 當集羣成員配置改變時,leader收到人工發出的重配置命令從Cold切成Cnew;
  2. Leader副本在本地生成一個新的log entry,其內容是Cold∪Cnew,代表當前時刻新舊拓撲配置共存,寫入本地日誌,同時將該log entry推送至其他Follower節點
  3. Follower副本收到log entry後更新本地日誌,並且此時就以該配置作爲自己瞭解的全局拓撲結構,
  4. 如果多數Follower確認了Cold U Cnew這條日誌的時候,Leader就Commit這條log entry;
  5. 接下來Leader生成一條新的log entry,其內容是全新的配置Cnew,同樣將該log entry寫入本地日誌,同時推送到Follower上;
  6. Follower收到新的配置日誌Cnew後,將其寫入日誌,並且從此刻起,就以該新的配置作爲系統拓撲,並且如果發現自己不在Cnew這個配置中會自動退出
  7. Leader收到多數Follower的確認消息以後,給客戶端發起命令執行成功的消息

異常分析

  • 如果Leader的Cold U Cnew尚未推送到Follower,Leader就掛了,此時選出的新的Leader並不包含這條日誌,此時新的Leader依然使用Cold作爲全局拓撲配置
  • 如果Leader的Cold U Cnew推送到大部分的Follower後就掛了,此時選出的新的Leader可能是Cold也可能是Cnew中的某個Follower;
  • 如果Leader在推送Cnew配置的過程中掛了,那麼和2一樣,新選出來的Leader可能是Cold也可能是Cnew中的某一個,那麼此時客戶端繼續執行一次改變配置的命令即可
  • 如果大多數的Follower確認了Cnew這個消息後,那麼接下來即使Leader掛了,新選出來的Leader也肯定是位於Cnew這個配置中的,因爲有Raft的協議保證。

爲什麼需要弄這樣一個兩階段協議,而不能直接從Cold切換至Cnew?

這是因爲,如果直接這麼簡單粗暴的來做的話,可能會產生多主。簡單說明下:

假設Cold爲拓撲爲(S1, S2, S3),且S1爲當前的Leader,如下圖:


假如此時變更了系統配置,將集羣範圍擴大爲5個,新增了S4和S5兩個服務節點,這個消息被分別推送至S2和S3,但是假如只有S3收到了消息並處理,S2尚未得到該消息


這時在S2的眼裏,拓撲依然是<S1, S2, S3>,而在S3的眼裏拓撲則變成了<S1, S2, S3, S4, S5>。假如此時由於某種原因觸發了一次新的選主,S2和S3分別發起選主的請求:


最終,候選者S2獲得了S1和S2自己的贊成票,那麼在它眼裏,它就變成了Leader,而S3獲得了S4、S5和S3自己的贊成票,在它眼裏S3也變成了Leader,那麼多Leader的問題就產生了。而產生該問題的最根本原因是S2和S3的系統視圖不一致。

參考

raft.github.io/raft.pdfraft.github.io/raft.pdf
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章