分散式協議與演算法-Raft演算法

yuan發表於2023-01-30

本文總結自:極客時間韓健老師的分散式協議與演算法實戰課程。
大家都知道,Raft演算法屬於Multi-Paxos演算法,它是在Multi-Paxos思想的基礎上,做了一些簡化和限制。關於Paxos演算法,博主在之前的文章有過總結,大家可以從這裡跳轉分散式協議與演算法-Paxos演算法
關於Raft演算法相關的開源社群有很多,如果你想深入理解RAFT演算法,博主在這裡推薦螞蟻金服SOFAJRaft,它是基於JAVA語言開發的一個生產級高效能共識演算法實現
博主總結過一些關於SOFAJRaft的原始碼,有興趣的讀者可以訪問:SOFAJRaft原始碼閱讀
@Author:Akai-yuan
@更新時間:2023/1/30

1.如何選舉領導者

1.1成員身份

成員身份,又叫做伺服器節點狀態,Raft 演算法支援領導者(Leader)、跟隨者(Follower)和候選人(Candidate) 3 種狀態。

跟隨者:就相當於普通群眾,默默地接收和處理來自領導者的訊息,當等待領導者心跳資訊超時的時候,就主動站出來,推薦自己當候選人。
候選人:候選人將向其他節點傳送請求投票(RequestVote)RPC 訊息,通知其他節點來投票,如果贏得了大多數選票,就晉升當領導者。
領導者:蠻不講理的霸道總裁,一切以我為準,平常的主要工作內容就是 3 部分,處理寫請求、管理日誌複製和不斷地傳送心跳資訊,通知其他節點“我是領導者,我還活著,你們現在不要發起新的選舉,找個新領導者來替代我。”

1.2選舉過程:

  • 首先,在初始狀態下,叢集中所有的節點都是跟隨者的狀態。

  • Raft 演算法實現了隨機超時時間的特性。也就是說,每個節點等待領導者節點心跳資訊的超時時間間隔是隨機的。透過上面的圖片你可以看到,叢集中沒有領導者,而節點 A 的等待超時時間最小(150ms),它會最先因為沒有等到領導者的心跳資訊,發生超時。這個時候,節點 A 就增加自己的任期編號,並推舉自己為候選人,先給自己投上一張選票,然後向其他節點傳送請求投票 RPC 訊息,請它們選舉自己為領導者

  • 如果其他節點接收到候選人 A 的請求投票 RPC 訊息,在編號為 1 的這屆任期內,也還沒有進行過投票,那麼它將把選票投給節點 A,並增加自己的任期編號。

  • 如果候選人在選舉超時時間內贏得了大多數的選票,那麼它就會成為本屆任期內新的領導者。

  • 節點 A 當選領導者後,他將週期性地傳送心跳訊息,通知其他伺服器我是領導者,阻止跟隨者發起新的選舉,篡權。

2.節點間如何通訊

在 Raft 演算法中,伺服器節點間的溝通聯絡採用的是遠端過程呼叫(RPC),在領導者選舉
中,需要用到這樣兩類的 RPC:

  1. 請求投票(RequestVote)RPC,是由候選人在選舉期間發起,通知各節點進行投票;
  2. 日誌複製(AppendEntries)RPC,是由領導者發起,用來複制日誌和提供心跳訊息。日誌複製 RPC 只能由領導者發起,這是實現強領導者模型的關鍵之一。

3.什麼是任期

我們知道,議會選舉中的領導者是有任期的,領導者任命到期後,要重新開會再次選舉。Raft 演算法中的領導者也是有任期的,每個任期由單調遞增的數字(任期編號)標識,比如節點 A 的任期編號是 1。任期編號是隨著選舉的舉行而變化的,這是在說下面幾點。

  1. 跟隨者在等待領導者心跳資訊超時後,推舉自己為候選人時,會增加自己的任期號。比如節點 A 的當前任期編號為 0,那麼在推舉自己為候選人時,會將自己的任期編號增加為 1。
  2. 如果一個伺服器節點,發現自己的任期編號比其他節點小,那麼它會更新自己的編號到較大的編號值。比如節點 B 的任期編號是 0,當收到來自節點 A 的請求投票 RPC 訊息時,因為訊息中包含了節點 A 的任期編號,且編號為 1,那麼節點 B 將把自己的任期編號更新為 1。

但是,與現實議會選舉中的領導者的任期不同,Raft 演算法中的任期不只是時間段,而且任期編號的大小,會影響領導者選舉和請求的處理。

  1. 在 Raft 演算法中約定,如果一個候選人或者領導者,發現自己的任期編號比其他節點小,那麼它會立即恢復成跟隨者狀態。比如分割槽錯誤恢復後,任期編號為 3 的領導者節點B,收到來自新領導者的,包含任期編號為 4 的心跳訊息,那麼節點 B 將立即恢復成跟隨者狀態。
  2. 還約定如果一個節點接收到一個包含較小的任期編號值的請求,那麼它會直接拒絕這個請求。比如節點 C 的任期編號為 4,收到包含任期編號為 3 的請求投票 RPC 訊息,那麼它將拒絕這個訊息。

在這裡,你可以看到,Raft 演算法中的任期比議會選舉中的任期要複雜。同樣,在 Raft 演算法中,選舉規則的內容也會比較多。

4.選舉有哪些規則

  1. 領導者週期性地向所有跟隨者傳送心跳訊息(即不包含日誌項的日誌複製 RPC 訊息),通知大家我是領導者,阻止跟隨者發起新的選舉。
  2. 如果在指定時間內,跟隨者沒有接收到來自領導者的訊息,那麼它就認為當前沒有領導者,推舉自己為候選人,發起領導者選舉。
  3. 在一次選舉中,贏得大多數選票的候選人,將晉升為領導者。
  4. 在一個任期內,領導者一直都會是領導者,直到它自身出現問題(比如當機),或者因為網路延遲,其他節點發起一輪新的選舉。
  5. 在一次選舉中,每一個伺服器節點最多會對一個任期編號投出一張選票,並且按照“先來先服務”的原則進行投票。比如節點 C 的任期編號為 3,先收到了 1 個包含任期編號為 4 的投票請求(來自節點 A),然後又收到了 1 個包含任期編號為 4 的投票請求(來自節點 B)。那麼節點 C 將會把唯一一張選票投給節點 A,當再收到節點 B 的投票請求RPC 訊息時,對於編號為 4 的任期,已沒有選票可投了。
  6. 當任期編號相同時,日誌完整性高的跟隨者(也就是最後一條日誌項對應的任期編號值更大,索引號更大),拒絕投票給日誌完整性低的候選人。比如節點 B、C 的任期編號都是 3,節點 B 的最後一條日誌項對應的任期編號為 3,而節點 C 為 2,那麼當節點 C請求節點 B 投票給自己時,節點 B 將拒絕投票。

5.如何理解隨機超時時間

在議會選舉中,常出現未達到指定票數,選舉無效,需要重新選舉的情況。在 Raft 演算法的選舉中,也存在類似的問題,那它是如何處理選舉無效的問題呢?
其實,Raft 演算法巧妙地使用隨機選舉超時時間的方法,把超時時間都分散開來,在大多數情況下只有一個伺服器節點先發起選舉,而不是同時發起選舉,這樣就能減少因選票瓜分導致選舉失敗的情況。
我想強調的是,在 Raft 演算法中,隨機超時時間是有 2 種含義的,這裡是很多同學容易理解出錯的地方,需要你注意一下:

  1. 跟隨者等待領導者心跳資訊超時的時間間隔,是隨機的;
  2. 當沒有候選人贏得過半票數,選舉無效了,這時需要等待一個隨機時間間隔,也就是說,等待選舉超時的時間間隔,是隨機的。

6.如何複製日誌

6.1如何理解日誌

副本資料是以日誌的形式存在的,日誌是由日誌項組成。日誌項是一種資料格式,它主要包含使用者指定的資料,也就是指令(Command),還包含一些附加資訊,比如索引值(Log index)、任期編號(Term)

6.2日誌複製過程

你可以把 Raft 的日誌複製理解成一個最佳化後的二階段提交(將二階段最佳化成了一階段),減少了一半的往返訊息,也就是降低了一半的訊息延遲。那日誌複製的具體過程是什麼呢?
首先,領導者進入第一階段,透過日誌複製(AppendEntries)RPC 訊息,將日誌項複製到叢集其他節點上。
接著,如果領導者接收到大多數的“複製成功”響應後,它將日誌項提交到它的狀態機,並返回成功給客戶端。如果領導者沒有接收到大多數的“複製成功”響應,那麼就返回錯誤給客戶端。
學到這裡,有同學可能有這樣的疑問了,領導者將日誌項提交到它的狀態機,怎麼沒通知跟隨者提交日誌項呢?
這是 Raft 中的一個最佳化,領導者不直接傳送訊息通知其他節點提交指定日誌項。因為領導者的日誌複製 RPC 訊息或心跳訊息,包含了當前最大的,將會被提交的日誌項索引值。所以透過日誌複製 RPC 訊息或心跳訊息,跟隨者就可以知道領導者的日誌提交位置資訊
因此,當其他節點接受領導者的心跳訊息,或者新的日誌複製 RPC 訊息後,就會將這條日誌項提交到它的狀態機。而這個最佳化,降低了處理客戶端請求的延遲,將二階段提交最佳化為了一段提交,降低了一半的訊息延遲。

6.3日誌一致的實現

在 Raft 演算法中,領導者透過強制跟隨者直接複製自己的日誌項,處理不一致日誌。也就是說,Raft 是透過以領導者的日誌為準,來實現各節點日誌的一致的。具體有 2 個步驟。
首先,領導者透過日誌複製 RPC 的一致性檢查,找到跟隨者節點上,與自己相同日誌項的最大索引值。也就是說,這個索引值之前的日誌,領導者和跟隨者是一致的,之後的日誌是不一致的了。
然後,領導者強制跟隨者更新覆蓋的不一致日誌項,實現日誌的一致。

為了方便演示,我們引入 2 個新變數:

  1. PrevLogEntry:表示當前要複製的日誌項,前面一條日誌項的索引值。比如在圖中,如果領導者將索引值為 8 的日誌項傳送給跟隨者,那麼此時 PrevLogEntry 值為 7。
  2. PrevLogTerm:表示當前要複製的日誌項,前面一條日誌項的任期編號,比如在圖中,如果領導者將索引值為 8 的日誌項傳送給跟隨者,那麼此時 PrevLogTerm 值為 4。

  1. 領導者透過日誌複製 RPC 訊息,傳送當前最新日誌項到跟隨者(為了演示方便,假設當前需要複製的日誌項是最新的),這個訊息的 PrevLogEntry 值為 7,PrevLogTerm 值為 4。
  2. 如果跟隨者在它的日誌中,找不到與 PrevLogEntry 值為 7、PrevLogTerm 值為 4 的日誌項,也就是說它的日誌和領導者的不一致了,那麼跟隨者就會拒絕接收新的日誌項,並返回失敗資訊給領導者。
  3. 這時,領導者會遞減要複製的日誌項的索引值,併傳送新的日誌項到跟隨者,這個訊息的 PrevLogEntry 值為 6,PrevLogTerm 值為 3。
  4. 如果跟隨者在它的日誌中,找到了 PrevLogEntry 值為 6、PrevLogTerm 值為 3 的日誌項,那麼日誌複製 RPC 返回成功,這樣一來,領導者就知道在 PrevLogEntry 值為 6、PrevLogTerm 值為 3 的位置,跟隨者的日誌項與自己相同。
  5. 領導者透過日誌複製 RPC,複製並更新覆蓋該索引值之後的日誌項(也就是不一致的日誌項),最終實現了叢集各節點日誌的一致。

7.成員變更

單節點變更,就是透過一次變更一個節點實現成員變更。如果需要變更多個節點,那你需要執行多次單節點變更。比如將 3 節點叢集擴容為 5 節點叢集,這時你需要執行 2 次單節點變更,先將 3 節點叢集變更為 4 節點叢集,然後再將 4 節點叢集變更為 5 節點叢集,就像下圖的樣子。

現在,讓我們回到開篇的思考題,看看如何用單節點變更的方法,解決這個問題。為了演示
方便,我們假設節點 A 是領導者:

目前的叢集配置為[A, B, C],我們先向叢集中加入節點 D,這意味著新配置為[A, B, C, D]。
成員變更,是透過這麼兩步實現的:
第一步,領導者(節點 A)向新節點(節點 D)同步資料;
第二步,領導者(節點 A)將新配置[A, B, C, D]作為一個日誌項,複製到新配置中所有節點(節點 A、B、C、D)上,然後將新配置的日誌項提交到本地狀態機,完成單節點變更。

在變更完成後,現在的叢集配置就是[A, B, C, D],我們再向叢集中加入節點 E,也就是說,
新配置為[A, B, C, D, E]。成員變更的步驟和上面類似:
第一步,領導者(節點 A)向新節點(節點 E)同步資料;
第二步,領導者(節點 A)將新配置[A, B, C, D, E]作為一個日誌項,複製到新配置中的所有節點(A、B、C、D、E)上,然後再將新配置的日誌項提交到本地狀態機,完成單節點變更。

這樣一來,我們就透過一次變更一個節點的方式,完成了成員變更,保證了叢集中始終只有一個領導者,而且叢集也在穩定執行,持續提供服務。
在正常情況下,不管舊的叢集配置是怎麼組成的,舊配置的“大多數”和新配置的“大多數”都會有一個節點是重疊的。 也就是說,不會同時存在舊配置和新配置 2個“大多數”。

從上圖中你可以看到,不管叢集是偶數節點,還是奇數節點,不管是增加節點,還是移除節點,新舊配置的“大多數”都會存在重疊(圖中的橙色節點)。
需要你注意的是,在分割槽錯誤、節點故障等情況下,如果我們併發執行單節點變更,那麼就可能出現一次單節點變更尚未完成,新的單節點變更又在執行,導致叢集出現 2 個領導者的情況。
如果你遇到這種情況,可以在領導者啟動時,建立一個 NO_OP 日誌項(也就是空日誌項),只有當領導者將 NO_OP 日誌項提交後,再執行成員變更請求。這個解決辦法,你記住就可以了,可以自己在課後試著研究下。具體的實現,可參考 **Hashicorp Raft **的原始碼,也就是 runLeader() 函式中:

noop := &logFuture{
log: Log{
Type: LogNoop,
},
}
r.dispatchLogs([]*logFuture{noop})

相關文章