2022-6.824-Lab2:Raft

0. 準備工作

lab 地址:https://pdos.csail.mit.edu/6.824/labs/lab-raft.html
github 地址:https://github.com/lawliet9712/MIT-6.824
論文翻譯地址:https://blog.csdn.net/Hedon954/article/details/119186225

Raft 的目標是在多個節點上維護一致的操作日誌。爲此,Raft 會首先在系統內自動選出一個 leader 節點,並由這個 leader 節點負責維護系統內所有節點上的操作日誌的一致性。Leader 節點將負責接收用戶的請求(一個請求即爲一條對 RSM 的操作日誌),將用戶請求中攜帶的操作日誌 replicate 到系統內的各個節點上,並擇機告訴系統內各個節點將操作日誌中的操作序列應用到各個節點中的狀態機上。

閱讀兩篇論文,其中 GFS 是 raft 分佈式算法的一種實踐。分佈式一致算法通常需要保證如下的幾個屬性:

  • 安全性(safety):在不出現 non-Byzantine 的條件下,系統不應該返回不正確的結果。Non-Byzantine 條件主要包括網絡上存在延遲、網絡上存在丟包現象等。
  • 可用性(Availability):當多數節點仍然處於正常工作狀態時,整個系統應該是可用的。多數節點指超過系統中節點總數一半的節點。
  • 不依賴時間來保證系統一致性。
  • 少數的運行速度遲緩的節點不會拖慢整個系統的性能。

lab2 分爲 4 個部分,2a, 2b, 2c, 2d,依次進行分析。

1. Part 2A: leader election (moderate)

第一個 part 主要實現一個 leader election 的機制,即在大多數情況下,這裏涉及到 3 種角色,初始情況下都爲 follower

  • leader
  • candidate
  • follower

其狀態機流轉如下圖:
image.png
目前該 part 涉及到 2 個 rpc:

  • RequestAppendEntries :leader 節點 -> 其他節點,目前功能僅做心跳使用
  • RequestVote :candidate 節點 -> 其他節點,candidate 成爲 leader 的前置步驟,只有當大多數節點(> 1/2) rpc 結果返回成功時,才能成爲 leader

由於該結構下,通常只會存在一個 leader 和多個 follower ,leader 單點顯然是非常不安全的,因此該 part 需要保證在 節點異常 情況下也能夠選舉出新的 leader。

1.1 分析

Leader election 的邏輯大致如下:

  1. 最開始所有節點都是 follower
  2. follower 【超時時間】內沒有收到任何消息就會轉變爲 candidate,然後自增任期 term,任期 term 的作用用來保證該任期內,節點只會給一個 candidate 投票。
  3. candidate 會發送 RequestVote rpc 調用給其他節點
  4. 當 rpc 調用中結果大部分爲成功,則 candidate 轉變爲 leader
  5. leader 需要定時(100ms)發送心跳給所有節點

關於節點角色的轉變,主要有如下情況:

  • follower
    • -> candidate :【超時時間】內沒有收到任何 RPC
  • candidate
    • -> follwer :
      • 收到 leader 的心跳,並且 leader 的 term >= 當前角色的 term
      • 收到任意 RPC,RPC 參數中的 term 大於當前角色的 term
    • -> leader :
      • 發送給其他節點的 RequestVote 大部分(大於 1/2)都成功了
  • leader
    • -> follwer
      • 收到任意 RPC,RPC 參數中的 term 大於當前角色的 term

1.2 實現

最初版的實現方式非常樸素,直接在 ticker 中循環,根據當前角色處理不同邏輯,只用到了 goroutine 。

1.2.1 Ticker

核心的 Ticker 實現如下:

// The ticker go routine starts a new election if this peer hasn't received
// heartsbeats recently.
func (rf *Raft) ticker() {
    for rf.killed() == false {
        // Your code here to check if a leader election should
        // be started and to randomize sleeping time using
        // time.Sleep().
        select {
        case <-rf.heartbeatTimer.C:
            rf.mu.Lock()
            if rf.currentRole == ROLE_Leader {
                rf.SendAppendEntries()
                rf.heartbeatTimer.Reset(100 * time.Millisecond)
            }
            rf.mu.Unlock()

        case <-rf.electionTimer.C:
            rf.mu.Lock()
            switch rf.currentRole {
            case ROLE_Candidate:
                rf.StartElection()
            case ROLE_Follwer:
                // 2B 這裏直接進行選舉,防止出現:
                /* leader 1 follwer 2 follwer 3
                1. follwer 3 長期 disconnect, term 一直自增進行 election
                2. leader 1 和 follower 2 一直在同步 log
                3. 由於 leader restriction, leader 1 和 follwer 2 的 log index 比 3 要長
                4. 此時 follwer 3 reconnect,leader 1 和 follwer 2 都轉爲 follwer,然後由於一直沒有 leader,會心跳超時,轉爲 candidate
                5. 此時會出現如下情況:
                    5.1 [3] 發送 vote rpc 給 [1] 和 [2]
                    5.2 [1] 和 [2] 發現 term 比自己的要高,先轉換爲 follwer,並修改 term,等待 election timeout 後開始 election
                    5.3 [3] 發送完之後發現失敗了,等待 election timeout 後再重新進行 election
                    5.4 此時 [3] 會比 [1] 和 [2] 更早進入 election([2]和[3]要接收到 rpc 並且處理完纔會等待 eletcion,而 [1] 基本發出去之後就進行等待了)
                */
                rf.SwitchRole(ROLE_Candidate)
                rf.StartElection()
            }
            rf.mu.Unlock()
        }
    }
}

邏輯比較簡單,follwer 和 candidate 的 sleep time 採用隨機 300 ~ 450 ms 。

1.2.1 Leader 處理

任意角色,其主要需要處理的爲兩件事:

  • 處理當前角色應當做的事情
  • 檢查當前是否觸發了角色轉變的條件

leader 主要的任務就是發送心跳,並且檢查目標節點的 Term,當前 Term小於目標節點的 Term 時,Leader 轉變爲 Follwer。

func (rf *Raft) SendHeartbeat() {
    for server, _ := range rf.peers {
        if server == rf.me {
            continue
        }
        go func(server int) {
            args := RequestAppendEntriesArgs{}
            reply := RequestAppendEntriesReply{}
            rf.mu.Lock()
            args.Term = rf.currentTerm
            rf.mu.Unlock()
            ok := rf.sendRequestAppendEntries(server, &args, &reply)
            if (!ok) {
                //fmt.Printf("[SendHeartbeat] id=%d send heartbeat to %d failed \n", rf.me, server)
                return
            }
           
            rf.mu.Lock()
            if (reply.Term > args.Term) {
                rf.switchRole(ROLE_Follwer)
                rf.currentTerm = reply.Term
                rf.votedFor = -1
            }
            rf.mu.Unlock()
        }(server)
    }
}

1.2.2 Candidate 處理

Candidate 的處理比較冗長一些,但是核心其實是一件事,發送 RequestVote rpc 給其他節點求票。

func (rf *Raft) StartElection() {
    /* 每一個 election time 收集一次 vote,直到:
    1. leader 出現,heart beat 會切換當前狀態
    2. 自己成爲 leader
    */
    rf.mu.Lock()
    // 重置票數和超時時間
    rf.currentTerm += 1
    rf.votedCnt = 1
    rf.votedFor = rf.me
    //fmt.Printf("[StartElection] id=%d role=%d term=%d Start Election ... \n", rf.me, rf.currentRole, rf.currentTerm)
    rf.mu.Unlock()
    // 集票階段
    for server, _ := range rf.peers {
        if server == rf.me {
            continue
        }

        // 由於 sendRpc 會阻塞,所以這裏選擇新啓動 goroutine 去 sendRPC,不阻塞當前協程
        go func(server int) {
            rf.mu.Lock()
            //fmt.Printf("[StartElection] id %d role %d term %d send vote req to %d\n", rf.me, rf.currentRole, rf.currentTerm, server)
            args := RequestVoteArgs{
                Term:        rf.currentTerm,
                CandidateId: rf.me,
            }
            reply := RequestVoteReply{}
            rf.mu.Unlock()
            ok := rf.sendRequestVote(server, &args, &reply)
            if !ok {
                //fmt.Printf("[StartElection] id=%d request %d vote failed ...\n", rf.me, server)
                return
            } else {
                //fmt.Printf("[StartElection] %d send vote req succ to %d\n", rf.me, server)
            }

            rf.mu.Lock()
            if reply.Term > rf.currentTerm {
                rf.switchRole(ROLE_Follwer)
                rf.currentTerm = reply.Term
                rf.votedFor = -1
                rf.mu.Unlock()
                return
            }

            if reply.VoteGranted {
                rf.votedCnt = rf.votedCnt + 1
            }
            votedCnt := rf.votedCnt
            currentRole := rf.currentRole
            rf.mu.Unlock()

            if votedCnt*2 >= len(rf.peers){
                // 這裏有可能處理 rpc 的時候,收到 rpc,變成了 follower,所以再校驗一遍
                rf.mu.Lock()
                if rf.currentRole == ROLE_Candidate {
                    //fmt.Printf("[StartElection] id=%d election succ, votecnt %d \n", rf.me, votedCnt)
                    rf.switchRole(ROLE_Leader)
                    currentRole = rf.currentRole
                }
                rf.mu.Unlock()
                if (currentRole == ROLE_Leader) {
                    rf.SendHeartbeat() // 先主動 send heart beat 一次
                }
            }
        }(server)
    }
}

1.2.3 Follwer 處理

follwer 的處理比較簡單

func (rf *Raft) CheckHeartbeat() {
    // 指定時間沒有收到 Heartbeat
    rf.mu.Lock()
    if rf.heartbeatFlag != 1 {
        // 開始新的 election, 切換狀態
        // [follwer -> candidate] 1. 心跳超時,進入 election
        //fmt.Printf("[CheckHeartbeat] id=%d role=%d term=%d not recived heart beat ... \n", rf.me, rf.currentRole, rf.currentTerm)
        rf.switchRole(ROLE_Candidate)
    }
    rf.heartbeatFlag = 0 // 每次重置 heartbeat 標記
    rf.mu.Unlock()
}

1.2.4 RequestVote

接下來是收到收集票的請求時的處理,這裏需要注意,相同任期內如果有多個 candidate,對於 follwer 來說,是先到先服務,後到者理論上無法獲取到票,這是通過 votedFor 字段保證的。

func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
    // Your code here (2A, 2B).
    rf.mu.Lock()
    //fmt.Printf("id=%d role=%d term=%d recived vote request \n", rf.me, rf.currentRole, rf.currentTerm)

    rf.heartbeatFlag = 1
    // 新的任期,重置下投票權
    if rf.currentTerm < args.Term {
        rf.switchRole(ROLE_Follwer)
        rf.currentTerm = args.Term
        rf.votedFor = -1
    }

    switch rf.currentRole {
    case ROLE_Follwer:
        if rf.votedFor == -1 {
            rf.votedFor = args.CandidateId
            reply.VoteGranted = true
        } else {
            reply.VoteGranted = false
        }
    case ROLE_Candidate, ROLE_Leader:
        reply.VoteGranted = false
    }

    reply.Term = rf.currentTerm
    rf.mu.Unlock()
}

1.2.5 RequestAppendEntries

目前來說,該 rpc 主要作爲心跳處理

func (rf *Raft) RequestAppendEntries(args *RequestAppendEntriesArgs, reply *RequestAppendEntriesReply) {
    /*
        0. 優先處理
        如果 args.term > currentTerm ,則直接轉爲 follwer, 更新當前 currentTerm = args.term
        1. candidate
        相同任期內,收到心跳,則轉變爲 follwer
        2. follwer
        需要更新 election time out
        3. leader
        無需處理
    */
    rf.mu.Lock()
    if rf.currentTerm < args.Term {
        rf.switchRole(ROLE_Follwer)
        rf.currentTerm = args.Term
        rf.votedFor = -1
        rf.heartbeatFlag = 1
    }

    // 正常情況下,重置 election time out 時間即可
    if rf.currentRole == ROLE_Follwer {
        rf.heartbeatFlag = 1
    } else if (rf.currentRole == ROLE_Candidate && rf.currentTerm == args.Term) {
        rf.switchRole(ROLE_Follwer)
        rf.currentTerm = args.Term
        rf.votedFor = -1
        rf.heartbeatFlag = 1
    }
    reply.Term = rf.currentTerm
    rf.mu.Unlock()
}

1.2.6 一些輔助函數和結構定義

// serverRole
type ServerRole int
const (
    ROLE_Follwer   ServerRole = 1
    ROLE_Candidate ServerRole = 2
    ROLE_Leader    ServerRole = 3
)
// A Go object implementing a single Raft peer.
type Raft struct {
    mu        sync.Mutex          // Lock to protect shared access to this peer's state
    peers     []*labrpc.ClientEnd // RPC end points of all peers
    persister *Persister          // Object to hold this peer's persisted state
    me        int                 // this peer's index into peers[]
    dead      int32               // set by Kill()

    // Your data here (2A, 2B, 2C).
    // Look at the paper's Figure 2 for a description of what
    // state a Raft server must maintain.
    currentTerm     int
    votedFor        int
    currentRole     ServerRole
    heartbeatFlag   int // follwer sleep 期間
    votedCnt        int
}

/********** RPC  *************/
type RequestVoteArgs struct {
    // Your data here (2A, 2B).
    Term        int // candidate's term
    CandidateId int // candidate global only id
}

type RequestVoteReply struct {
    // Your data here (2A).
    Term        int  // Term id
    VoteGranted bool // true 表示拿到票了
}

type RequestAppendEntriesArgs struct {
    Term int
}

type RequestAppendEntriesReply struct {
    Term int
}
/********** RPC  *************/

// 獲取下次超時時間
func getRandomTimeout() int64 {
    // 150 ~ 300 ms 的誤差
    return (rand.Int63n(150) + 150) * 1000000
}

// 獲取當前時間
func getCurrentTime() int64 {
    return time.Now().UnixNano()
}

// 切換 role
func (rf *Raft) switchRole(role ServerRole) {
    if role == rf.currentRole {
        return
    }
    //fmt.Printf("[SwitchRole] id=%d role=%d term=%d change to %d \n", rf.me, rf.currentRole, rf.currentTerm, role)
    rf.currentRole = role
    if (rf.currentRole == ROLE_Follwer) {
        rf.votedFor = -1
    }
}

1.3 select + channel 版實現

網上還有另一種實現方式,通過 select + channel 的方式,這裏不多贅述,可以參考 https://www.cnblogs.com/mignet/p/6824_Lab_2_Raft_2A.html

1.4 小結

完成該 part 的時候遇到了一些坑,主要還是之前沒怎麼寫過多線程相關的 code ,個人感覺有如下的點需要注意:

  1. 臨界區要儘可能短,像 sleep 或者 sendRPC 都沒有必要 lock ,否則可能出現死鎖。有如下例子:

2 個 process p1 p2
每個 process 都有 2 個 goroutine,g1 g2
g1 是一個 ticker , g2 是專門處理 rpc(實際上是每來一個 rpc 就開個 goroutine,就當做有一個固定的 goroutine 在處理 rpc 好了),這兩個在執行的時候都會有一小段臨界區持有 mutex
p1 的 ticker g1 先操作:

  1. mutex.lock
  2. send rpc to p2
  3. mutex.unlock
    p2 收到 rpc 的時候,可能正 執行 ticker,此時 g2 會暫時 lock,等 g1 ticker 釋放 lock
    p2 的 g1 ticker 這個時候有可能操作
  4. mutex.lock
  5. send rpc to p1
  6. mutex.unlock
    由於 p1 正在等 p2 處理 rpc,會阻塞(這裏 send rpc 是阻塞的),所以 p1 的 g2 無法處理 rpc
    這裏就循環依賴死鎖了
  1. lab 中 sendRpc 是阻塞的,因此儘量使用 goroutine 去執行該操作
  2. 對於角色轉換,有一條優先規則是:假設有節點 a 和節點 b 在 rpc 通信,發現節點 a 的 term < 節點 b 的 term,節點 a 無論是什麼 role,都需要轉換爲 follwer。
  3. 爲了防止同時多個節點進行 election,因此在設置 election timeout 的時候會加上 150 ~ 300 ms 的誤差,儘量拉開不同節點的 election 時間。
  4. 爲了防止出現多個 leader,同個 term 的 follwer 只有一票,一個 term 的 follwer 只能給一個 candidate 投票。票數刷新的時機在於:當前 term 發生轉換

關於 term,論文中也有一張圖描述的比較貼切,每次 term 的遞增總是伴隨着新的 election 開始。
image.png

2. Part 2B: log (hard)

第二個 Part 主要實現 log replication 機制,log 可以理解爲一個 command ,也就是要將 log 從 leader 同步到各個 follwer 中。參考下圖,當 leader 收到一個 log 的時候,需要將其【複製】到大部分節點上,當大部分節點都【複製】完畢 log 的時候,需要將其進行 【提交】,可以理解爲類似寫 DB 的操作,因此 log replication 的流程大致可以分爲 3 步:

  • leader 收到 log
  • leader 【複製】 log
  • leader 確認當前是否大多數節點都已經收到 log
  • leader 【提交】 log,並通知大多數節點提交 log

image.png

2.1 分析

2.1.1 複製與提交

首先需要完成最基本的【複製】和【提交】功能。這兩個功能都是通過 AppendEntries RPC 實現的,可以再閱讀下 論文 Figure 2 中該 RPC 接口的描述

  • 【複製】簡單來說就是將 leader 收到的新的 log entries 通過 rpc 發送給其他 follwer
  • 【提交】就是將已經同步給大多數節點的 log entries 給 apply 到 applyCh 這個 channel 當中,需要注意的是,leader 和 follwer 都需要 apply。

image.png
Arguments 中 entries[] 字段是需要複製給其他節點的 log,而 leaderCommit 是 leader 當前已經 commit 到了第幾個 log。有了這兩個字段 follwer 就能知道當前應該提交到第幾個 log,並且當前應該新增哪些 log。
對於 leader 來說,他需要知道每個節點當前 log 的複製情況以及提交情況。從而知道該同步哪些 log 給 follwer,因此 leader 需要新增如下字段:
image.png
nextIndex[] 記錄每個 follwer 下一個應該複製的 log,因此 AppendEntriesentries 字段實際上就是 [nextIndex[follwer.id], leader.log[len(leader.log)] 這個區間內的 log
對於每個節點,需要記錄當前提交的情況
image.png

2.1.2 衝突

前面實現了基本的 【複製】 與 【提交】功能之後,需要考慮節點各種異常的情況,如下圖:
image.png
正常操作期間,leader 和 follower 的日誌都是保持一致的,所以 AppendEntries 的一致性檢查從來不會失敗。但是,如果 leader 崩潰了,那麼就有可能會造成日誌處於不一致的狀態,比如說老的 leader 可能還沒有完全複製它日誌中的所有條目它就崩潰了。這些不一致的情況會在一系列的 leader 和 follower 崩潰的情況下加劇。爲了解決以上衝突,raft 會強制 leader 覆蓋 follwer 的日誌 。
爲了對比衝突,leader -> follwer 的 RPC 增加了兩個參數 prevLogIndexprevLogTerm
image.png
這兩個參數含義爲當前 leader 認爲對端 follwer 目前最後一條日誌的 Index 和 Term(根據 leader 專門記錄的 nextIndex[] ),follwer 接收到這兩個參數後,如果發現不一致,則返回 success = false,leader 檢查到這種情況後,會修改 nextIndex[]

2.1.3 安全性

有一種情況,一個 follower 可能會進入不可用狀態,在此期間,leader 可能提交了若干的日誌條目,然後這個 follower 可能被選舉爲新的 leader 並且用新的日誌條目去覆蓋這些【已提交】的日誌條目。這樣就會造成不同的狀態機執行不同的指令的情況。爲了防止這種情況,raft 增加了一個叫 **leader restriction **的機制:

  • 對於給定的任意任期號,該任期號對應的 leader 都包含了之前各個任期所有被提交的日誌條目
  • 一個 candidate 如果想要被選爲 leader,那它就必須跟集羣中超過半數的節點進行通信,這就意味這些節點中至少一個包含了所有已經提交的日誌條目。如果 candidate 的日誌至少跟過半的服務器節點一樣新,那麼它就一定包含了所有以及提交的日誌條目,一旦有投票者自己的日誌比 candidate 的還新,那麼這個投票者就會拒絕該投票,該 candidate 也就不會贏得選舉。

所謂 “新” :
Raft 通過比較兩份日誌中的最後一條日誌條目的索引和任期號來定義誰的日誌更新。

  • 如果兩份日誌最後條目的任期號不同,那麼任期號大的日誌更新
  • 如果兩份日誌最後條目的任期號相同,那麼誰的日誌更長(LogIndex 更大),誰就更新

2.2 實現

2.2.1 leader 同步日誌到 follwer

這一步主要處理三個事情:

  1. 根據記錄的 nextIndex[] ,給 follwer 同步日誌
  2. 檢查日誌是否已經同步到大多數 follwer,是則進行提交操作
  3. 更新 matchIndex[]nextIndex[]
func (rf *Raft) SendAppendEntries() {
    for server := range rf.peers {
        if server == rf.me {
            continue
        }
        go func(server int) {
            args := AppendEntriesArgs{}
            reply := AppendEntriesReply{}

            // 1. check if need replicate log
            rf.mu.Lock()
            if rf.currentRole != ROLE_Leader {
                rf.mu.Unlock()
                return
            }
            args.Term = rf.currentTerm
            args.LeaderCommit = rf.commitIndex
            args.LeaderId = rf.me
            args.PrevLogIndex = rf.nextIndex[server] - 1
            args.PrevLogTerm = rf.log[args.PrevLogIndex].Term
            // 意味着有日誌還沒被 commit
            if len(rf.log) != rf.matchIndex[server] {
                for i := rf.nextIndex[server]; i <= len(rf.log); i++ { // log 的 index 從 1 開始
                    args.Entries = append(args.Entries, rf.log[i])
                }
            }
            rf.mu.Unlock()
            ////fmt.Printf("%d send heartbeat %d , matchIndex=%v nextIndex=%v\n", rf.me, server, rf.matchIndex, rf.nextIndex)
            ok := rf.sendAppendEntries(server, &args, &reply)
            if !ok {
                ////fmt.Printf("[SendAppendEntries] id=%d send heartbeat to %d failed \n", rf.me, server)
                return
            }

            rf.mu.Lock()
            defer rf.mu.Unlock()
            if reply.Term > args.Term {
                rf.SwitchRole(ROLE_Follwer)
                rf.currentTerm = reply.Term
                rf.persist()
                return
            }

            if rf.currentRole != ROLE_Leader || rf.currentTerm != args.Term {
                return
            }

            // 如果同步日誌失敗,則將 nextIndex - 1,下次心跳重試
            if !reply.Success {
                rf.nextIndex[server] = reply.ConflictIndex

                // if term found, override it to
                // the first entry after entries in ConflictTerm
                if reply.ConflictTerm != -1 {
                    for i := args.PrevLogIndex; i >= 1; i-- {
                        if rf.log[i].Term == reply.ConflictTerm {
                            // in next trial, check if log entries in ConflictTerm matches
                            rf.nextIndex[server] = i
                            break
                        }
                    }
                }
            } else {
                // 1. 如果同步日誌成功,則增加 nextIndex && matchIndex
                rf.nextIndex[server] = args.PrevLogIndex + len(args.Entries) + 1
                rf.matchIndex[server] = rf.nextIndex[server] - 1
                ////fmt.Printf("%d replicate log to %d succ , matchIndex=%v nextIndex=%v\n", rf.me, server, rf.matchIndex, rf.nextIndex)
                // 2. 檢查是否可以提交,檢查 rf.commitIndex
                for N := len(rf.log); N > rf.commitIndex; N-- {
                    if rf.log[N].Term != rf.currentTerm {
                        continue
                    }

                    matchCnt := 1
                    for j := 0; j < len(rf.matchIndex); j++ {
                        if rf.matchIndex[j] >= N {
                            matchCnt += 1
                        }
                    }
                    //fmt.Printf("%d matchCnt=%d\n", rf.me, matchCnt)
                    // a. 票數 > 1/2 則能夠提交
                    if matchCnt*2 > len(rf.matchIndex) {
                        rf.setCommitIndex(N)
                        break
                    }
                }
            }

        }(server)
    }
}

2.2.2 follwer 接收日誌

這一步主要處理兩個事情:

  1. 檢查日誌是否存在衝突,衝突則返回失敗,等待 leader 調整發送的日誌,最後從衝突位置開始使用 leader 的日誌覆蓋衝突日誌
  2. 檢查日誌是否可以提交
func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
    /*
        part 2A 處理心跳
        0. 優先處理
        如果 args.term > currentTerm ,則直接轉爲 follwer, 更新當前 currentTerm = args.term
        1. candidate
        無需處理
        2. follwer
        需要更新 election time out
        3. leader
        無需處理
        part 2B 處理日誌複製
        1. [先檢查之前的]先獲取 local log[args.PrevLogIndex] 的 term , 檢查是否與 args.PrevLogTerm 相同,不同表示有衝突,直接返回失敗
        2. [在檢查當前的]遍歷 args.Entries,檢查 3 種情況
            a. 當前是否已經有了該日誌,如果有了該日誌,且一致,檢查下一個日誌
            b. 當前是否與該日誌衝突,有衝突,則從衝突位置開始,刪除 local log [conflict ~ end] 的 日誌
            c. 如果沒有日誌,則直接追加

    */
    // 1. Prev Check
    rf.mu.Lock()
    defer rf.mu.Unlock()
    defer rf.persist()
    if rf.currentTerm > args.Term {
        reply.Success = false
        reply.Term = rf.currentTerm
        return
    }

    if rf.currentTerm < args.Term {
        rf.SwitchRole(ROLE_Follwer)
        rf.currentTerm = args.Term
    }
    ////fmt.Printf("[ReciveAppendEntires] %d electionTimer reset %v\n", rf.me, getCurrentTime())
    rf.electionTimer.Reset(getRandomTimeout())
    reply.Term = rf.currentTerm
    // 1. [先檢查之前的]先獲取 local log[args.PrevLogIndex] 的 term , 檢查是否與 args.PrevLogTerm 相同,不同表示有衝突,直接返回失敗
    /* 有 3 種可能:
    a. 找不到 PrevLog ,直接返回失敗
    b. 找到 PrevLog, 但是衝突,直接返回失敗
    c. 找到 PrevLog,不衝突,進行下一步同步日誌
    */
    // a
    lastLogIndex := len(rf.log)
    if lastLogIndex < args.PrevLogIndex {
        reply.Success = false
        reply.Term = rf.currentTerm
        // optimistically thinks receiver's log matches with Leader's as a subset
        reply.ConflictIndex = len(rf.log) + 1
        // no conflict term
        reply.ConflictTerm = -1
        return
    }

    // b. If an existing entry conflicts with a new one (same index
    // but different terms), delete the existing entry and all that
    // follow it (§5.3)
    if rf.log[(args.PrevLogIndex)].Term != args.PrevLogTerm {
        reply.Success = false
        reply.Term = rf.currentTerm
        // receiver's log in certain term unmatches Leader's log
        reply.ConflictTerm = rf.log[args.PrevLogIndex].Term

        // expecting Leader to check the former term
        // so set ConflictIndex to the first one of entries in ConflictTerm
        conflictIndex := args.PrevLogIndex
        // apparently, since rf.log[0] are ensured to match among all servers
        // ConflictIndex must be > 0, safe to minus 1
        for rf.log[conflictIndex-1].Term == reply.ConflictTerm {
            conflictIndex--
        }
        reply.ConflictIndex = conflictIndex
        return
    }

    // c. Append any new entries not already in the log
    // compare from rf.log[args.PrevLogIndex + 1]
    unmatch_idx := -1
    for i := 0; i < len(args.Entries); i++ {
        index := args.Entries[i].Index
        if len(rf.log) < index || rf.log[index].Term != args.Entries[i].Term {
            unmatch_idx = i
            break
        }
    }

    if unmatch_idx != -1 {
        // there are unmatch entries
        // truncate unmatch Follower entries, and apply Leader entries
        // 1. append leader 的 Entry
        for i := unmatch_idx; i < len(args.Entries); i++ {
            rf.log[args.Entries[i].Index] = args.Entries[i]
        }
    }

    // 3. 持久化提交
    if args.LeaderCommit > rf.commitIndex {
        commitIndex := args.LeaderCommit
        if commitIndex > len(rf.log) {
            commitIndex = len(rf.log)
        }
        rf.setCommitIndex(commitIndex)
    }
    reply.Success = true
}

2.2.3 candidate 選舉限制

這一步主要就是 2.1.3 中的安全性限制,防止出現 follower 可能被選舉爲新的 leader 並且用新的日誌條目去覆蓋這些【已提交】的日誌條目的情況,因此在 RequestVote 階段進行限制(27 ~ 32 行)

func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
    // Your code here (2A, 2B).
    rf.mu.Lock()
    defer rf.mu.Unlock()
    defer rf.persist()
    //fmt.Printf("id=%d role=%d term=%d recived vote request %v\n", rf.me, rf.currentRole, rf.currentTerm, args)

    reply.Term = rf.currentTerm
    if rf.currentTerm > args.Term ||
        (args.Term == rf.currentTerm && rf.votedFor != -1 && rf.votedFor != args.CandidateId) {
        reply.VoteGranted = false
        return
    }

    rf.electionTimer.Reset(getRandomTimeout())
    // 新的任期,重置下投票權
    if rf.currentTerm < args.Term {
        rf.SwitchRole(ROLE_Follwer)
        rf.currentTerm = args.Term
    }

    // 2B Leader restriction,拒絕比較舊的投票(優先看任期)
    // 1. 任期號不同,則任期號大的比較新
    // 2. 任期號相同,索引值大的(日誌較長的)比較新
    lastLog := rf.log[len(rf.log)]
    if (args.LastLogIndex < lastLog.Index && args.LastLogTerm == lastLog.Term) || args.LastLogTerm < lastLog.Term {
        //fmt.Printf("[RequestVote] %v not vaild, %d reject vote request\n", args, rf.me)
        reply.VoteGranted = false
        return
    }

    rf.votedFor = args.CandidateId
    reply.VoteGranted = true
}

2.3 小結

這裏主要需要注意:

  1. leader 需要維護好 matchIndex[] (表示 follwer 的 commit 情況)和 nextIndex[] (表示 follwer 的 log replication 情況),leader 崩潰後也需要正常重新初始化好這兩個數組。
  2. follwer 接收到日誌後,需要注意是否存在衝突,通過檢查 RPC 中 leader 認爲的當前 follwer 的 prevLogIndexprevLogTerm 來判斷日誌是否存在衝突,需要 leader 將 follwer 的日誌從衝突部分開始強行覆蓋
  3. 新的 leader 的日誌需要確保擁有所有已經 commit 的 log,其次一個 follower 可能會進入不可用狀態,在此期間,leader 可能提交了若干的日誌條目,可能出現 follower 被選舉爲新的 leader 並且用新的日誌條目去覆蓋這些【已提交】的日誌條目的情況,candidate 選舉時需要增加 leader restriction 機制,即 follwer 只給持有的最後一條日誌比自己新的 candidate 的投票,新的定義如下:
  • 任期號不同,則任期號大的比較新
  • 任期號相同,索引值大的(日誌較長的)比較新

3. Part 2C: persistence (hard)

這一 part 主要做的事情是在 node crash 後能保證恢復一些狀態,簡單來說就是實現 persist()readPersist() 函數,一個保存 raft 的狀態,另一個在 raft 啓動時恢復之前保存的數據。

參考資料:
lab2C 代碼 :https://www.cnblogs.com/mignet/p/6824_Lab_2_Raft_2C.html
Figure 8 解讀:https://zhuanlan.zhihu.com/p/369989974
lab2C bug:https://www.jianshu.com/p/59a224fded77?ivk_sa=1024320u
lab2C test case : https://cloud.tencent.com/developer/article/1193877
幽靈復現問題:https://mp.weixin.qq.com/s?__biz=MzIzOTU0NTQ0MA==&mid=2247494453&idx=1&sn=17b8a97fe9490d94e14b6a0583222837&scene=21#wechat_redirect

3.1 分析

3.1.1 保存狀態

首先需要確定要保存 raft 哪些字段,通過 Raft-Extended 的 Figure 2 可以看到,已經註明了三個保存的字段:currentTerm,votedFor,log[]
image.png

3.1.2 保存時機

在字段值修改的時候進行 persist() 操作即可

3.2 實現

3.2.1 persist

//
// save Raft's persistent state to stable storage,
// where it can later be retrieved after a crash and restart.
// see paper's Figure 2 for a description of what should be persistent.
//
func (rf *Raft) persist() {
    // Your code here (2C).
    w := new(bytes.Buffer)
    e := labgob.NewEncoder(w)
    e.Encode(rf.currentTerm)
    e.Encode(rf.votedFor)
    e.Encode(rf.log)
    data := w.Bytes()
    rf.persister.SaveRaftState(data)
}

3.2.2 readPersist

//
// restore previously persisted state.
//
func (rf *Raft) readPersist(data []byte) {
    if data == nil || len(data) < 1 { // bootstrap without any state?
        return
    }
    // Your code here (2C).
    r := bytes.NewBuffer(data)
    d := labgob.NewDecoder(r)
    var currentTerm int
    var votedFor int
    var log map[int]LogEntry
    if d.Decode(&currentTerm) != nil ||
        d.Decode(&votedFor) != nil ||
        d.Decode(&log) != nil {
        fmt.Printf("[readPersist] decode failed ...")
    } else {
        rf.currentTerm = currentTerm
        rf.votedFor = votedFor
        rf.log = log
    }
}

3.3 小結

這一章的 Test case 中最麻煩的是 Figure 8 以及 Figure 8 unreliable,Figure 8 如下:
image.png
Figure 8 Test 的通過關鍵點是,當前 Leader 只能提交自己任期內的日誌,在圖 c 的情況下,如果 s1 新增了 log index 3 後 crash 了,並且在 crash 之前提交了 log index 2,隨後 s5 成爲了 leader,s5 會覆蓋已經提交的日誌,即圖 d 的情況,我們需要保證 commit 後的日誌不能被修改,因此這裏即便前任任期的日誌已經複製到大多數節點了,也不能對其提交。
Figure 8 Unreliable 主要問題在於其對 rpc 進行了亂序發送的操作,因此需要考慮兩點:

  1. Leader 發送 AppendEntries ,複製成功的時候,nextIndex 和 matchIndex 的維護需要注意使用 args 中的 prevLogIndex + 實際同步的 log 數量,而不能單純累加。
  2. 還有一種情況如下:

Test (2C): Figure 8 (unreliable) ...
2019/05/01 09:49:14 apply error: commit index=235 server=0 7998 != server=2 4299
exit status 1
FAIL raft 28.516s

在拿到 reply 後他要用 currentTerm 和 args.Term去做比較。也就是說 2 個 sendRPC 的地方,在拿到 reply 之後,還要做個 CHECK ,如果 currentTerm 和 args.Term 不一致,就要直接 return ,而忽略這個 reply。
完整代碼參考 github ,目前 2C 的 Figure 8 unreliable 還有小概率 Failed,後續繼續觀察修復 bug。

4. Part 2D: log compaction (hard)

該 part 主要實現快照機制,raft 會定時觸發保存快照,將部分舊 log 截斷保存爲快照。從而達到節約內存的功能,防止 log 無限增長。
image.png

4.1 分析

4.1.1 快照保存

正常來說,快照保存需要考慮:

  • 選擇快照保存的日誌範圍
  • 選擇快照保存的時機
  • 日誌序列化爲快照,精簡原有日誌

但是 raft 上述情況都基本幫我們實現了,我們只需要精簡日誌即可。
快照保存函數爲:func (rf *Raft) Snapshot(index int, snapshot []byte)index 表示從第 index 個日誌開始,往前所有的日誌(包括 index位置的日誌)保存爲快照,snapshot 參數實際上就是已經保存好的快照數據,我們只需要將日誌快照部分截斷即可。

4.1.2 快照同步

由於日誌被截斷,當有新的 follwer 加入時,可能會出現需要同步快照內的日誌,raft 採用將整個快照都發送過去的方式來實現同步,此處還需要記錄快照最後一個日誌的 Index 以及最後一個日誌的 Term。因此在這裏新增一個 Snapshot 結構

type Snapshot struct {
    lastIncludedTerm  int
    lastIncludedIndex int
}

RPC 的邏輯參考如下:
image.png

4.1.3 下標索引轉換

由於日誌被截斷了,因此原先索引日誌的方式也需要調整,從原來直接獲取下標轉換成兩種下標:logicIndexrealIndex ,分別表示日誌邏輯上的下標大小以及實際訪問時下標的位置(主要對快照的 lastIncludedIndex 做偏移計算)。

func (rf * Raft) getLastLogLogicIndex() int {
    return len(rf.log) - 1 + rf.snapshot.lastIncludedIndex
}

func (rf * Raft) getLastLogRealIndex() int {
    return len(rf.log) - 1
}

func (rf *Raft) getLogLogicSize() int {
    return len(rf.log) + rf.snapshot.lastIncludedIndex
}

func (rf *Raft) getLogRealSize() int {
    return len(rf.log)
}

func (rf *Raft) logicIndexToRealIndex(logicIndex int) int {
    return logicIndex - rf.snapshot.lastIncludedIndex
}

func (rf *Raft) realIndexToLogicIndex(realIndex int) int {
    return realIndex + rf.snapshot.lastIncludedIndex
}

4.2 實現

4.2.1 Snapshot

// the service says it has created a snapshot that has
// all info up to and including index. this means the
// service no longer needs the log through (and including)
// that index. Raft should now trim its log as much as possible.
func (rf *Raft) Snapshot(index int, snapshot []byte) {
    // Your code here (2D).
    rf.mu.Lock()
    defer rf.mu.Unlock()
    if index <= rf.snapshot.lastIncludedIndex {
        return
    }
    defer rf.persist()
    // get real index
    realIndex := rf.logicIndexToRealIndex(index) - 1
    rf.snapshot.lastIncludedTerm = rf.log[realIndex].Term
    // discard before index log
    if rf.getLogLogicSize() <= index {
        rf.log = []LogEntry{}
    } else {
        rf.log = append([]LogEntry{}, rf.log[realIndex+1:]...)
    }
    rf.snapshot.lastIncludedIndex = index
    DPrintf("[Snapshot] %s do snapshot, index = %d", rf.role_info(), index)
    rf.persister.SaveStateAndSnapshot(rf.persister.ReadRaftState(), snapshot)
}

4.2.2 InstallSnapshot

leader 同步給客戶端的日誌在快照中時,發送 InstallSnapshot RPC,客戶端的處理邏輯如下:

func (rf *Raft) InstallSnapshot(args *InstallSnapshotArgs, reply *InstallSnapshotReply) {

    // 1. Reply immediately if term < currentTerm
    rf.mu.Lock()
    defer rf.mu.Unlock()
    reply.Term = rf.currentTerm
    if args.Term < rf.currentTerm || args.LastIncludedIndex <= rf.snapshot.lastIncludedIndex {
        DPrintf("[InstallSnapshot] return because rf.currentTerm > args.Term , %s", rf.role_info())
        return
    }
    DPrintf("[InstallSnapshot] %s recive InstallSnapshot rpc %v", rf.role_info(), args)
    defer rf.persist()
    if rf.currentTerm < args.Term {
        rf.SwitchRole(ROLE_Follwer)
        rf.currentTerm = args.Term
    }
    rf.electionTimer.Reset(getRandomTimeout())

    rf.commitIndex = args.LastIncludedIndex - 1
    rf.lastApplied = args.LastIncludedIndex - 1
    realIndex := rf.logicIndexToRealIndex(args.LastIncludedIndex) - 1
    DPrintf("[InstallSnapshot] %s commitIndex=%d, Log=%v", rf.role_info(), rf.commitIndex, rf.log)
    if rf.getLogLogicSize() <= args.LastIncludedIndex {
        rf.log = []LogEntry{}
    } else {
        rf.log = append([]LogEntry{}, rf.log[realIndex+1:]...)
    }
    rf.snapshot.lastIncludedIndex = args.LastIncludedIndex
    rf.snapshot.lastIncludedTerm = args.LastIncludedTerm
    go func() {
        rf.applyCh <- ApplyMsg{
            SnapshotValid: true,
            Snapshot:      args.Data,
            SnapshotTerm:  args.LastIncludedTerm,
            SnapshotIndex: args.LastIncludedIndex,
        }
        rf.mu.Lock()
        defer rf.mu.Unlock()
        rf.persister.SaveStateAndSnapshot(rf.persister.ReadRaftState(), args.Data)
    }()
}

4.2.3 SendInstallSnapshot

快照同步,執行時機爲 Leader 發現 AppendEntries 中的 PrevIndex < snapshot.lastIncludedIndex,則表示需要同步的日誌在快照中,此時需要同步整個快照,並且修改 nextIndex 爲 snapshot.lastIncludedIndex。

func (rf *Raft) SendInstallSnapshot(server int) {
    rf.mu.Lock()
    args := InstallSnapshotArgs{
        Term:              rf.currentTerm,
        LastIncludedIndex: rf.snapshot.lastIncludedIndex,
        LastIncludedTerm:  rf.snapshot.lastIncludedTerm,
        // hint: Send the entire snapshot in a single InstallSnapshot RPC.
        // Don't implement Figure 13's offset mechanism for splitting up the snapshot.
        Data: rf.persister.ReadSnapshot(),
    }
    reply := InstallSnapshotReply{}
    rf.mu.Unlock()
    ok := rf.sendInstallSnapshot(server, &args, &reply)
    if ok {
        // check reply term
        rf.mu.Lock()
        defer rf.mu.Unlock()
        if rf.currentRole != ROLE_Leader || rf.currentTerm != args.Term {
            return
        }

        if reply.Term > args.Term {
            DPrintf("[SendInstallSnapshot] %v to %d failed because reply.Term > args.Term, reply=%v\n", rf.role_info(), server, reply)
            rf.SwitchRole(ROLE_Follwer)
            rf.currentTerm = reply.Term
            rf.persist()
            return
        }

        // update nextIndex and matchIndex
        rf.nextIndex[server] = args.LastIncludedIndex
        rf.matchIndex[server] = rf.nextIndex[server] - 1
        DPrintf("[SendInstallSnapshot] %s to %d nextIndex=%v, matchIndex=%v", rf.role_info(), server, rf.nextIndex, rf.matchIndex)
    }
}

4.2.4 持久化

需要注意,readPersist 的時候,需要更新 commitIndex 和 lastApplied

func (rf *Raft) persist() {
    // Your code here (2C).
    w := new(bytes.Buffer)
    e := labgob.NewEncoder(w)
    e.Encode(rf.currentTerm)
    e.Encode(rf.votedFor)
    //e.Encode(rf.commitIndex)
    e.Encode(rf.log)
    e.Encode(rf.snapshot.lastIncludedIndex)
    e.Encode(rf.snapshot.lastIncludedTerm)

    data := w.Bytes()
    rf.persister.SaveRaftState(data)
}

//
// restore previously persisted state.
//
func (rf *Raft) readPersist(data []byte) {
    if data == nil || len(data) < 1 { // bootstrap without any state?
        return
    }
    // Your code here (2C).
    r := bytes.NewBuffer(data)
    d := labgob.NewDecoder(r)
    var currentTerm int
    var votedFor int
    var log []LogEntry
    var snapshot Snapshot
    //var commitIndex int
    if d.Decode(&currentTerm) != nil ||
        d.Decode(&votedFor) != nil ||
        //d.Decode(&commitIndex) != nil ||
        d.Decode(&log) != nil ||
        d.Decode(&snapshot.lastIncludedIndex) != nil ||
        d.Decode(&snapshot.lastIncludedTerm) != nil {
        DPrintf("[readPersist] decode failed ...")
    } else {
        rf.currentTerm = currentTerm
        rf.votedFor = votedFor
        rf.log = log
        rf.snapshot = snapshot
        rf.lastApplied = snapshot.lastIncludedIndex - 1
        rf.commitIndex = snapshot.lastIncludedIndex - 1
        DPrintf("[readPersist] Term=%d VotedFor=%d, Log=%v ...", rf.currentTerm, rf.votedFor, rf.log)
    }
}

4.3 小結

該 part 主要麻煩的點在於下標的轉換,需要把所有用到下標的地方都進行處理。其次需要注意,截斷日誌時,不能簡單使用切片,可能會有 data race ,其次直接對 rf.log 使用切片會有引用未釋放的問題。

5. 測試

對代碼總體進行測試 100 次,結果基本通過,參考 測試結果。中間測試時遇到一些問題:

  • 由於使用 select 接收 timer 的信號,切換 role 的時候沒有 stop timer,select 在多個信號到達時,會隨機選擇一個,導致會有 timer 超時不觸發,比如 election time out 但是沒有發起 election。因此切換 role 時需要 stop timer。(修改了此處後 Figure8 unreliable 基本都 pass 了)
  • 發送 AppendEntries RPC 的時候,出現了 data race,因爲對參數中的日誌使用了切片,導致持有了參數的引用,發送 rpc 時需要對參數進行序列化,兩者產生 data race。解決方式就是使用如 copy 之類的操作,深拷貝,防止 data race。
  • 目前還有一種小概率情況會導致 case 不會過,假定有 3 個節點,節點 a 競選後成爲 leader,並且 start 了一條 log,此時節點的 Term 都爲 2,但是 log 還沒同步給其他節點時,節點 b election time out 了,也發起了投票,因此節點 b Term 更新爲 3,由於節點 c 未同步到 log,因此會給 b 投票,b 成爲了新的 leader(2/3),最初的那條 log 就會丟失,這種情況應該是允許出現的,檢查了 test,發現 start log 有個可以 retry 的參數,如果 retry 爲 true 則會進行重試。只 start 一次 log 的情況下理論上是會有丟失的可能的,因此不認爲此處爲 bug。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章