別再懷疑自己的智商了,Raft協議本來就不好理解

老錢發表於2018-05-07

Raft聲稱是一種易於理解的分散式一致性演算法。有不少工程師們翻了它的論文,參考了很多資料,最後只好懷疑自己是不是智商有問題。

Raft一直以來是很多高階資深程式設計師技術上的天花板,捅破相當有難度。每次剛剛拿起時洶湧澎湃,過不了多久便偃旗息鼓了,有一種喪屍般的難受。渴望逃離技術舒適區時會經常經歷有這種挫折。在分散式系統領域,Raft就是一道很高的門檻,邁過了這道坎後面技術的自由度就可以再上一個臺階。

Raft Paper的內容對於一個普通程式設計師來說不是太容易理解。選舉模組還算比較簡單,日誌複製表面上也很好理解,快照模組也很形象。但是深入進去看細節,一頭霧水是必然的。特別是對叢集節點變更模組的理解更是艱難。

開原始碼

github上找到了一個看起來還不錯的開源專案,基於Netty的Raft專案的實現,是百度的工程師開源的。

https://github.com/wenweihu86/raft-java

最近花了一些時間把他的程式碼通讀了一邊,發現居然都可以理解,感覺離目標更近了一步。加上之前實現過RPC框架,自己再擼一套Raft應該是可以很快變成現實了。

巨集觀結構

別再懷疑自己的智商了,Raft協議本來就不好理解

首先我們假設有三個RaftNode,每個RaftNode都會開設一個埠,這個埠的作用就是接受客戶端的(Get/Set)請求以及其它RaftNode的RPC請求。這裡需要說明的是多數著名開源專案一般會選擇兩個埠,一個面向客戶端,一個面向RPC,好處是可以選擇不同的IP地址,客戶端埠可以面向外網,而RPC則是安全的內網通訊。作者選擇了一個埠是因為只用於內網,在實現上也會簡單不少。

客戶端可以連線任意一個節點。如果連線的不是Leader,那麼傳送的請求會在服務端進行轉發,從當前連線的RaftNode轉發到Leader進行處理。

另外一種可選的設計是所有的客戶端都連線到Leader,這樣就避免了轉發的過程,可以提升效能。

但是服務端轉發也有它的好處,那就是當客戶端在資料一致性要求不好的情況下,讀請求可以不用轉發,直接在當前的RaftNode進行處理。所以返回的資料可能不是實時的。這可以擋住大部分客戶端請求,提升整體的讀效能。

RaftNode的細節

別再懷疑自己的智商了,Raft協議本來就不好理解

RaftNode中包含的重要元件都在這張圖上了。

首先Local Server接收到請求後,立即將請求日誌附加到SegmentedLog中,這個日誌會實時存到檔案系統中,同時記憶體裡也會保留一份。因為作者考慮到日誌檔案過大可能會影響效能和安全性(具體原因未知,Redis的aof日誌咋就不需要分段呢),所以對日誌做了分段處理,順序分成多個檔案,所以叫SegmentedLog。

別再懷疑自己的智商了,Raft協議本來就不好理解

日誌有四個重要的索引,分別是firstLogIndex/lastLogIndex和commitIndex/applyIndex,前兩個就是當前記憶體中日誌的開始和結束索引位置,後面兩個是日誌的提交索引和生效索引。之所以是用firstLogIndex而不是直接用零,是因為日誌檔案如果無限增長會非常龐大,Raft有策略會定時清理久遠的日誌,所以日誌的起始位置並不是零。commitIndex指的是過半節點都成功同步的日誌的最大位置,這個位置之前的日誌都是安全的可以應用到狀態機中。Raft會將當前已經commit的日誌立即應用到狀態機中,這裡使用applyIndex來標識當前已經成功應用到狀態機的日誌索引。

該專案示例提供的狀態機是RocksDB。RocksDB提供了高效的鍵值對儲存功能。實際使用時還有很多其它選擇,比如使用純記憶體的kv或者使用leveldb。純記憶體的缺點就是資料庫的內容都在記憶體中,Rocksdb/Leveldb的好處就是可以落盤,減輕記憶體的壓力,效能自然也會有所折損。

如果伺服器設定了本地落盤即可返回(isAsyncWrite),那麼Local Server將日誌塞進SegmentedLog之後就會立即向客戶端返回請求成功訊息。但是這可能會導致資料安全問題。因為Raft協議要求必須等待過半伺服器成功響應後才可以認為資料是安全的,才可以告知客戶端請求成功。之所以提供了這樣一個配置項,純粹是為了效能考慮。分散式資料庫Kafka同樣也有類似的選項。是通過犧牲資料一致性來提高效能的折中方法。

正常情況下,Local Server通過一個Condition變數的await操作懸掛住當前的請求不予返回。

別再懷疑自己的智商了,Raft協議本來就不好理解

對於每個RPCClient,它也要維護日誌的兩個索引,一個是matchIndex表示對方節點已經成功同步的位置,可以理解為區域性的commitIndex。而nextIndex就是下一個要同步的日誌索引位置。隨著Leader和Follower之間的訊息同步,matchIndex會努力追平nextIndex。同樣隨著客戶端的請求的連續到來,nextIndex也會持續前進。

Local Server在懸掛住使用者的請求後,會立即發出一次非同步日誌同步操作。它會通過RPC Client向其它節點傳送一個AppendEntries訊息(也是心跳訊息),包含當前尚未同步的所有日誌資料(從commitIndex到lastLogIndex)。然後等待對方實時反饋。如果反饋成功,就可以前進當前的日誌同步位置matchIndex。

matchIndex是每個RPCClient區域性的位置,當有過半RPCClient的matchIndex都前進了,那麼全域性的commitIndex也就可以隨之前進,取過半節點的matchIndex最小值即可。

commitIndex一旦前進,意味著前面的日誌都已經成功提交了,那麼懸掛的客戶端也可以繼續下去了。所以立即通過Condition變數的signalAll操作喚醒所有正在懸掛住的請求,通知它們馬上給客戶端響應。

注意日誌同步時還得看節點日誌是否落後太多,如果落後太多,通過AppendEntries這種方式同步是比較慢的,這時就是要考慮走另一條路線來同步日誌,那就是snapshot快照同步。

RaftNode會定時進行快照,將當前的狀態機內容序列化到檔案系統中,然後清理久遠的SegmentedLog日誌,給Raft的請求日誌瘦身。

快照同步就是Leader將最新的快照檔案傳送到Follower節點,Follower安裝快照後成功後,Leader繼續同步SegmentedLog,力圖讓Follower追平自己。

RaftNode啟動流程

RaftNode啟動的第一步是載入SegmentedLog,再載入最新的Snapshot形成狀態機的初始狀態。緊接著使用RPCClient去連線其它節點,開啟snapshot定時任務。隨之正式進入選舉流程。

選舉流程

別再懷疑自己的智商了,Raft協議本來就不好理解

RaftNode初始是處於Follower狀態,啟動後立即開啟一個startNewElection定時器,在這個定時器到點之前如果沒有收到來自Leader的心跳訊息或者其它Candidate的請求投票訊息,就立即變身成為Candidate,發起新一輪選舉過程。

RaftNode變成Candidate後,會向其它節點傳送一個請求投票(requestVote)的訊息,然後立即開啟一個startElection定時器,在這個定時器到點之前RaftNode如果沒有變身Follower或者Leader就會立即再次發起一輪新的選舉。

RaftNode處於Candidate狀態時,如果收到來自Leader的心跳訊息,就會立即變身為Follower。如果發出去的投票請求得到了半數節點的成功迴應,就會立即變身為Leader,並週期性地向其它節點廣播心跳訊息,以儘可能長期維持自己的統治地位。

當選Leader的條件

並不是任意一個節點都可以變成Leader。如果要當Leader,這個節點包含的日誌必須最全。Candidate通過RequestVote訊息拉票的時候,需要攜帶當前日誌列表的lastLogIndex和相應日誌的term值(尾部日誌的term和index)。 其它節點需要和這兩個值進行匹配,凡是沒自己新的拉票請求都拒絕。簡單一點說,組裡最牛逼的節點才可以當領導。

日誌同步

Leader發生切換的時候,新Leader的日誌和Follower的日誌可能會存在不一致的情形。這時Follower需要對自身的日誌進行截斷處理,再從截斷的位置重新同步日誌。Leader自身的日誌是Append-Only的,它永遠不會抹掉自身的任何日誌。

標準的策略是Leader當選後立即向所有節點廣播AppendEntries訊息,攜帶最後一條日誌的資訊。Follower收到訊息後和自己的日誌進行比對,如果最後一條日誌和自己的不匹配就回絕Leader。

Leader被打擊後,就會開始回退一步,攜帶最後兩條日誌,重新向拒絕自己的Follower傳送AppendEntries訊息。如果Follower發現訊息中兩條日誌的第一條還是和自己的不匹配,那就繼續拒絕,然後Leader被打擊後繼續後退重試。如果匹配的話,那麼就把訊息中的日誌項覆蓋掉本地的日誌,於是同步就成功了,一致性就實現了。

叢集成員變化

叢集配置變更可能是Raft演算法裡最複雜的一個模組。為了理解這個模組我也是費了九牛二虎之力。看了很多文章後發現這些作者們實際上都沒深入理解這個叢集成員變化演算法,不過是把論文中說的拷貝了一遍。我相信它們最多隻把Raft實現了一半,完整的整個演算法如果沒有對細節精緻地把握那是難以寫出來的。

別再懷疑自己的智商了,Raft協議本來就不好理解

分散式系統的一個非常頭疼的問題就是同樣的動作發生的時間卻不一樣。比如上圖的叢集從3個變成5個,叢集的配置從OldConfig變成NewConfig,這些節點配置轉變的時間並不完全一樣,存在一定的偏差,於是就形成了新舊配置的疊加態。

在圖中紅色剪頭的時間點,舊配置的叢集下Server[1,2]可以選舉Server1為Leader,Server3不同意沒關係,過半就行。而同樣的時間,新配置的叢集下Server[3,4,5]則可以選舉出Server5為另外一個Leader。這時候就存在多Leader並存問題。

為了避免這個問題,Raft使用單節點變更演算法。一次只允許變動一個節點,並且要按順序變更,不允許並行交叉,否則會出現混亂。如果你想從3個節點變成5個節點,那就先變成4節點,再變成5節點。變更單節點的好處是叢集不會分裂,不會同時存在兩個Leader。

別再懷疑自己的智商了,Raft協議本來就不好理解

如圖所示,藍色圈圈代表舊配置的大多數(majority),紅色圈圈程式碼新配置的帶多數。新舊配置下兩個叢集的大多數必然會重疊(舊配置節點數2k的大多數是k+1,新配置節點數2k+1的大多數是k+1,兩個叢集的大多數之和是2k+2大於叢集的節點數2k+1)。這兩個叢集的term肯定不一樣,而同一個節點不可能有兩個term。所以這個重疊的節點只會屬於一個大多數,最終也就只會存在一個叢集,也就只有一個Leader。

叢集變更操作日誌不同於普通日誌。普通日誌要等到commit之後才可以apply到狀態機,而叢集變更日誌在leader將日誌追加持久化後,就可以立即apply。為什麼要這麼做,可以參考知乎孫建良的這篇文章 https://zhuanlan.zhihu.com/p/29678067,這裡面精細描述了commit & reply叢集變更日誌帶來的叢集不可用的場景。

最後

文章是寫完了,但是感覺還是有點懵,總覺得有很多細枝末節還沒有搞清楚。另外就是開始覺得百度實現的這個Raft應該很完善,深入瞭解之後發現原來還是有很多不完善之處,這個專案應該只是一個demo。後續還得繼續研究etcd的raft程式碼,它的程式碼要複雜一些,但是應該要完善很多。它具備prevote流程和Leader提交之後的no-op日誌同步,這些是raft-java專案欠缺的地方所在。

關注公眾號「碼洞」,一起進階Raft協議

相關文章