文章目錄
一、概述
在上一篇博文中我們分析了整個系統整體結構,在這一篇博文中我們將會更加深入的分析一下系統中 Raft Lib 的整體結構,主要涉及系統對於 Raft 算法中的選舉功能實現、數據同步功能實現以及快照功能實現等。
博客內所有文章和代碼均爲 原創,所有示意圖均爲 原創,若轉載請附原文鏈接。
二、結構概述
2.1 整體結構示意圖
2.2 系統各部分功能概述
系統整體可以劃分爲四個部分,分別爲 Raft Timer 、Raft Worker 、Raft RPC Sender 和 Raft PRC Handler ,其中 RPC Sender 和 RPC Handler 又可以統一劃分爲 RPC 層,各部分的功能基本如下:
- Raft Timer :系統計時器,主要負責系統中計時任務,具體又可分爲 ElectionTimer 負責選舉倒計時,HeartbeatTimer 負責心跳倒計時;
- Raft Worker :工作者協程(goroutine),主要負責併發執行系統中的一切任務,具體可分爲負責選舉任務的 ElectionWorker ,負責數據一致性檢查及同步的 ConsistentWorker ,以及負責執行日誌壓縮和快照持久化的 SnapshotWorker ;
- Raft RPC Sender :RPC 發送模塊主要負責各種 RPC 的調用,具體包括髮送選舉請求的 SendRequestVote ,負責數據同步和心跳的 SendAppendEntries ,以及負責發送快照的 SendInstallSnapshot ;
- Raft PRC Handler :RPC 處理模塊主要負責處理各種 RPC 的調用,具體包括處理選舉投票事務的 RequestVoteHandler ,處理日誌同步操作和接收心跳的 AppendEntriesHandler ,以及處理領導者發送的快照的 InstallSnapshotHandler ;
三、詳述結構細節
3.1 Raft Timer
Raft Timer 模塊主要負責系統中的各種計時工作,其它模塊通過 ElectionTimerChannel 和 HeartbeatTimerChannel 兩個通道(Channel)來發送指令操作兩個計時器,而計時器模塊會從通道中接收 Start | Stop | Restart 指令,並 開啓 | 停止 | 重置 當前計時器,當倒計時結束後會執行特定的任務並自動重啓該倒計時。在開始的第一版代碼設計中我是將兩個計時器集成到了一起(因爲兩個計時器正常情況下應永遠是僅開啓一個的),但是在後面的測試過程發現這樣的結構因爲每次開啓計時器時都要進行狀態判斷,導致心跳計時器經常不能被及時開啓,從而導致心跳不能夠被及時發送,最終導致出現頻繁選舉的問題。因此在後面的重構過程中,我將整個系統中的計時器重新劃分爲了 ElectionTimer 和 HeartbeatTimer 兩個部分。
- ElectionTimer :選舉計時器,這個計時器由跟隨者(Follower)開啓,每次隨機選取 200ms~400ms 的時間進行倒計時,當倒計時結束時該節點將發起選舉。且當該節點成爲領導者時應 停止 該計時器,避免發起重複選舉;而當節點從領導者變爲跟隨者時應 開啓 該計時器(初始狀態默認爲開啓),從而可以在倒計時結束後發起新一輪的選舉;而當跟隨者接收到領導者發送的心跳包時,應及時 重置 該計時器,避免發起不必要的重複選舉。
- HeartbeatTimer :發送心跳包計時器,這個計時器由領導者(Leader)開啓,時間間隔爲固定的 100ms,當倒計時結束時該節點應及時向所有的跟隨者發送心跳包來重置它們的選舉計時器,以此來鞏固自己的統治地位,防止其他節點開啓不必要的選舉。當節點成爲領導者時應第一時間 開啓 該計時器;而當該節點從領導者變爲跟隨者時應及時 關閉 該計時器,避免發送無效的心跳包導致數據混亂;而當節點爲領導者且發送快照同步包後可以 重置 該計時器,這主要是因爲快照同步包同樣可以看做一次心跳包的發送。
首先,對於系統中的計時器我是統一使用 time.Timer 來實現的,在外部包裹 For 循環來實現計時器的自動重啓,並使用 Select 實現計時器對外部干預(停止或重啓)的響應。
其次,對於 ElectionTimer 的時間間隔設置是完全隨機的,按照 Paper 中給出的範圍,當 ElectionTimer 處於 [150ms, 300ms] 時系統會獲得最好的性能,同時 HeartbeatTimer 應滿足等式 HeartbeatTimer << ElectionTimer 來避免頻繁的選舉。但是爲了能夠通過 Lab 中給出的測試用例,所以按照 Lab 要求當前系統中 ElectionTimer 的隨機範圍是 200ms~400ms ,而對於 HeartbeatTimer 目前的選擇是 100ms 。
最後,ElectionTimer 隨機取值的目的主要是爲了避免同一時間內多個節點的 ElectionTimer 同時倒計時結束,從而導致多個節點同時發起選舉且最終因選票不足無法選出領導者而導致整個系統陷入死鎖的情況。
3.2 Raft Worker
Raft Worker 主要負責執行系統中一些需要不間斷執行,且需要和主邏輯併發執行的任務,在系統中我對 Raft Worker 的實現採用的是相比於線程更加輕量級的 Goroutine ,而整個系統中的 Worker 可以大致分爲 ElectionWorker 、ConsistentWorker 和 SnapshotWorker 這三個部分,它們彼此之間相互獨立,各自執行自己的邏輯。
- ElectionWorker :此協程專門負責選舉的相關事務,當系統中的 ElectionTimer 倒計時結束後會通知該協程開啓選舉操作(此時 ElectionTimer 會開啓新一輪的計時),具體的操作主要包括生成選票,發送選票給其它節點以及接收其它節點返回的選票結果,並在統計後決定當前節點是否可以成爲領導者。如果可以成爲領導者則進入到領導者邏輯,立即向其他節點發送心跳包來聲明自己的身份,而如果選票不足以成爲領導者則會一直等待(這期間可以接收來自其它節點的投票請求和心跳包),直到 ElectionTimer 超時後開啓新一輪的選舉。
- ConsistentWorker :該工作者協程主要是負責節點之間的數據同步工作,而更確切的來說該協程們,因爲在此係統中對於節點間的數據同步工作,我是採用了每個節點中均爲其它所有的節點創建一個 ConsistentWorker 的方式來定向實現的。協程中的主要邏輯就是判斷當前節點是否有同步數據的權限(是否爲領導者),然後再判斷當前協程所對應的節點中的數據是否和當前節點中的數據存在差異,如果存在差異即進入到數據同步階段,而這裏的數據同步又分爲直接發送數據同步和發送快照進行同步兩種方式。
- SnapshotWorker :此協程的主要作用是通過通道(Channel)接收 Server 發送的壓縮日誌命令並持久化當前節點的狀態和快照。首先當 Server 監控到當前節點中的數據已經接近最大日誌長度時,就會對當前 Server 中的數據進行快照,然後通過通道發送給節點,節點在該協程內接收到指令和快照後,會根據 Server 發送的快照信息來壓縮當前日誌(刪除快照之前的無效日誌),並將節點當前的狀態和 Server 發送的快照一同進行持久化操作。
ElectionWorker 協程中的邏輯主要涉及選舉算法的實現,這個協程跟 ConsistentWorker 不一樣,它是每個節點都需要維護的。而這裏需要注意的幾點就是當 ElectionTimer 開啓 ElectionWorker 進入選舉流程後,ElectionTimer 緊接着就會重啓自己的計時器,準備下一次的選舉,如果在 ElectionTimer 超時時間內該節點沒有獲得足夠的選票來贏得選舉且沒有接收到其它節點發送的心跳包,那麼就再次開啓新一輪的選舉。這樣做的方式主要是出於兩個方面考慮,首先如果在發送選票時剛好有節點因爲網絡狀況不好而沒能收到選票,進而無法回覆候選者,那候選者就會一直等待,造成死鎖;而另一個方面就是如果出現恰好兩個節點同時發起選舉,那麼可能導致兩個節點最終都因無法獲得足夠的選票而失敗,因此需要在 ElectionTimer 超時後再次進行選舉,而因爲 ElectionTimer 的時間間隔是隨機的,這樣就可以保證在下一輪選舉中一定可以選出領導者。
而另一方面 ElectionWorker 協程中的邏輯是隨時都可能被終止的,這主要是因爲如果恰好有另一個節點在發起選舉後獲得足夠的選票成爲了領導者,那麼它就會發送心跳包來聲明自己的身份,因此當該節點在接收到心跳包並確認該領導者有效(選舉屆值大於等於自身)後就會立刻停止自己的選舉邏輯,從而避免出現雙主的現象(其實因爲無法獲得足夠的選票,該節點本身也永遠不可能獲選)。接收心跳包的邏輯是位於 AppendEntriesHandler 中,所以兩者是可以同時進行的。
對於上述的 ConsistentWorker 的協程模型我們可以通過下面這張示意圖來進行理解,在每個領導者節點中都會爲其他的所有跟隨者節點維護一個 ConsistentWorker 協程,這個協程專門負責同步該跟隨者節點和領導者節點之間的數據,並且因爲根據 Raft Protocol 模型所述所有的數據流向都應是從領導者流向跟隨者的,因此在跟隨者節點中沒有必要去維護該協程。
而這樣的實現方式我也是從可用性和靈活性以及性能上面來進行考慮的,在初期時我嘗試過在領導者每次接收到數據時就發送 RPC 來對各個跟隨者進行數據同步,但如果按照 Paper 所述每次發送失敗後就再次重試直至成功,而在重試的過程中可能領導者又接收到了新的數據,又需要對新的數據進行同步,所以這裏可能又需要再開一個新循環直至重試成功,這樣帶來的問題就是會讓代碼的邏輯越來越複雜,尤其是在網絡波動很大的時候,可能會存在領導者剛跟跟隨者對齊過數據還沒來得及發送新數據跟隨者就又掛掉了,而一個又一個的重試循環會不斷地發送 RPC ,跟隨者在接收的過程中很有可能接收到已經過時的消息,最終導致過時的信息直接將新的信息覆蓋掉。當然,前面這種問題是在 Lab 極端的測試用例下發生的(網絡條件極不穩定),如果正常情況下估計也還是可以的。
重構後的同步操作使用了 ConsistentWorker 模型,即在領導者接收到新的數據後僅先將其保存到自己的日誌中,然後更新自己的日誌索引信息,將數據的同步工作完全交由 ConsistentWorker 來完成。每個 ConsistentWorker 會定時監控領導者分配給它的跟隨者節點的日誌信息(這裏主要是跟蹤日誌索引),如果發現跟隨者中的日誌落後於領導者節點中的日誌就會立即進入到同步操作中。通過這樣的方式我們可以保證在日誌同步的過程中,領導者僅存在單一的協程與跟隨者進行數據同步,這樣就不會出現數據混亂的問題,且因爲減少了數據的共享使用又可以大大減少鎖的使用。而事實也證明這樣的優化方式使集羣在極端條件下日誌同步的時間從 7 秒左右直接提升到了 2 秒左右,代碼的整體邏輯也更加清晰。
然後再談一談 ConsistentWorker 中的 數據同步方式 ,在上面的簡述中已經提到這裏存在直接發送數據包同步和發送快照同步兩種方式,下面就來簡單介紹一下兩種同步方式的使用場景。
- 發送數據包同步 :具體實現方式就是將跟隨者落後於領導者的日誌打包後通過 Raft PRC Sender 中的 SendAppendEntries 方法來進行發送,跟隨者通過 AppendEntriesHandler 接收到後會在完成數據對齊後將新數據追加到自己的日誌末尾。這種同步方式使用的場景主要是當跟隨者和領導者之間的數據差距不是很多時,也是最經常使用的同步方式。
- 發送快照同步 :具體實現方式是將領導者當前的快照打包後通過 Raft PRC Sender 中的 SendInstallSnapshot 方法來進行發送。跟隨者接收後在確認快照的版本新於本地的版本後會更新本地快照,並通過通道發送(Channel)快照給 Server ,讓 Server 也根據快照完成數據的同步。這種同步方式使用的場景主要是當跟隨者的日誌遠遠落後於領導者的日誌時,這時再通過上面的方式同步數據會造成很大的網絡資源浪費,所以可以採用直接發送快照的方式進行同步;而另一種場景就是當領導者要發送給跟隨者的日誌已經被快照時,因爲此時領導者中已經不再包含該日誌,所以只能通過發送快照的方式來完成數據的同步。
而對於兩種同步方式的底層實現在 Lab 中是統一使用 LabRPC 進行的,我的想法是可以模仿 etcd 的方式,將數據量較小的發送數據包同步通過 RPC 來完成;而對於數據量較大的快照如果採用 RPC 的方式會需要進行多次的分塊發送,所以可以換個思路採用在領導者和跟隨者之間維護長連接的方式來完成。
最後一部分是 SnapshotWorker 協程,它的主要任務就是執行日誌壓縮和持久化快照等邏輯。因爲 Server 和節點之間是存在一條通道來實現快照信息通訊的,Server 會監控節點中每次添加數據後日志的大小,如果日誌的長度 接近 外部提供的最大長度時,就會將當前 Server 中的數據進行快照,並將當前節點中的一些信息也保存其中,然後通過通道(Channel)發送給節點,節點在 SnapshotWorker 協程中接收到快照後,就知道當前需要壓縮日誌,所以會刪除日誌中位於快照之前的無效數據,然後將快照和節點當前的狀態一起完成持久化操作。
3.3 Raft RPC Sender and Handler
最後一部分就是 Raft RPC 層,在這一層中主要包括 Raft RPC Sender 和 Raft RPC Handler 兩部分,分別負責 RPC 的調用和 RPC 的響應。
- Raft RPC Sender :這部分主要是對底層的 RPC 進行一層包裝,目前系統中 RPC 調用的還是依託底層的 LabRPC 來實現。在這一層目前包括髮送選舉請求的 SendRequestVote ,負責數據同步和心跳的 SendAppendEntries ,以及負責發送快照的 SendInstallSnapshot 這三部分。
- Raft RPC Handler :這部分主要是對外部 RPC 的調用進行響應,目前系統中主要包括處理選舉投票事務的 RequestVoteHandler ,處理日誌同步操作和接收心跳的 AppendEntriesHandler ,以及處理領導者發送的快照的 InstallSnapshotHandler 這三部分。
四、系統源碼
https://github.com/TIYangFan/DistributedSystemBaseGolang ( 如果可以幫到你,Please Star ^ _ ^ ~ )
五、內容總結
在這篇博文裏我爲大家分析了一下系統中對於 Raft 算法的實現,且介紹了系統中 Raft Lib 的整體結構,在接下來的博文中會繼續分析 KV Server 的整體結構,並在完成分析後進入 Lab4 Shared Server 的開發,或者進入系統底層使用 gRPC 對 RPC 層進行擴展開發,在這期間也會繼續寫博文來記錄相關的過程。