【轉載】分佈式存儲算法之Raft 一致性算法

分佈式系統中,如何保證多個節點的狀態一致?Raft 一致性算法與 Paxos 不同,號稱簡單易學,且已經廣泛應用在生產中。例如 k8s 和 CoreOS 中使用的 etcd;tikv 中使用 Raft 完成分佈式同步;Redis Cluster 中使用類似 Raft 的選主機制等等。今天我們來一探究竟吧。

複製狀態機/Replicated state machines

複製狀態機的想法是將服務器看成一個狀態機,而一致性算法的目的是讓多臺服務器/狀態機能夠計算得到相同的狀態,同時,如果有部分機器宕機,集羣作爲一個整體依然能繼續工作。複製狀態機一般通過複製日誌(replicated log)來實現,如下圖:

                              

服務器會將客戶端發來的命令存成日誌,日誌是有序的。而服務器狀態機執行命令的結果是確定的,這樣如果每臺服務器的狀態機執行的命令是相同的,狀態機最終的狀態也會是相同的,輸出的結果也會是相同的。而如何保證不同服務器間的日誌是一樣的呢?這就是其中的“一致性模塊”的工作了。

一致性模塊(consensus module)在收到客戶端的命令時(②),一方面要將命令添加到自己的日誌隊列中,同時需要與其它服務器的一致性模塊溝通,確保所有的服務器將最終擁有相同的日誌,即使有些服務器可能掛了。實踐中至少需要“大多數(大於一半)”服務器同步了命令才認爲同步成功了。

Raft 算法

接下去會從 3 個方面講解 Raft 算法:

  1. 選主(Leader Election)。Raft 在同一時刻只有一個主節點能接收寫命令。
  2. 日誌複製(Log Replication)。Raft 如何將接收到的命令複製到其它服務器上,使其保持一致?
  3. 安全性,爲什麼 Raft 在各種情況下依舊能保證各服務器的日誌一致性?

基礎概念

首先是節點的 3 種狀態/角色:

  • Follower/從節點不發起請求,單純響應 Candidate 和 Leader 的請求
  • Leader/主節點負責響應客戶端發起的請求,如果客戶端的請求發送到 Follower,請求會被轉發到主節點
  • Candidate/候選節點是一種中間狀態,只在選主期間存在。

它們的轉換關係如下:

                      

其次是任期(term)的概念。Raft 將時間切分成多個 term,每個 term 以選主開始,選主期間各節點嘗試當上主節點,選舉結束後開始正常處理客戶端的請求。如圖:

                              

 

Raft 會保證每一個 term 中至多隻有一個 leader,如果選主時選票被分散導致沒有節點獲得多數票(如 t3),則會開始新一輪選舉。

term 就像邏輯上的“時間”,用來記錄和比較各節點的“進度”。如果某個節點收到信息時發現自己的 term 是落後的,它會立即將自己的 term 更新爲更大的 term;同時節點不會理睬 term 比自己小的消息;另外如果主節點收到 term 比自己大的消息,則會立馬進入 follower 的狀態。

例如由於網絡情況不佳,一個主節點 A 與其它節點失聯,其它節點選了一個新的主節點 B,當網絡恢復正常時,舊主節點 A 收到主節點 B 的消息時,它會判斷新主節點 B 的 term 大於自己,說明自己錯過了一些事件,因此選擇放棄自己的主節點身份。

選主/Leader Election

節點啓動時,默認處於 Follower 的狀態,所以開始時所有節點均是 Follower,那麼什麼時候觸發選主呢?Raft 用“心跳”的方式來保持主從節點的聯繫,如果長時間沒有收到主節點的心跳,則開始選主。這裏會涉及到兩個時間:

  • 心跳間隔,主節點隔多長時間發送心跳信息
  • 等待時間(election timeout),如果超過這個時間仍然沒有收到心跳,則認爲主節點宕機。一般每個節點各自在 150~300ms 間隨機取值。

當一個節點在等待時間內沒有收到主節點的心跳信息,它首先將自己保存的 term 增加 1 並進入 Candidate 狀態。此時它會先投票給自己,然後並行發送 RequestVote消息給其它所有節點,請求這些節點投票給自己。然後等待直到以下 3 種情形之一發生:

  1. 收到大於一半的票,當選爲主節點
  2. 有其它節點當選了主節點,此時會收到新的主節點的心跳
  3. 過了一段時間後依舊沒有當選,此時該節點會嘗試開始新一輪選舉

                  

對於第一種情形,Candidate 節點需要收到集羣中與自己 term 相同的所有節點中大於一半的票數(當然如果節點 term 比自己大,是不會理睬自己的選舉消息的)。節點投票時會採取先到先得的原則,對於某個 term,最多投出一票(後面還會再對投票加一些限制)。這樣能保證某個 term 中,最多隻會產生一個 leader。當一個 Candidate 變成主節點後,它會向其它所有節點發送心跳信息,這樣其它的 Candidate 也會變成 Follower。

第二種情形是在等待投票的過程中,Candidate 收到其它主節點的心跳信息(只有主節點纔會向其它節點發心跳),且信息中包含的 term 大於等於自己的 term,則當前節點放棄競選,進入 Follower 狀態。當然,如前所說,如果心跳中的 term 小於自己,則不予理會。

第三種情形一般發生在多個 Follower 同時觸發選舉,而各節點的投票被分散了,導致沒有 Candidate 能得到多數票。超過投票的等待時間後,節點觸發新一輪選舉。理論上,選舉有可能永遠平票,Raft 中由於各個節點的超時時間是隨機的,實際上平票不太會永遠持續下去。

日誌複製/Log Replication

Log Replication 分爲兩個主要步驟:複製/Replication 和 提交/Commit。當一個節點被選爲主節點後,它開始對外提供服務,收到客戶端的 command 後,主節點會首先將 command 添加到自己的日誌隊列中,然後並行地將消息發送給其它所有的節點,在確保消息被安全地複製(下文解釋)後,主節點會將該消息提交到狀態機中,並返回狀態機執行的結果。如果follower 掛了或因爲網絡原因消息丟失了,主節點會不斷重試直到所有從節點最終成功複製該消息。

日誌結構示例如下:

                                    

日誌由許多條目(log entry)組成,條目順序編號。條目包含它生成時節點所在的 term (小方格中上方的數字),以及日誌的內容。當一個條目被認爲安全地被複制,且提交到狀態機時,我們認爲它處於“已提交(committed)”狀態。

是否將一個條目提交到狀態機是由主節點決定的。Raft 要保證提交的條目會最終被所有的節點執行。當主節點判斷一個條目已經被複制到大多數節點時,就會提交 /Commit該條目,提交一個條目的同時會提交該條目之前的所有條目,包括那些之前由其它主節點創建的條目(還有些特殊情況下面會提)。主節點會記錄當前提交的日誌編號 (log index),並在發送心跳時帶上該信息,這樣其它節點最終會同步提交日誌。

上面說的是“提交”,那麼“複製”是如何進行的?在現實情況下,主從節點的日誌可能不一致(例如在消息到達從節點前主節點掛了,而從節點被選爲了新的主節點,此時主從節點的日誌不一致)。Raft 算法中,主節點需要處理不一致的情況,它要求所有的從節點複製自己的所有日誌(當然下一小節會介紹額外的限制,保證複製是安全的)。

要複製所有日誌,就要先找到日誌開始不一致的位置,如何做到呢?Raft 當主節點接收到新的 command 時,會發送 AppendEntries 讓從節點複製日誌,不一致的情況也會在這時被處理(AppendEntries 消息同時還兼職作爲心跳信息)。下面是日誌不一致的示例:

                       

主節點需要爲每個從節點記錄一個 nextIndex,作爲該從節點下一條要發送的日誌的編號。當一個節點剛被選爲主節點時,爲所有從節點的 nextIndex 初始化自己最大日誌編號加 1(如上圖示例則爲 11)。接着主節點發送 AppendEntries 給從節點,此時從節點會進行一致性檢查(Consistency Check)。

所謂一致性檢查,指的是當主節點發送 AppendEntries 消息通知從節點添加條目時,需要將新條目 A 之前的那個條目 B 的 log index 和 term,這樣,當從節點收到消息時,就可以判斷自己第log index 條日誌的 term 是否與 B 的 term 相同,如果不相同則拒絕該消息,如果相同則添加條目 A。

主節點的消息被某個從節點拒絕後,主節點會將該從節點的 nextIndex 減一再重新發送AppendEntries 消息。不斷重試,最終就能找主從節點日誌一致的 log index,並用主節點的新日誌覆蓋從節點的舊日誌。當然,如果從節點接收 AppendEntries 消息後,主節點會將 nextIndex 增加一,且如果當前的最新 log index 大於 nextIndex 則會繼續發送消息。

通過以上的機制,Raft 就能保證:

  • 如果兩個日誌條目有相同的 log index 和 term,則它們的內容一定相同。
  • 如果兩個節點中的兩個條目有相同的 log index 和 term,則它們之前的所有日誌一定相同。

安全性

要保證所有的狀態機有一樣的狀態,單憑前幾節的算法還不夠。例如有 3 個節點 A、B、 C,如果 A 爲主節點期間 C 掛了,此時消息被多數節點(A,B)接收,所以 A 會提交這些日誌。此時若 A 掛了,而 C 恢復且被選爲主節點,則 A 已經提交的日誌會被 C 的日誌覆蓋,從而導致狀態機的狀態不一致。

選主的限制

在所有的主從結構的一致性算法中,主節點最終都必須包含所有提交的日誌。有些算法在從節點不包含所有已提交日誌的情況下,依舊允許它當選爲主節點,之後從節點會將這些日誌同步到主節點上。但是 Raft 採用了簡單的方式,只允許那些包含所有已提交日誌的節點當選爲主節點。

注意到節點當選主節點要求得到多數票,同時一個日誌被提交的前提條件是它被多數節點接收,綜合這兩點,說明選舉要產生結果,則至少有一個節點在場,它是包含了當前已經提交的所有日誌的。

因此,Raft 算法在處理要求選舉的 RequestVote 消息時做了限制:消息中會攜帶 Candidate 的 log 消息,而在投票時,Follower 會判斷 Candidate 的消息是不是比自己“更新”(下文定義),如果不如自己“新”,則拒絕爲該 Candidate 投票。

Raft 會首先判斷兩個節點最後一個 log entry 的 term,哪個節點的對應的 term 更大則代表該節點的日誌“更新”;如果 term 的大小一致,則誰的 log entry 更多誰就“更新”。

注意,加了這個限制後,選出的節點不會是“最新的”,即包含所有日誌;但會是足夠新的,至少比半數節點更新,而這也意味着它所包含的日誌都是可以被提交的(但不一定已經提交)。

提交前一個 term 的日誌

這裏我們要討論一個特別的情況。我們知道一個主節點如果發現自己任期(term)內的某條日誌已經被存儲到了多數節點上,主節點就會提交這條日誌。但如果主節點在提交之前就掛了,之後的主節點會嘗試把前任未提交的這些日誌複製到所有子節點上,但與之前不同,僅僅判斷這些日誌被複制到多數節點,新的主節點並不能立馬提交這些日誌,下面舉一個反例:

                          

在 (a) 時,S1 當選並將日誌編號爲 2 的日誌複製到其它節點上。在 (b) 時,S1 宕機,S5 獲得來自 S3 與 S4 的投票,當選爲 term 3 的主節點,此時收到來自客戶端的消息,寫入自己編號爲 2 的日誌。(c) 期間,S5 宕機而 S1 重啓完畢,它重新當選爲主節點並繼續將自己的日誌複製給 S3,此時編號爲 2 且 term 爲 2 的日誌已經被複制到多數節點,但它還不能被提交。如果此時 S1 宕機,如 (d) 所示,此時 S5 獲得來自 S2 S3 S4 的投票,當選新的主節點,此時它將用自己的編號爲 2,term 爲 3 的日誌覆蓋其它節點的日誌。而如果 S1 繼續存活,且在自己的任期內將某條日誌複製到多數節點,如 (e) 所示,則此時 S5 已經不可能繼續當選爲主節點,因此該日誌之前的所有日誌均可被提交(包括前任創建的,編號 2 的日誌)。

 

上例中的 (c) 和 (d) 說明了,即使前任的日誌已經被複制到多數節點上,它依然可能被覆蓋。因此 Raft 並不通過計算前任日誌的複製次數來判斷是否提交這些日誌, Raft 只對自己任期內的日誌計數並在複製到多數節點時進行提交,且在提交這條日誌的同時提交之前的所有日誌。

Raft 算法會出現這個額外的問題,是因爲它在複製前任的日誌時,會保留前任的 term,而其它一致性算法會爲這些日誌使用新的 term。Raft 算法的優勢在於方便推理日誌的形成過程,同時新的主節點需要發送的前任日誌數目會更少。

安全性說明

Raft 算法的安全性是經過理論證明的,這部分博主不熟悉相關領域,只得請大家自行看原論文了。

Follower 與 Candidate 宕機

這部分 Raft 的處理非常簡單,如果 Follower 或 Candidate 宕機,主節點會不斷進行重試,即不管掛不掛都照常發送 AppendEntries 消息。這樣當 Follower 或 Candidate 恢復之後,日誌仍能被正確複製。有時 Follower 會處理消息卻在響應前宕機,此時由於 Raft 算法是冪等的,因此重複發送也沒有關係。

算法僞代碼

下圖來源於原論文:

 

 

成員變更

假設已經有了一個 Raft 集羣,現在要往集羣中增加/移除若干個節點,要如何實現?

雙多數派問題

一種方法是先停止所有節點,修改配置增加新的節點,再重啓所有節點,但是這樣服務起停時就會中斷服務,同時也可能增加人爲操作失誤的風險。另一種方法配置好新的節點直接加入集羣,這樣也會出問題:在某個時刻使用不同配置的兩部分節點可能會各自選出一個主節點。如下圖:

 

                                  

 

圖中綠色爲舊的配置,藍色爲新的配置,在中間的某個時刻,Server 1/2/3 可能會選出一個主節點,而 Server 3/4/5 可能會選出另一個,從而破壞了一致性。

成員變更算法

原版論文 提出了一個比較複雜的算法,利用一箇中間的過度狀態來從 C_old 過度到 C_new (這裏的 C_xxx 指的是成員的配置),在作者的博士論文中指出了一個更簡單的方法。作者發現,如果一次只增加或減少一個節點,那麼並不會出現上面說的兩個多數派的問題。示例如下:

                               上圖的 4 種情況分別代表原始節點數爲奇數和偶數的情況下,添加或移除一個節點時可能產生的“大多數節點”的分組情況。注意到所有的分組都至少會一個節點會出現在兩個分組中,那麼,如果該節點是主節點,則其它所有節點均不可能當選主節點;如果該節點不是主節點,則它至少應該投票給其中一個分組中的其它節點,但這樣一來另一個分組就達到不到票數來產生新的主節點了。因此在只增加/減少一個節點的情況下,不可能同時產生兩個主節點。

 

當主節點收到對當前集羣(C_old)新增/移除節點的請求時,它會將新的集羣配置(C_new)作爲一條新的日誌加入到隊列中,並用上文提到的機制複製到其它各個節點。當一個節點收到新的日誌時,日誌中的 C_new 會立即生效,即該節點的日誌會被複制到 C_new 中配置的其它節點,且日誌是否被提交也以 C_new 中指定的節點作爲依據。這意味着節點不需要等 C_new 日誌被提交後纔開始啓用 C_new,且每個節點總是使用它的日誌中最新的配置。

當主節點提交 C_new 日誌後,新增/移除節點的操作就算結束。此時,主節點能確定至少 C_new 中的多數節點已經啓用了 C_new 配置,同時,那些還沒有啓用 C_new 的節點也不再可能組成新的“多數節點”。

算法僞代碼

下圖來源於原論文:

 

 

參考

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