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

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

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

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

開源代碼

github上有一個看起來還不錯的開源項目,基於Netty的Raft項目的實現,是百度的工程師開源的。

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

github.com

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

宏觀結構

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

客戶端可以連接任意一個節點。如果連接的不是Leader,那麼發送的請求會在服務端進行轉發,從當前連接的RaftNode轉發到Leader進行處理。另外一種可選的設計是所有的客戶端都連接到Leader,這樣就避免了轉發的過程,可以提升性能。

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

RaftNode的細節

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

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

日誌有四個重要的索引,分別是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操作懸掛住當前的請求不予返回。

對於每個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定時任務。隨之正式進入選舉流程。

選舉流程

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實現了一半,完整的整個演算法如果沒有對細節精緻地把握那是難以寫出來的。

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

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

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

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

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

最後

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

關注公眾號「碼洞」,加入我們一起來進階Raft演算法。


推薦閱讀:

不要騙我了,這能有多難嘛!
The Part-Time Parliament(三):協議流程
Paxos協議
簡談zookeeper的選主過程

TAG:Raft | 分散式系統 | Paxos |