Go實現Raft第二篇:選舉

360雲端計算發表於2020-03-20

今天小編為大家分享一篇關於Golang實現Raft的文章,本篇文章為系列中的第二篇,對Raft中的選舉機制進行介紹並使用go進行實現。希望能對大家有所幫助。

文章為Raft系列文章中的第二篇,Raft中的選舉。該篇中,我們將解釋Raft實現的一般結構,並著重介紹該演算法的領導者選舉相關內容。該部分的程式碼以及相關的測試會在文章結尾給出,其中包含一些系統測試。 儘管不能響應客戶請求,也不能維護日誌。所有這些都將在下一部分中新增。

  • Go實現Raft第一篇:介紹

程式碼結構

關於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伺服器狀態

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

Go實現Raft第二篇:選舉

這可能有點迷惑,因為上一部分花費了大量時間來解釋Raft如何幫助實現狀態機。通常情況下,狀態一詞在這裡是過載的。Raft是用於實現任意複製狀態機的演算法,但它內部也有一個小型狀態機。後面,該狀態意味著從上下文中可以清楚地確定,不明確的地方我們也會指出該狀態。

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

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

任期

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

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

選舉計時器

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

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

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

問:如果跟隨者與叢集斷開連線(分割槽)怎麼辦?它不會因為沒有聽到領導者的聲音而開始選舉嗎?

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

對等RPC

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

目前為止,我們已經實現了活動部分-初始化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
}

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

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

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

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

狀態和goroutine

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

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

候選人:也同時具有選舉goroutine的計時器,除此之外,還有許多goroutines傳送RPC。具有與跟隨者相同的保護措施,可以在新的執行程式停止執行時停止“舊的”選舉程式。請記住,RPC goroutine可能需要很長時間才能完成,因此,如果他們注意到RPC呼叫返回時它們已過時,必須安靜地退出,這一點至關重要。

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

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

使所有這些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%的時間叢集都是在正常狀態。

下一步

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

Raft參考:

程式碼參考:


說明:有小夥伴留言說原文是外網寫的,我們直接拿來發了,是否是盜用。實際上小編也是經過原作者Eli同意了的。發此係列文章的目的也很簡單,僅僅是為了能讓更多的小夥伴看到優質的文章,可以學習提升自己。

以下為Eli的郵件回覆:

Go實現Raft第二篇:選舉

360雲端計算



來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69966971/viewspace-2681615/,如需轉載,請註明出處,否則將追究法律責任。

相關文章