實現Raft協議:Part 1 - 選主

翻譯自Eli Bendersky系列博客,已得到原做者受權。git

本文是系列文章中的第一部分,本系列文章旨在介紹Raft分佈式一致性協議及其Go語言實現。文章的完整列表以下:github

在這一部分,我會介紹咱們的Raft實現代碼的結構,並重點介紹算法的選主部分。本文的代碼包括一個全功能的測試工具和一些您能夠用來測試系統的案例。可是它不會響應客戶端的請求,也很差維護日誌,這些功能會在第2部分添加。算法

代碼結構

簡單介紹一下Raft實現的代碼結構,本系列的全部部分都是通用的。安全

一般來講,Raft都被實現爲一個可嵌入某些服務的對象。由於咱們不會真正地開發一個服務,只是研究Raft協議自己,因此建立了一個簡單的Server類型,其中包裝了一個ConsensusModule類型,以期儘量隔離出代碼中最有趣的部分:服務器

Architecture of a consensus module embedded into a server

一致性模塊(CM)實現了Raft算法的核心,在raft.go文件中。該模塊從網絡細節以及與集羣中其它副本的鏈接中完成抽象出來,ConsensusModule中與網絡相關的惟一字段就是:網絡

// id 是一致性模塊中的服務器ID
id int

// peerIds 是集羣中全部同伴的ID列表
peerIds []int

// server 是包含該CM的服務器. 該字段用於向其它同伴發起RPC調用
server *Server
複製代碼

在實現過程當中,每一個Raft副本將集羣中的其它副本稱爲「同伴」。集羣中的每一個同伴都有一個惟一的數值ID,以及記錄其同伴ID的列表。server字段是指向模塊所在Server*(在server.go中實現)的指針,後者能夠容許ConsensusModule將消息發送給同伴。稍後咱們將看到這是如何完成的。數據結構

這樣設計的目的就是要將全部的網絡細節排除在外,從而專一於Raft算法自己。總之,要將Raft論文對照到本實現的話。你只須要ConsensusModule類及其方法。Server代碼是一個很是簡單的Go語言網絡框架,有一些細微的複雜之處來應對嚴格的測試。本系列文章中,我不會花時間討論它。可是若是有什麼不清楚的地方,請隨意提問。併發

Raft服務器狀態

總的來講,Raft CM就是一個具備3個狀態的狀態機[1]框架

Raft high level state machine

由於在序言中 花費了不少篇幅解釋Raft如何幫助實現狀態機,因此對這裏可能會有一點困惑,可是必須說明一下,這裏的術語*狀態含義是不一樣的。Raft是一個實現任意複製狀態機的算法,可是Raft內部也包含一個小的狀態機。後面的章節中,某個地方的狀態*是什麼含義均可以結合上下弄清楚——若是不能的話,我確定是指出來的。分佈式

在一個典型的穩態場景中,集羣中有一個服務器是領導者,而其它副本都是追隨者。儘管咱們很但願系統能夠一直這樣運行下去,可是Raft協議的目標就是容錯。所以,咱們會花費大部分時間來討論一下非典型的故障場景,如某些服務器崩潰,其它服務器斷開鏈接,等等。

以前提到過,Raft使用的是強領導模型。領導者響應客戶端請求,向日志中添加新條目並將其複製給其它追隨者。每一個追隨者隨時準備接管領導權,以防領導者故障或中止通訊。這也就是上圖中從追隨者候選人(Candidate)的轉變(「等待超時,開始選舉」)。

任期(Terms)

就是正常的選舉同樣,Raft中也有任期。任期指的就是某一服務器做爲領導者的一段時間。新的選舉會觸發新的任期,並且Raft算法保證在給定的任期中只有一個領導者。

可是這個比喻就到此爲止吧,由於Raft中的選主跟真正的選舉區別仍是很大的。在Raft中,選舉是更具協同性的,候選者的目標不是要不惜一切代價贏得選舉——全部候選人有一個共同的目標,那就是在任意給定的任期都有合適的服務器贏得選舉。什麼稍後會詳細討論」合適「的含義。

選舉定時器

Raft算法中的一個關鍵組成部分就是選舉定時器。這是每一個追隨者都會持續運行的定時器,每次接收到當前領導者的消息時就從新啓動它。領導者會發送週期性的心跳,所以當追隨者接收不到這些心跳信號時,他會認爲當前領導者出現故障或者斷開鏈接,並開始新一輪選舉(切換爲候選者狀態)。

:全部的追隨者不會同時變成候選人嗎?

:選舉定時器是隨機的,這也是Raft協議保持簡單性的關鍵之一。Raft經過這種隨機化來下降多個追隨者同時進行選舉的可能性。可是即使它們在同一時刻變成候選人,在任何一個任期內只有一個服務器會被選爲領導者。在極少數狀況下,會出現投票分裂致使沒有候選人獲勝,此時將進行新一輪的選舉(使用新的任期)。雖然在理論上有可能會永遠在從新選舉,可是每多一輪選舉,發生這種狀況的機率會大大下降。

:若是追隨者從集羣中斷開鏈接(分區)怎麼辦?它不會由於沒有收到領導者的消息而開始選舉嗎?

:這就是網絡分區問題的隱蔽性,由於追隨者沒法區分誰被分割了。確實,這個追隨者會開啓新一輪選舉。可是,若是這個追隨者被斷開鏈接,那麼此次選舉也會無果而終——由於它沒法聯繫到其它同伴,也就不會得到任何選票。它可能會在候選者狀態一直自旋(每隔一段時間就開啓新一輪的選舉)直到從新接入集羣中。稍後咱們會詳細討論這種狀況。

同伴間RPC

Raft協議中,同伴間會發送兩類RPC請求。詳細的參數和規則能夠參考論文中的Figure 2,或者本文的附錄。這裏簡單說明一下兩種請求:

  • RequestVote(RV):只有候選人狀態下會使用。在一輪選舉中,候選人經過該接口向同伴請求選票。返回值中包含是否贊成投票的標誌。
  • AppendEntries(AE):只有領導者狀態下使用。領導者經過該RPC將日誌條目複製給追隨者,也用來發送心跳。即便沒有要複製的日誌條目,也會按期向追隨者發送該RPC請求。

明眼人可能看出來追隨者不會發送任何的RPC請求。這是對的,追隨者不會向同伴發起RPC請求,可是它們在後臺會運行一個選舉定時器。若是定時器結束以前沒有接收到當前領導者的信息,追隨者就變成候選人並開始發送RV請求。

實現選舉定時器

是時候開始研究代碼了。除非特別說明,不然下面展現的全部代碼示例都出自這個文件。我不會把ConsensusModule結構體的全部字段——你能夠在代碼文件中去查看。

咱們的CM模塊經過在goroutime中執行如下函數來實現選舉定時器:

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)

    /* 循環會在如下條件結束: 1 - 發現再也不須要選舉定時器 2 - 選舉定時器超時,CM變爲候選人 對於追隨者而言,定時器一般會在CM的整個生命週期中一直在後臺運行。 */
    ticker := time.NewTicker(10 * time.Millisecond)
	defer ticker.Stop()
	for {
		<-ticker.C

		cm.mu.Lock()
        // CM再也不須要定時器
		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
		}

		// 若是在超時以前沒有收到領導者的信息或者給其它候選人投票,就開始新一輪選舉
		if elapsed := time.Since(cm.electionResetEvent); elapsed >= timeoutDuration {
			cm.startElection()
			cm.mu.Unlock()
			return
		}
		cm.mu.Unlock()
	}
}
複製代碼

首先經過調用cm.electionTimeout()選擇一個(僞)隨機的選舉超時時間,咱們這裏根據論文的建議將範圍設置爲150ms到300ms。像ConsensusModule中的大多數方法同樣,runElectionTimer在訪問屬性時會先鎖定結構體對象。這一步是必不可少的,由於咱們要儘量地支持併發,而這也是Go的優點之一。這也意味着代碼須要順序執行,而不能分散到多個事件處理程序中。不過,RPC請求同時也在發生,因此咱們必須保護共享數據結構。咱們後面會介紹RPC處理器。

主循環中運行了一個週期爲10ms的ticker。還有更有效的方法能夠實現等待事件,可是使用這種寫法的代碼是最簡單的。每過10ms都會執行一次循環,理論上說定時器能夠在整個等待過程當中sleep,可是這樣會致使服務響應速度降低,並且日誌中的調試/跟蹤操做會更困難。咱們會檢查狀態是否跟預期一致[2],以及任期有沒有改變,若是有任何問題,咱們就中止選舉定時器。

若是距離上一次」選舉重置事件「時間過長,服務器會開始新一輪選舉並變成候選人。什麼是選舉重置事件?能夠是任何可以終止選舉的事件——好比,收到了有效的心跳信息,爲其它候選人投票。咱們很快會看到這部分代碼。

成爲候選者

前面提到,若是追隨者在一段時間內沒有收到領導者或其它候選人的信息,它就會開始新一輪的選舉。在查看代碼以前,咱們先思考一下進行選舉須要作哪些事情:

  1. 將狀態切換爲候選人並增長任期,由於這是算法對每次選舉的要求。
  2. 發送RV請求給其它同伴,請他們在本輪選舉中爲本身投票。
  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

  // 向其它全部服務器發送RV請求
  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 {
              // 得到票數超過一半,選舉獲勝,成爲最新的領導者
              cm.dlog("wins election with %d votes", votes)
              cm.startLeader()
              return
            }
          }
        }
      }
    }(peerId)
  }

  // 另行啓動一個選舉定時器,以防本次選舉不成功
  go cm.runElectionTimer()
}
複製代碼

候選人首先給本身投票——將votesReceived初始化爲1,並賦值cm.votedFor = cm.id

而後並行地向全部的同伴發送RPC請求。每一個RPC都是在各自的goroutine中完成的,由於咱們的RPC調用的同步的——程序會阻塞至收到響應爲止,這可能須要一段時間。

這裏正好能夠演示一下RPC是如何實現的:

cm.server.Call(peerId, "ConsensusModule.RequestVote", args, &reply);
複製代碼

咱們使用ConsensusModule.server中保存的Server指針來發起遠程調用,並指定ConsensusModule.RequestVotes做爲請求的方法名,最終會調用第一個參數指定的同伴服務器中的RequestVote方法。

若是RPC調用成功,由於已通過去了一段時間,咱們必須檢查服務器狀態來決定下一步操做。若是咱們的狀態不是候選人,退出。何時會出現這種狀況呢?舉例來講,咱們可能由於其它RPC請求返回了足夠多的票數而勝選成爲領導者,或者某個RPC請求從其它服務器收到了更高的任期,所以咱們退化爲跟隨者。必定要要記住,在網絡不穩定的狀況下,RPC請求可能須要很長時間才能到達——當咱們收到回覆時,可能其它代碼已經繼續執行了,在這種狀況下優雅地放棄很是重要。

若是收到回覆時咱們仍是候選人狀態,先檢查回覆信息中的任期並與咱們發送請求時的任期進行比較。若是返回信息中的任期更高,咱們就恢復到追隨者狀態。例如,咱們在收集選票時其它服務器勝選就會出現該狀況。

若是返回的任期與咱們發送時一致,檢查是否同意投票。咱們使用原子變量votes從多個goroutine中安全地收集選票,若是服務器收到了大多數的同意票(包括本身的同意票),就變成領導者。

注意這裏的startElection方法是非阻塞的。方法中會更新一些狀態,啓動一批goroutine並返回。所以,還應該在goroutine中啓動新的選舉定時器——也就是最後一行代碼所作的事。這樣能夠保證,若是本輪選舉沒有任何結果,在定時結束後會開始新一輪的選舉。這也解釋了runElectionTimer中的狀態檢查:若是本輪選舉確實將該服務器變成了領導者,那麼併發運行的runElectionTimer在觀察到服務器狀態與指望值不一樣時會直接返回。

成爲領導者

咱們已經看到,當投票結果顯示當前服務器勝選時,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()

    // 只要當前服務器是領導者,就要週期性發送心跳
    for {
      cm.leaderSendHeartbeats()
      <-ticker.C

      cm.mu.Lock()
      if cm.state != Leader {
        cm.mu.Unlock()
        return
      }
      cm.mu.Unlock()
    }
  }()
}
複製代碼

這其實是一個至關簡單的方法:全部的內容就是心跳定時器——只要當前的CM是領導者,這個goroutine就會每隔50ms調用一次leaderSendHeartbeats。下面是leaderSendHeartbeats對應的代碼:

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

  // 向全部追隨者發送AE請求
  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返回的任期高於咱們本身的任期值,則當前服務器變爲追隨者。這裏正好查看一下becomeFollower方法:

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
}
複製代碼

注意這裏檢查了「dead」狀態,稍後會討論這一點。

首先是一段熟悉的邏輯,檢查任期是否過期並轉換爲追隨者。若是它已是一個追隨者,狀態不會改變可是其它狀態屬性會重置。

不然,若是調用者的任期與咱們一致,並且咱們還沒有給其它候選人投票,那咱們就同意該選票。咱們決不會向舊任期發起的RPC請求投票。

下面是AppendEntries的代碼:

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)
}
複製代碼

Q:若是服務器是領導者呢——爲何要變成其它領導者的追隨者?

A:Raft協議保證了在任一給定的任期都只有惟一的領導者。若是你本身研究RequestVote的邏輯,以及startElection中發送RV請求的代碼,你會發如今集羣中不會有兩個使用相同任期的領導者存在。這個條件對於那些發現其它同伴贏得本輪選舉的候選人很重要。

狀態和goroutine

咱們有必要回顧一下CM中可能存在的全部狀態,以及其對應運行的不一樣goroutine:

追隨者:當CM被初始化爲追隨者,或者每次執行becomeFollower方法時,都會啓動新的goroutine運行runElectionTimer,這是追隨者的附屬操做。請注意,在短期內可能同時運行多個選舉定時器。假設一個追隨者收到了領導者發出的帶有更高任期的RV請求,這將觸發一次becomeFollower調用並啓動一個新的定時器goroutine。可是舊的goroutine只要注意到任期的變化就會天然退出。

候選人:候選人也有一個並行運行的選舉定時器goroutine,可是除此以外,它還有一些發送RPC請求的goroutine。它與追隨者具備相同的保護措施,能夠在新選舉開始時中止」舊「的選舉goroutine。必定要記住,RPC goroutine可能須要很長時間才能完成,所以,若是RPC調用返回時,它們發現自身的任期已通過時,那麼它們必須安靜地退出。

領導者:領導者沒有選舉定時goroutine,可是它確定有一個每隔50ms執行一次的心跳goroutine。

代碼中還有一個附加的狀態——Dead狀態。這純粹是爲了有序關閉CM。調用」Stop「方法會將狀態置爲Dead,全部的goroutine在觀察到該狀態後會當即退出。

這些goroutine的運行可能會讓人擔心——若是其中一些goroutine滯留在後臺,該怎麼辦?或者出現更糟的狀況,這些goroutine不斷泄漏並且數量無限制地增加,怎麼辦?這也就是泄漏檢查的目的,並且一些測試案例中也啓用了泄漏檢查。這些測試中會執行很是規的一系列Raft選舉操做,並保證在測試結束後沒有任何遊離的goroutine在運行(在調用stop方法以後,給這些goroutine一些時間去退出)。

服務器失控和增長任期

做爲這一部分的總結,咱們來研究一個可能出現的複雜場景以及Raft如何應對。我以爲這個例子頗有趣,也頗有啓發性。這裏我試圖以故事的方式來呈現,可是你最好有一張紙來記錄各服務器的狀態。若是你無法理解這個示例——請發郵件告知我,我很樂意將它改得更清楚一些。

想象一個有三臺服務器A,B和C的集羣。假設A是領導者,起始任期是1,而且集羣正在完美運行着。A每隔50ms都想B、C發一次心跳AE請求,並在幾毫秒內獲得及時響應。每一次的AE請求都會重置B、C中的electionResetEvent屬性,所以它們也都很願意繼續作追隨者。

在某個時刻,因爲網絡路由器的臨時故障,服務器B與A、C之間出現了網絡分區。A仍然每隔50ms發一次AE請求,可是這些AE要求要麼當即失敗,或者是因爲底層RPC引擎的超時致使失敗。A對此無能爲力,可是問題也不大。咱們目前尚未涉及到日誌複製,可是由於3臺服務器中的2臺都是正常的,集羣仍然能夠提交客戶端指令。

那麼B呢?假設在斷開鏈接的時候,它的選舉超時設置爲了200ms。在斷開鏈接大約200ms後,B的runElectionTimer會意識到在選舉等待時間內沒有收到領導者的信息,B沒法區分是誰出了錯,因此它就變爲了候選者並開啓一輪選舉。

所以B的任期將變爲2(而A和C的任期仍然是1)。B會向A和C發送RV請求,要求他們爲本身投票;固然,這些請求會丟失在網絡中。不要驚慌!B中的startElection方法也啓動了另外一個goroutine執行runElectionTimer任務,假設這個goroutine會等待250ms(要記住,咱們的超時時間是在150ms-300ms之間隨機選擇的),以查看上一輪選舉是否出現實質性的結果。由於B仍然被徹底隔離,也就不會發生什麼,所以runElectionTimer會發起另外一輪選舉,並將任期增長到3。

如此這般,B的服務器在幾秒鐘以後自我重置並從新上線,與此同時,B因爲每隔一段時間都發起選舉,它的任期已經變爲8。

這時網絡分區問題已經修復,B從新鏈接到了A和C。

不久以後,A發送的AE請求到了。回想一下,A每隔50ms都會發送心跳信息,即便B一直沒有回覆。

B的AppendEntries被調用,而且回覆信息中攜帶的任務爲8.

A在leaderSendHeartbeats方法中收到此回覆,檢查回覆信息中的任期後發起比本身的任期更高。A將自身的任期改成8並變成追隨者。集羣暫時失去了領導者。

接下來根據定時的不一樣,可能會出現多種狀況。B是候選者,可是它可能在網絡恢復以前已經發送了RV請求;C是追隨者,可是因爲在選舉超時內沒有收到A的AE請求,也會變成候選人;A變成了追隨者,也可能由於選舉超時變成候選人。

因此其中任何一個服務器均可能在下一輪的選舉中勝選,注意,這只是由於咱們在這裏並無複製任何日誌。咱們將在下一部分看到,實際狀況下,A和C可能會在B離線的時候添加了一些寫的客戶端指令,所以它們的日誌是最新的。所以,B不會變成新的領導者——會出現新的一輪選舉,並且A或C會勝選。在下一部分咱們會再次討論這個場景。

假如在B斷開鏈接以後沒有新增任何指令,則從新鏈接以後更換領導者也是徹底能夠的。

看起來可能有些效率低下——確實如此。這裏更換領導者是沒必要要的,由於A在整個場景中都是很是健康的。可是,以犧牲特殊狀況下的效率爲代價,保證算法邏輯的簡單性,這也是Raft作出的選擇之一。算法在通常情形(沒有任何異常)下的效率更重要,由於集羣99.9%的時間都處於該狀態。

下一步

爲了確保你對實現的理解不只僅侷限在理論,我強烈建議你運行一下代碼

代碼庫中的README文件對於代碼交互、運行測試用例、觀察結果提供了詳細的說明。代碼中附帶了不少針對特定場景的測試(包括前面章節中提到的場景),運行一個測試用例並查看Raft日誌對於學習頗有意義。注意到代碼中調用的cm.dlog(...)了嗎?倉庫中提供了一個工具能夠將這些日誌在HTML文件中進行可視化——能夠在README文件中查看說明。運行代碼,查看日誌,也能夠在代碼中隨意添加本身的dlog,以便更好地理解代碼中的不一樣部分是在什麼時候運行的。

本系統的第2部分會描述更完整的Raft實現,其中處理了客戶端的指令,並在集羣中複製這些日誌。敬請關注!

附:

Raft論文中的圖2以下所示,這裏對其作簡要的翻譯及說明。其中有部分關於日誌複製和提交的,能夠在看完下一篇以後從新對照理解。

Raft-RPC參數

狀態 State

服務器中的狀態字段有三類,分別進行介紹。

全部服務器中都須要持久化保存的狀態(在響應RPC請求以前須要更新到穩定的存儲介質中)

字段 說明
currentTerm 服務器接收到的最新任期(啓動時初始化爲0,單調遞增)
votedFor 在當前任期內收到贊同票的候選人ID(若是沒有就是null)
log[] 日誌條目;每一個條目中 包含輸入狀態機的指令,以及領導者接收條目時的任期(第一個索引是1)

全部服務器中常常修改的狀態字段:

字段 說明
commitIndex 確認已提交的日誌條目的最大索引值(初始化爲0,單調遞增)
lastApplied 應用於狀態機的日誌條目的最大索引值(初始化爲0,單調遞增)

領導者服務器中常常修改的狀態字段(選舉以後從新初始化):

字段 說明
nextIndex[] 對於每一個服務器,存儲要發送給該服務器的下一條日誌條目的索引(初始化爲領導者的最新日誌索引+1)
matchIndex[] 對於每一個服務器,存儲確認複製到該服務器的日誌條目的最大索引值(初始化爲0,單調遞增)

AE請求

AE請求即AppendRntries請求,由領導者發起,用於向追隨者複製客戶端指令,也用於維護心跳。

請求參數
參數 說明
term 領導者的任期
leaderId 領導者ID,追隨者就能夠對客戶端進行重定向
prevLogIndex 緊接在新日誌條目以前的條目的索引
prevLogTerm prevLogIndex對應條目的任期
entries[] 須要報錯的日誌條目(爲空時是心跳請求;爲了高效可能會發送多條日誌)
leaderCommit 領導者的commitIndex
返回值
參數 說明
term currentTerm,當前任期,回覆給領導者。領導者用於自我更新
success 若是追隨者保存了prevLogIndexprevLogTerm相匹配的日誌條目,則返回true
接收方實現:
  1. 若是term < currentTerm,返回false
  2. 若是日誌中prevLogIndex對應條目的任期與prevLogTerm不匹配,返回false
  3. 若是本地已存在的日誌條目與新的日誌衝突(索引相同,可是任期不一樣),刪除本地已存在的條目及其後全部的條目;
  4. 追加全部log中未保存的新條目;
  5. 若是leaderCommit > commitIndex,就將commitIndex設置爲leaderCommit和最新條目的索引中的較小值。

RV請求

候選人執行,用於在發起選舉時收集選票。

請求參數
字段 說明
term 候選人的任期
candidateId 請求選票的候選人ID
lastLogIndex 候選人的最新日誌條目對應索引
lastLogTerm 候選人的最新日誌條目對應任期
返回值
字段 說明
term currentTerm,當前任期,回覆給候選人。候選人用於自我更新
voteGranted true表示候選人得到了同意票
接收方實現:
  1. 若是term < currentTerm返回false
  2. 若是votedFor爲空或等於candidateId,而且候選人的日誌至少與接收方的日誌同樣新,投出同意票。

服務器響應規則

按照服務器當下的狀態(所處的角色),分別進行介紹:

全部服務器:
  • 若是commitIndex > lastApplied:增長lastApplied,將log[lastApplied]應用到狀態機;
  • 若是RPC請求或者響應中攜帶的任期T知足T > currentTerm:設置currentTerm = T,轉換爲追隨者。
追隨者:
  • 響應候選人和領導者的RPC請求;
  • 若是超時等待時間內沒有收到當前領導者的AE請求或者給候選人投出選票:轉換爲候選人。
候選人:
  • 剛轉換爲候選人時,啓動選舉:
    • 增長當前任期,currentTerm
    • 給本身投票
    • 重置選舉定時器
    • 向其它全部服務器發送RV請求
  • 若是接收到多數服務器的同意票:變成領導者
  • 若是接收到新領導者發出的AE請求:轉換爲追隨者
  • 若是選舉等待超時:開始新一輪選舉
領導者:
  • 當選時:向每一個服務器發送初始化的空AE請求(心跳);在空閒時間也重複發送AE請求,防止追隨者出現等待超時;
  • 若是從客戶端接收到指令:向本地日誌中追加條目,在新指令被應用到狀態機以後響應客戶端;
  • 若是最新的日誌索引index與追隨者下一條日誌索引nextIndex 知足 index ≥ nextIndex:向追隨者發送AE請求,攜帶從nextIndex開始的全部日誌條目:
    • 若是成功:更新追隨者對應的nextIndexmatchIndex
    • 若是AE因爲日誌不一致而失敗:減少nextIndex並重試;
  • 若是存在N,知足N > commitIndex,多數的matchIndex[i] ≥ N,而且log[N].term == currentTerm:設置commitIndex = N

  1. 這張示意圖與Raft論文中的圖4是相同的。正好也能夠提醒一下,在本系列的文章中。我都假設您已經讀過這篇論文了。 ↩︎

  2. 檢查狀態是否追隨者和候選人可能看起來有點奇怪。難道服務器能夠不經過runElectionTimer發起的選舉而忽然成爲領導者嗎? 繼續日後閱讀了解候選人是如何重啓選舉計數器的。 ↩︎

相關文章
相關標籤/搜索