學習分佈式一致性協議:自己實現一個Raft算法

前言

MIT6.824是麻省理工學院開設的一個很棒的分佈式系統公開課程,課程的Schedule在這裏 ,這門課程的學習方式主要是通過教授的 lecture 講解、Paper閱讀、FAQ答疑,以及實踐lab來完成的,是一個學習理論知識,然後動手實踐的過程,個人認爲是很好的學習方式,而MIT6.824公開課讓更多不是麻省理工的學生也能很好的學習分佈式系統知識,免費學習MIT課程學到就是賺到!

MIT6.824主要圍繞以下4個lab進行學習

  • lab1->MapReduce:實現一個MapReduce系統,其是一個具有Map和Reduce功能的分佈式計算系統
  • lab2->Raft:實現Raft算法,其是一個分佈式一致性協議,分爲以下3個部分
    • 2A:Leader選舉
    • 2B:日誌複製
    • 2C:持久化數據
  • lab3->分佈式容錯的Key/Value存儲服務:搭建一個容錯的Key-Value分佈式服務,其是建立在lab2-Raft的一個上層建築,需要在lab2的基礎上實現日誌快照等功能,對外可以提供 K-V 存儲服務
  • lab4->Shared Key/Value服務:一個分片的存儲服務

而本篇文章討論的是如何學習lab2的部分,也就是實現一個Raft算法,本文會指出學習方式,以及你需要做到的一些要點、常見的坑、資料等等。你可以將本文作爲一個lab2的Guide來進行閱讀。

如果讀者對其他lab有興趣,也可以參照本文差不多的方式進行其他lab的學習。

首先放一張lab2A、2B、3C,3pass圖(做完還是有滿滿的成就感的)

在這裏插入圖片描述
前段時間花了一週左右的時間動手寫代碼完成了MIT6.824課程中的lab2,達到 bug-free 屬實不易,在做的過程中踩過許多坑,發現做lab的時候交流、溝通代碼中的一些問題很重要,交流會開拓了我們的思路、解決方法,如果沒人交流,就比較容易出現一個疑難雜症會卡好幾個小時甚至幾天的情況,比較容易產生氣餒、想放棄的情緒,我在做lab2C部分的最後一個具有挑戰性的unreliable test的時候有一個bug硬找了快兩天,中途有幾次想過放棄,但意志力和對技術的熱情驅使我不能將就,所以堅持下來,最終會找到解決方法的思路的。

學習MIT6.824課程,我們不像MIT學生那樣,學生之間可以進行討論,有問題可以詢問助教、教授,我們在做的時候只是一個人,你最多可以找到MIT6.824的交流羣,但羣裏真正能幫助你解決一些問題的人不多,最終靠自己的比重還是比較大的,所以一些學習資料就顯得比較重要,這也是本文創作的初衷,想讓更多人學習到MIT6.824這門課程,學習Raft算法不止是閱讀paper和一些理論知識,沒有什麼比直接實現一個Raft還能夠深刻學習分佈式一致性協議的了。其次自己實現一個Raft,想想就很有意思。

學習lab2,我希望至少需要有CAP和分佈式一致性相關知識基礎,起碼要了解他們,知道Raft是幹嘛用的,爲什麼需要使用Raft。這裏推薦自己的一篇文章,從CAP理論延伸來講講分佈式一致性,點擊查看

1. Lab前的預備工作

1.1 如何檢驗Raft算法的正確性

感覺這個是大多數人首先都比較關心的問題,這個Raft算法做出來之後我怎麼知道它能work呢?lab中首先會給你一個代碼大致骨架,骨架中附帶了很多單元測試可以測試你的代碼的正確性,所以按照一定規則去實現你的算法之後run一遍單元測試就行了。

1.2 編程語言

MIT6.824 中 lab 使用的語言均爲Go語言,不會Go語言的同學不要就這麼打退堂鼓了,我在做lab之前也不會Go語言,但這個語言簡單高效,如果有Java或者C++的基礎的話上手會非常快,實際做lab的話只用到了少數併發的Go庫函數,所以庫函數的學習成本也不會特別高,Go的語法與Java、C++類似,熟悉幾天就能上手,關於IDE我個人使用的是GoLand 30天免費體驗,也可以使用比較強大的 Vim -> vim使用文檔,用熟練之後效率不亞於GoLand。

在Go中使用的一些特定的Go的庫函數、一些比如定時器的做法在下面介紹lab的時候會具體涉獵

1.3 閱讀論文

做lab之前,首當其衝的當然就是閱讀Paper

  • Raft論文:https://raft.github.io/raft.pdf (英文版)
    • https://www.ulunwen.com/archives/229938 (中文版)
    • 有英文底子的都建議看英文版,因爲難免英文原著有些意思翻譯成中文會丟失了一些味道,直接看中文的話有的地方可能會有點疑惑

建議先讀一遍paper,大概瞭解瞭解Raft算法的具體構思,看不懂的先跳過,第一遍不求甚解,有個大致思想即可。

1.4 Lecture

此時你大致已經對Raft有一定的想法了,相當於預習了一遍課程,這時候就可以開始上課了,如果只做lab2的話,你需要關注以下幾個lecture:

  1. Lecture 5: Go, Threads, and Raft
  2. Lecture 6: Fault Tolerance: Raft (1)
  3. Lecture 7: Fault Tolerance: Raft (2)

其中第一個lecture講的是在使用Go語言實現Raft時會出現的幾個問題,有參考價值,第二個和第三個lecture講的是Raft算法的一些細節,這幾個lecture建議都要看,對實現lab有一定的幫助。

以下是我找到的有三個課程資源:

  • YouTube的全英文無字幕高端玩家版:https://www.youtube.com/channel/UC_7WrbZTCODu1o_kfUMq88g/videos
    • 這個比較適合英文特別好的,門檻比較高不是很推薦哈哈
  • simviso團隊中文翻譯版:https://www.simtoco.com/#/albums?id=1000019
    • 翻譯的不錯,但缺點是沒翻譯完,只翻譯了Lecture5、6和Lecture7的前面一點點。不過也翻譯了大部分了,前面大半部分可以參考這裏
  • 機翻的中英文雙字幕:https://www.bilibili.com/video/BV1qk4y197bB?p=7
    • 由於是機翻,很多翻譯不到位,需要有一定的英文閱讀水平,看英文字幕就可以了,結合上面的中文翻譯版的這裏就只需要從Lecture7開始看

1.5 回顧論文

可以動手做lab之前我認爲有一個指標就是你至少需要懂論文中的Figure2中的每一個字的意思,知道爲什麼這樣子設計,Raft算法由簡單易懂著稱,其只有兩個RPC方法,一個是AppendEntries日誌複製,一個是RequestVote請求投票,以及一系列的Raft屬性都在Figure2中,同時有一系列Follower、Candidate、Leader、AllServer需要遵循的規則,理解這些規則並且做lab的時候一定要按照論文中的這些規則說的去做。

當你對某個Figure2中的規則產生疑惑,請多回顧多讀幾遍論文,這是做lab時bug-free的關鍵。做之前務必保證理解了Figure2。

1.6 參考資料

最後總結幾個參考資料,做lab時應該能幫到你:

  • 助教的blog:Students’ Guide to Raft -> https://thesquareplanet.com/blog/students-guide-to-raft/
    • 課程的助教總結了幾個做lab時需要注意的幾個點,和一些bug經驗,做之前可以參考參考
  • 教授寫的Lock鎖使用建議:http://nil.csail.mit.edu/6.824/2020/labs/raft-locking.txt
  • 教授寫的Raft結構建議:http://nil.csail.mit.edu/6.824/2020/labs/raft-structure.txt
  • lecture的講義:http://nil.csail.mit.edu/6.824/2020/notes/l-raft2.txt

2. 開始lab2實現Raft

課程主頁

務必遵循paper中的Figure2的每條規則來實現你的lab

現在就開始着手做lab了,進入課程主頁,左邊的導航中進入lab2 ,開始動手之前務必保證讀一遍教授說的話,以及仔細閱讀每個Task下面的Hint提示(我做的時候進的是2018的網頁,提示相當少,做完才發現有2020年的網頁,提示變多了好幾條)

2.1 Lab2A

首先是2A,實現Leader選舉,剛開始2A裏的兩個測試個人認爲是最簡單的,因爲leader選舉在下面的2B、2C都會迎來更大的挑戰,如果你能pass2A,並不能代表Leader選舉的邏輯就一定ok,也就是說在2B、2C中如果出現BUG還是有可能因爲你的Leader選舉邏輯有問題導致的。

下面就提幾個要點幫助你快速上手實現Raft

要點只會設計一些Raft算法無關的東西,比如語言這塊,初衷是希望算法之外的東西不要浪費大家太多時間,更多關注算法的實現

2.1.1 加鎖建議

一個原則,不要考慮鎖性能(鎖的粒度)問題,我們更關注的是算法的正確性,有可能data-race的時候請毫不猶豫加上一把大鎖

可見性與原子性

由於算法中很多地方都需要併發編程,比如Candidate發起投票請求RPC,要同時給所有節點發送RPC,此時就開多個goroutine進行RPC,一旦涉及併發編程,就會有data-race、數據可見性的問題,參照Happen-before原則,在所有有data-race的地方都加一把鎖,爲了可見性也爲了原子性。

func (rf *Raft) GetState() (int, bool) {
  // 爲了可見性
	rf.mu.Lock()
	defer rf.mu.Unlock()
	return rf.currentTerm, rf.state == Leader
}

這裏獲取節點中的當前Term和節點的state屬性的時候加鎖是爲了可見性,currentTerm、state這兩個屬性明顯會有data-race,所以這裏一定注意可見性,不然Agoroutine修改了currentTerm,Bgoroutine調用上面的GetState方法有可能看不到最新的currentTerm值

同時有些方法需要加一把大鎖,有些方法需要你讀取currentTerm,然後又要根據某個值去修改currentTerm,請毫不猶豫加上一把大鎖。

死鎖

如何避免死鎖?大部分死鎖是由於鎖獲取順序問題,比如有兩把鎖X和Y,同時有兩個線程A和B,A先獲取X鎖後再去請求Y鎖,B先獲取Y鎖後再去請求X鎖造成死鎖。這裏鎖獲取順序一個是先X後Y,一個是先Y後X,有這種鎖獲取順序的時候務必注意死鎖問題。

也就是說,我們避免鎖上加鎖的問題就可以避免死鎖,所以一個原則,在RPC調用的時候不要持有鎖,爲什麼呢?舉一個例子:

func (rf *Raft) TimeoutAndVote() {
  rf.mu.Lock()
  // 節點的選舉計時器超時,開始發起選舉投票RPC
  for i := 0; i < peersCount; i++ {
    go func(server int) {
      // 發送RPC投票請求
		  rf.sendRequestVote(server, &request, reply)          
    }
  }
  rf.mu.Unlock()
}

func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
  rf.mu.Lock()
  // 節點收到請求投票RPC後的處理函數
  rf.mu.Unlock()
}

每一個節點都是不同的Raft實例,也就是說每個節點都是不同的鎖,集羣3個節點就總共有3把鎖

假設集羣兩個節點A和B,A、B同時選舉超時,發起選舉投票,所以A、B同時進入TimeoutAndVote函數發起選舉投票,A獲取了A鎖,B獲取了B鎖,此時A發送RPC給B,進入RequestVote函數,需要獲取到B鎖,同時B發送RPC給A,進入RequestVote函數,需要獲取到A鎖,鎖獲取順序一個是先A後B,一個是先B後A,所以發生了死鎖。

如果我在調用RPC之前釋放了鎖,然後RPC結束之後重新獲取鎖,這樣的話就避免了鎖上加鎖的情況,沒有了多鎖場景自然就沒有死鎖問題。所以一個原則,調用RPC過程不要持有鎖。個人在做lab的時候遵循這個原則死鎖就不會出現。

死鎖調試

爲了死鎖能方便調試,你可以選擇性把加鎖函數封裝起來,打上日誌

func (rf *Raft) lock(where string) {
  // DPrintf是src/raft/util.go的一個日誌工具函數,通過修改其Debug值方便選擇是否開啓日誌
  DPrintf("%s lock", where)
  rf.mu.Lock()
}
func (rf *Raft) unlock(where string) {
  DPrintf("%s unlock", where)
  rf.mu.Lock()
}

當然我是沒用到這種技巧,如果你遵循上面原則,並且在Lock的地方都記得Unlock了,基本不會有死鎖(我在做的時候死鎖都是出現在忘記unlock上了。。。)如果打上日誌,在程序死鎖的時候會比較方便排查問題

2.1.2 定時器實現

節點有一段時間收不到Leader的心跳或AE(AppendEntries,下文稱AE)的時候,就會變爲Candidate併發起投票選舉,這是2A中需要實現的,實現這個功能就需要一個定時器,那麼你可以這樣做:

// 設置一個時間值
const CandidateDurationMin = time.Duration(time.Millisecond * 200)
// 初始化定時器
rf.electionTimer = time.NewTimer(CandidateDuration)

// 另外開一個線程進行不斷循環
for !rf.isKilled {

  // 阻塞
  <-rf.electionTimer.C
  // timeout之後往下走

  if rf.isKilled {
    break
  }

  // here 2A...
  // 重新倒計時
  rf.electionTimer.Reset(time.Duration(CandidateDuration))
}

timer的實現是依靠Go中的channel管道來做的,可以理解爲一個阻塞隊列,等你設置的timeout之後就會往阻塞隊列裏面放值, <-rf.electionTimer.C 這行代碼在timeout之前會被阻塞,這樣就實現了定時器的功能。在收到心跳或者AE的時候就像最後一行調用Reset函數那樣重置定時器,這樣就能保證Follower收到RPC就永遠不會發起一個投票選舉。若想馬上開始走定時器邏輯:

rf.electionTimer.Reset(time.Duration(0))

2.1.3 等待RPC建議

一個節點變爲Candidate後,會發起投票選舉,向其餘所有節點發送RPC,此時若獲取到大多數選票(3個節點就只需要獲取到1票,和自己的一票一共兩票)就可以返回並聲明自己是Leader,換句話說,3個節點發送2次RPC的情況下,收到其中一個RPC投票OK的響應,主線程就可以繼續往下做Leader的邏輯了,不需要等待另一個RPC投票響應。那麼這種邏輯怎麼做呢?

WaitGroup

我使用了比較取巧的waitGroup的方式(個人淺顯理解感覺很像Java的CountDownLatch,就直接拿來當CountDownLatch來用了)

var wg sync.WaitGroup
wg.Add(1)

for i := 0; i < peersCount; i++ {

  go func(server int) {
    // RPC
    rf.sendRequestVote(server, &request, &reply);

    // if 大多數ok 或 全部節點RPC都結束
    if reply.xxx {

      defer func() {
        if err := recover(); err != nil {
        }
      }()
      // 如果滿足了大多數,喚醒主線程
      wg.Done()
    }
  }
}(i)
}
// 阻塞主線程,直到得到大多數節點的選票或者全部節點
wg.Wait()

因爲調用Done方法的時候有可能被調用兩次(一次是滿足了大多數,一次是全部RPC return),所以這裏我使用recover方法吞掉異常。。比較取巧。個人會比較建議用下面助教推薦的方式來做,看自己喜好了。

Condition

這個方法也是lecture5裏助教說的方法,類似Java裏的Object#wait()、Object#notify(),主要思路是在主線程for循環一直檢查條件,大多數或全部RPC結束,然後調用wait(),每次goroutine的RPC返回後都調用notifyall() 方法喚醒主線程去檢查條件。這裏不多說,主要看lecture5我記得是第一個助教在說的。

2.1.4 Debug調試建議

做lab的過程中出了問題,我基本都是通過打日誌的方式來調試,不斷在關鍵地方打Log,不斷Run你的Test,到後面2C的時候有幾個測試比較複雜,我建議你在腦子過一下你的實現,review你剛剛寫的代碼是很重要的,我出的大部分bug都是由於代碼粗心,有幾個小錯誤,經過review,在腦子裏跑一下自己的代碼會比較能看出來。如果問題實在複雜,建議查看test源碼,看看test到底以什麼方式跑的。所以總結我用的調試方法有如下三點:

  • 在關鍵地方打日誌看數據變化
  • 在腦子裏跑一遍自己的代碼實現,review你的code
  • 看看 test code 的工作原理,從而明白錯誤爲什麼會產生

2.1.5 其他的小Tips

  • 多看看 http://nil.csail.mit.edu/6.824/2020/labs/lab-raft.html 教授寫的Hint

  • RPC時方法參數的對象struct中的字段要大寫,如果小寫就相當於Java的private,訪問限定會報錯

  • RPC怎麼寫?

    func (rf *Raft) sendAppendEntries(server int, args *AppendEntriesArgs, reply *AppendEntriesReply) bool {
       // Call方法第一個參數是RPC接收方會被調用的方法
       ok := rf.peers[server].Call("Raft.AppendEntries", args, reply)
       return ok
    }
    // RPC會被調用到這裏來
    func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
      // ...
    }
    
  • test會實現RPC超時的邏輯,一般來說你不需要去實現超時判斷,除非你會想要精確控制RPC超時時間,那麼你可以利用channel和select去做:

    // channel,相當於阻塞隊列裏面是布爾值
    rstChan := make(chan bool)
    ok := false
    // 不阻塞,在下面select中阻塞
    go func() {
      rst := rf.peers[server].Call("Raft.AppendEntries", args, reply)
      // 返回的結果給到channel阻塞隊列
      rstChan <- rst
    }()
    // select會輪詢(應該是?不是特別瞭解)
    // 直到兩個channel哪個ok就return,如果timer.C的channel先return了,就是超時了
    // 同時返回ok的默認值false,表示RPC超時,請求失敗
    select {
      case ok = <-rstChan:
    	case <-time.After(TimeoutDuration):
      }
    return ok
    }
    
  • 隨機數怎麼做?

    // 以rf.me做隨機種子,保證每個節點種子不一樣,足夠隨機
    rf.random = rand.New(rand.NewSource(time.Now().UnixNano() + int64(rf.me)))
    // 獲取隨機值
    randomVal := rf.random.Intn(200)
    

2.2 Lab2B

2.2.1 提交log

在入口方法 Make方法中有一個channel參數爲 applyCh chan ApplyMsg ,將被視作已提交的日誌放到這個channel中

msg := ApplyMsg{
  CommandValid: true,
  Command:      logEntry.Command,
  CommandIndex: logEntry.Index,
}
// 提交log
applyCh <- msg

new一個ApplyMsg對象然後put到channel中,這樣test纔會知道這個節點的這段日誌被提交了。

其中Leader會確保日誌在半數以上節點被複制完成,纔會提交這條log到channel,然後更新自己的commitIndex,表示這條日誌被提交,隨着AE心跳或者日誌複製,leader會告訴follower這條日誌被提交,然後follower也需要做一個提交動作,將leader告訴自己的這條log提交到channel中。

我曾經以爲只需要在leader中put channel就可以了,但這樣test會認爲你的follower這條日誌沒有被提交,某些test需要檢測日誌在所有節點已經被提交,從而無法pass test。所以注意follower也需要put log到channel

2.2.2 兩個優化建議

加速日誌同步

在下面幾個測試中節點會大量的宕機,日誌會大量的亂序,當follower從宕機中恢復,需要與leader通信日誌Index,此時就需要同步日誌,將follower日誌與leader日誌同步起來,此時follower需要找到最後一個與leader一樣的日誌(相同Index處的log的Term也相同被視作相同的日誌),從這條日誌往後開始進行復制,也就是paper中提到的prevLogIndex、prevLogTerm的作用,笨方法是leader與follower一條一條從最後往前開始對比日誌哪條一樣,但如果日誌比較長,會造成有一條不同的日誌就需要一次RPC,非常耗時,你需要優化加速這一過程,不是每條日誌都進行比較,而是會跨過整個Term進行比較。

至於優化方式爲了lab效果這裏不多聊。可以參考lecture7教授會講到3個case,和助教的blog中的 An aside on optimizations 也有提到。

批量提交日誌

這條的必要性有待考究,我在做lab的時候潛意識就將實現做成批量提交的方式,所以不知道這項優化是否會影響test,個人建議還是做成批量提交的比較好。下面的某些test的log有可能會達到幾百幾千條,如果一條一條日誌慢慢提交,慢慢check大多數條件然後apply,個人感覺會比較慢,而每個test都有時間限制,也出於自己對代碼的嚴格要求,不將就,建議做成批量提交的比較優雅。

2.3 Lab2C

lab中持久化不是持久化到硬盤,而是將數據Encode之後變爲byte數組存在內存中。服務器重啓之後會讀取這部分內存中的byte數組到Raft實例這部分內存中使用。code骨架中有持久化例子,在persist和readPersist方法中,分別有Decode與Encode的方式與事先準備好的持久化方法 rf.persister.SaveRaftState(data)

這個lab的目標外表看上去是持久化(我做到2C時已經覺得做完了lab,覺得2C不會花多少時間,實際上這部分出現的BUG是我調試最久的。。),但其實重點難點並不在持久化怎麼做,而是在什麼時機持久化,以及更有挑戰性的test,大概率會導致你的leader選舉與AE日誌複製出現BUG,所以2C在我看來是對日誌複製與Leader選舉更大的挑戰,如果你沒做好上面一節說的優化建議,很有可能無法PASS 2C的test。所以這裏我沒什麼好建議給你,加油幹就完事了,堅持不懈不要被BUG擊退。

分佈式一致性算法中相對於Paxos,Raft還是比較簡單易懂的,實現一個Raft對於學習分佈式一致性還是很有幫助的,Raft真正的難點在於工業級的優化,論文中只是教你如何實現,但粗略的實現在生產環境上性能並不是那麼的理想,所以優化是難點也是一個重點。對優化感興趣的讀者可以參考Raft的工業級實現比如etcd

3. 最後

如果你完成了lab,請不要將你的lab上傳到例如GitHub這樣的公開代碼庫,如果學習lab的同學直接參考源碼那學習效果將會大打折扣。同時這也是MIT6.824的教授Morris所要求的,如果要上傳到代碼庫給特定的人蔘考或是其他用途,最好將倉庫設置爲Private權限訪問。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章