寫在前面
lab2D是今年新添加的部分,網上很難找到博客資源。
這一部分要求我們為raft添加log compaction功能:在運行一段時間后,raft的上層service可以生成一個snapshot,並通知raft。在這之后,raft就可以丟棄snapshot包含的log entries,起到節約空間的作用。
這部分難度不大,但是細節略多。
(測試結果:Lab2D的testcase跑50次,全部PASS)
代碼見:https://github.com/sun-lingyu/MIT6.824-spring21/tree/Raft-2D
關於CondInstallSnapshot
看過Lab2D實驗指導的人都會發現,如果follower收到了一個InstallSnapshot RPC,其處理邏輯是非常扭曲的:
首先,在InstallSnapshot Handler中,follower需要將收到的snapshot通過applyCh發送給上層service。此時follower並不會安裝這個snapshot。
在一段時間后,上層service會調用CondInstallSnapshot函數,詢問raft是否應該安裝此snapshot。若在follower執行InstallSnapshot Handler到執行CondInstallSnapshot的這段時間里,raft沒有因為收到applyentries RPC導致其commitID超過該snapshot。
在什么情況下CondInstallSnapshot會拒絕安裝snapshot?
當然,出現“CondInstallSnapshot拒絕安裝snapshot”這種情況,是可以理解的。下面給出一種可能的情況:
leader的當前狀態如下。圖中的直線代表leader的log,且假設所有的log entry都已commit。
leader向其中一個落后的follower發送appendEntries RPC。其中包含了從nextIndex直到log末尾的所有entry。
由於種種原因(不穩定的網絡或follower fail),這個包並沒有及時被follower接受。
接下來,leader的上層應用調用Snapshot(),對leader的log進行壓縮:
如圖,這個snapshot可能超過了nextIndex。因此,當leader試圖重發剛剛的appendEntries時,它將只能發送整個snapshot給follower。
若由於網絡波動,follower首先收到了snapshot,它將執行InstallSnapshot RPC。
若在它還未執行CondInstallSnapshot時,它收到了先前發送的appendEntries,並commit,那么在執行CondInstallSnapshot時,它將拒絕安裝此snapshot。
為什么InstallSnapshot RPC的處理邏輯這么扭曲?
論文中對InstallSnapshot 的描述遠比實驗指導中簡單:論文中甚至根本沒有提及過CondInstallSnapshot。
那么我們為什么需要CondInstallSnapshot?為什么不可以在InstallSnapshot RPC handler中直接安裝snapshot?
實驗指導中給出的答案是:這樣可以確保service與raft安裝snapshot的原子性。
如果你對Lab2B的實現還有印象,就會發現:這個問題的答案與我們的實現高度相關。
讓我從“如何向applyCh發送log entry”講起:
在我的Lab2B實現中,向applyCh發送log entry的時機有兩個:
1. appendEntries RPC handler中,發現commitIndex需要更新之后
2. leader appendEntries RPC receiver中,commit新的log entry之后
在這兩個發送時機,執行發送的goroutine都是持有鎖的。
如果在向applyCh發送任何信息時,總持有鎖,那么可以保證raft與上層service的通信是嚴格串行的。(沒有任何不確定性)
那么如果在InstallSnapshot RPC handler中,向applyCh發送snapshot時也持有鎖,就也可以保證service與raft安裝snapshot的原子性。
即:raft向上傳遞log entry和snapshot是有嚴格順序的。service和raft是嚴格同步的(雖然service可能滯后raft一段時間),其正確性可以保證。
這樣一來,似乎可以在InstallSnapshot RPC handler中直接安裝snapshot。
但是:這個過程涉及了一把鎖與一個blocking channel。一旦處理不當,將導致死鎖。
雖然在2B/2C中,這樣的實現不會產生問題。這是因為raft的上層service一定會及時讀取applyCh。
但是在2D中引入Snapshot函數后,這種機制將導致死鎖!
在實驗提供的service代碼中:當上層service從applyCh中收到了一定數量的log entry后,它將執行Snapshot函數,使raft壓縮其log。在Snapshot函數返回前,上層service不會繼續執行。
執行Snapshot函數需要獲取鎖。若此時有goroutine正在持有鎖並試圖向applyCh中發送log entry,則會發生死鎖。
因此,Frans Kaasoek教授在講解2A/2B時,特意提到不要在向applyCh發送log entry時持有鎖。在他的實現中,向applyCh發送是采用一個專門goroutine執行來確保串行的。(我發現2B/2C的testcase都能過,就沒當回事😭😭😭)
在把實現改為用專門goroutine(以下稱為applier)向applyCh發送后,我發現為了避免復制導致使用額外空間,applier最好直接在rf.log上操作。但是這樣一來,applier就只能串行發送log entry,而很難把發送snapshot這件事情插入到log entry的串行發送過程中。(這里可能沒有講清楚。可以看我Lab2D中applier的代碼,一看就明白了。)
這時候,按照實驗指導的方式操作就變成了一件自然的事情:
我們不要求 發送snapshot和發送log entry這兩件事在同一個串行事件隊列中發生。即:
logEntry--logEntry--snapshot--logEntry--snapshot--logEntry
而是選擇退而求其次,允許發送log entry與發送snapshot並發執行。即:
logEntry--logEntry--logEntry--logEntry
snapshot--snapshot
也就是說,applier不負責發送snapshot。
這樣一來,service什么時候能接收到snapshot成了一件不確定的事情。我們必須引入CondInstallSnapshot函數,確定service接收到snapshot的時間。
進一步,我們就可以確保service和raft同時安裝snapshot,避免了不確定性。
一些實現細節
在理解了上面的內容后,按照實驗指導和論文的figure13,一步一步實現即可。
為了方便對之前的代碼進行改動,我建議將log由先前簡單的logEntry slice改為較復雜的struct,封裝其結構。如下:
這個struct封裝了log與snapshot的信息,並提供兩個函數:
1. 提供從log index到log.entries index的轉換方法。
2. 提供log最后一項的log index。
我建議在index函數中,在發生
index < l.LastIncludedIndex
這種情況(即:請求的index已經被包含在snapshot中)時panic。可以很快地找出潛在的bug。
還有一個需要注意的點是:在InstallSnapshot Handler中,若決定將此snapshot發送給applyCh,需要reset election timer。
也不要忘記在Make函數(初始化)中把rf.lastApplied 賦值為 rf.log.LastIncludedIndex。
寫在最后
總的來說,Lab2D難度不大,但是需要修改之前2B的很多邏輯和細節。只要足夠細心,就可以很快通過。
我覺得,2D最重要的事情是:理解為什么需要ConInstallSnapshot函數。
知其然,更應知其所以然。不能因為實驗指導這樣說了,就盲目服從。這樣對學習是沒有幫助的。