詳解螞蟻金服 SOFAJRaft:生產級高效能 Java 實現

weixin_33858249發表於2019-04-03

SOFAStack(Scalable Open Financial Architecture Stack) 是螞蟻金服自主研發的金融級分散式架構,包含了構建金融級雲原生架構所需的各個元件,是在金融場景裡錘鍊出來的最佳實踐。

前言

SOFAJRaft 是一個基於 Raft 一致性演算法的生產級高效能 Java 實現,支援 MULTI-RAFT-GROUP,適用於高負載低延遲的場景。SOFAJRaft 是從百度的 braft 移植而來,做了一些優化和改進,感謝百度 braft 團隊開源瞭如此優秀的 C++ Raft 實現。

GitHub 地址:https://github.com/alipay/sofa-jraft

之前,我們有一篇介紹 SOFAJRaft 的文章,可在文末獲得連結,延續這個內容,今天的演講分為三部分,先簡要介紹 Raft 演算法,然後介紹 SOFAJRaft 的設計,最後說說它的優化。

\"\"

Raft 共識演算法

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

Raft 演算法和其他的共識演算法相比,又有了如下幾個不同的特性:

  • Strong leader:Raft 叢集中最多隻能有一個 Leader,日誌只能從 Leader 複製到 Follower 上;
  • Leader election:Raft 演算法採用隨機選舉超時時間觸發選舉來避免選票被瓜分的情況,保證選舉的順利完成;
  • Membership changes:通過兩階段的方式應對叢集內成員的加入或者退出情況,在此期間並不影響叢集對外的服務。

共識演算法有一個很典型的應用場景就是複製狀態機。Client 向複製狀態機傳送一系列能夠在狀態機上執行的命令,共識演算法負責將這些命令以 Log 的形式複製給其他的狀態機,這樣不同的狀態機只要按照完全一樣的順序來執行這些命令,就能得到一樣的輸出結果。所以這就需要利用共識演算法保證被複制日誌的內容和順序一致。

\"\"

Leader 選舉

複製狀態機叢集在利用 Raft 演算法保證一致性時,要做的第一件事情就是 Leader 選舉。在講 Leader 選舉之前我們先要說一個重要的概念:Term。Term 用來將一個連續的時間軸在邏輯上切割成一個個區間,它的含義類似於“美國第 26 屆總統”這個表述中的“26”。

\"img\"

每一個 Term 期間叢集要做的第一件事情就是選舉 Leader。起初所有的 Server 都是 Follower 角色,如果 Follower 經過一段時間( election timeout )的等待卻依然沒有收到其他 Server 發來的訊息時,Follower 就可以認為叢集中沒有可用的 Leader,遂開始準備發起選舉。在發起選舉的時候 Server 會從 Follower 角色轉變成 Candidate,然後開始嘗試競選 Term + 1 屆的 Leader,此時他會向其他的 Server 傳送投票請求,當收到叢集內多數機器同意其當選的應答之後,Candidate 成功當選 Leader。但是如下兩種情況會讓 Candidate 退回 (step down) 到 Follower,放棄競選本屆 Leader:

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

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

當然了,當一個 Leader 發現有 Term 更高的 Leader 時也會退回到 Follower 狀態。

當選舉 Leader 成功之後,整個叢集就可以向外提供正常讀寫服務了,如圖所示,叢集由一個 Leader 兩個 Follower 組成,Leader 負責處理 Client 發起的讀寫請求,同時還要跟 Follower 保持心跳或者把 Log 複製給 Follower。

Log 複製

下面我們就詳細說一下 Log 複製。我們之前已經說了 Log 就是 Client 傳送給複製狀態機的一系列命令,。這裡我們再舉例解釋一下 Log,比如我們的複製狀態機要實現的是一個銀行賬戶系統,那麼這個 Log 就可以是 Client 發給賬戶系統的一條存錢的命令,比如“存 100 元錢”。

Leader 與 Follower 之間的日誌複製是共識演算法運用於複製狀態機的重要目的,在 Raft 演算法中 Log 由 TermId、LogIndex、LogValue 這三要素構成,在這張圖上每一個小格代表一個 Log。當 Leader 在向 Follower 複製 Log 的時候,Follower 還需要對收到的 Log 做檢查,以確保這些 Log 能和本地已有的 Log 保持連續。我們之前說了,Raft 演算法是要嚴格保證 Log 的連續性的,所以 Follower 會拒絕無法和本地已有 Log 保持連續的複製請求,那麼這種情況下就需要走 Log 恢復的流程。總之,Log 複製的目的就是要讓所有的 Server 上的 Log 無論在內容上還是在順序上都要保持完全一致,這樣才能保證所有狀態機執行結果一致。

\"\"

目前已經有一些很優秀的對 Raft 的實現,比如 C++ 寫的 braft,Go 寫的 etcd,Rust 寫的 TiKV。當然了,SOFAJRaft 並不是 Raft 演算法的第一個 Java 實現,在我們之前已經有了很多專案。但是經過我們的評估,覺得目前還是沒有一個 Raft 的 Java 實現庫類能夠滿足螞蟻生產環境的要求,這也是我們去寫 SOFAJRaft 的主要原因。

SOFAJRaft 介紹

接下來我們介紹 SOFAJRaft。

SOFAJRaft 是基於 Raft 演算法的生產級高效能 Java 實現,支援 MULTI-RAFT-GROUP。從去年 3 月開發到今年 2 月完成,並在今年 3 月開源。應用場景有 Leader 選舉、分散式鎖服務、高可靠的元資訊管理、分散式儲存系統,目前使用案例有 RheaKV,這是 SOFAJRaft 中自帶的一個分散式 KV 儲存,還有今天開源的 SOFA 服務註冊中心中的元資訊管理模組也是用到了 SOFAJRaft,除此之外還有一些內部的專案也有使用,但是因為沒有開源,所以就不再詳述了。\"\"

這張圖就是 SOFAJRaft 的設計圖,Node 代表了一個 SOFAJRaft Server 節點,這些方框代表他內部的各個模組,我們依然用之前的銀行賬戶系統舉例來說明 SOFAJRaft 的各模組是如何工作的。

當 Client 向 SOFAJRaft 發來一個“存 100 元”的命令之後,Node 的 Log 儲存模組首先將這個命令以 Log 的形式儲存到本地,同時 Replicator 會把這個 Log 複製給其他的 Node,Replicator 是有多個的,叢集中有多少個 Follower 就會有多少個 Replicator,這樣就能實現併發的日誌複製。當 Node 收到叢集中半數以上的 Node 返回的“複製成功” 的響應之後,就可以把這條 Log 以及之前的 Log 有序的送到狀態機裡去執行了。狀態機是由使用者來實現的,比如我們現在舉的例子是銀行賬戶系統,所以狀態機執行的就是賬戶金額的借貸操作。如果 SOFAJRaft 在別的場景中使用,狀態機就會有其他的執行方式。

Meta Storage 是用來儲存記錄 Raft 實現的內部狀態,比如當前 Term 、投票給哪個節點等資訊。

Snapshot 是快照,所謂快照就是對資料當前值的一個記錄,Leader 生成快照有這麼幾個作用:

  1. 當有新的 Node 加入叢集的時候,不用只靠日誌複製、回放去和 Leader 保持資料一致,而是通過安裝 Leader 的快照來跳過早期大量日誌的回放;

  2. Leader 用快照替代 Log 複製可以減少網路上的資料量;

  3. 用快照替代早期的 Log 可以節省儲存空間。

剛才我們說的是一個節點內部的情況,那在 Raft Group 中至少需要 3 個節點,所以這是一個三副本的架構圖。

\"\"

我們會因為各種各樣的需求而去構建一個 Raft 叢集,如果你的目標是實現一個儲存系統的話,那單個 Raft 叢集可能沒有辦法承載你所有的儲存需求;如果你的目標是實現一個為使用者請求提供 Service 的系統的話,因為 Raft 叢集內只有 Leader 提供讀寫服務,所以讀寫也會形成單點的瓶頸。因此為了支援水平擴充套件,SOFAJRaft 提供了 Multi-Group 部署模式。如圖所示,我們可以按某種 Key 進行分片部署,比如使用者 ID,我們讓 Group 1 對 [0, 10000) 的 ID 提供服務,讓 Group 2 對 [10000, 20000) 的 ID 提供服務,以此類推。

\"\"

SOFAJRaft 特性

\"\"

這是我們所支援的 Raft 特性,其中:

  • Membership change 成員管理:叢集內成員的加入和退出不會影響叢集對外提供服務;
  • Transfer leader:除了叢集根據演算法自動選出 Leader 之外,還支援通過指令強制指定一個節點成為 Leader。
  • Fault tolerance 容錯性:當叢集內有節點因為各種原因不能正常執行時,不會影響整個叢集的正常工作。
  • 多數派故障恢復:當叢集內半數以上的節點都不能正常服務的時候,正常的做法是等待叢集自動恢復,不過 SOFAJRaft 也提供了 Reset 的指令,可以讓整個叢集立即重建。
  • Metrics:SOFAJRaft 內建了基於 Metrics 類庫的效能指標統計,具有豐富的效能統計指標,利用這些指標資料可以幫助使用者更容易找出系統效能瓶頸。

SOFAJRaft 定位是生產級的 Raft 演算法實現,所以除了幾百個單元測試以及部分 Chaos 測試之外, SOFAJRaft 還使用 jepsen 這個分散式驗證和故障注入測試框架模擬了很多種情況,都已驗證通過:

  • 隨機分割槽,一大一小兩個網路分割槽
  • 隨機增加和移除節點
  • 隨機停止和啟動節點
  • 隨機 kill -9 和啟動節點
  • 隨機劃分為兩組,互通一箇中間節點,模擬分割槽情況
  • 隨機劃分為不同的 majority 分組

網路分割槽包括兩種,一種是非對稱網路分割槽,一種是對稱網路分割槽。

\"\"

在對稱網路分割槽中,S2 和其他節點通訊中斷,由於無法和 Leader 通訊,導致它不斷嘗試競選 Leader,這樣等到網路恢復的時候,S2 由於之前的不斷嘗試,其 Term 已經高於 Leader 了。這會迫使 S1 退回到 Follower 狀態,叢集重新進行選舉。為避免這種由於對稱網路分割槽造成的不必要選舉,SOFAJRaft 增加了預投票(pre-vote),一個 Follower 在發起投票前會先嚐試預投票,只有超過半數的機器認可它的預投票,它才能繼續發起正式投票。在上面的情況中,S2 在每次發起選舉的時候會先嚐試預選舉,由於在預選舉中它依然得不到叢集內多數派的認可,所以預投票無法成功,S2 也就不會發起正式投票了,因此他的 Term 也就不會在網路分割槽的時候持續增加了。

\"\"

在非對稱網路分割槽中,S2 和 Leader S1 無法通訊,但是它和另一個 Follower S3 依然能夠通訊。在這種情況下,S2 發起預投票得到了 S3 的響應,S2 可以發起投票請求。接下來 S2 的投票請求會使得 S3 的 Term 也增加以至於超過 Leader S1(S3 收到 S2 的投票請求後,會相應把自己的 Term 提升到跟 S2 一致),因此 S3 接下來會拒絕 Leader S1 的日誌複製。為解決這種情況,SOFAJRaft 在 Follower 本地維護了一個時間戳來記錄收到 Leader 上一次資料更新的時間,Follower S3 只有超過 election timeout 之後才允許接受預投票請求,這樣也就避免了 S2 發起投票請求。

SOFAJRaft 優化

接下來我們說一下 SOFAJRaft 的優化。

\"\"

為了提供支援生產環境執行的高效能,SOFAJRaft 主要做了如下幾部分的效能優化,其中:

  • 並行 append log:在 SOFAJRaft 中 Leader 持久化 Log 和向 Followers 傳送 Log 是並行的。
  • 併發複製:Leader 向所有 Follwers 傳送 Log 也是完全相互獨立和併發的。
  • 非同步化:SOFAJRaft 中整個鏈路幾乎沒有任何阻塞,完全非同步的,是一個完全的 Callback 程式設計模型。

下面我們再說說另外三項:批量化、複製流水線以及線性一致讀。

批量化是效能優化最常用的手段之一。SOFAJRaft 通過批量化的手段合併 IO 請求、減少方法呼叫和上下文切換,具體包括批量提交 Task、批量網路傳送、本地 IO 批量寫入以及狀態機批量應用。值得一提的是 SOFAJRaft 主要是通過 Disruptor 來實現批量的消費模型,通過這種 Ring Buffer 的方式既可以實現批量消費,又不需要為了攢批而等待。

複製流水線主要是利用 Pipeline 的通訊方式來提高日誌複製的效率,如果 Leader 跟 Followers 節點的 Log 同步是序列 Batch 的方式,那麼每個 Batch 傳送之後需要等待 Batch 同步完成之後才能繼續傳送下一批(ping-pong), 這樣會導致較長的延遲。通過 Leader 跟 Followers 節點之間的 Pipeline 複製可以有效降低更新的延遲, 提高吞吐。

\"\"

什麼是線性一致讀呢?簡單來說就是要在分散式環境中實現 Java volatile 語義的效果,也就是說當一個 Client 向叢集發起寫操作的請求並且得到成功響應之後,該寫操作的結果要對所有後來的讀請求可見。和 volatile 的區別是 volatile 是實現執行緒之間的可見,而 SOFAJRaft 需要實現 Server 之間的可見。實現這個目的最常規的辦法是走 Raft 協議,將讀請求同樣按照 Log 處理,通過 Log 複製和狀態機執行來得到讀結果,然後再把結果返回給 Client。這種辦法的缺點是需要 Log 儲存、複製,這樣會帶來刷盤開銷、儲存開銷、網路開銷,因此在讀操作很多的場景下對效能影響很大。所以 SOFAJRaft 採用 ReadIndex 來替代走 Raft 狀態機的方案,簡單來說就是依靠這樣的原則直接從 Leader 讀取結果:所有已經複製到多數派上的 Log(可視為寫操作)就可以被視為安全的 Log,Leader 狀態機只要按序執行到這條 Log 之後,該 Log 所體現的資料就能對 Client 可見了。具體可以分解為以下四個步驟:

  1. Client 發起讀請求;
  2. Leader 確認最新複製到多數派的 LogIndex;
  3. Leader 確認身份;
  4. 在 LogIndex apply 後執行讀操作。

通過 ReadIndex 的優化,SOFAJRaft 已經能夠達到 RPC 上限的 80%了。但是我們其實還可以再往前走一步,上面的步驟中可以看到第 3 步還是需要 Leader 通過向 Followers 發心跳來確認自己的 Leader 身份,因為 Raft 叢集中的 Leader 身份隨時可能發生改變。所以我們可以採用 LeaseRead 的方式把這一步 RPC 省略掉。租約可以理解為叢集會給 Leader 一段租期(lease)的身份保證,在此期間 Leader 的身份不會被剝奪,這樣當 Leader 收到讀請求之後,如果發現租期尚未到期,就無需再通過和 Followers 通訊來確認自己的 Leader 身份,這樣就可以跳過第 3 步的網路通訊開銷。通過 LeaseRead 優化,SOFAJRaft 幾乎已經能夠達到 RPC 的上限。但是通過時鐘維護租期本身並不是絕對的安全(時鐘漂移問題),所以 SOFAJRaft 中預設配置是線性一致讀,因為通常情況下線性一致讀效能已足夠好。

效能

\"\"

這是我們效能測試的情況,測試條件如下:

  • 3 臺 16C 20G 記憶體的 Docker 容器作為 Server Node (3 副本)
  • 2 ~ 8 臺 8C Docker 容器 作為 Client
  • 24 個 Raft 複製組,平均每臺 Server Node 上各自有 8 個 Leader 負責讀寫請求,不開啟 Follower 讀
  • 壓測目標為 JRaft 中的 RheaKV 模組,只壓測 Put、Get 兩個介面,其中 get 是保證線性一致讀的,Key 和 Value 大小均為 16 位元組
  • 讀比例 10%,寫比例 90%

可以看到在開啟複製流水線之後,效能可以提升大約 30%。而當複製流水線和 Client-Batching 都開啟之後,8 臺 Client 能夠達到 40w+ ops。

目前 SOFARaft 最新的版本是 v1.2.4,由於 Raft 演算法本身也比較複雜,而且 SOFAJRaft 在實現中還做了很多優化,所以如果對今天的講演有什麼不清楚的地方,歡迎繼通過 SOFAJRaft wiki 繼續瞭解更多細節,另外我們還有一個如何使用 SOFAJRaft 的示例,在 wiki 上也有詳細的說明。除此之外,家純同學寫過一篇很詳細的介紹文章《螞蟻金服開源 SOFAJRaft:生產級 Java Raft 演算法庫》,大家也可以看一看。

歡迎 Star SOFAJRaft 幫助我們改進。

文中涉及到的相關連結

相關文章