Go 實現 Raft 第三篇:命令和日誌複製

CrazyZard發表於2020-04-03

在第一篇文章中我們簡要討論了客戶端互動,如果不清晰建議可以再回顧一下。這裡我們先不關注客戶端如何找到領導者,將重點討論當找到一個領導者時會發生什麼。

  1. 首先,客戶端將命令提交給領導者。在Raft叢集中,命令通常只提交給單個節點。

  2. 領導者將命令複製到其跟隨者。

  3. 最後,如果大多數叢集節點都承認在其日誌中有該命令,該命令將被提交,並向所有客戶端通知新的提交。

注意提交和提交命令之間的不對稱性 - 在檢查我們即將討論的實現決策時,這一點很重要。命令被提交到單個Raft節點,但是多個節點(特別是所有已連線/活動的節點啊)會在一段時間後將其提交併通知其客戶端。

回顧此圖:

Go 實現 Raft 第三篇:命令和日誌複製

狀態機代表使用Raft進行復制的任意服務。

然後我們在Raft ConsensusModule模組的上下文中討論客戶端,我們通常指的是此服務,因為這是將提交報告到的地方。換句話說,從 Consensus 模組到服務狀態機的黑色箭頭就是該通知。

在我們的實現中,當一個 ConsensusModule 被建立時,它接受一個提交管道 - 一個用來向呼叫者傳送提交命令的通道:commitChan chan<-CommitEntry。定義如下:

// CommitEntry is the data reported by Raft to the commit channel. Each commit
// entry notifies the client that consensus was reached on a command and it can
// be applied to the client's state machine.
type CommitEntry struct {
  // Command is the client command being committed.
  Command interface{}

  // Index is the log index at which the client command is committed.
  Index int

  // Term is the Raft term at which the client command is committed.
  Term int
}

使用通道是一種設計選擇,但不是唯一方式。也可以改用回撥。建立ConsensusModule時,呼叫者將註冊一個回撥函式,只要有要提交的命令,就會呼叫該回撥函式。

在實現通道上傳送條目的功能之前。我們需要先討論 Raft 伺服器如何複製命令並確定命令是否已提交。

在文章中多次提到 Raft 日誌,但還沒有詳細介紹。日誌只是應該應用於狀態機的線性命令序列;如果有需要,日誌應該足以從某個開始狀態“重放”狀態機。在正常執行期間,所有 Raft 節點的日誌都是相同的;當領導者收到新命令時,將其存放在自己的日誌中,然後複製到跟隨者。跟隨者將命令放在日誌中,並確認給領導者,領導者將保留已安全複製到群集中大多數伺服器的最新日誌索引的計數。

Go 實現 Raft 第三篇:命令和日誌複製

每個框都是一個日誌條目;框頂部的數字是將其新增到日誌中的任期。底部是此日誌包含的鍵值命令。每個日誌條目都有一個線性索引。框的顏色是任期的另一種表示形式。

如果將此日誌應用於空鍵值儲存,則最終結果將具有值x = 4,y = 7。

如果不懂日誌儲存方式,傳送門
在我們的實現中,日誌條目由以下形式表示:

type LogEntry struct {
    Command interface{}
    Term int
}

每個ConsensusModule的日誌都只是log []LogEntry。使用者端通常不在乎任期。任期對Raft的正確性至關重要,在閱讀程式碼時務必牢記。

新的 Submit 方法,使客戶端可以提交新命令:

func (cm *ConsensusModule) Submit(command interface{}) bool {
  cm.mu.Lock()
  defer cm.mu.Unlock()

  cm.dlog("Submit received by %v: %v", cm.state, command)
  if cm.state == Leader {
    cm.log = append(cm.log, LogEntry{Command: command, Term: cm.currentTerm})
    cm.dlog("... log=%v", cm.log)
    return true
  }
  return false
}

很簡單,如果此CM是領導者,則將新命令附加到日誌中並返回true。否則,將被忽略並返回false。

問:“提交”返回的真實值是否表明客戶端已向領導者提交了命令?

答:在極少數情況下,領導者可能會與其他 Raft 伺服器分開,而後者在一段時間後會繼續選舉新的領導者。但是,客戶可能仍在與舊的領導者通訊。客戶端應等待一段合理的時間,以使其提交的命令出現在提交通道上;如果不是,則表示它聯絡了錯誤的領導者,應與其他領導者重試。

我們看到,提交給領導者的新命令被新增到日誌的末尾。這個新命令如何到達跟隨者?領導者遵循的步驟在Raft論文中進行了精確描述。我們在 leaderSendHeartbeats 中完成實現。

func (cm *ConsensusModule) leaderSendHeartbeats() {
  cm.mu.Lock()
  savedCurrentTerm := cm.currentTerm
  cm.mu.Unlock()

  for _, peerId := range cm.peerIds {
    go func(peerId int) {
      cm.mu.Lock()
      ni := cm.nextIndex[peerId]
      prevLogIndex := ni - 1
      prevLogTerm := -1
      if prevLogIndex >= 0 {
        prevLogTerm = cm.log[prevLogIndex].Term
      }
      entries := cm.log[ni:]

      args := AppendEntriesArgs{
        Term:         savedCurrentTerm,
        LeaderId:     cm.id,
        PrevLogIndex: prevLogIndex,
        PrevLogTerm:  prevLogTerm,
        Entries:      entries,
        LeaderCommit: cm.commitIndex,
      }
      cm.mu.Unlock()
      cm.dlog("sending AppendEntries to %v: ni=%d, args=%+v", peerId, ni, args)
      var reply AppendEntriesReply
      if err := cm.server.Call(peerId, "ConsensusModule.AppendEntries", args, &reply); err == nil {
        cm.mu.Lock()
        defer cm.mu.Unlock()
        if reply.Term > savedCurrentTerm {
          cm.dlog("term out of date in heartbeat reply")
          cm.becomeFollower(reply.Term)
          return
        }

        if cm.state == Leader && savedCurrentTerm == reply.Term {
          if reply.Success {
            cm.nextIndex[peerId] = ni + len(entries)
            cm.matchIndex[peerId] = cm.nextIndex[peerId] - 1
            cm.dlog("AppendEntries reply from %d success: nextIndex := %v, matchIndex := %v", peerId, cm.nextIndex, cm.matchIndex)

            savedCommitIndex := cm.commitIndex
            for i := cm.commitIndex + 1; i < len(cm.log); i++ {
              if cm.log[i].Term == cm.currentTerm {
                matchCount := 1
                for _, peerId := range cm.peerIds {
                  if cm.matchIndex[peerId] >= i {
                    matchCount++
                  }
                }
                if matchCount*2 > len(cm.peerIds)+1 {
                  cm.commitIndex = i
                }
              }
            }
            if cm.commitIndex != savedCommitIndex {
              cm.dlog("leader sets commitIndex := %d", cm.commitIndex)
              cm.newCommitReadyChan <- struct{}{}
            }
          } else {
            cm.nextIndex[peerId] = ni - 1
            cm.dlog("AppendEntries reply from %d !success: nextIndex := %d", peerId, ni-1)
          }
        }
      }
    }(peerId)
  }
}

這比我們在上一部分中所做的要複雜得多,但實際上它僅遵循本文的圖2。關於此程式碼的一些注意事項:

  • 現在已完全填充了AE RPC的欄位:有關其含義,請參見本文中的圖2。

  • AE響應有一個 success 欄位,該欄位告訴領導者跟隨者是否看到prevLogIndex 和 prevLogTerm 匹配。領導者基於此欄位更新此跟隨者的nextIndex。

  • commitIndex 根據複製特定日誌索引的關注者的數量進行更新。如果索引被多數複製,則 commitIndex 前進到該索引。

與我們之前討論的使用者端互動有關,這部分程式碼特別重要:

if cm.commitIndex != savedCommitIndex {
  cm.dlog("leader sets commitIndex := %d", cm.commitIndex)
  cm.newCommitReadyChan <- struct{}{}
}

newCommitReadyChan 是CM內部使用的通道,用於指示已準備好將新條目通過提交通道傳送到客戶端。它由在CM啟動時在goroutine中執行的以下方法起作用:

func (cm *ConsensusModule) commitChanSender() {
  for range cm.newCommitReadyChan {
    // Find which entries we have to apply.
    cm.mu.Lock()
    savedTerm := cm.currentTerm
    savedLastApplied := cm.lastApplied
    var entries []LogEntry
    if cm.commitIndex > cm.lastApplied {
      entries = cm.log[cm.lastApplied+1 : cm.commitIndex+1]
      cm.lastApplied = cm.commitIndex
    }
    cm.mu.Unlock()
    cm.dlog("commitChanSender entries=%v, savedLastApplied=%d", entries, savedLastApplied)

    for i, entry := range entries {
      cm.commitChan <- CommitEntry{
        Command: entry.Command,
        Index:   savedLastApplied + i + 1,
        Term:    savedTerm,
      }
    }
  }
  cm.dlog("commitChanSender done")
}

此方法更新 lastApplied 狀態變數以確定哪些條目已經傳送到客戶端,並且僅傳送新條目。

我們已經看到了領導者如何處理新的日誌條目。現在介紹跟隨者的程式碼實現。特別是 AppendEntries RPC。

func (cm *ConsensusModule) AppendEntries(args AppendEntriesArgs, reply *AppendEntriesReply) error {
  cm.mu.Lock()
  defer cm.mu.Unlock()
  if cm.state == Dead {
    return nil
  }
  cm.dlog("AppendEntries: %+v", args)

  if args.Term > cm.currentTerm {
    cm.dlog("... term out of date in AppendEntries")
    cm.becomeFollower(args.Term)
  }

  reply.Success = false
  if args.Term == cm.currentTerm {
    if cm.state != Follower {
      cm.becomeFollower(args.Term)
    }
    cm.electionResetEvent = time.Now()

    // Does our log contain an entry at PrevLogIndex whose term matches
    // PrevLogTerm? Note that in the extreme case of PrevLogIndex=-1 this is
    // vacuously true.
    if args.PrevLogIndex == -1 ||
      (args.PrevLogIndex < len(cm.log) && args.PrevLogTerm == cm.log[args.PrevLogIndex].Term) {
      reply.Success = true

      // Find an insertion point - where there's a term mismatch between
      // the existing log starting at PrevLogIndex+1 and the new entries sent
      // in the RPC.
      logInsertIndex := args.PrevLogIndex + 1
      newEntriesIndex := 0

      for {
        if logInsertIndex >= len(cm.log) || newEntriesIndex >= len(args.Entries) {
          break
        }
        if cm.log[logInsertIndex].Term != args.Entries[newEntriesIndex].Term {
          break
        }
        logInsertIndex++
        newEntriesIndex++
      }
      // At the end of this loop:
      // - logInsertIndex points at the end of the log, or an index where the
      //   term mismatches with an entry from the leader
      // - newEntriesIndex points at the end of Entries, or an index where the
      //   term mismatches with the corresponding log entry
      if newEntriesIndex < len(args.Entries) {
        cm.dlog("... inserting entries %v from index %d", args.Entries[newEntriesIndex:], logInsertIndex)
        cm.log = append(cm.log[:logInsertIndex], args.Entries[newEntriesIndex:]...)
        cm.dlog("... log is now: %v", cm.log)
      }

      // Set commit index.
      if args.LeaderCommit > cm.commitIndex {
        cm.commitIndex = intMin(args.LeaderCommit, len(cm.log)-1)
        cm.dlog("... setting commitIndex=%d", cm.commitIndex)
        cm.newCommitReadyChan <- struct{}{}
      }
    }
  }

  reply.Term = cm.currentTerm
  cm.dlog("AppendEntries reply: %+v", *reply)
  return nil
}

目前為止,我們已經研究了新增的新程式碼以支援日誌複製。但是,日誌也會影響Raft的選舉。Raft使用選舉程式來防止候選人贏得選舉,除非其日誌至少與叢集中大多數節點的日誌一樣。

因此,RV包含 lastLogIndex 和 lastLogTerm 欄位。當候選人發出RV時,將使用有關其最後一個日誌條目的資訊填充這些RV。跟隨者將這些欄位與自己的欄位進行比較,並確定候選人是否是最新的才可以被選舉

func (cm *ConsensusModule) startElection() {
  cm.state = Candidate
  cm.currentTerm += 1
  savedCurrentTerm := cm.currentTerm
  cm.electionResetEvent = time.Now()
  cm.votedFor = cm.id
  cm.dlog("becomes Candidate (currentTerm=%d); log=%v", savedCurrentTerm, cm.log)

  var votesReceived int32 = 1

  // Send RequestVote RPCs to all other servers concurrently.
  for _, peerId := range cm.peerIds {
    go func(peerId int) {
      cm.mu.Lock()
      savedLastLogIndex, savedLastLogTerm := cm.lastLogIndexAndTerm()
      cm.mu.Unlock()

      args := RequestVoteArgs{
        Term:         savedCurrentTerm,
        CandidateId:  cm.id,
        LastLogIndex: savedLastLogIndex,
        LastLogTerm:  savedLastLogTerm,
      }

      cm.dlog("sending RequestVote to %d: %+v", peerId, args)
      var reply RequestVoteReply
      if err := cm.server.Call(peerId, "ConsensusModule.RequestVote", args, &reply); err == nil {
        cm.mu.Lock()
        defer cm.mu.Unlock()
        cm.dlog("received RequestVoteReply %+v", reply)

        if cm.state != Candidate {
          cm.dlog("while waiting for reply, state = %v", cm.state)
          return
        }

        if reply.Term > savedCurrentTerm {
          cm.dlog("term out of date in RequestVoteReply")
          cm.becomeFollower(reply.Term)
          return
        } else if reply.Term == savedCurrentTerm {
          if reply.VoteGranted {
            votes := int(atomic.AddInt32(&votesReceived, 1))
            if votes*2 > len(cm.peerIds)+1 {
              // Won the election!
              cm.dlog("wins election with %d votes", votes)
              cm.startLeader()
              return
            }
          }
        }
      }
    }(peerId)
  }

  // Run another election timer, in case this election is not successful.
  go cm.runElectionTimer()
}

lastLogIndexAndTerm是一個新的幫助器方法:

// lastLogIndexAndTerm returns the last log index and the last log entry's term
// (or -1 if there's no log) for this server.
// Expects cm.mu to be locked.
func (cm *ConsensusModule) lastLogIndexAndTerm() (int, int) {
  if len(cm.log) > 0 {
    lastIndex := len(cm.log) - 1
    return lastIndex, cm.log[lastIndex].Term
  } else {
    return -1, -1
  }
}

我們的實現是基於0的索引,而不是基於1的Raft索引。因此-1經常作為一個標記值。

這是一個更新的 RV 處理程式,實現選舉安全檢查:

func (cm *ConsensusModule) RequestVote(args RequestVoteArgs, reply *RequestVoteReply) error {
  cm.mu.Lock()
  defer cm.mu.Unlock()
  if cm.state == Dead {
    return nil
  }
  lastLogIndex, lastLogTerm := cm.lastLogIndexAndTerm()
  cm.dlog("RequestVote: %+v [currentTerm=%d, votedFor=%d, log index/term=(%d, %d)]", args, cm.currentTerm, cm.votedFor, lastLogIndex, lastLogTerm)

  if args.Term > cm.currentTerm {
    cm.dlog("... term out of date in RequestVote")
    cm.becomeFollower(args.Term)
  }

  if cm.currentTerm == args.Term &&
    (cm.votedFor == -1 || cm.votedFor == args.CandidateId) &&
    (args.LastLogTerm > lastLogTerm ||
      (args.LastLogTerm == lastLogTerm && args.LastLogIndex >= lastLogIndex)) {
    reply.VoteGranted = true
    cm.votedFor = args.CandidateId
    cm.electionResetEvent = time.Now()
  } else {
    reply.VoteGranted = false
  }
  reply.Term = cm.currentTerm
  cm.dlog("... RequestVote reply: %+v", reply)
  return nil
}

我們的實現是基於0的索引,而不是基於1的Raft索引。因此-1經常作為一個標記值。

這是一個更新的RV處理程式,實現選舉安全檢查:

func (cm *ConsensusModule) RequestVote(args RequestVoteArgs, reply *RequestVoteReply) error {
  cm.mu.Lock()
  defer cm.mu.Unlock()
  if cm.state == Dead {
    return nil
  }
  lastLogIndex, lastLogTerm := cm.lastLogIndexAndTerm()
  cm.dlog("RequestVote: %+v [currentTerm=%d, votedFor=%d, log index/term=(%d, %d)]", args, cm.currentTerm, cm.votedFor, lastLogIndex, lastLogTerm)

  if args.Term > cm.currentTerm {
    cm.dlog("... term out of date in RequestVote")
    cm.becomeFollower(args.Term)
  }

  if cm.currentTerm == args.Term &&
    (cm.votedFor == -1 || cm.votedFor == args.CandidateId) &&
    (args.LastLogTerm > lastLogTerm ||
      (args.LastLogTerm == lastLogTerm && args.LastLogIndex >= lastLogIndex)) {
    reply.VoteGranted = true
    cm.votedFor = args.CandidateId
    cm.electionResetEvent = time.Now()
  } else {
    reply.VoteGranted = false
  }
  reply.Term = cm.currentTerm
  cm.dlog("... RequestVote reply: %+v", reply)
  return nil
}

在目前的Raft實現中,有一個問題是沒有進行持久化操作。如果伺服器故障重啟,將會造成資訊丟失。為此,我們將在下一部分增加持久化操作,以及對本篇中部分功能進行優化。敬請關注!

Raft參考:https://raft.github.io/raft.pdf

程式碼參考:https://github.com/eliben/raft

轉載自 平臺開發 360雲端計算

本作品採用《CC 協議》,轉載必須註明作者和本文連結

快樂就是解決一個又一個的問題!

相關文章