Go 實現 Raft 第二篇:選舉

CrazyZard發表於2020-03-31

關於Raft實現結構方式的相關資訊;適用於系列文章中的所有部分。

通常,Raft是作為引入到某些服務中的物件實現的。由於我們不在這裡開發服務,而是僅研究Raft本身,因此我建立了一個簡單的 Server 型別,該型別包裹 ConsensusModule 型別以儘可能地隔離程式碼中更感興趣的部分:

Go實現Raft第二篇:選舉

共識模組(CM)實現了Raft演算法的核心,位於 raft.go 檔案中。它完全從與叢集中其他副本的網路和連線的細節中抽象出來。ConsensusModule中與網路相關的唯一欄位是:

// CM中的伺服器ID
id int

// 叢集中節點的ID列表
peerIds []int

// 包含CM的伺服器,處理節點間的RPC通訊
server *Server

在實現中,每個 Raft 副本將群集中的其他副本稱為”端點“。叢集中的每個端點都有唯一的數字 ID ,以及所有端點的 ID 列表。server 欄位是指向包含 Server 的指標(在server.go中實現),使 ConsensusModule 可以將訊息傳送給端點。稍後我們將看到它是如何實現的。

這樣設計的目標是將所有網路細節都排除掉,把重點放在Raft演算法上。通常,要將Raft論文對映到此實現上,只需要 ConsensusModule 型別及其方法。伺服器程式碼是一個相當簡單的Go網路框架,有一些小的複雜之處來支援嚴格的測試。在本系列文章中,我們不會花時間,但是如果有不清楚的地方,可以留言提問。

從總體上講,Raft CM是一個具有3種狀態的狀態機:

Go實現Raft第二篇:選舉

  1. 超時,開始選舉
  2. 超時,新的選舉
  3. 發現當前的領導人或者新的任期
  4. 接收來自大多數伺服器的投票
  5. 發現更高的任期服務
    這可能有點迷惑,因為上一部分花費了大量時間來解釋Raft如何幫助實現狀態機。通常情況下,狀態一詞在這裡是過載的。Raft是用於實現任意複製狀態機的演算法,但它內部也有一個小型狀態機。後面,該狀態意味著從上下文中可以清楚地確定,不明確的地方我們也會指出該狀態。

在一個典型的穩定狀態場景中,群集中的一臺伺服器是領導者,而其他所有伺服器都是跟隨者。儘管我們不希望出問題,但Raft的目標就是容錯,因此我們將花費大部分時間來討論非典型場景,失敗情況,某些伺服器崩潰,其他伺服器斷開連線等。

正如前面所說,Raft 使用了一個強大的領導模型。領導者響應客戶端的請求,將新條目新增到日誌中,並將其複製到跟隨者。萬一領導者失敗或停止響應,每個跟隨者都隨時準備接管領導權。這是圖中從“跟隨者”到“候選者”的“響應超時,開始選舉”的過渡。

就像常規選舉一樣,在 Raft 中也有任期。任期是某個伺服器擔任領導者的時間段。新的選舉觸發一個新的任期,並且 Raft 演算法可確保給定的任期只有一個領導者。

但是,這個類別有點牽強,因為 Raft 選舉與真實選舉有很大差別。在 Raft 中,選舉更加合作;候選者的目標不是贏得選舉,而是在任何一個特定的任期內有合適的候選者贏得選舉。我們稍後將詳細討論“合適”的含義。

Raft 演算法的關鍵組成部分是選舉計時器。 這是每個跟隨者連續執行的計時器,每次收到當前領導者的訊息就會重新啟動它。領導者傳送週期性的心跳,因此當這些心跳停止到達時,跟隨者會認為領導者已經崩潰或斷開連線,並開始選舉(切換到候選狀態)。

問:不是所有的跟隨者都會同時成為候選人?

選舉計時器是隨機的,這是 Raft 簡單化的關鍵之一。Raft 使用此隨機方法來降低多個關注者同時進行選舉的機會。但是,即使他們確實同時成為候選人,在任何給定的任期中也只有一個當選為領導者。在極少數情況下,如果投票分裂,以致沒有候選人能贏得選舉,將進行新的選舉(有新任期)。從理論上講,永久地重新進行選舉是可行的,但在每一輪選舉中發生這種情況的可能性都大大降低。

問:如果跟隨者與叢集斷開連線(分割槽)怎麼辦?它不會因為沒有聽到領導者的聲音而開始選舉嗎?
答:這是網路分割槽的隱患,因為跟隨者無法區分誰被分割槽。它確實將開始選舉。但是,如果是這個跟隨者被斷開,那麼這次選舉將無濟於事-因為它無法與其他端點聯絡,所以不會獲得任何投票。它可能會繼續保持候選狀態(每隔一段時間重新啟動一次新選舉),直到重新連線到叢集。稍後我們將更詳細地研究這種情況。

Raft有兩種 RPC 在端點之間互相傳送。有關這些 RPC 的詳細引數和規則,參見圖2。簡要討論它們的目標:

  • RequestVotes(RV):僅在候選狀態下使用;候選人使用它來請求選舉中的端點投票。答覆中包含是否批准投票的指示。

  • AppendEntries(AE):僅在領導者狀態下使用;領導者使用此RPC將日誌條目複製到跟隨者,也傳送心跳。即使沒有新的日誌條目要複製,該RPC也會定期傳送給每個跟隨者。

從以上內容可以推斷出跟隨者沒有傳送任何RPC。這是對的;跟隨者不會向其他端點發起RPC,但是他們在後臺執行選舉計時器。如果在沒有當前領導者的通訊的情況下經過了此計時器,則跟隨者將成為候選者並開始傳送RV。

現在開始深入研究程式碼。以下程式碼檔案會在文章末尾給出。關於 ConsensusModule 結構的欄位的完整列表,可以在程式碼檔案中檢視。

我們的 CM 通過在 goroutine 中執行以下功能來實現選舉計時器:

func (cm *ConsensusModule) runElectionTimer() {
    timeoutDuration := cm.electionTimeout()
    cm.mu.Lock()
    termStarted := cm.currentTerm
    cm.mu.Unlock()
    cm.dlog("election timer started (%v), term=%d", timeoutDuration, termStarted)

    // This loops until either:
    // - we discover the election timer is no longer needed, or
    // - the election timer expires and this CM becomes a candidate
    // In a follower, this typically keeps running in the background for the
    // duration of the CM's lifetime.
    ticker := time.NewTicker(10 * time.Millisecond)
    defer ticker.Stop()
    for {
        <-ticker.C

        cm.mu.Lock()
        if cm.state != Candidate && cm.state != Follower {
        cm.dlog("in election timer state=%s, bailing out", cm.state)
        cm.mu.Unlock()
        return
    }

    if termStarted != cm.currentTerm {
        cm.dlog("in election timer term changed from %d to %d, bailing out", termStarted, cm.currentTerm)
        cm.mu.Unlock()
        return
    }

    // Start an election if we haven't heard from a leader or haven't voted for
    // someone for the duration of the timeout.
    if elapsed := time.Since(cm.electionResetEvent); elapsed >= timeoutDuration {
        cm.startElection()
        cm.mu.Unlock()
        return
    }
    cm.mu.Unlock()
    }
}

首先通過呼叫 cm.electionTimeout 選擇一個偽隨機的選舉超時時間。正如論文中的建議,我們在這裡使用的範圍是150到300毫秒。和 ConsensusModule 的大多數方法一樣,runElectionTimer 在訪問欄位時會鎖定結構。這是必須要做的,因為實現嘗試儘可能地保持同步,這也是Go的優勢之一。這意味著順序程式碼是…順序執行的,並且不會拆分為多個事件處理程式。但是,RPC 仍然同時發生,因此我們必須保護共享資料結構。我們很快就會講到 RPC 處理程式。

這個方法的主迴圈執行一個10毫秒的程式碼。有更有效的方法來等待事件,但是這種習慣用法程式碼最為簡單。每次迴圈迭代都在10毫秒之後進行。從理論上講,這可以使整個選舉超時,但隨後響應速度會變慢,並且在日誌中進行除錯/跟蹤會更加困難。我們檢查狀態是否仍然如預期且任期未更改。如果有任何關閉,我們終止選舉計時器。

如果自上次“選舉重置事件”以來已經過去了足夠的時間,則此端點開始選舉併成為候選人。這是什麼選舉重置事件?可以終止選舉的任何因素-例如,收到有效的心跳,或給另一個候選人投票。

上面可以看到,一旦經過足夠的時間而沒有跟隨者收到領導者或其他候選人的訊息,它將開始選舉。在檢視程式碼之前,我們考慮一下進行選舉所需的事情:

  1. 將狀態切換為候選項,並增加條件項,因為這是演算法為每次選舉指定的條件。

  2. 將RV RPC傳送給所有端點,要求他們在這次選舉中為我們投票。

  3. 等待對這些 RPC 的答覆,然後計數是否獲得足夠的選票成為領導者。

在Go中,所有這些邏輯都可以在一個函式中實現:

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) {
            args := RequestVoteArgs{
            Term: savedCurrentTerm,
            CandidateId: cm.id,
        }
        var reply RequestVoteReply

        cm.dlog("sending RequestVote to %d: %+v", peerId, args)
        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()
}

候選人首先為自己投票-將 voiceReceived 初始化為1並設定 cm.votedFor =cm.id。

然後,它與所有其他端點並行發出 RPC 。每個 RPC 都在自己的 goroutine 中完成,因為我們的 RPC 呼叫是同步的-它們會阻塞直到收到響應為止,這可能需要一段時間。

rpc實現:

cm.server.Call(peer, “ConsensusModule.RequestVote”, args, &reply)

我們使用 ConsensusModule.server 欄位中包含的Server指標發出遠端呼叫,使用 ConsensusModule.RequestVotes 作為遠端方法名稱。最終呼叫第一個引數中給出的端點的RequestVote方法。

如果 RPC 成功,已經過了一段時間,因此我們必須檢查狀態,看看有哪些選項。如果我們的狀態不再是候選人,就退出。什麼時候會發生這種情況?例如,我們可能贏得了選舉,因為其他 RPC 呼叫中有足夠的選票。或者收到其他 RPC 呼叫中的一個具有更高的任期,所以我們切換成跟隨者。重要的是,在網路不穩定的情況下,RPC可能需要很長時間才能到達-當我們收到答覆時,其餘程式碼可能會繼續進行,因此在這種情況下妥協放棄非常重要。

如果響應返回時我們仍然是候選人,我們將檢查響應的任期,並將其與傳送請求時的原始任期進行比較。如果答覆的任期較高,我們將恢復為跟隨者狀態。例如,如果其他候選人在我們收集選票時贏得了選舉,就會發生這種情況。

如果該任期與我們發出的任期相同,請檢查是否已投票。我們使用一個原子投票變數來安全地從多個 goroutine 中收集投票。如果此伺服器擁有多數表決權(包括它自己授予的表決權),它將成為領導者。

請注意,startElection 方法不會阻塞。它更新一些狀態,啟動一堆 goroutines 並返回。因此,它還應該在 goroutine 中啟動一個新的選舉計數器-在最後一行進行。這樣可以確保如果這次選舉沒有任何用處,則超時後將開始新的選舉。這也解釋了 runElectionTimer 中的狀態檢查:如果此選舉確實將端點轉變為領導者,則併發執行的 runElecionTimer 將在觀察它不希望進入的狀態時才返回。

當投票記錄顯示該端點獲勝時,我們已經在 startElection 中看到了 startLeader 呼叫。

func (cm *ConsensusModule) startLeader() {
  cm.state = Leader
  cm.dlog("becomes Leader; term=%d, log=%v", cm.currentTerm, cm.log)

  go func() {
    ticker := time.NewTicker(50 * time.Millisecond)
    defer ticker.Stop()

    // Send periodic heartbeats, as long as still leader.
    for {
      cm.leaderSendHeartbeats()
      <-ticker.C

      cm.mu.Lock()
      if cm.state != Leader {
        cm.mu.Unlock()
        return
      }
      cm.mu.Unlock()
    }
  }()
}

這實際上是一個相當簡單的方法:所有操作都是執行心跳計時器-一個goroutine,只要此CM仍然是領導者,它將每50毫秒呼叫一次 leaderSendHeartbeats。

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

  for _, peerId := range cm.peerIds {
    args := AppendEntriesArgs{
      Term:     savedCurrentTerm,
      LeaderId: cm.id,
    }
    go func(peerId int) {
      cm.dlog("sending AppendEntries to %v: ni=%d, args=%+v", peerId, 0, 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
        }
      }
    }(peerId)
  }
}

有點類似於 startElection,從某種意義上說,它為每個對等點啟動了一個 goroutine 以傳送 RPC 。這次 RPC 是沒有日誌內容的 AppendEntries(AE),在 Raft 中起著心跳的作用。

與處理 RV 響應類似,如果 RPC 返回的任期高於我們的任期,則此端點切換為跟隨者。

func (cm *ConsensusModule) becomeFollower(term int) {
  cm.dlog("becomes Follower with term=%d; log=%v", term, cm.log)
  cm.state = Follower
  cm.currentTerm = term
  cm.votedFor = -1
  cm.electionResetEvent = time.Now()

  go cm.runElectionTimer()
}

它將 CM 的狀態設定為跟隨者,並重置其條件和其他重要狀態欄位。啟動一個新的選舉計時器。

目前為止,我們已經實現了活動部分-初始化RPC、計時器和狀態轉換。在我們看到伺服器方法之前,演示還不完整。 從 RequestVote 開始:

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

  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) {
    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
}

注意檢查是否為“死亡”狀態。我們稍後再討論。

檢查該任期是否過時併成為跟隨者。如果已經是跟隨者,則狀態不會更改,但其他狀態欄位將重置。

否則,如果呼叫者的任期與該任期一致,而我們還沒有投票給其他候選人,將進行投票。不會對較舊的 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()
        reply.Success = true
    }

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

注意檢查是否為“死亡”狀態。我們稍後再討論。

檢查該任期是否過時併成為跟隨者。如果已經是跟隨者,則狀態不會更改,但其他狀態欄位將重置。

否則,如果呼叫者的任期與該任期一致,而我們還沒有投票給其他候選人,將進行投票。不會對較舊的 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()
    reply.Success = true
  }

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

該邏輯也與上圖的選擇部分保持一致。需要了解的一個複雜情況是:

if cm.state != Follower {
  cm.becomeFollower(args.Term)
}

問:如果此端點是領導者怎麼辦?為什麼它成為另一個領導者的跟隨者?

答:Raft 在任何給定的任期內都保證只有一個領導者存在。如果仔細地遵循 RequestVote 的邏輯以及傳送 RV的 startElectio n中的程式碼,將看到在叢集中不能使用相同的任期存在兩個領導者。對於發現其他端點贏得選舉的候選人而言,這一條件很重要。

回顧一下 CM 可能處於的所有狀態,並在其中執行不同的 goroutine:

跟隨者:將 CM 初始化為跟隨者,並且在對 beginFollower 的每次呼叫中,一個新的 goroutine 開始執行 runElectionTimer 。注意,在短時間內一次可以執行多個。假設跟隨者在較高的任期內從領導者那裡獲得了 RV ;將觸發另一個 beginFollower 呼叫,該呼叫將啟動新的計時器 goroutine。但是,舊的一旦發現任期發生變化,將不做任何事情直接退出。

總結以上部分,我們研究一個可能發生的特殊場景以及 Raft 如何應對。這個例子非常有趣並且很有啟發性。在這裡,我試圖將其呈現為一個故事,但是您可能希望使用一張紙來跟蹤不同伺服器的狀態。 如果您不能遵循該示例-請給我傳送電子郵件-我們將很樂意對其進行修復以使其更加清晰。

設想一個有三臺伺服器的群集:A,B和C。假設A是領導者,起始項為1,並且該群集執行正常。A每隔50毫秒向B和C傳送一次心跳AE RPC,並在幾毫秒內獲得快速響應;每個這樣的AE都會重置B和C的 eletementResetEvent,因此它們仍然是的跟隨者。

在某個時間點,由於網路路由器出現故障,伺服器B從A和C中分割槽了。A仍每50毫秒向其傳送一次AE,但是這些AE要麼立即出錯,要麼在底層 RPC 引擎超時出錯。A對此無能為力,但這沒什麼大不了的。我們還沒有討論日誌複製,但是由於三臺伺服器中的兩臺還處於活動狀態,因此群集具有提交客戶端命令的數量。

B呢?假設當斷開連線時,其選舉超時設定為200毫秒。斷開連線大約200毫秒後,B的 runElectionTimer goroutine 意識到沒有收到來自領導者的選舉超時訊息。B無法區分誰存在,所以它將成為候選人並開始新的選舉。

因此,B的任期將變為2(而A和C的項仍為1)。B會將RV RPC傳送給A和C,要求他們投票。當然,這些RPC在B的網路中丟失了。B的 startElection 在開始時就啟動了另一個 runElectionTimer goroutine,該 goroutine 等待250毫秒(超時範圍在150-300毫秒之間是隨機的),檢視是否由於上次選舉而發生了重要的事情。B沒做任何事情,因為它仍然是完全隔離的。因此,runElectionTimer 開始另一個新的選舉,將期限增加到3。

很長時間,B的路由器需要花費幾秒鐘的時間來重置並恢復線上狀態。同時,B偶爾會重新選舉一次,其任期已到8。

此時,網路分割槽恢復,並且B重新連線到A和C。

然後,AE RPC從A到達。回想一下A一直每50 ms傳送一次,儘管B暫時沒有回覆。

B的 AppendEntries 被執行,並以term = 8傳送回一個答覆。

A在 LeaderSendHeartbeats 中獲得了此答覆,檢查了答覆的任期,並發現其高於其本身。它將自己的任期更新為8,併成為跟隨者。叢集暫時失去領導者。

現在可能會發生多種情況。B是候選者,但它可能在網路恢復之前已經傳送了RV。C是跟隨者,但在其自身的選舉超時時間內,它將成為候選者,因為它不再從A接收定期的AE。A成為跟隨者,並且還將在其選舉超時時間內成為候選者。

因此,這三臺伺服器中的任何一臺都可以贏得下一次選舉。這僅是因為我們實際上未在此處複製任何日誌。正如我們將在下一部分中看到,在實際情況下,A和C可能會在B不在時新增一些新的客戶端命令,因此它們的日誌將是最新的。因此,B不能成為新的領導者-將會發生新的選舉,由A或C贏得;我們將在下一部分中再次討論該場景。

假設自從B斷開連線以來未新增任何新命令,則由於重新連線而導致更換領導者也是完全可以的。

這看起來效率很低。因為領導者的更換並不是真正必要的,因為在整個場景中A都非常健康。但是,在個別情況下以不降低效率為代價來使不變數保持簡單是Raft做出的設計選擇之一。在最常見的情況下(沒有任何中斷),效率才是關鍵,因為99.9%的時間叢集都是在正常狀態。
候選人:也同時具有選舉 goroutine 的計時器,除此之外,還有許多 goroutines 傳送 RPC 。具有與跟隨者相同的保護措施,可以在新的執行程式停止執行時停止“舊的”選舉程式。請記住,RPC goroutine 可能需要很長時間才能完成,因此,如果他們注意到RPC呼叫返回時它們已過時,必須安靜地退出,這一點至關重要。

領導者:領導者沒有選舉 goroutine ,但有心跳 goroutine 每50毫秒執行一次。

程式碼中還有一個附加狀態-死亡狀態。是為了有序地關閉CM。呼叫Stop會將狀態設定為Dead,所有 goroutine 會在觀察到該狀態後立即退出。

使所有這些 goroutine 執行可能會令人擔憂-如果其中一些仍在後臺執行怎麼辦?或更糟糕的是,它們反覆洩漏,其數量無邊無際地增長?這就是洩漏檢查的目的,並且一些測試啟用了洩漏檢查。

本系列的下一部分中將描述一個更完整的Raft實現,包括實際處理客戶端命令並在整個叢集中複製。敬請關注!

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

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

轉載: 平臺開發 - 360雲端計算

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

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

相關文章