Go 實現 Raft 第四篇:持久化和調優

CrazyZard發表於2020-04-30

像 Raft 這樣的共識演算法的目標是通過在隔離的伺服器之間複製任務來建立一個比其各個部分具有更高可用性的系統。到目前為止,我們一直專注於網路分割槽的故障情況,其中群集中的某些伺服器與其他伺服器(或與客戶端)斷開連線。 失敗的另一種模式是崩潰,其中伺服器停止工作並重新啟動。

對於其他伺服器,它看起來像一個網路分割槽-伺服器暫時斷開連線,而對於崩潰的伺服器本身,情況則大不相同,因為重新啟動後,其所有記憶體狀態都會丟失。

正是由於這個原因,Raft 論文中的圖2清楚地標記了哪個狀態應該保持不變;持久狀態將在每次更新時寫入並重新整理到持久化儲存中。在伺服器發出下一個 RPC 或答覆正在進行的 RPC 之前,伺服器必須保留的任何狀態都將保留。

Raft 只能通過保留其狀態的子集來實現,即:

  • currentTerm - 此伺服器觀察到的最新任期

  • votedFor - 此伺服器在最新任期為其投票的節點 ID

  • log - Raft 日誌條目

在 Raft 中,視不同情況,一個命令可以多次傳遞給客戶端。有幾種可能發生這種情況的場景,包括崩潰導致重新啟動(再次重播日誌時)。

就訊息傳遞語義而言,Raft 選擇的是”至少一次”。提交命令後,它將最終複製到所有客戶端,但是某些客戶端可能多次看到同一命令。因此,建議命令帶有唯一的 ID,並且客戶端應忽略已交付的命令。這在 Raft 論文中的第8節有更詳細的描述。

為了實現永續性,我們在程式碼中新增了以下介面:

type Storage interface {
  Set(key string, value []byte)

  Get(key string) ([]byte, bool)

  // HasData returns true iff any Sets were made on this Storage.
  HasData() bool
}

可以將它看作是一個對映,從字串對映到一個由持久儲存支援的通用位元組切片。

CM 建構函式現在將接受一個 Storage 作為引數並呼叫:

if cm.storage.HasData() {
cm.restoreFromStorage(cm.storage)
}

restoreFromStorage 方法也是新增。它從儲存中載入持久狀態變數,使用標準的 encoding/gob 包對它們進行反序列化

func (cm *ConsensusModule) restoreFromStorage(storage Storage) {
  if termData, found := cm.storage.Get("currentTerm"); found {
    d := gob.NewDecoder(bytes.NewBuffer(termData))
    if err := d.Decode(&cm.currentTerm); err != nil {
      log.Fatal(err)
    }
  } else {
    log.Fatal("currentTerm not found in storage")
  }
  if votedData, found := cm.storage.Get("votedFor"); found {
    d := gob.NewDecoder(bytes.NewBuffer(votedData))
    if err := d.Decode(&cm.votedFor); err != nil {
      log.Fatal(err)
    }
  } else {
    log.Fatal("votedFor not found in storage")
  }
  if logData, found := cm.storage.Get("log"); found {
    d := gob.NewDecoder(bytes.NewBuffer(logData))
    if err := d.Decode(&cm.log); err != nil {
      log.Fatal(err)
    }
  } else {
    log.Fatal("log not found in storage")
  }
}

映象方法為 persistToStorage - 將所有這些狀態變數編碼並儲存到提供的 Storage 中:

func (cm *ConsensusModule) persistToStorage() {
  var termData bytes.Buffer
  if err := gob.NewEncoder(&termData).Encode(cm.currentTerm); err != nil {
    log.Fatal(err)
  }
  cm.storage.Set("currentTerm", termData.Bytes())

  var votedData bytes.Buffer
  if err := gob.NewEncoder(&votedData).Encode(cm.votedFor); err != nil {
    log.Fatal(err)
  }
  cm.storage.Set("votedFor", votedData.Bytes())

  var logData bytes.Buffer
  if err := gob.NewEncoder(&logData).Encode(cm.log); err != nil {
    log.Fatal(err)
  }
  cm.storage.Set("log", logData.Bytes())
}

我們只需在這些狀態變數發生變化的每個點呼叫 pesistToStorage 來實現持久化。如果看一下第2部分中CM的程式碼與本部分之間的區別,會發現它們散佈在少數地方。

當然,這不是實現永續性的最有效的方法,但是簡單有效,所以足以滿足我們的需要。效率最低的是儲存整個日誌,這在實際應用中可能很大。為了真正解決這個問題,Raft 有一個日誌壓縮機制,該機制在本文的第7節中進行了描述。我們不打算實現壓縮,但是可以將其作為練習新增到我們的實現中。

實施永續性後,我們的 Raft 叢集在一定程度上可以應對崩潰。只要叢集中的少數節點崩潰並在以後的某個時間點重新啟動,叢集就將對客戶端保持可用。具有2N + 1個伺服器的 Raft 群集將容忍N臺故障伺服器,並且只要其他 N + 1 臺伺服器仍保持相互連線,便會保持可用。

如果檢視此部分的測試,會注意到新增了許多新測試。崩潰伸縮可以測試更大範圍的人為情況組合,本文中也對此進行了一定程度的描述

需要注意的另一個方面是不可靠的 RPC 交付。到目前為止,我們已經假設在連線的伺服器之間傳送的 RPC 可能到達目的地的時間很短。如果檢視 server.go ,會注意到它使用了一種稱為 RPCProxy 的型別來實現這些延遲。每個 RPC 都會延遲1-5毫秒,以模擬位於同一資料中心的節點的真實性。

RPCProxy 讓我們實現的另一件事是可選的不可靠交付。啟用 RAFT_UNRELIABLE_RPC 環境變數後,RPC 有時會明顯延遲(延遲75毫秒)或完全中斷。模擬了實際的網路故障。

我們可以在 RAFT_UNRELIABLE_RPC 開啟的情況下重新執行所有測試,並觀察 Raft 群集在出現這些故障時的行為。如果有興趣,可以嘗試調整 RPCProxy,不僅讓 RPC 請求延遲,還可以讓 RPC 答覆延遲

正如在第2部分中簡要提到的,當前的領導者執行效率很低。領導者在 LeaderSendHeartbeats 中傳送AE,定時器每隔50毫秒呼叫一次。假設提交了一條新命令;領導者將等到下一個50毫秒的邊界,而不是立即通知跟隨者。更糟的是,因為需要兩次AE往返來通知跟隨者命令已提交。如圖:

Go實現Raft第四篇:持久化和調優

在時間(1),領導者將心跳 AE 傳送給跟隨者,並在幾毫秒內獲得響應。例如,在35毫秒後提交了新命令。領導者一直等到下一個50毫秒邊界(2)才將更新的日誌傳送給跟隨者。跟隨者答覆該命令已成功新增到日誌(3)。此時,領導者已經提高了提交索引(假設它獲得了多數),可以立即通知跟隨者,但是它一直等到下一個50毫秒邊界(4)為止。最後,當跟隨者收到更新的 leaderCommit時,它可以將新提交的命令通知其自己的客戶端。

在領導者的 Submit(X) 和跟隨者的 commitChan <-X 之間經過的大部分時間對於實現來講都是不必要的。

真正想要的是使序列看起來像這樣:

Go實現Raft第四篇:持久化和調優

看一下實現的新部分,從startLeader開始。

func (cm *ConsensusModule) startLeader() {
  cm.state = Leader

  for _, peerId := range cm.peerIds {
    cm.nextIndex[peerId] = len(cm.log)
    cm.matchIndex[peerId] = -1
  }
  cm.dlog("becomes Leader; term=%d, nextIndex=%v, matchIndex=%v; log=%v", cm.currentTerm, cm.nextIndex, cm.matchIndex, cm.log)

  // This goroutine runs in the background and sends AEs to peers:
  // * Whenever something is sent on triggerAEChan
  // * ... Or every 50 ms, if no events occur on triggerAEChan
  go func(heartbeatTimeout time.Duration) {
    // Immediately send AEs to peers.
    cm.leaderSendAEs()

    t := time.NewTimer(heartbeatTimeout)
    defer t.Stop()
    for {
      doSend := false
      select {
      case <-t.C:
        doSend = true

        // Reset timer to fire again after heartbeatTimeout.
        t.Stop()
        t.Reset(heartbeatTimeout)
      case _, ok := <-cm.triggerAEChan:
        if ok {
          doSend = true
        } else {
          return
        }

        // Reset timer for heartbeatTimeout.
        if !t.Stop() {
          <-t.C
        }
        t.Reset(heartbeatTimeout)
      }

      if doSend {
        cm.mu.Lock()
        if cm.state != Leader {
          cm.mu.Unlock()
          return
        }
        cm.mu.Unlock()
        cm.leaderSendAEs()
      }
    }
  }(50 * time.Millisecond)
}

不僅要等待50 ms的計時,startLeader中的迴圈還要等待兩個可能的事件之一:

  • 在cm.triggerAEChan上傳送
  • 計時器計數50毫秒

我們將很快看到觸發 cm.triggerAEChan 的原因。這是現在應該傳送AE的訊號。每當觸發通道時,計時器都會重置,並執行心跳邏輯-如果領導者沒有新的要報告的內容,則最多等待50毫秒。

還要注意,實際傳送AE的方法已從 leaderSendHeartbeats 重新命名為 leaderSendAE,可以更好地在新程式碼中反映其目的。

我們所期望的,觸發cm.triggerAEChan的方法之一是SubmitL:

func (cm *ConsensusModule) Submit(command interface{}) bool {
  cm.mu.Lock()
  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.persistToStorage()
    cm.dlog("... log=%v", cm.log)
    cm.mu.Unlock()
    cm.triggerAEChan <- struct{}{}
    return true
  }

  cm.mu.Unlock()
  return false
}

修改成:

每當提交新命令時,都會呼叫cm.persistToStorage來保留新的日誌條目。

一個空結構在 cm.triggerAEChan 上傳送。將通知領導者goroutine中的迴圈。

鎖定處理將重新排序;在傳送cm.triggerAEChan時不想保持鎖定,因為在某些情況下可能導致死鎖。
在領導者中處理AE答覆並推進提交索引的程式碼中 cm.triggerAEChan 將被通知。

if cm.commitIndex != savedCommitIndex {
    cm.dlog("leader sets commitIndex := %d", cm.commitIndex)
    // Commit index changed: the leader considers new entries to be
    // committed. Send new entries on the commit channel to this
    // leader's clients, and notify followers by sending them AEs.
    cm.newCommitReadyChan <- struct{}{}
    cm.triggerAEChan <- struct{}{}
}

這個優化很重要,它使實現比以前對新命令的響應速度更快。

現在,每次呼叫 Submit 都會觸發很多活動 - 領導者立即向所有跟隨者廣播 RPC。如果想一次提交多個命令,連線Raft群集的網路可能會被RPC 淹沒。

儘管它看起來效率低,但是安全。Raft的 RPC 都是冪等的,也就是說多次獲得具有基本相同資訊的 RPC 不會造成任何危害。

如果擔心一次要頻繁提交許多命令時的網路流量,那麼批處理應該很容易實現。最簡單的方法是提供一種將整個命令片段傳遞到 Submit 的方法。這樣 Raft 實現中的程式碼改動會很小,並且客戶端將能夠提交整個命令組,而不會產生太多的 RPC 通訊。有興趣的可以嘗試一下!

摘抄: 360雲端計算

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

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

相關文章