別再懷疑本身的智商了,Raft協議原本就很差理解

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

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

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

開源代碼

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

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

最近花了一些時間把他的代碼通讀了一邊,發現竟然均可以理解,感受離目標更近了一步。加上以前實現過RPC框架,本身再擼一套Raft應該是能夠很快變成現實了。數據庫

宏觀結構

首先咱們假設有三個RaftNode,每一個RaftNode都會開設一個端口,這個端口的做用就是接受客戶端的(Get/Set)請求以及其它RaftNode的RPC請求。這裏須要說明的是多數著名開源項目通常會選擇兩個端口,一個面向客戶端,一個面向RPC,好處是能夠選擇不一樣的IP地址,客戶端端口能夠面向外網,而RPC則是安全的內網通訊。做者選擇了一個端口是由於只用於內網,在實現上也會簡單很多。安全

客戶端能夠鏈接任意一個節點。若是鏈接的不是Leader,那麼發送的請求會在服務端進行轉發,從當前鏈接的RaftNode轉發到Leader進行處理。服務器

另一種可選的設計是全部的客戶端都鏈接到Leader,這樣就避免了轉發的過程,能夠提高性能。app

可是服務端轉發也有它的好處,那就是當客戶端在數據一致性要求很差的狀況下,讀請求能夠不用轉發,直接在當前的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。爲何要這麼作,能夠參考知乎孫建良的這篇文章 https://zhuanlan.zhihu.com/p/29678067,這裏面精細描述了commit & reply集羣變動日誌帶來的集羣不可用的場景。

最後

文章是寫完了,可是感受仍是有點懵,總以爲有不少細枝末節尚未搞清楚。另外就是開始以爲百度實現的這個Raft應該很完善,深刻了解以後發現原來仍是有不少不完善之處,這個項目應該只是一個demo。後續還得繼續研究etcd的raft代碼,它的代碼要複雜一些,可是應該要完善不少。它具有prevote流程和Leader提交以後的no-op日誌同步,這些是raft-java項目欠缺的地方所在。

關注公衆號「碼洞」,一塊兒進階Raft協議

相關文章
相關標籤/搜索