使用hashicorp Raft開發分佈式服務

使用hashicorp Raft開發高可用服務

開發raft時用到的比較主流的兩個庫是Etcd Raft 和hashicorp Raft,網上也有一些關於這兩個庫的討論之前分析過etcd Raft,發現該庫相對hashicorp Raft比較難以理解,其最大的問題是沒有實現網絡層,實現難度比較大,因此本文在實現時使用了hashicorp Raft。

下文中會參考consul的一致性協議來講解如何實現Raft協議。

Raft概述

術語

  • Log entry:Raft的主要單元。Raft將一致性問題分解爲日誌複製。日誌是一個有序的表項,其包含了Raft的集羣變更信息(如添加/移除節點)以及對應用數據的操作等。
  • FSM:Finite State Machine。FSM是有限狀態的集合。當一條日誌被Raft apply後,可以對FSM進行狀態轉換。相同順序的日誌在apply之後必須產生相同的結果,即行爲必須是確定性的。
  • Peer set:指所有參與日誌複製的成員。
  • Quorum:仲裁指peer set中的大部分成員:對於包含N個成員的peer set,仲裁要求有(N/2)+1個成員。如果出於某些原因導致仲裁節點不可用,則集羣會變爲unavailable狀態,且新的日誌也不會被commit。
  • Committed Entry:當一個Log entry持久化到仲裁數量的節點後,該認爲該Log entry是Committed的。只有當Log entry 被Committed之後,它纔會被FSM apply。
  • Leader:任何時間,peer set會選舉一個節點作爲leader。leader負責處理新的Log entry,並將其複製給follower,以及決定何時將Log entry判定爲committed狀態。

Raft機制簡介

Raft節點總是處於三種狀態之一: follower, candidate, leader。一開始,所有的節點都是follower,該狀態下,節點可以從leader接收log,並參與選舉,如果一段時間內沒有接收到任何Log entry,則節點會自提升到candidate狀態。在candidate狀態下,節點會請求其他節點的選舉,如果一個candidate接收到大部分節點(仲裁數目)的認同,就會被提升爲leader。leader必須接收新的Log entry,並複製到所有其他follower。

如果用戶無法接受舊的數據,則所有的請求必須由leader執行。

一旦一個集羣有了leader,就可以接收新的Log entry。客戶端可以請求leader追加一個新的Log entry。Leader會將Log entry寫入持久化存儲,並嘗試將其複製給仲裁數目的follower。一旦Log entry被commit,就可以將該Log entry apply到FSM。FSM是應用特定的存儲,在Consul中,使用 MemDB來維護集羣狀態。

無限量複製log的方式是不可取的。Raft 提供了一種機制,可以對當前狀態進行快照並壓縮log。由於 FSM 的抽象,FSM 的狀態恢復必須與replay log的狀態相同。Raft 可以捕獲某個時刻的 FSM 狀態,然後移除用於達到該狀態的所有log。這些操作可以在沒有用戶干預的情況下自動執行,防止無限使用磁盤,同時最小化replay log所花費的時間。

Raft Consensus中所有的操作都必須經過Leader,因此需要保證所有的請求都能夠發送到Leader節點,然後由Leader將請求發送給所有Follower,並等待大部分(仲裁數目的)節點處理完成該命令,leader通過選舉機制產生。之後每個follower會執行如下操作:

  1. 在接收到命令之後,使用WAL方式將數據保存爲Log entry
  2. 在成功寫入log entry之後,將數據發給FSM進行處理
  3. 在FSM成功處理完數據之後,返回數據。之後Leader會注意到該節點已經成功完成數據處理。

如下表述來自Raft Protocol Overview,它的意思是,如果是查詢類的請求,直接從FSM返回結果即可,如果是修改類的請求,則需要通過raft.Apply來保證變更的一致性

所有raft集羣中的成員都知道當前的leader,當一個RPC請求到達一個非leader的成員時,它會將該請求轉發給當前的leader。如果是查詢類型的RPC,意味着它是隻讀的,leader會基於FSM的當前狀態生成結果;如果是事務類型的RPC,意味着它需要修改狀態,Leader會生成一條新的log entry,並執行Raft.Apply,當log entry被提交併apply到FSM之後,事務纔算執行完成。

接口和原理描述

如下是官方給出的Raft Apply的流程圖:

sequenceDiagram autonumber participant client participant leadermain as leader:main participant leaderfsm as leader:fsm participant leaderreplicate as leader:replicate (each peer) participant followermain as follower:main (each peer) participant followerfsm as follower:fsm (each peer) client-)leadermain: applyCh to dispatchLogs leadermain->>leadermain: store logs to disk leadermain-)leaderreplicate: triggerCh leaderreplicate-->>followermain: Transport.AppendEntries RPC followermain->>followermain: store logs to disk opt leader commit index is ahead of peer commit index followermain-)followerfsm: fsmMutateCh <br>apply committed logs followerfsm->>followerfsm: fsm.Apply end followermain-->>leaderreplicate: respond success=true leaderreplicate->>leaderreplicate: update commitment opt quorum commit index has increased leaderreplicate-)leadermain: commitCh leadermain-)leaderfsm: fsmMutateCh leaderfsm->>leaderfsm: fsm.Apply leaderfsm-)client: future.respond end

Apply是數據進入Raft的接口,整個Raft的主要作用是維護數據操作的一致性。在上圖中,由兩個apply:一個是Raft.Apply(內部會通過applyCh傳遞log),其也是外部數據的入口。另一個是FSM.Apply,其數據源頭是Raft.Apply。FSM基於Raft實現了一致性讀寫。在上圖中可以看到,leader的FSM.Apply是在數據commit成功(仲裁成功)之後才執行的,這樣就能以Raft的方式保證分佈式場景下應用數據的一致性,可以將FSM.Apply理解爲應用數據的寫入操作。

Raft中的一條log表示一個操作。使用hashicorp/raft時應該將實現分爲兩層:一層是底層的Raft,支持Raft數據的存儲、快照等,集羣的選舉和恢復等,這一部分由Raft模塊自實現;另一層是應用層,需要由用戶實現FSM接口,FSM的接口並不對外,在Raft的處理過程中會調用FSM的接口來實現應用數據的存儲、備份和恢復等操作。這兩層都有數據的讀寫和快照實現,因此在理解上需要進行區分。

Raft節點的初始化

如果是新建的Raft節點,可以使用BootstrapCluster方法初始化該節點。爲避免非新的節點被初始化,在調用BootstrapCluster前可以使用raft.HasExistingState來判斷實例中是否包含相關狀態(logs,當前term或snapshot):

if (s.config.Bootstrap) && !s.config.ReadReplica {
  hasState, err := raft.HasExistingState(log, stable, snap)
  if err != nil {
    return err
  }
  if !hasState {
    configuration := raft.Configuration{
      Servers: []raft.Server{
        {
          ID:      s.config.RaftConfig.LocalID,
          Address: trans.LocalAddr(),
        },
      },
    }
    if err := raft.BootstrapCluster(s.config.RaftConfig,
      log, stable, snap, trans, configuration); err != nil {
      return err
    }
  }
}

Raft節點的創建

Raft節點的創建方法如下,如果存儲非空,則Raft會嘗試恢復該節點:

func NewRaft(conf *Config,
    fsm FSM,
    logs LogStore,
    stable StableStore,
    snaps SnapshotStore,
    trans Transport) (*Raft, error) {

包括:

  • fsm:由應用實現,用於處理應用數據。FSM中的數據來自底層的Raft log

  • logsstablesnapslogs(存儲Raft log) ,stable(保存Raft選舉信息,如角色、term等信息) 可以使用raftboltdb.New進行初始化, snaps用於Leader和follower之間的批量數據同步以及(手動或自動)集羣恢復,可以使用raft.NewFileSnapshotStoreraft.NewFileSnapshotStoreWithLogger進行初始化。

  • trans:Transport是raft集羣內部節點之間的信息通道,節點之間需要通過該通道來同步log、選舉leader等。下面接口中的AppendEntriesPipelineAppendEntries方法用於log同步,RequestVote用於leader選舉,InstallSnapshot用於在follower 的log落後過多的情況下,給follower發送snapshot(批量log)。

    可以使用raft.NewTCPTransportraft.NewTCPTransportWithLoggerraft.NewNetworkTransportWithConfig方法來初始化trans。

    type Transport interface {
      ...
      AppendEntriesPipeline(id ServerID, target ServerAddress) (AppendPipeline, error)
      AppendEntries(id ServerID, target ServerAddress, args *AppendEntriesRequest, resp *AppendEntriesResponse) error
    
      // RequestVote sends the appropriate RPC to the target node.
      RequestVote(id ServerID, target ServerAddress, args *RequestVoteRequest, resp *RequestVoteResponse) error
      InstallSnapshot(id ServerID, target ServerAddress, args *InstallSnapshotRequest, resp *InstallSnapshotResponse, data io.Reader) error
      ...
    }
    

NewRaft方法中會運行如下後臺任務:

r.goFunc(r.run)           //處理角色變更和RPC請求
r.goFunc(r.runFSM)        //負責將logs apply到FSM
r.goFunc(r.runSnapshots)  //管理FSM的snapshot

監控Leader變化

爲保證數據的一致性,只能通過leader寫入數據,因此需要及時瞭解leader的變更信息,在Raft的配置中有一個變量NotifyCh chan<- bool,當Raft變爲leader時會將true寫入該chan,通過讀取該chan來判斷本節點是否是leader。在初始化Raft配置的時候傳入即可:

  leaderNotifyCh := make(chan bool, 10)
  raftConfig.NotifyCh = leaderNotifyCh

還有其他方式可以獲取leader變更狀態:

如下方法可以生成一個chan,當本節點變爲Leader時會發送true,當本節點丟失Leader角色時發送false,該方法的用途與上述方式相同,但由於該方法沒有緩存,可能導致丟失變更信號,因此推薦使用上面的方式。

func (r *Raft) LeaderCh() <-chan bool

實現FSM

至此已經完成了Raft的初始化。下面就是要實現初始化函數中要求實現的內容,主要就是實現FSM接口。其中logsstablesnapstrans已經提到,使用現成的方法初始化即可。對於存儲來說,也可以根據需要採用其他方式,如S3。

下面是LogStoreSnapshotStoreStableStore的接口定義。

type LogStore interface {//用於存儲Raft log
  // FirstIndex returns the first index written. 0 for no entries.
  FirstIndex() (uint64, error)

  // LastIndex returns the last index written. 0 for no entries.
  LastIndex() (uint64, error)

  // GetLog gets a log entry at a given index.
  GetLog(index uint64, log *Log) error

  // StoreLog stores a log entry.
  StoreLog(log *Log) error

  // StoreLogs stores multiple log entries.
  StoreLogs(logs []*Log) error

  // DeleteRange deletes a range of log entries. The range is inclusive.
  DeleteRange(min, max uint64) error
}
type SnapshotStore interface {//用於快照的生成和恢復
  // Create is used to begin a snapshot at a given index and term, and with
  // the given committed configuration. The version parameter controls
  // which snapshot version to create.
  Create(version SnapshotVersion, index, term uint64, configuration Configuration,
    configurationIndex uint64, trans Transport) (SnapshotSink, error)

  // List is used to list the available snapshots in the store.
  // It should return then in descending order, with the highest index first.
  List() ([]*SnapshotMeta, error)

  // Open takes a snapshot ID and provides a ReadCloser. Once close is
  // called it is assumed the snapshot is no longer needed.
  Open(id string) (*SnapshotMeta, io.ReadCloser, error)
}
type StableStore interface { //用於存儲集羣元數據
  Set(key []byte, val []byte) error

  // Get returns the value for key, or an empty byte slice if key was not found.
  Get(key []byte) ([]byte, error)

  SetUint64(key []byte, val uint64) error

  // GetUint64 returns the uint64 value for key, or 0 if key was not found.
  GetUint64(key []byte) (uint64, error)
}

FSM基於Raft來實現,包含三個方法:

  • Apply:在Raft完成commit索引之後,保存應用數據。
  • Snapshot:用於支持log壓縮,可以保存某個時間點的FSM快照。需要注意的是,由於ApplySnapshot運行在同一個線程中(如runrunFSM線程),因此要求函數能夠快速返回,否則會阻塞Apply的執行。在實現中,該函數只需捕獲指向當前狀態的指針,而對於IO開銷較大的操作,則放到FSMSnapshot.Persist中執行。
  • Restore:用於從snapshot恢復FSM
type FSM interface {
  // Apply is called once a log entry is committed by a majority of the cluster.
  //
  // Apply should apply the log to the FSM. Apply must be deterministic and
  // produce the same result on all peers in the cluster.
  //
  // The returned value is returned to the client as the ApplyFuture.Response.
  Apply(*Log) interface{}

  // Snapshot returns an FSMSnapshot used to: support log compaction, to
  // restore the FSM to a previous state, or to bring out-of-date followers up
  // to a recent log index.
  //
  // The Snapshot implementation should return quickly, because Apply can not
  // be called while Snapshot is running. Generally this means Snapshot should
  // only capture a pointer to the state, and any expensive IO should happen
  // as part of FSMSnapshot.Persist.
  //
  // Apply and Snapshot are always called from the same thread, but Apply will
  // be called concurrently with FSMSnapshot.Persist. This means the FSM should
  // be implemented to allow for concurrent updates while a snapshot is happening.
  Snapshot() (FSMSnapshot, error)

  // Restore is used to restore an FSM from a snapshot. It is not called
  // concurrently with any other command. The FSM must discard all previous
  // state before restoring the snapshot.
  Restore(snapshot io.ReadCloser) error
}

FSMSnapshot是實現快照需要實現的另一個接口,用於保存持久化FSM狀態,後續可以通過FSM.Restore方法恢復FSM。該接口不會阻塞Raft.Apply,但在持久化FSM的數據時需要保證不影響Raft.Apply的併發訪問。

FSMSnapshot.Persist的入參sink是調用SnapshotStore.Creates時的返回值。如果是通過raft.NewFileSnapshotStore初始化了SnapshotStore,則入參sink的類型就是FileSnapshotStore

FSMSnapshot.Persist執行結束之後需要執行SnapshotSink.Close() ,如果出現錯誤,則執行SnapshotSink.Cancel()

// FSMSnapshot is returned by an FSM in response to a Snapshot
// It must be safe to invoke FSMSnapshot methods with concurrent
// calls to Apply.
type FSMSnapshot interface {
  // Persist should dump all necessary state to the WriteCloser 'sink',
  // and call sink.Close() when finished or call sink.Cancel() on error.
  Persist(sink SnapshotSink) error

  // Release is invoked when we are finished with the snapshot.
  Release()
}

FSM的備份和恢復

FSM的備份和恢復的邏輯比較難理解,一方面備份的數據存儲在Raft中,FSM接口是由Raft主動調用的,另一方面又需要由用戶實現FSM的備份和恢復邏輯,因此需要了解Raft是如何與FSM交互的。

FSM依賴snapshot來實現備份和恢復,snapshot中保存的也都是FSM信息。

何時備份和恢復
備份的時機
  • 當用戶執行RecoverCluster接口時會調用FSM.Snapshot觸發創建一個新的FSM snapshot

  • 手動調用如下接口也會觸發創建FSM snapshot:

    func (r *Raft) Snapshot() SnapshotFuture
    
  • Raft自動備份也會觸發創建FSM snapshot,默認時間爲[120s, 240s]之間的隨機時間。

恢復的時機
  • 當用戶執行RecoverCluster接口時會調用FSM.Restore,用於手動恢復集羣

  • 當用戶執行Raft.Restore接口時會調用FSM.Restore,用於手動恢復集羣

  • 通過NewRaft創建Raft節點時會嘗試恢復snapshot(Raft.restoreSnapshot-->Raft.tryRestoreSingleSnapshot-->fsmRestoreAndMeasure-->fsm.Restore)

因此在正常情況下,Raft會不定期創建snapshot,且在創建Raft節點(新建或重啓)的時候也會嘗試通過snapshot來恢復FSM。

備份和恢復的內部邏輯

FSM的備份和恢復與SnapshotStore接口息息相關。

在備份FSM時的邏輯如下,首先通過SnapshotStore.Create創建一個snapshot,然後初始化一個FSMSnapshot實例,並通過FSMSnapshot.Persist將FSM保存到創建出的snapshot中:

sink, err := snaps.Create(version, lastIndex, lastTerm, configuration, 1, trans) //創建一個snapshot
snapshot, err := fsm.Snapshot() //初始化一個FSMSnapshot實例
snapshot.Persist(sink)          //調用FSMSnapshot.Persist將FSM保存到上面的snapshot中

恢復FSM的邏輯如下,首先通過SnapshotStore.List獲取snapshots,然後通過SnapshotStore.Open逐個打開獲取到的snapshot,最後調用FSM.Restore恢復FSM,其入參可以看做是snapshot的文件描述符:

snapshots, err = snaps.List()
for _, snapshot := range snapshots {
  _, source, err = snaps.Open(snapshot.ID)
  crc := newCountingReadCloser(source)
  err = fsm.Restore(crc)
  // Close the source after the restore has completed
  source.Close()
}

下面以consul的實現爲例看下它是如何進行FSM的備份和恢復的。

備份

FSM.Snapshot()的作用就是返回一個SnapshotSink接口對象,進而調用SnapshotSink.Persist來持久化FSM。

下面是consul的SnapshotSink實現,邏輯比較簡單,它將FSM持久化到了一個snapshot中,注意它在寫入snapshot前做了編碼(編碼類型爲ChunkingStateType):

// Persist saves the FSM snapshot out to the given sink.
func (s *snapshot) Persist(sink raft.SnapshotSink) error {
  ...
  // Write the header
  header := SnapshotHeader{
    LastIndex: s.state.LastIndex(),
  }
  encoder := codec.NewEncoder(sink, structs.MsgpackHandle)
  if err := encoder.Encode(&header); err != nil {
    sink.Cancel()
    return err
  }
  ...

  if _, err := sink.Write([]byte{byte(structs.ChunkingStateType)}); err != nil {
    return err
  }
  if err := encoder.Encode(s.chunkState); err != nil {
    return err
  }

  return nil
}

func (s *snapshot) Release() {
  s.state.Close()
}
恢復

備份時將FSM保存在了snapshot中,恢復時讀取並解碼對應類型的snapshot即可:

// Restore streams in the snapshot and replaces the current state store with a
// new one based on the snapshot if all goes OK during the restore.
func (c *FSM) Restore(old io.ReadCloser) error {
  defer old.Close()
  ...

  handler := func(header *SnapshotHeader, msg structs.MessageType, dec *codec.Decoder) error {
    switch {
    case msg == structs.ChunkingStateType: //解碼數據
      chunkState := &raftchunking.State{
        ChunkMap: make(raftchunking.ChunkMap),
      }
      if err := dec.Decode(chunkState); err != nil {
        return err
      }
      if err := c.chunker.State(chunkState); err != nil {
        return err
      }
      ...
    default:
      if msg >= 64 {
        return fmt.Errorf("msg type <%d> is a Consul Enterprise log entry. Consul OSS cannot restore it", msg)
      } else {
        return fmt.Errorf("Unrecognized msg type %d", msg)
      }
    }
    return nil
  }
  if err := ReadSnapshot(old, handler); err != nil {
    return err
  }
  ...

  return nil
}
// ReadSnapshot decodes each message type and utilizes the handler function to
// process each message type individually
func ReadSnapshot(r io.Reader, handler func(header *SnapshotHeader, msg structs.MessageType, dec *codec.Decoder) error) error {
  // Create a decoder
  dec := codec.NewDecoder(r, structs.MsgpackHandle)

  // Read in the header
  var header SnapshotHeader
  if err := dec.Decode(&header); err != nil {
    return err
  }

  // Populate the new state
  msgType := make([]byte, 1)
  for {
    // Read the message type
    _, err := r.Read(msgType)
    if err == io.EOF {
      return nil
    } else if err != nil {
      return err
    }

    // Decode
    msg := structs.MessageType(msgType[0])

    if err := handler(&header, msg, dec); err != nil {
      return err
    }
  }
}

至此已經完成了Raft的開發介紹。需要注意的是,FSM接口都是Raft內部調用的,用戶並不會直接與之交互

更多參見:Raft Developer Documentation

Raft關鍵對外接口

Raft節點管理

將節點添加到集羣中,節點剛添加到集羣中時狀態是staging,當其ready之後就會被提升爲voter,參與選舉。如果節點已經是voter,則該操作會更新服務地址。該方法必須在leader上調用

func (r *Raft) AddVoter(id ServerID, address ServerAddress, prevIndex uint64, timeout time.Duration) IndexFuture

如下方法用於添加一個只接收log entry、但不參與投票或commit log的節點:該方法必須在leader上調用

func (r *Raft) AddNonvoter(id ServerID, address ServerAddress, prevIndex uint64, timeout time.Duration) IndexFuture

將節點從集羣中移除,如果移除的節點是leader,則會觸發leader選舉。該方法必須在leader上調用

func (r *Raft) RemoveServer(id ServerID, prevIndex uint64, timeout time.Duration) IndexFuture

取消節點的投票權,節點不再參與投票或commit log。該方法必須在leader上調用

func (r *Raft) DemoteVoter(id ServerID, prevIndex uint64, timeout time.Duration) IndexFuture

重新加載節點配置:

func (r *Raft) ReloadConfig(rc ReloadableConfig) error
Raft數據的存儲和讀取

用於阻塞等待FSM apply所有操作。該方法必須在leader上調用

func (r *Raft) Barrier(timeout time.Duration) Future

apply一個命令到FSM,該方法必須在leader上調用

func (r *Raft) Apply(cmd []byte, timeout time.Duration) ApplyFuture

從上面接口可以看到,在Raft協議中,必須通過leader才能寫入(apply)數據,在非leader的節點上執行Apply()會返回ErrNotLeader的錯誤。

Apply方法會調用LogStore接口的StoreLogs方法存儲log(cmd)。Raft.applyCh負責將log發送給FSM進行處理,最後通過dispatchLogs將log分發給其他節點(dispatchLogs會調用Transport.AppendEntries來將log分發給對端)。

在分佈式環境中,外部請求可能通過LB轉發到非leader節點上,此時非leader節點需要將請求轉發到leader節點上進行處理,在consul中會通過ForwardRPC將請求轉發給leader,再由leader執行Apply操作。

集羣恢復

當集羣中的節點少於仲裁數目時,集羣將無法正常運作,此時可以手動調用如下接口嘗試恢復集羣,但這樣會可能會導致原本正在複製的日誌被commit。

最佳方式是停止所有節點,並在所有節點上運行RecoverCluster,當集羣重啓之後,會發生選舉,Raft也會恢復運作。

func RecoverCluster(conf *Config, fsm FSM, logs LogStore, stable StableStore,
  snaps SnapshotStore, trans Transport, configuration Configuration) error 

通過如下方式可以讓集羣使用外部snapshot(如備份的snapshot)。注意該操作只適用於DR,且只能在Leader上運行

func (r *Raft) Restore(meta *SnapshotMeta, reader io.Reader, timeout time.Duration) error
狀態獲取

獲取節點的狀態信息

func (r *Raft) Stats() map[string]string

返回當前leader的地址和集羣ID。如果當前沒有leader則返回空:

func (r *Raft) LeaderWithID() (ServerAddress, ServerID)
節點數據交互

各個節點之間主要通過RPC來交互log和選舉信息,可以分爲RPC客戶端和RPC服務端。

RPC客戶端通過調用Transport接口方法來傳遞數據(如Leader執行Raft.Apply log之後會調用Transport.AppendEntries來分發log)。

RPC服務端的實現如下,其處理了不同類型的RPC請求,如AppendEntriesRequest就是Leader執行Transport.AppendEntries傳遞的請求內容:

func (r *Raft) processRPC(rpc RPC) {
  if err := r.checkRPCHeader(rpc); err != nil {
    rpc.Respond(nil, err)
    return
  }

  switch cmd := rpc.Command.(type) {
  case *AppendEntriesRequest:
    r.appendEntries(rpc, cmd)
  case *RequestVoteRequest:
    r.requestVote(rpc, cmd)
  case *InstallSnapshotRequest:
    r.installSnapshot(rpc, cmd)
  case *TimeoutNowRequest:
    r.timeoutNow(rpc, cmd)
  default:
    r.logger.Error("got unexpected command",
      "command", hclog.Fmt("%#v", rpc.Command))
    rpc.Respond(nil, fmt.Errorf("unexpected command"))
  }
}

實現描述

實現Raft時需要考慮如下幾點:

  • 實現FSM接口,包含FSMFSMSnapshot這兩個接口
  • 如何實現Raft節點的自動發現,包含節點的加入和退出
  • 客戶端和應用的交互接口,主要用於應用數據的增刪改等查等操作,對FSM的修改必須通過Raft.Apply接口實現,以保證FSM的數據一致性,而在讀取應用數據時,如果要求數據強一致,則需要從leader的FSM讀取,否則也可以從follower的FSM讀取
  • 在非Leader節點接收到客戶端的修改類請求後,如何將請求轉發給Leader節點

在此次實現Raft的過程中,主要參考了stcacheconsul的源代碼,其中FSM的實現參考了前者,而Raft的初始化和節點發現參考了後者。

源代碼結構如下:

- src
    discovery #節點發現代碼
    raft      #raft管理代碼
    rpc       #請求轉發代碼
    service   #主服務管理代碼
  • discovery:採用serf來實現節點發現,它底層採用的還是memberlist,通過gossip來管理節點。

  • rpc:實現了非Leader節點向Leader節點轉發請求的功能,本demo僅實現了/api/v1/set接口轉發,對於/api/v1/get接口,則直接從本節點的FSM中獲取數據,因此get接口不是強一致性的。

    使用如下命令可以生成rpc模塊的pb.go文件:

    $ protoc --go_out=. --go_opt=paths=source_relative  --go-grpc_out=. --go-grpc_opt=paths=source_relative ./forward.proto
    

啓動demo

下面啓動3個節點來組成Raft集羣:

入參描述如下:

  • httpAddress:與用戶交互的服務
  • raftTCPAddress:Raft服務
  • rpcAddress:請求轉發的gRpc服務
  • serfAddress:serf節點發現服務
  • dataDir:Raft存儲路徑,創建Raft節點時會用到
  • bootstrap:該節點是否需要使用bootstrap方式啓動
  • joinAddress:加入Raft集羣的地址,爲serfAddress,可以添加多個,如add1,add2
  1. 第一個節點啓動時並沒有需要加入的集羣,因此第一個節點以bootstrap方式啓動,啓動後成爲leader。

    $ raft-example --httpAddress 0.0.0.0:5000 --raftTCPAddress 192.168.1.42:6000 --rpcAddress=0.0.0.0:7000 --serfAddress 192.168.1.42:8000 --dataDir /Users/charlie.liu/home/raftDatadir/node0 --bootstrap true
    

    注意:raftTCPAddress不能爲0.0.0.0,否則raft會報錯誤:"local bind address is not advertisable"serfAddress的地址最好也不要使用0.0.0.0

  2. 啓動第2、3個節點,後續的節點啓動的時候需要加入集羣,啓動的時候指定第一個節點的地址:

    $ raft-example --httpAddress 0.0.0.0:5001 --raftTCPAddress 192.168.1.42:6001 --rpcAddress=0.0.0.0:7001 --serfAddress 192.168.1.42:8001 --dataDir /Users/charlie.liu/home/raftDatadir/node1 --joinAddress 192.168.1.42:8000
    
    $ raft-example --httpAddress 0.0.0.0:5002 --raftTCPAddress 192.168.1.42:6002 --rpcAddress=0.0.0.0:7002 --serfAddress 192.168.1.42:8002 --dataDir /Users/charlie.liu/home/raftDatadir/node2 --joinAddress 192.168.1.42:8000
    

在節點啓動之後,就可以在Leader的標準輸出中可以看到Raft集羣中的成員信息:

[INFO]  raft: updating configuration: command=AddVoter server-id=192.168.1.42:6002 server-addr=192.168.1.42:6002 servers="[{Suffrage:Voter ID:192.168.1.42:6000 Address:192.168.1.42:6000} {Suffrage:Voter ID:192.168.1.42:6001 Address:192.168.1.42:6001} {Suffrage:Voter ID:192.168.1.42:6002 Address:192.168.1.42:6002}]"

使用/api/maintain/stats接口可以查看各個節點的狀態,num_peers展示了對端節點數目,state展示了當前節點的角色。

$ curl 0.0.0.0:5000/api/maintain/stats|jq   //node0爲Leader
{
  "applied_index": "6",
  "commit_index": "6",
  "fsm_pending": "0",
  "last_contact": "0",
  "last_log_index": "6",
  "last_log_term": "2",
  "last_snapshot_index": "0",
  "last_snapshot_term": "0",
  "latest_configuration": "[{Suffrage:Voter ID:192.168.1.42:6000 Address:192.168.1.42:6000} {Suffrage:Voter ID:192.168.1.42:6001 Address:192.168.1.42:6001} {Suffrage:Voter ID:192.168.1.42:6002 Address:192.168.1.42:6002}]",
  "latest_configuration_index": "0",
  "num_peers": "2",
  "protocol_version": "3",
  "protocol_version_max": "3",
  "protocol_version_min": "0",
  "snapshot_version_max": "1",
  "snapshot_version_min": "0",
  "state": "Leader",
  "term": "2"
}

$ curl 0.0.0.0:5001/api/maintain/stats|jq   //node2爲Follower
{
  "applied_index": "6",
  "commit_index": "6",
  "fsm_pending": "0",
  "last_contact": "15.996792ms",
  "last_log_index": "6",
  "last_log_term": "2",
  "last_snapshot_index": "0",
  "last_snapshot_term": "0",
  "latest_configuration": "[{Suffrage:Voter ID:192.168.1.42:6000 Address:192.168.1.42:6000} {Suffrage:Voter ID:192.168.1.42:6001 Address:192.168.1.42:6001} {Suffrage:Voter ID:192.168.1.42:6002 Address:192.168.1.42:6002}]",
  "latest_configuration_index": "0",
  "num_peers": "2",
  "protocol_version": "3",
  "protocol_version_max": "3",
  "protocol_version_min": "0",
  "snapshot_version_max": "1",
  "snapshot_version_min": "0",
  "state": "Follower",
  "term": "2"
}

$ curl 0.0.0.0:5002/api/maintain/stats|jq   //node2爲Follower
{
  "applied_index": "6",
  "commit_index": "6",
  "fsm_pending": "0",
  "last_contact": "76.764584ms",
  "last_log_index": "6",
  "last_log_term": "2",
  "last_snapshot_index": "0",
  "last_snapshot_term": "0",
  "latest_configuration": "[{Suffrage:Voter ID:192.168.1.42:6000 Address:192.168.1.42:6000} {Suffrage:Voter ID:192.168.1.42:6001 Address:192.168.1.42:6001} {Suffrage:Voter ID:192.168.1.42:6002 Address:192.168.1.42:6002}]",
  "latest_configuration_index": "0",
  "num_peers": "2",
  "protocol_version": "3",
  "protocol_version_max": "3",
  "protocol_version_min": "0",
  "snapshot_version_max": "1",
  "snapshot_version_min": "0",
  "state": "Follower",
  "term": "2"
}

Leader切換

停掉上述Demo中的Leader節點(node0),可以看到node1稱爲新的leader,且term變爲4:

$ curl 0.0.0.0:5001/api/maintain/stats|jq  //新的Leader
{
  "applied_index": "15",
  "commit_index": "15",
  "fsm_pending": "0",
  "last_contact": "0",
  "last_log_index": "15",
  "last_log_term": "4",
  "last_snapshot_index": "0",
  "last_snapshot_term": "0",
  "latest_configuration": "[{Suffrage:Voter ID:192.168.1.42:6000 Address:192.168.1.42:6000} {Suffrage:Voter ID:192.168.1.42:6001 Address:192.168.1.42:6001} {Suffrage:Voter ID:192.168.1.42:6002 Address:192.168.1.42:6002}]",
  "latest_configuration_index": "0",
  "num_peers": "2",
  "protocol_version": "3",
  "protocol_version_max": "3",
  "protocol_version_min": "0",
  "snapshot_version_max": "1",
  "snapshot_version_min": "0",
  "state": "Leader",
  "term": "4"
}

$ curl 0.0.0.0:5002/api/maintain/stats|jq
{
  "applied_index": "15",
  "commit_index": "15",
  "fsm_pending": "0",
  "last_contact": "42.735ms",
  "last_log_index": "15",
  "last_log_term": "4",
  "last_snapshot_index": "0",
  "last_snapshot_term": "0",
  "latest_configuration": "[{Suffrage:Voter ID:192.168.1.42:6000 Address:192.168.1.42:6000} {Suffrage:Voter ID:192.168.1.42:6001 Address:192.168.1.42:6001} {Suffrage:Voter ID:192.168.1.42:6002 Address:192.168.1.42:6002}]",
  "latest_configuration_index": "0",
  "num_peers": "2",
  "protocol_version": "3",
  "protocol_version_max": "3",
  "protocol_version_min": "0",
  "snapshot_version_max": "1",
  "snapshot_version_min": "0",
  "state": "Follower",
  "term": "4"
}

在本實現中,如果停止一個Raft節點,則Leader節點會一直打印連接該節點失敗的日誌,原因是在Ctrl+c停止Raft節點的時候沒有調用Raft.RemoveServer來移除該節點。這種處理方式是合理的,因爲當一個節點重啓或故障的時候,不應該從Raft中移除,此時應該查明原因,恢復集羣。

本實現中沒有主動移除Raft節點的接口,也可以添加一個接口來調用Raft.RemoveServer,進而移除預期的節點,注意只能在Leader節點上執行Raft.RemoveServer

應用數據的讀寫

下面我們驗證應用數據的寫入和讀取。

  1. 向非Leader節點寫入數據,其會將寫入請求轉發給leader,由leader執行數據寫入。下面展示向非Leader節寫入數據的場景:

    $ curl 0.0.0.0:5001/api/maintain/stats|jq
    {
      "applied_index": "64",
      "commit_index": "64",
      "fsm_pending": "0",
      "last_contact": "4.312667ms",
      "last_log_index": "64",
      "last_log_term": "137",
      "last_snapshot_index": "0",
      "last_snapshot_term": "0",
      "latest_configuration": "[{Suffrage:Voter ID:0.0.0.0:7000 Address:192.168.1.42:6000} {Suffrage:Voter ID:0.0.0.0:7001 Address:192.168.1.42:6001} {Suffrage:Voter ID:0.0.0.0:7002 Address:192.168.1.42:6002}]",
      "latest_configuration_index": "0",
      "num_peers": "2",
      "protocol_version": "3",
      "protocol_version_max": "3",
      "protocol_version_min": "0",
      "snapshot_version_max": "1",
      "snapshot_version_min": "0",
      "state": "Follower",  #非Leader節點
      "term": "137"
    }
    
    $ curl -XPOST localhost:5001/api/v1/set --header 'Content-Type: application/json' --header 'Content-Type: application/json' -d '
    {
        "key" : "testKey",
        "value" : "testValue"
    }'
    
  2. 向所有節點查詢寫入的數據,可以看到所有節點都可以查詢到該數據:

    $ curl -XGET localhost:5000/api/v1/get --header 'Content-Type: application/json' --header 'Content-Type: application/json' -d '
    {
        "key" : "testKey"
    }'
    testValue
    
    $ curl -XGET localhost:5001/api/v1/get --header 'Content-Type: application/json' --header 'Content-Type: application/json' -d '
    {
        "key" : "testKey"
    }'
    testValue
    
    $curl -XGET localhost:5002/api/v1/get --header 'Content-Type: application/json' --header 'Content-Type: application/json' -d '
    {
        "key" : "testKey"
    }'
    testValue
    

TIPS

  • 驗證場景下,如果節點IP發生變動,可以通過刪除--dataDir目錄來清除集羣元數據
  • 如果集羣中的節點不足仲裁數目,則節點可能處理candidate狀態,無法變爲Leader,因此要保證集羣中有足夠的節點,避免一次停掉過多節點。

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章