Raft: 一點閱讀筆記

KatyuMarisa 發表於 2021-06-07

前言

如果想要對Raft演算法的瞭解更深入一點的話,僅僅做6.824的Lab和讀《In Search of an Understandable Consensus Algorithm》這篇論文是不夠的。我個人粗略閱讀了一下Tikv中關於Raft的實現(https://github.com/tikv/raft-rs ,使用rust語言,基本移植了etcd中關於raft演算法的實現),並配合著註釋閱讀了下原作者的博士論文《CONSENSUS: BRIDGING THEORY AND PRACTICE》,配合著PingCap的一些Blog,還是比較有收穫的。開這篇Blog是希望記錄一下6.824的Lab和《In Search of an Understandable Consensus Algorithm》中沒有涉及到的一些東西。

Cluster Member Change

叢集成員變更足足佔了一整張Ongano的論文,重要性不言而喻吧..

Safty Guarantee

Conf Change的設計首先要保證安全問題;假設當前使用舊配置的機器構成集合Mold,使用新配置的機器構成集合Mnew;在Ongano的論文中,貌似將安全性問題等價為了雙主問題,即Mold和Mnew是否會形成兩個互不交疊Qurom(兩個Qurom存在選出兩個Leader的可能性);換句話說,原作者認為只要解決了雙主問題,Conf Change的安全性就得以保證;

Raft: 一點閱讀筆記

上圖為一種雙主問題的情景;在箭頭所指的地方,{3, 4, 5} ∈ Mnew, {1, 2} ∈ Mold;nQurom(Mold) = (3 + 1) / 2 = 2,nQurom(Mnew) = (5 + 1) / 2 = 3;可以看到{1, 2}可以構成Qurom,{3, 4, 5}也可以構成Qurom,這樣就可能出現雙主錯誤;

在2014年的論文中,採用了一種Join Concensus演算法解決雙主問題;而在Phd的論文中提出了一種更加簡單的方法:一次Conf Change只新增/刪除一臺Server;在這種情景下,兩個Qurom至少有一個交集,因此雙主錯誤可以避免,而更加複雜的Conf Change也可以由多次增/刪機器來實現;

Raft: 一點閱讀筆記

Conf Change

1)當Server收到Conf Change時,必須立刻使用,無需等待其對應的日誌被提交

2)只有上一條Conf Change成功提交後,才允許下一條Conf Change開始;

3)Conf Change可能最終未能提交而被截斷;在這種情景下,Server必須恢復到Conf之前的狀態

Unfortunately, this decision does imply that a log entry for a configuration change can be removed (if leadership changes); in this case, a server must be prepared to fall back to the previous configuration in its log.

作者在論文中解釋了為什麼Conf Change的日誌為什麼會立即生效,而不是在Commit時生效:

If servers adopted Cnew only when they learned that Cnew was committed, Raft leaders would have a difficult time knowing when a majority of the old cluster had adopted it. They would need to track which servers know of the entry’s commitment, and the servers would need to persist their commit index to disk;

增添機器

我們考慮新的機器的加入會對叢集的Avalibility產生哪些影響:

1)新的機器初始日誌是空的,它Catch-Up需要一段不短的時間;

2)新的機器可能會被算入Qurom,它的Catch Up所需時間可能會影響效能;例如3臺機器構成的cluster其Qurom為2,而4臺機器構成的Qurom為3,為此整個叢集必須等待那個新加入者順利Catch Up才能提交新的請求;

  1. 新的機器可能效能或環境很差,迫使Leader為它花費更多的開銷;例如說必須給他送多次AppendEntries浪費網路頻寬,或者想做一次日誌壓縮,但被迫要等待那臺慢的機器;

Raft演算法充分考慮了上述情景,並給出瞭解決方案:

  1. 為這些新加入的機器設定設定一個Learner的角色;Learner可以投票,可以接收日誌,但其本身不被計入Qurom內,等它Catch Up到了一定階段後,再把它計入到Qurom內;這解決了問題1和2,但又引入了一個新的問題,就是怎麼評估新機器的Catch Up進度,以及這個進度到哪個程度時才把它計入Qurom;

  2. 為了解決3以及前面新帶來的問題,作者提出了一種Catch-Up演算法;設定一個max_round,每個round裡都會將自己所有的日誌(第一輪round)或者新機器缺的日誌(後面的rounds)全量傳送;如果發現新的機器可以趕得上,那麼就把它計入Qurom,否則會回報一個錯誤,期望叢集管理者將這臺機器撤除掉

    If the last round lasts less than an election timeout, then the leader adds the new server to the cluster, under the assumption that there are not enough unreplicated entries to create a significant availability gap. Otherwise, the leader aborts the configuration change with an error.

注意在新的配置中,雖然新的機器不被計入Qurom,但Qurom仍然被定義為Majority,因此不存在雙主問題,僅僅是Qurom不包括那臺龜速機而已,叢集仍然是可用的;同時要注意到這個錯誤也不會違背invariants;讓我們再回顧一下為什麼這套錯誤機制為什麼會被引入:

The leader should also abort the change if the new server is unavailable or is so slow that it will never catch up. This check is important: Lamport’s ancient Paxon government broke down because they did not include it. They accidentally changed the membership to consist of only drowned sailors and could make no more progress [48]. Attempting to add a server that is unavailable or slow is often a mistake.(這個mistake應該指的是人工錯誤)

撤出機器

增添機器要擔心的事情已經很多了,但撤出機器要煩心的事情遠比增添機器要多得多;撤除機器要擔憂的不再是對Avalibility的影響,而是具體的實現細節;

1)被撤出的機器什麼時候可以關停?(注意我使用“撤出”來描述Conf Change,而不是“停機”or"關閉"之類的,意圖在指出機器撤出並不意味著它不再和叢集有通訊)

2)當前的Leader不屬於Cnew,該怎麼辦?

3)對於那些撤出的機器,該怎樣處理RPC,包括要不要給它們發RPC,以及怎樣處理來自它們的RPC?

Incomming RPC的處理

第3個問題是最嚴重的,因為它事關具體的實現;我們先解決一個子集,即如何處理那些自己收到的RPC,即Incomming RPC

先看一下作者的原話:

• A server accepts AppendEntries requests from a leader that is not part of the server’s latest configuration. Otherwise, a new server could never be added to the cluster (it would never accept any log entries preceding the configuration entry that adds the server).

• A server also grants its vote to a candidate that is not part of the server’s latest configuration (if the candidate has a sufficiently up-to-date log and a current term). This vote may occasionally be needed to keep the cluster available.

第一條其實又回到了增添機器這一操作的具體實施措施上;注意最初新機器的日誌是空的,這意味著它不知道Cnew是什麼東西,所以它必須接收任何來自Leader的AppendEntries,不然Catch Up機制就是一紙空談;

第二條告訴我們,允許投票給那些不屬於Cnew的機器;這是為了保證Avalibility;

用作者的原文總結:

Thus, servers process /incoming RPC requests/ without consulting their current configurations.

對於Incomming RPC全體照接不誤;

Leader Step Down

A leader that is removed from the configuration steps down once the Cnew entry is committed.

Leader Step Down規則規定了當Leader ∉ Cnew時怎麼應對Cnew Entry;對於那些非Leader的Server,收到Cnew時直接就要採用其中的配置,不需要其他的行動;對於Leader來說,它同樣也要立刻採用其中的配置,但很顯然它不應該繼續做Leader了,因為新的Conf中不包含它,即我們期望它應當被移除掉;Raft演算法要求這種Leader在順利Commit了Cnew之後就Step Down;

一定要注意 Step Down != Shut Down,只是讓Leader下臺而已;注意到此時Leader只會向那些屬於Cnew的Server傳送AppendEntries(而不會給自己傳送),因此新的Leader必定屬於Cnew;

作者也吐槽了自己的演算法引入的奇怪情景:

First, there will be a period of time (while it is committing Cnew) when a leader can manage a cluster that does not include itself; it replicates log entries but does not count itself in majorities.

Second, a server that is not part of its own latest configuration should still start new elections, as it might still be needed until the Cnew entry is committed (as in Figure 4.6). It does not count its own vote in elections unless it is part of its latest configuration.

第一,這意味存在這樣一種情景,即一個不屬於當前配置的Leader在管理整個叢集,且這個Leader不會把自己算入Qurom中;

第二,如果建立Conf Change的機器最終不會在這次Conf Change中不會被移除,那沒啥問題;問題在於如果Leader ∉ Cnew 但 Leader建立了Cnew的情景;假如在Cnew被提交前由於某些原因被罷黜了,它還不能躺平,還必須參與競選!注意前文中我們知道Server對於一切的Incomming RPC是照單全收的,這造成了一個詭異的現象:一臺Removed Server身上有Cnew,它知道自己不在Cnew中,但它仍然要參與競選,它還可能勝選並把Cnew給提交;

Avoid Disruptions

Once the cluster leader has created the Cnew entry, a server that is not in Cnew will no longer receive heartbeats, so it will time out and start new elections. Furthermore, it will not receive the Cnew entry or learn of that entry’s commitment, so it will not know that it has been removed from the cluster.

這裡其實也暗喻了Conf Change中對Outgoing RPC的處理方法,即Leader不再向哪些不屬於Cnew的機器傳送心跳包和AppendEntries,這會導致這群機器TimeOut,然後觸發選舉機制擾亂整個叢集;

注意上述這個情景不一定是壞的,反而在某些時刻是必要且合理的,例如Leader Step Down中所描述的第二個場景,我們更期望那個擁有Cnew的機器當選;

preVote可以很大程度上避免這種情景,但並不完全夠用;preVote機制可以保證當Cnew成功備份到Majority時,那些不屬於Cnew的機器無法通過preVote階段,但在Cnew成功備份到Majority之前,preVote機制無法避免這次Distruption:

Raft: 一點閱讀筆記

Raft的作者提供的建議是使用HeartBeat + RequestVote來最大程度的減小上述場景;在原演算法中,任何Server只要發現拉票者的term至少比自己新,且擁有最新的日誌,就會向它投票並考慮更新自己的term(如果term更高可能會直接或間接的罷黜Leader,即我們討論的Distruptive);這裡做出了一點限制:

在一個election timeout內如果收到了心跳包,則拒絕投票,拒絕重置定時器,或者會把這個請求延後;

理論上講,如果election timeout內如果收到了心跳包,那麼Leader大概率是存在的,因此問題不算很大;

這裡與Leader Transfer的思想存在衝突,Leader Transfer是期望立刻觸發選舉的,因此對於那些Leader Transfer的請求還需要捎帶一個flag,表示“我自己有disrupt the leader”的權力;

Conf Change 小結

Cluster Member Change是一個必要且重要的操作,基本可以抽象為兩類行為:新增新的機器,或者撤出某些機器;這裡討論了Cluster Member Change的三個問題:安全保證、可用性、具體實現;

首先是安全保證;Cluster Member Change唯一需要考慮的安全性問題是雙主問題,即必須避免Cnew和Cold中的機器分別構成Qurom產生雙主;最簡單直接的方法就是一次僅增/刪一臺機器,這種情景下Cold和Cnew中的Qurom必定有交集,即最多隻能形成一個Qurom,這樣保證了安全性問題;

第二是可用性(Avalibility)問題;這主要體現在增加機器的場景下;新增的機器可能需要一段不短的時間來Catch Up,也可能新增的機器效能很差或環境很差;對於第一種情況,Raft演算法設定了一種Learner角色,新加入的機器能夠接收AppendEntries,但不被算入Qurom內,其投票請求也會被忽視,直到Leader認為它的確“趕上了”為止;對於第二種情況,作者提供了一種Catch Up演算法,當新的Server無法趕上時,Leader會放棄這臺機器並向管理員告知一個錯誤,期望將新的機器給撤出掉;注意到我們的Conf Change日誌並不會到這臺機器上,因此Conf Change只要在原來的叢集上順利提交,就可以將這個拖累的機器給撤出;

第三就是具體的實現細節了;對於Incomming RPC,必須全部接受並進行處理;對於Outgoing RPC,只需要傳送給自己當前應用的配置下的Server就可以了;對於那些在新配置中被撤出的機器,要避免他們超時後拉票造成的Disruptive,這裡使用了心跳機制 + RequestVote限制來拒絕拉票;

解耦

TiKV裡關於Raft基本移植了etcd中關於Raft的實現。一個很讓我感到很驚奇的地方在它實現了Raft演算法對儲存、通訊的解耦合。

當然也對使用提出了非常嚴格的限制。第一,由於內部邏輯中是沒有時鐘的,因此定時的邏輯必須由使用者來驅動(例如每100ms都要主動呼叫tick);第二,使用者必須保證正確處理全部的訊息,例如說保證持久化、保證和其他peer之間的通訊,如果處理出錯甚至可能導致拜占庭錯誤。

選舉

由於Leader Transfer機制的引入和Conf Change的影響,選舉規則還需要作出一些其他的調整;

首先討論Conf Change。Conf Change中已經脫離了叢集的機器由於無法收到AppendEntries,因此會成為Candidate並試圖向其他Server拉票。由於Server必須處理一切的incomming RPC(不管這些RPC的源是否屬於當前配置),因此叢集可能被擾動。preVote機制是無法避免叢集擾動的,因為已經脫離了當前config的Candidate仍然可能持有最新的日誌並當選為Leader(這不可避免,因為Conf Change的實現也部分依賴於此,可以參考前文中關於Conf Change的討論)。

Ongano的論文提出了一種方案:如果一臺機器收到了拉票請求,即使對方的term比自己高,它也可以拒絕投票,如果:

  1. 它自己是Leader且最近check_qurom返回成功,或者
  2. 它自己是Follower且在election_timeout裡收到了心跳;
  3. 滿足1.2且請求訊息並不是Leader Transfer請求;

上述1、2情景都可以推斷存在一個合法的存活的Leader,因此拒絕投票以避免新Leader的產生;3是一個特例,因為Leader Transfer機制本身就是期望能在一個election timeout內選舉出一個新的Leader。

Leader Transfer

Leader Transfer是做負載均衡的策略之一,例如說將Leader的角色交給一臺效能更好地伺服器;同時也是必要的,例如說我們希望關停某臺機器進行維護,而它偏偏是個Leader,關停它可能會丟掉部分請求且導致一段時間內叢集不可用;

Leader Transfer的規則大致如下:

  1. 當前Leader拒接一切client的請求,因為Leader會改變,所以這些請求大概率會被截斷,不如不接;

  2. Leader檢查transferee的match Index,如果transferee缺少日誌的話,則全量將自己的日誌傳送給它;

  3. 使用一條MsgTimeoutNow告知Transferee計時器立刻超時並觸發選舉流程;

  4. 如果一個election timeou中Leader仍未發現自己被罷黜,那麼應當立刻撤銷Leader Trasnferee的過程,恢復接受client的請求;

回顧一下我之前擔心的幾個問題:

  1. 當前的Leader不是最新的Leader;
  2. 過期的LeaderTransferee;
  3. 與lease這種基於election timeout的策略產生衝突;

在etcd的實現中,Leader在tick時都會立刻進行一次check_qurom,如果check_qurom失敗了就會立刻主動下臺(在原論文中沒有提及Leader主動下臺的情景),這樣雖然存在假陽性的情況,但無疑大大避免了問題1的情景;

問題3主要在考慮原Leader的lease期會和transferee的Leader期存在交集導致了一種類似”雙主“的情景,但原Leader此時已經停止接收一切客戶請求了,因此應該不會出現問題;

比較麻煩的是2;Leader Transfer訊息被作為本地訊息並沒有被標註訊息發出時的term,這意味著Follower會相應並執行一切的這些訊息,這可能擾亂叢集;選舉規則應該能避免這種情景;

Group Commit

Group Commit並不是一種優化,而是一種對Commit情景的更加嚴格的限制;初始階段假定所有的peers都屬於同一個group,也可以為每個peer指定不同的group_id;提交時,要求entries至少備份到不同的group上,否則不允許提交;

Tests group commit.

  1. Logs should be replicated to at least different groups before committed;

  2. all peers are configured to the same group, simple quorum should be used.

優化型別

從這部分開始可以討論一下Raft的優化了。優化是一個很大的課題,可以優化的層次非常多:

  1. Multi - Raft,對 Key - Value 進行range - based或者hash - based的分片(Sharding);

  2. prepare優化,即增添Follower -> Leader 的日誌流,打破Leader必須擁有全部已提交日誌的限制條件;

  3. Batch、Pipeline;

  4. 讀優化,例如ReadIndex、Follower Read、Lease Read;

  5. 落盤、通訊的優化等;

優化的層次非常多,這裡只討論內聯到演算法內部的優化;即3和4;2我只聽說PolarDB實現了,但沒見過。

ReadIndex、Follower Read、Lease Read

首先,不要把讀操作狹義的理解為Key - Value中的讀一個或幾個Key的操作。讀操作應該抽象成某個時刻的狀態機的部分或全部狀態的只讀操作;因此Raft中不會提供讀操作的具體實現,而是僅僅告知應用層讀操作可以進行。

ReadIndex可以滿足強一致讀,即必定返回的是某個時刻的最新資料。請求到來時會生成一條ReadIndex記錄,其中記錄了這個時刻的commitIndex和讀請求本身的ctx;

如果是Follower收到了讀請求,Follower會將這個請求轉發給Leader;Leader收到了讀請求時,會記錄下這個請求以及請求到來時的commitIndex;在傳送心跳包/AppendEntries時,會記錄下有哪些Follower成功響應。如果超過Qurom的Follower們回覆了響應,那麼當appliedIndex執行到那個讀請求的commitIndex時,那個讀請求以及之前的所有讀請求都可以正確的執行了,此時Raft會嚮應用層返回一個ReadState,裡面記錄了所有可以實現一致讀的請求,應用層根據ReadState檢索並執行讀請求;如果這個讀請求來自於Follower,那麼一條MsgReadIndexResp也將轉發給Follower,裡面記錄了相應的commitIndex以及讀請求的ctx,Follower也可以根據這條訊息生成自己的ReadState返還給應用層;

那麼ReadIndex為什麼滿足強一直讀呢?注意經過一輪Qurom響應後,Leader即可確認在讀請求到來時刻,自己的commitIndex是那個時刻最新的commitIndex,那麼那個時刻的最新狀態,就應該是commitIndex之前所有的日誌均被執行後的狀態,即appliedIndex == commitIndex時的狀態。這樣也可以理解為什麼經過了Qurom check之後,仍然要等待appliedIndex == commitIndex才能響應讀請求了。

以上ReadIndex流程其實就是Follower Read的實現原理。注意MsgReadIndexResp返回的不僅僅是讀請求本身,還有讀請求到來時刻Leader的commitIndex,即那個時刻的最新的commitIndex。對於Follower來說,它也可以等待自己的appliedIndex == MsgReadIndexResp.commitIndex後執行隊請求了。

你可能會疑惑,明明說的是Follower Read,為什麼一條只讀請求仍然經過了Leader且要check qurom?這樣Leader豈不是仍然擔負了讀操作本身的開銷?並不是這樣的,這裡Leader擔負的僅僅是同步和通訊的開銷。讀操作本身可能很大,例如說資料庫掃描一整張表,這個操作本身並沒有被Leader所執行,不過是這個請求在Leader這裡走了一圈而已。當這個請求已經被Leader成功的做了Qurom Check之後,讀表這個操作就可以在Follower這裡做了。這也是為什麼我前文中強調的,不要把讀操作的理解狹義化,讀操作本身不是在Raft演算法裡實現的,而是應用層實現的,Raft演算法只是告知應用層可以做讀請求了而已

Lease Read需要依賴時間來保證強一致性(如果time drift過大就悲劇了)。Lease期的設定必須小於election_timeout,否則可能導致舊Leader的Lease期與新Leader的任期出現交疊,導致一種“雙主”情景的出現。Lease在每次成功check_qurom會成功續約;在時間上,如果一個Leader在Lease期內,那麼被其check_qurom的機器以及它本身會拒絕Lease期內發起preVote和RequeseVote的請求(具體原因請看前文中對拒絕投票原因的討論),即Lease期內不會出現更新的Leader,因此讀操作可以直接進行而無需為了這個讀操作再進行一次check_qurom。

如果開啟了Lease Read模式,那麼ReadOnly請求就不會進行Qurom Check了,僅僅檢查了一下自己是否是當前的Leader,就生成了ReadState並告知了應用層。根據 https://pingcap.com/blog-cn/tikv-source-code-reading-19/ 的描述,Lease Read的Lease期檢查應該是被放在了應用層,因此Raft庫中沒有相關的程式碼。

Batch & Pipeline

對於通訊過程,可以通過Batch和Pipeline。注意TiKV的Raft庫和通訊的實現是解耦的,一切需要傳送給其他peers的訊息都會被放在RawNode.messages之中,由應用層取出併傳送給對應的peer,因此Batch操作需要在應用層實現。具體來說就是將訊息進行打包,減少後設資料佔據的總資料的比例以降低通訊開銷。但也必須要設定一個具體的範圍,例如說等待多長時間後必須傳送訊息,或者對訊息大小進行限制。

Leader要為每一個peer維護一個nextIndex,表明下一次給它們傳送日誌時要從nextIndex開始;Pipeline指的是無需等待上一輪的Msg得到相應的MsgResp,就可以傳送新的Msg,即加快傳送訊息的效率。

Parallelly Append & Asynchronous Apply

除了對通訊的優化外,還有兩個可以優化的地方:日誌的持久化,以及日誌的apply。

對於Leader來說,日誌的持久化可以和AppendEntries並行進行。當然,在收到Qurom的確認前如果Leader尚未完成日誌的持久化,那麼它不能調整自己的commitIndex,因為調整commitIndex意味著日誌本身已不可撤銷。

日誌的apply既包括執行也很可能涉及到新的落盤操作,開銷不可忽視。實際上apply過程完全可以和Raft演算法的執行並行進行,因為日誌的apply改動的是狀態機的狀態(例如資料庫就是一個狀態機),而不是Raft演算法所維護的狀態。我們完全可以在日誌成功commit之後,開啟另一個執行緒去非同步的Apply日誌,通過回撥來改動applyIndex;

參考文獻

[1] CONSENSUS: BRIDGING THEORY AND PRACTICE

[2] In Search of an Understandable Consensus Algorithm.

[3] https://pingcap.com/blog-cn/lease-read/

[4] https://youjiali1995.github.io/raft/etcd-raft-log-replication/

[5] https://pingcap.com/blog-cn/optimizing-raft-in-tikv/

[6] https://pingcap.com/blog-cn/tikv-source-code-reading-19/