天天看點

從JRaft來看Raft協定實作細節

分布式系統和一緻性問題

一緻性問題(consensus problem)是分布式系統需要解決的一個核心問題。分布式系統一般是由多個地位相等的節點組成,各個節點之間的互動就好比幾個人聚在一起讨論問題。讓我們設想一個更具體的場景,比如三個人讨論中午去哪裡吃飯,第一個人說附近剛開了一個火鍋店,聽說味道非常不錯;但第二個人說,不好,吃火鍋花的時間太久了,還是随便喝點粥算了;而第三個人說,那個粥店我昨天剛去過,太難喝了,還不如去吃麥當勞。結果,三個人僵持不下,始終達不成一緻。

有人說,這還不好解決,投票呗。于是三個人投了一輪票,結果每個人仍然堅持自己的提議,問題還是沒有解決。有人又想了個主意,幹脆我們選出一個leader,這個leader說什麼,我們就聽他的,這樣大家就不用争了。于是,大家開始投票選leader。結果很悲劇,每個人都覺得自己應該做這個leader。三個人終于發現,「選leader」這件事仍然和原來的「去哪裡吃飯」這個問題在本質上是一樣的,同樣難以解決。

這時恐怕有些讀者們心裡在想,這三個人是有毛病吧……就吃個飯這麼點小事,用得着争成這樣嗎?實際上,在分布式系統中的每個節點之間,如果沒有某種嚴格定義的規則和協定,它們之間的互動就真的有可能像上面說的情形一樣。整個系統達不成一緻,就根本沒法工作。

是以,就有聰明人設計出了一緻性協定(consensus protocol),像我們常見的比如Paxos、Raft、Zab之類。與前面幾個人商量問題類似,如果翻譯成Paxos的術語,相當于每個節點可以提出自己的提議(稱為proposal,裡面包含提議的具體值),協定的最終目标就是各個節點根據一定的規則達成相同的proposal。但以誰的提議為準呢?我們容易想到的一個規則可能是這樣:哪個節點先提出提議,就以誰的為準,後提出的提議無效。

但是,在一個分布式系統中的情況可比幾個人聚在一起讨論問題複雜多了,這裡邊還有網絡延遲的問題,舉個簡單的例子,假設節點A和B分别幾乎同時地向節點X和Y發出了自己的proposal,但由于消息在網絡中的延遲情況不同,最後結果是:X先收到了A的proposal,後收到了B的proposal;但是Y正好相反,它先收到了B的proposal,後收到了A的proposal。這樣在X和Y分别看來,誰先誰後就難以達成一緻了。

如果考慮到節點當機和消息丢失的可能性,情況還會更複雜。節點當機可以看成是消息丢失的特例,相當于發給這個節點的消息全部丢失了。這在CAP的理論架構下,相當于發生了網絡分割(network partitioning),也就是對應CAP中的P。比如,有若幹個節點聯系不上了,也就是說,對于其它節點來說,它們發送給這些節點的消息收不到任何回應。真正的原因,可能是網絡中間不通了,也可能是那些目的節點當機了,也可能是消息無限期地被延遲了。總之,就是系統中有些節點聯系不上了,它們不能再參與決策,但也不代表它們過一段時間不能重新聯系上。

為了表達上更直覺,下面我們還是假設某些節點當機了。那在這個時候,剩下的節點在缺少了某些節點參與決策的情況下,還能不能對于提議達成一緻呢?即使是達成了一緻,那麼在那些當機的節點重新恢複過來之後(注意這時候它們對于其它節點之間已經達成一緻的提議可能一無所知),它們會不會對于已經達成的一緻提議重新提出異議,進而造成混亂?所有這些問題,都是分布式一緻性協定需要解決的。

實際上,了解問題本身比了解問題的答案要重要的多。

拜占庭将軍問題

在分布式系統理論中,這個問題被抽象成了一個著名的問題——拜占庭将軍問題(Byzantine Generals Problem)。

拜占庭帝國派出多支軍隊去圍攻一支敵軍,每支軍隊有一個将軍,但由于彼此距離較遠,他們之間隻能通過信使傳遞消息。敵方很強大,固而必須有超過半數的拜占庭軍隊一同參與進攻才可能擊敗敵人。在此期間,将軍們彼此之間需要通過信使傳遞消息并協商一緻後,在同一時間點發動進攻。

相關論文:

《The Byzantine Generals Problem》

《Reaching Agreement in the Presence of Faults》

三将軍的難題

假設隻有三個拜占庭将軍,分别為A、B、C,他們要讨論的隻有一件事情:明天是進攻還是撤退。為此,将軍們需要依據“少數服從多數”原則投票表決,隻要有兩個人意見達成一緻就可以了。

舉例來說,A和B投進攻,C投撤退:

  1. 那麼A的信使傳遞給B和C的消息都是進攻;
  2. B的信使傳遞給A和C的消息都是進攻;
  3. 而C的信使傳給A和B的消息都是撤退。

如果稍微做一個改動:三個将軍中出了一個叛徒呢?叛徒的目的是破壞忠誠将軍間一緻性的達成,讓拜占庭的軍隊遭受損失。

作為叛徒的C,你必然不會按照正常出牌,于是你讓一個信使告訴A的内容是你“要進攻”,讓另一個信使告訴B的則是你“要撤退”。

至此,A将軍看到的投票結果是:進攻方 :撤退方 = 2 : 1 ,而B将軍看到的是 1 : 2 。第二天,忠誠的A沖上了戰場,卻發現隻有自己一支軍隊發起了進攻,而同樣忠誠的B,卻早已撤退。最終,A的軍隊敗給了敵人。

Raft算法要成立都是建立在一個前提下的:不存在惡意節點,才能達成一緻。否則,這些著名的算法會随之失效。

從一個Counter例子說起

需求

提供一個 Counter,Client 每次計數時可以指定步幅,也可以随時發起查詢。

這個看似簡單的需求,主要有三個功能點:

  • 實作:Counter server,具備計數功能,具體運算公式為:Cn = Cn-1 + delta;
  • 提供寫服務,寫入 delta 觸發計數器運算;
  • 提供讀服務,讀取目前 Cn 值;

除此之外,我們還有一個可用性的可選需求,需要有備份機器,讀寫服務不能不可用。

系統架構1.0

根據剛才分析出來的功能需求,我們設計出 1.0 的架構,這個架構很簡單,一個節點 Counter Server 提供計數功能,接收用戶端發起的計數請求和查詢請求。

但是這樣的架構設計存在這樣兩個問題:一是 Server 是一個單點,一旦 Server 節點故障服務就不可用了;二是運算結果都存儲在記憶體當中,節點故障會導緻資料丢失。

系統架構1.5

針對問題一,當節點故障之時,我們要新起一台備用機器。針對問題二,我們優化一下,加一個本地檔案存儲。這樣每次計數器完成運算之後都将資料落盤。

但是同時也引來另外的問題:磁盤 IO 很頻繁,同時這種冷備的模式也依然會導緻一段時間的服務不可用。

系統架構2.0

由于上面的問題僅僅通過加機器已經無法解決,是以我們提出架構 2.0,采用叢集的模式提供服務。我們用三個節點組成叢集,由一個節點對外提供服務,當 Server 接收到 Client 發來的寫請求之後,Server 運算出結果,然後将結果複制給另外兩台機器,當收到其他所有節點的成功響應之後,Server 向 Client 傳回運算結果。

但是這樣的架構也存在這問題:

  • 我們選擇哪一台 Server 扮演 Leader 的角色對外提供服務;
  • 當 Leader 不可用之後,選擇哪一台接替它;
  • Leader 處理寫請求的時候需要等到所有節點都響應之後才能響應 Client;
  • 也是比較重要的,我們無法保證 Leader 向 Follower 複制資料是有序的,是以任一時刻三個節點的資料都可能是不一樣的;

是以為了保證複制資料的順序和内容,這就有了共識算法的用武之地,我們使用SOFAJRaft來建構我們的3.0 架構。

系統架構3.0

3.0 架構中,Counter Server 使用 SOFAJRaft 來組成一個叢集,Leader 的選舉和資料的複制都交給 SOFAJRaft 來完成。

在時序圖中我們可以看到,Counter 的業務邏輯重新變得像架構 1.0 中一樣簡潔,維護資料一緻的工作都交給 SOFAJRaft 來完成,是以圖中灰色的部分對業務就不感覺了。

在使用 SOFAJRaft 的 3.0 架構中,SOFAJRaft 幫我們完成了 Leader 選舉、節點間資料同步的工作,除此之外,SOFAJRaft 隻需要半數以上節點響應即可,不再需要叢集所有節點的應答,這樣可以進一步提高寫請求的處理效率。

Raft 共識算法

小論文:《In Search of an Understandable consensus Algorithm》

大論文:《Consensus:Bridging theory and practice》

Raft 是一種共識算法,其特點是讓多個參與者針對某一件事達成完全一緻:一件事,一個結論。同時對已達成一緻的結論,是不可推翻的。可以舉一個銀行賬戶的例子來解釋共識算法:假如由一批伺服器組成一個叢集來維護銀行賬戶系統,如果有一個 Client 向叢集發出“存 100 元”的指令,那麼當叢集傳回成功應答之後,Client 再向叢集發起查詢時,一定能夠查到被存儲成功的這 100 元錢,就算有機器出現不可用情況,這 100 元的賬也不可篡改。這就是共識算法要達到的效果。

Raft 中的基本概念

Raft-node 的 3 種角色/狀态

在一個由 Raft 協定組織的叢集中有三類角色:

  1. Leader(領袖)
  2. Follower(群衆)
  3. Candidate(候選人)

就像一個民主社會,領袖由群眾投票選出。剛開始沒有領袖,所有叢集中的參與者都是群衆,那麼首先開啟一輪大選,在大選期間所有群衆都能參與競選,這時所有群衆的角色就變成了候選人,民主投票選出領袖後就開始了這屆領袖的任期,然後選舉結束,所有除領袖的候選人又變回群衆角色服從領袖上司。這裡提到一個概念「任期」,用術語 Term 表達。

Message 的 3 種類型

  1. RequestVote RPC:由 Candidate 發出,用于發送投票請求;
  2. AppendEntries (Heartbeat) RPC:由 Leader 發出,用于 Leader 向 Followers 複制日志條目,也會用作 Heartbeat (日志條目為空即為 Heartbeat);
  3. InstallSnapshot RPC:由 Leader 發出,用于快照傳輸,雖然多數情況都是每個伺服器獨立建立快照,但是Leader 有時候必須發送快照給一些落後太多的 Follower,這通常發生在 Leader 已經丢棄了下一條要發給該Follower 的日志條目(Log Compaction 時清除掉了) 的情況下。

任期邏輯時鐘

  1. 時間被劃分為一個個任期 (term),term id 按時間軸單調遞增;
  2. 每一個任期的開始都是 Leader 選舉,選舉成功之後,Leader 在任期内管理整個叢集,也就是 “選舉 + 正常操作”;
  3. 每個任期最多一個 Leader,可能沒有 Leader (spilt-vote 導緻)。

Leader election

選舉步驟

這裡摘取了論文中的步驟來進行說明:

  1. When servers start up, they begin as followers
  2. If a follower receives no communication over a period of time called the election timeout, then it assumes there is no viable leader and begins an election to choose a new leader
  3. To begin an election, a follower increments its current term and transitions to candidate state
  4. then votes for itself and issues RequestVote RPCs in parallel to each of

    the other servers in the cluster.

  5. A candidate wins an election if it receives votes from a majority of the servers in the full cluster for the same term
  6. Once a candidate wins an election, it becomes leader
  7. a candidate may receive an AppendEntries RPC
  8. If the leader’s term is at least as large as the candidate’s current term, then the candidate recognizes the leader as legitimate and returns to follower state
  9. If the term in the RPC is smaller than the candidate’s current term, then the candidate rejects the RPC and continues in candidate state

vote split

if many followers become candidates at the same time, votes could be split so that no candidate obtains a majority. When this happens, each candidate will time out and start a new election by incrementing its term and initiating another round of RequestVote RPCs.

Raft uses randomized election timeouts to ensure that split votes are rare and that they are resolved quickly.

下面來使用一個個的例子來具體說明選舉的過程

選舉要解決什麼

一個分布式叢集可以看成是由多條戰船組成的一支艦隊,各船之間通過旗語來保持資訊交流。這樣的一支艦隊中,各船既不會互相完全隔離,但也沒法像陸地上那樣保持非常密切的聯系,天氣、海況、船距、船隻戰損情況導緻船艦之間的聯系存在但不可靠。

艦隊作為一個統一的作戰叢集,需要有統一的共識、步調一緻的指令,這些都要依賴于旗艦指揮。各艦船要服從于旗艦發出的指令,當旗艦不能繼續工作後,需要有别的戰艦接替旗艦的角色。

如何在艦隊中,選出一艘得到大家認可的旗艦,這就是 SOFAJRaft 中選舉要解決的問題。

何時可以發起選舉

在 SOFAJRaft 中,觸發标準就是通信逾時,當旗艦在規定的一段時間内沒有與 Follower 艦船進行通信時,Follower 就可以認為旗艦已經不能正常擔任旗艦的職責,則 Follower 可以去嘗試接替旗艦的角色。這段通信逾時被稱為 Election Timeout (簡稱 ET), Follower 接替旗艦的嘗試也就是發起選舉請求。

何時真正發起選舉

在選舉中,隻有當艦隊中超過一半的船都同意,發起選舉的船才能夠成為旗艦,否則就隻能開始一輪新的選舉。是以如果 Follower 采取盡快發起選舉的政策,試圖盡早為艦隊選出可用的旗艦,就可能引發一個潛在的風險:可能多艘船幾乎同時發起選舉,結果其中任何一支船都沒能獲得超過半數選票,導緻這一輪選舉無果,這就是上面所說的vote split。

為避免這種情況,我們采用随機的選舉觸發時間,當 Follower 發現旗艦失聯之後,會選擇等待一段随機的時間 Random(0, ET) ,如果等待期間沒有選出旗艦,則 Follower 再發起選舉。

哪些候選者值得選票

SOFAJRaft 的選舉中包含了對兩個屬性的判斷:LogIndex 和 Term,這是整個選舉算法的核心部分。

  1. Term:我們會對艦隊中旗艦的曆史進行編号,比如艦隊的第1任旗艦、第2任旗艦,這個數字我們就用 Term 來表示。由于艦隊中同時最多隻能有一艘艦船擔任旗艦,是以每一個 Term 隻歸屬于一艘艦船,顯然 Term 是單調遞增的。
  2. LogIndex:每任旗艦在職期間都會釋出一些指令(稱其為“旗艦令”,類比“總統令”),這些旗艦令當然也是要編号歸檔的,這個編号我們用 Term 和 LogIndex 兩個次元來辨別,表示“第 Term 任旗艦釋出的第 LogIndex 号旗艦令”。不同于現實中的總統令,我們的旗艦令中的 LogIndex 是一直遞增的,不會因為旗艦的更疊而從頭開始計算。

具體來說,參與投票的船 V 不會對下面兩種候選者 C 投票:一種是 lastTermC < lastTermV;另一種是 (lastTermV == lastTermC) && (lastLogIndexV > lastLogIndexC)。

第一種情況說明候選者 C 最後一次通信過的旗艦已經不是最新的旗艦了;第二種情況說明,雖然 C 和 V 都與同一個旗艦有過通信,但是候選者 C 從旗艦處獲得的旗艦令不如 V 完整 (lastLogIndexV > lastLogIndexC),是以 V 不會投票給它。

什麼情況下會發生step down

step down可以發生在Candidate回退到Follower,也可以發生在Leader中。如果是Candidate發生step down,那麼放棄競選本屆 Leader。如果是Leader,那麼會回退到Follower狀态,重新開啟選舉。

如下兩種情況會讓 Candidate 退回 (step down) 到 Follower:

  1. 如果在 Candidate 等待 Servers 的投票結果期間收到了其他擁有更高 Term 的 Server 發來的投票請求;
  2. 如果在 Candidate 等待 Servers 的投票結果期間收到了其他擁有更高 Term 的 Server 發來的心跳;

而對于Leader來說,當發現有 Term 更高的 Leader 時也會退回到 Follower 狀态。

如何避免不夠格的候選者“搗亂”

SOFAJRaft 将 LogIndex 和 Term 作為選舉的評選标準,是以當一艘船發起選舉之前,會自增 Term 然後填到選舉請求裡發給其他船隻 (可能是一段很複雜的旗語),表示自己競選“第 Term + 1 任”旗艦。

這裡要先說明一個機制,它被用來保證各船隻的 Term 同步遞增:當參與投票的 Follower 船收到這個投票請求後,如果發現自己的 Term 比投票請求裡的小,就會自覺更新自己的 Term 向候選者看齊,這樣能夠很友善的将 Term 遞增的資訊同步到整個艦隊中。

但是這種機制也帶來一個麻煩,如果一艘船因為自己的原因沒有看到旗艦發出的旗語,他就會自以為是的試圖競選成為新的旗艦,雖然不斷發起選舉且一直未能當選(因為旗艦和其他船都正常通信),但是它卻通過自己的投票請求實際擡升了全局的 Term,這在 SOFAJRaft 算法中會迫使旗艦 stepdown (從旗艦的位置上退下來)。

是以我們需要一種機制阻止這種“搗亂”,這就是預投票 (pre-vote) 環節。候選者在發起投票之前,先發起預投票,如果沒有得到半數以上節點的回報,則候選者就會識趣的放棄參選,也就不會擡升全局的 Term。

在上面的比喻中,我們可以看到整個選舉操作的主線任務就是:

  1. Candidate 被 ET 觸發
  2. Candidate 開始嘗試發起 pre-vote 預投票
  3. Follower 判斷是否認可該 pre-vote request
  4. Candidate 根據 pre-vote response 來決定是否發RequestVoteRequest
  5. Follower 判斷是否認可該 RequestVoteRequest
  6. Candidate 根據 response 來判斷自己是否當選

Log replication

  1. leader對外提供服務

    一旦leader被選舉出來後,就需要對外提供服務了。下面是論文的原文:

    Once a leader has been elected, it begins servicing client requests. Each client request contains a command to be executed by the replicated state machines.

    翻譯:一旦leader被選舉出來後,它需要對外提供服務。每個發送給leader的請求都會被複制的狀态機執行。

  2. leader執行日志複制

    The leader appends the command to its log as a new entry, then issues AppendEntries RPCs in parallel to each of the other servers to replicate the entry.

    翻譯:leader會将每次請求的指令作為一個對象寫入日志中,然後通過AppendEntries操作通知其他Follower複制該日志。

  3. 日志複制成功

    當leader複制給Follower的時候,有兩種情況,一種是日志被安全的複制到Follower節點中:

    When the entry has been safely replicated (as described below), the leader applies the entry to its state machine and returns the result of that execution to the client.

    翻譯:當日志被安全的複制到Follower後,leader會将該請求交給狀态機執行,然後傳回執行結果給用戶端。

  4. 日志複制失敗

另一種情況是Follower出現故障的情況:

If followers crash or run slowly, or if network packets are lost, the leader retries AppendEntries RPCs indefinitely (even after it has responded to the client) until all followers eventually store all log entries.

翻譯:如果Follower當機或者運作很慢,亦或是網絡包丢失,那麼leader會重複的進行AppendEntries操作,直到Follower正常處理該日志複制。

LogEntry

如上圖所示,每個方格代表一個LogEntry,可以看到Log是由一個個LogEntry組成的,理想情況下所有執行個體上該數組都是一緻的。Log元素根據狀态的不同,又分為未送出和已送出。隻有已送出的LogEntry才會傳回用戶端寫入成功。

最上面一行是log index,也就是下标值,單調遞增,且連續的。方格内的數字代表的是term任期。

committed entry:A log entry is committed once the leader that created the entry has replicated it on a majority of the servers

也就是說如果一個日志被複制到大多數的節點中,那麼這個日志才能算是一個已送出的日志。

Once a follower learns that a log entry is committed, it applies the entry to its local state machine (in log order).

一旦Follower得知這個LogEntry已送出,那麼就會将這個LogEntry放到狀态機中執行。

Follower日志不一緻

一般的情況下,leader和Follower的日志是保持一緻的,然後現實中leader并不能保證不會crash,是以日志可能會出現如下所示不一緻的情況:

如上圖,Follower可能比leader日志少,可能會有多餘的日志,可能會既丢失日志也出現多餘的日志。

是以Raft需要載保證日志的一緻性下做這幾件事:

  1. consistency check

    由于leader在發送LogEntry的時候會帶上index和term,是以Follower在收到LogEntry之後要去檢測這條LogEntry是否是和之前的日志是連續的,是以 Follower 會拒絕無法和本地已有 Log 保持連續的複制請求,那麼這種情況下就需要走 Log 恢複的流程。

  2. find the latest log entry

    如果不一緻的話,那麼需要找到leader和Follower雙方都認可的那條日志,這條日志必須在Follower中是連續的,并且是在leader中存在的,具體操作如下:

    1. 由于leader會為每個Follower維護一個nextIndex表,是以leader知道Follower最新的日志需要發送哪條。

      The leader maintains a nextIndex for each follower,

      which is the index of the next log entry the leader will send to that follower.

    2. 當leader首次當選的時候,會将nextIndex設定為自己最新的log的下一個Index
      When a leader first comes to power, it initializes all nextIndex values to the index just after the last one in its log.
    3. Leader 節點在通過 Replicator 和 Follower 建立連接配接之後,要發送一個 Probe 類型的探針請求,目的是知道 Follower 已經擁有的的日志位置
    4. 如果發現日志不一緻(term和index要一緻),那麼leader将會decrement nextIndex,然後重新發送AppendEntries請求,直至達到一個雙方都認可的日志位置
      If a follower’s log is inconsistent with the leader’s, the AppendEntries consistency check will fail in the next AppendEntries RPC. After a rejection, the leader decrements nextIndex and retries the AppendEntries RPC. Eventually nextIndex will reach a point where the leader and follower logs match.
    5. 當leader發送的AppendEntries請求是成功的時候,那麼Follower會清除沖突的日志,并接受leader的日志。
      Eventually nextIndex will reach a point where the leader and follower logs match. When this happens, AppendEntries will succeed, which removes any conflicting entries in the follower’s log and appends entries from the leader’s log

下面講一下JRaft中日志複制的細節

被複制的日志是有序且連續的

SOFAJRaft 在日志複制時,其日志傳輸的順序也要保證嚴格的順序,所有日志既不能亂序也不能有空洞 (也就是說不能被漏掉)。

複制日志是并發的

SOFAJRaft 中 Leader 節點會同時向多個 Follower 節點複制日志,在 Leader 中為每一個 Follower 配置設定一個 Replicator,專用來處理複制日志任務。

複制日志是批量的

日志複制中的快照

用 Snapshot 能夠讓 Follower 快速跟上 Leader 的日志進度,不再回放很早以前的日志資訊,即緩解了網絡的吞吐量,又提升了日志同步的效率。

複制日志的 pipeline 機制

Pipeline 使得 Leader 和 Follower 雙方不再需要嚴格遵從 “Request -Response - Request” 的互動模式,Leader 可以在沒有收到 Response 的情況下,持續的将複制日志的 AppendEntriesRequest 發送給 Follower。

在具體實作時,Leader 隻需要針對每個 Follower 維護一個隊列,記錄下已經複制的日志,如果有日志複制失敗的情況,就将其後的日志重發給 Follower。這樣就能保證日志複制的可靠性。

複制日志的細節

  1. 檢測Follower日志狀态

    Leader 節點在通過 Replicator 和 Follower 建立連接配接之後,要發送一個 Probe 類型的探針請求,目的是知道 Follower 已經擁有的的日志位置,以便于向 Follower 發送後續的日志。

  1. 用 Inflight 來輔助實作 pipeline

Inflight 是對批量發送出去的 logEntry 的一種抽象,他表示哪些 logEntry 已經被封裝成日志複制 request 發送出去了。

Leader 維護一個 queue,每發出一批 logEntry 就向 queue 中 添加一個代表這一批 logEntry 的 Inflight,這樣當它知道某一批 logEntry 複制失敗之後,就可以依賴 queue 中的 Inflight 把該批次 logEntry 以及後續的所有日志重新複制給 follower。既保證日志複制能夠完成,又保證了複制日志的順序不變。

線性一緻性讀

所謂線性一緻讀,一個簡單的例子是在 t1 的時刻我們寫入了一個值,那麼在 t1 之後,我們一定能讀到這個值,不可能讀到 t1 之前的舊值。

當 Client 向叢集發起寫操作的請求并且獲得成功響應之後,該寫操作的結果要對所有後來的讀請求可見。

Raft Log read

實作線性一緻讀最正常的辦法是走 Raft 協定,将讀請求同樣按照 Log 處理,通過 Log 複制和狀态機執行來擷取讀結果,然後再把讀取的結果傳回給 Client。因為 Raft 本來就是一個為了實作分布式環境下線性一緻性的算法,是以通過 Raft 非常友善的實作線性 Read,也就是将任何的讀請求走一次 Raft Log,等此 Log 送出之後在 apply 的時候從狀态機裡面讀取值,一定能夠保證這個讀取到的值是滿足線性要求的。

當然,因為每次 Read 都需要走 Raft 流程,Raft Log 存儲、複制帶來刷盤開銷、存儲開銷、網絡開銷,走 Raft Log不僅僅有日志落盤的開銷,還有日志複制的網絡開銷,另外還有一堆的 Raft “讀日志” 造成的磁盤占用開銷,導緻 Read 操作性能是非常低效的,是以在讀操作很多的場景下對性能影響很大,在讀比重很大的系統中是無法被接受的,通常都不會使用。

在 Raft 裡面,節點有三個狀态:Leader,Candidate 和 Follower,任何 Raft 的寫入操作都必須經過 Leader,隻有 Leader 将對應的 Raft Log 複制到 Majority 的節點上面認為此次寫入是成功的。是以如果目前 Leader 能确定一定是 Leader,那麼能夠直接在此 Leader 上面讀取資料,因為對于 Leader 來說,如果确認一個 Log 已經送出到大多數節點,在 t1 的時候 apply 寫入到狀态機,那麼在 t1 後的 Read 就一定能讀取到這個新寫入的資料。

也就是說,這樣相比Raft Log read來說,少了一個Log複制的過程,取而代之的是隻要确認自己的leader身份就可以直接從leader上面直接讀取資料,進而保證資料一定是準确的。

那麼如何确認 Leader 在處理這次 Read 的時候一定是 Leader 呢?在 Raft 論文裡面,提到兩種方法:

  • ReadIndex Read
  • Lease Read

第一種是 ReadIndex Read,當 Leader 需要處理 Read 請求時,Leader 與過半機器交換心跳資訊确定自己仍然是 Leader 後可提供線性一緻讀:

  1. Leader 将自己目前 Log 的 commitIndex 記錄到一個 Local 變量 ReadIndex 裡面;
  2. 接着向 Followers 節點發起一輪 Heartbeat,如果半數以上節點傳回對應的 Heartbeat Response,那麼 Leader就能夠确定現在自己仍然是 Leader;
  3. Leader 等待自己的 StateMachine 狀态機執行,至少應用到 ReadIndex 記錄的 Log,直到 applyIndex 超過 ReadIndex,這樣就能夠安全提供 Linearizable Read,也不必管讀的時刻是否 Leader 已飄走;
  4. Leader 執行 Read 請求,将結果傳回給 Client。

使用 ReadIndex Read 提供 Follower Read 的功能,很容易在 Followers 節點上面提供線性一緻讀,Follower 收到 Read 請求之後:

  1. Follower 節點向 Leader 請求最新的 ReadIndex;
  2. Leader 仍然走一遍之前的流程,執行上面前 3 步的過程(确定自己真的是 Leader),并且傳回 ReadIndex 給 Follower;
  3. Follower 等待目前的狀态機的 applyIndex 超過 ReadIndex;
  4. Follower 執行 Read 請求,将結果傳回給 Client。

ReadIndex Read 使用 Heartbeat 方式代替了日志複制,省去 Raft Log 流程。相比較于走 Raft Log 方式,ReadIndex Read 省去磁盤的開銷,能夠大幅度提升吞吐量。雖然仍然會有網絡開銷,但是 Heartbeat 本來就很小,是以性能還是非常好的。

雖然 ReadIndex Read 比原來的 Raft Log Read 快很多,但畢竟還是存在 Heartbeat 網絡開銷,是以考慮做更進一步的優化。

Raft 論文裡面提及一種通過 Clock + Heartbeat 的 Lease Read 優化方法,也就是 Leader 發送 Heartbeat 的時候首先記錄一個時間點 Start,當系統大部分節點都回複 Heartbeat Response,由于 Raft 的選舉機制,Follower 會在 Election Timeout 的時間之後才重新發生選舉,下一個 Leader 選舉出來的時間保證大于 Start+Election Timeout/Clock Drift Bound,是以可以認為 Leader 的 Lease 有效期可以到 Start+Election Timeout/Clock Drift Bound 時間點。Lease Read 與 ReadIndex 類似但更進一步優化,不僅節省 Log,而且省掉網絡互動,大幅提升讀的吞吐量并且能夠顯著降低延時。

Lease Read 基本思路是 Leader 取一個比 Election Timeout 小的租期(最好小一個數量級),在租約期内不會發生選舉,確定 Leader 不會變化,是以跳過 ReadIndex 的發送Heartbeat的步驟,也就降低了延時。

由此可見 Lease Read 的正确性和時間是挂鈎的,依賴本地時鐘的準确性,是以雖然采用 Lease Read 做法非常高效,但是仍然面臨風險問題,也就是存在預設的前提即各個伺服器的 CPU Clock 的時間是準的,即使有誤差,也會在一個非常小的 Bound 範圍裡面,時間的實作至關重要,如果時鐘漂移嚴重,各個伺服器之間 Clock 走的頻率不一樣,這套 Lease 機制可能出問題。