Raft協(xié)議實現(xiàn)之etcd(一):基本架構

前言

之前解析過Raft協(xié)議基本原理(傳送門),一直想找個具體實現(xiàn)來看一下。Etcd是一款開源的分布式KV數(shù)據(jù)庫,由于K8S中使用它作為注冊和配置中心而被廣泛認知。跟它最類似的產品還有相同應用場景Zookeeper,經常被用作注冊中心和分布式鎖。Etcd也是比較早的使用Raft協(xié)議來實現(xiàn)分布式存儲的產品,而且它的架構設計中將raft協(xié)議部分單獨剝離出來,這樣其它的程序如果想用raft協(xié)議,可以直接依賴它的raft模塊。后續(xù)幾篇文章將通過etcd源碼來查看如何完整實現(xiàn)一個Raft協(xié)議。

知識準備

Etcd是使用Go語言實現(xiàn)的,如果之前沒了解過Go但是用過其它面向對象的語言(如Java),相信對文章中90%以上內容的理解都不會有問題。如果想要自己對照著源碼一起看文章,了解下Go的基本概念還是必要的??梢灾亓私庀翯o的如下特性:

  • interface和struct
  • 方法和函數(shù)的區(qū)別
  • channel 通道,Go語言設計思想的核心,etcd設計中大量用到

etcd架構

Etcd本質上是一個分布式KV數(shù)據(jù)庫,Raft只是它為了實現(xiàn)分布式數(shù)據(jù)一致性所采用的協(xié)議。在整個架構上,我們只需要關心圍繞著Raft部分模塊的設計。相關模塊的架構如下:

EtcdServer

以上的架構并不是整個etcd,而只是提取了和Raft相關的模塊。從上圖中可以看出,etcd中raft協(xié)議模塊和其它模塊是分開的,Raft模塊只負責協(xié)議狀態(tài)機的正常轉換,而數(shù)據(jù)的持久化及和其它節(jié)點的通信通過EtcdServer調用其它模塊來實現(xiàn)。這樣就很好的保證了協(xié)議部分的低耦合,其它的程序如果要用Raft協(xié)議,可以直接將Raft模塊集成進自己程序。
EtcdServer
etcd的主線程,代表一個etcd節(jié)點。
Storage
負責日志條目的持久化以及數(shù)據(jù)Snapshot備份
KV
etcd的KV持久化存儲,這個模塊其實跟raft沒有多大關系。因為raft協(xié)議只負責告訴使用方什么時候可以把日志應用生效,至于日志中的數(shù)據(jù)怎么用不是Raft關心的范疇。etcd是KV數(shù)據(jù)庫,它的數(shù)據(jù)存儲在boltDB中。當etcd客戶端寫數(shù)據(jù)時,etcd通過raft leader節(jié)點將數(shù)據(jù)復制到超過半數(shù)節(jié)點并應用到boltDB后返回成功。
Raft
Raft協(xié)議算法實現(xiàn),接收EtcdServer調用來進行狀態(tài)轉換

模塊分解

EtcdServer

EtcdServer可以看作是etcd節(jié)點的核心控制器,它即實現(xiàn)了etcd的一個服務節(jié)點,每個EtcdServer都包含了一個Raft的節(jié)點實現(xiàn)。它主要包含如下的功能:
節(jié)點初始化
etcd節(jié)點啟動時會初始化并啟動一個EtcdServer,啟動的過程主要包含如下的部分:

  • 重啟時會加載之前已經持久化到磁盤的數(shù)據(jù),恢復整個Server。數(shù)據(jù)包括WAL中的日志數(shù)據(jù)和KV的snapshot數(shù)據(jù)。etcd節(jié)點在運行過程中,如果數(shù)據(jù)已經寫到boltDB中,則會永久存儲。但是日志數(shù)據(jù)有可能還沒有commit或者apply,這部分數(shù)據(jù)會在WAL文件中,etcd啟動時會重新將這部分數(shù)據(jù)重新加載到raft內存中。
  • 啟動Raft狀態(tài)機,啟動后將通過raftNode和raft狀態(tài)機進行交互
  • 初始化和集群中其它節(jié)點的通信

請求通信
EtcdServer會發(fā)送/接收兩種來源的請求,客戶端和集群其它節(jié)點

  • 客戶端請求,EtcdServer收到客戶端的寫請求和線性讀請求后,會交給Raft來處理,然后等待處理完成后回復客戶端。
  • 集群節(jié)點間的請求,包括心跳、投票、日志復制,當raft模塊發(fā)現(xiàn)需要有請求發(fā)送給其它節(jié)點時就會通過EtcdServer來轉發(fā),這樣就降低了Raft模塊的復雜性。

發(fā)起心跳
EtcdServer通過內置一個定時器來定時觸發(fā)Raft的心跳接口。如果是Leader,則發(fā)送心跳給集群中其他節(jié)點;如果是Follower則檢查Leader的心跳是否超時,以決定是否發(fā)起一次新的選舉 。

日志持久化
上面的架構圖中,Raft模塊也保存有日志條目,但是這個日志條目其實只是在內存中,真正的數(shù)據(jù)持久化是由EtcdServer完成的?;貞浵翿aft協(xié)議中,只規(guī)定了哪些數(shù)據(jù)和屬性應該持久化,而并沒有規(guī)定怎么存,存在什么地方。所以etcd將持久化部分和Raft協(xié)議部分分開實現(xiàn)是合理的。

KV數(shù)據(jù)操作
Raft協(xié)議只保證日志復制的一致性,對于最終日志應用不關心。etcd是KV數(shù)據(jù)庫,所以收到raft可以將日志應用的時候,就會將日志中包含的操作應用到后端的KV存儲中。KV存儲部分的邏輯不屬于Raft的范圍,所以后續(xù)不會關注這一塊的實現(xiàn)。

Raft

Raft模塊是協(xié)議算法的實現(xiàn),后續(xù)的源碼解讀也會集中在這一模塊上。
Node
Node接口代表raft集群中的一個節(jié)點,對外提供操作raft協(xié)議節(jié)點的方法。如果有別的程序想只使用etcd的raft實現(xiàn)的話,可以啟動一個Node,然后直接調用這個接口來和raft狀態(tài)機交互就可以了。

type Node interface {
    // 觸發(fā)一次心跳,raft會在觸發(fā)后檢查leader選舉超時或發(fā)送心跳
    Tick()
    // 觸發(fā)節(jié)點將自己變成候選人,開始選舉
    Campaign(ctx context.Context) error
    // 提交日志條目
    Propose(ctx context.Context, data []byte) error
    // 集群配置變更
    ProposeConfChange(ctx context.Context, cc pb.ConfChangeI) error
    // 發(fā)送一條消息給狀態(tài)機,觸發(fā)狀態(tài)變化
    Step(ctx context.Context, msg pb.Message) error
    // 如果raft狀態(tài)機有變化,會通過channel返回一個Ready的數(shù)據(jù)結構,里面包含變化信息,比如日志變化、心跳發(fā)送等。調用方在處理完后需要調用Advance()方法告訴狀態(tài)機上一個Ready處理完了
    Ready() <-chan Ready
    Advance()
    // 應用集群變化到狀態(tài)機
    ApplyConfChange(cc pb.ConfChangeI) *pb.ConfState
    // 將Leader轉給transferee.
    TransferLeadership(ctx context.Context, lead, transferee uint64)
    // 請求一次線性讀
    ReadIndex(ctx context.Context, rctx []byte) error
    // raft state machine當前狀態(tài).
    Status() Status
    // 告訴狀態(tài)機指定id節(jié)點不可達.
    ReportUnreachable(id uint64)
    // 告訴狀態(tài)機給id節(jié)點發(fā)送snapshot的最終處理狀態(tài).
    ReportSnapshot(id uint64, status SnapshotStatus)
    // 關閉節(jié)點.
    Stop()
}

struct node 是Node接口的默認實現(xiàn),它包含了多了go channel來接收請求,也就是說對它的調用大部分都是異步處理的。在啟動的時候會讀取channel中的消息,然后調用RawNode相應的方法來處理請求。

type node struct {
    propc      chan msgWithResult
    recvc      chan pb.Message
    confc      chan pb.ConfChangeV2
    confstatec chan pb.ConfState
    readyc     chan Ready
    advancec   chan struct{}
    tickc      chan struct{}
    done       chan struct{}
    stop       chan struct{}
    status     chan chan Status

    rn *RawNode
}

node中定義的channel基本可以和接口中的方法對應上,propc用來接收日志,recvc用來接收除日志之外的消息。
RawNode
RawNode只是對raft做了一層封裝,可以看成是raft的Facade接口。Node通過調用RawNode的接口來操作raft,而RawNode的大部分操作只是對raft的Step()方法做了一個封裝。RawNode同時緩存了兩個State屬性,是為了在獲取raft狀態(tài)機數(shù)據(jù)時比對狀態(tài)是否有變化。

type RawNode struct {
    raft       *raft
    prevSoftSt *SoftState
    prevHardSt pb.HardState
}

RawNode對于狀態(tài)機操作的方法都直接發(fā)給raft,比如接收新日志的方法實現(xiàn)如下:

func (rn *RawNode) Propose(data []byte) error {
    return rn.raft.Step(pb.Message{
        Type: pb.MsgProp,
        From: rn.raft.id,
        Entries: []pb.Entry{
            {Data: data},
        }})
}

還有一對重要的方法是Ready()和Advance(),當raft狀態(tài)機有變化時,包括狀態(tài)和數(shù)據(jù)變化,通過Ready()方法就可以取到變化的數(shù)據(jù),調用方拿到數(shù)據(jù)后該持久化的持久化,該發(fā)送的發(fā)送。在調用方處理完后調用Advance()方法通知raft狀態(tài)機已處理完成。
raft
協(xié)議算法的核心實現(xiàn),實際上就是一個狀態(tài)機的實現(xiàn),當有外部請求來的時候,比如新的日志,發(fā)送心跳等,就調用它的Step()方法來觸發(fā)狀態(tài)機。它維護了Raft協(xié)議中規(guī)定的所有屬性,如Term、CommitIndex、Vote等。同時通過RaftLog來持有日志。

type raft struct {
    //在raft集群中的唯一id
    id uint64
    //選舉周期
    Term uint64
    //上一次投票的節(jié)點,Leader等于自己的id
    Vote uint64
    //線性讀狀態(tài)
    readStates []ReadState
    // 日志
    raftLog *raftLog
    ....
    // 跟蹤Follower節(jié)點的狀態(tài),比如日志復制的matchIndex
    prs tracker.ProgressTracker
    // 當前節(jié)點的類型
    state StateType
    // 節(jié)點是不是 learner.
    isLearner bool
     // 待發(fā)送給其它節(jié)點的消息
    msgs []pb.Message
    // leader id,leader人工轉移時的目標節(jié)點
    leadTransferee uint64
    ...
    // 還未提交的日志條數(shù),非準確值
    uncommittedSize uint64
    readOnly *readOnly
    // 選舉時記錄ticks
    electionElapsed int
    // 記錄心跳的ticks
    heartbeatElapsed int
    ...
    //心跳超時
    heartbeatTimeout int
    // 選舉超時
    electionTimeout  int
    //隨機選舉超時
    randomizedElectionTimeout int
    disableProposalForwarding bool

    tick func()
    step stepFunc
    ...
}

raft的屬性基本跟raft協(xié)議中規(guī)定的節(jié)點的屬性對應,其中raftLog在內存中存儲了日志條目,prs跟蹤節(jié)點日志復制的進度。
關于心跳和選舉超時的計時,etcd是用tick的方式來計算的,每兩次tick之間的耗時其實就是心跳時間。所有的超時都是以tick的倍數(shù)來計算的,比如electionTimeout等于2,就是選舉超時是兩倍的心跳時間間隔。randomizedElectionTimeout就是raft協(xié)議中建議的,選舉超時應該是在一個范圍內的隨機值,防止所有Follower在Leader超時后同時發(fā)起選舉。
msg屬性用來存儲需要處理的消息,比如心跳。前面講過raft模塊只負責狀態(tài)機的算法處理,而持久化及通信部分則交給調用方處理,這個msg就是存放需要處理的消息。
raft中最后兩個屬性tick和step是函數(shù)類型,在節(jié)點處于不同角色時,這個屬性對應的方法實現(xiàn)是不一樣的。比如tick()方法,在Follower中被調用時是檢查距離上次收到Leader心跳是否超時,而在Leader中是向Follower發(fā)送心跳。step()方法也一樣,不同的節(jié)點類型收到同一種消息時的處理邏輯是不一樣的。

函數(shù)類型對Java程序員可能比較陌生,在有的語言中也叫方法指針。是指一個成員變量指向的是一個方法,在賦值的時候是將一個方法實現(xiàn)賦給這個變量

其它概念

  • HardState, 封裝了raft協(xié)議中規(guī)定的需要實時持久化的狀態(tài)屬性:當前選舉周期、投票和已提交的Index
type HardState struct {
    Term             uint64 `protobuf:"varint,1,opt,name=term" json:"term"`
    Vote             uint64 `protobuf:"varint,2,opt,name=vote" json:"vote"`
    Commit           uint64 `protobuf:"varint,3,opt,name=commit" json:"commit"`
    XXX_unrecognized []byte `json:"-"`
}
  • SoftState, 封裝了raft協(xié)議中規(guī)定的無需持久化的狀態(tài)信息:當前的Leader,節(jié)點角色
type SoftState struct {
    Lead      uint64 // must use atomic operations to access; keep 64-bit aligned.
    RaftState StateType
}
  • state,一個raft節(jié)點只可能是4中角色中的一種:
    Follower:接收Leader的日志
    Candidate: 發(fā)起投票的候選人
    Leader:發(fā)送心跳和日志給Follower
    PreCandidate:如果PreVote打開的話,在正式變成候選人之前需要獲得超半數(shù)節(jié)點的同意,在征詢意見時節(jié)點處于的角色
  • step,所有發(fā)給對狀態(tài)機的請求,其實都是調用的它的step方法,對于不同的角色,處理Step方法的參數(shù)邏輯也是不一樣的

RaftLog

從名字可以看出,RaftLog用于存儲日志條目,它的所有數(shù)據(jù)都是在內存中。

type raftLog struct {
    // 上一次snapshot之后,已經被持久化的日志條目
    storage Storage
    // 所有還沒有被持久化的日志條目
    unstable unstable
    // 已經commit的日志index
    committed uint64
    // 已經應用到狀態(tài)機的日志的index
    applied uint64
    ...
    ...
}

RaftLog中的日志條目不是一直存著的,這樣內存會爆掉,對于EtcdServer來說,已經應用到KV的日志條目不會再用到了(除非有Follower的日志落后很多),所以EtcdServer會定期對KV做Snapshot,然后告訴raft狀態(tài)機可以刪除已包含在快照中日志。

Storage

日志的持久化存儲和最終應用不是raft協(xié)議關心的部分,它只是要求節(jié)點在收到日志并持久化后才能回復客戶端成功。所以etcd并沒有把持久化的部分放在raft狀態(tài)機中,而是通過EtcdServer來做的。
WAL
WAL全稱是Write Ahead Log,是數(shù)據(jù)庫中常用的持久化數(shù)據(jù)的方法。比如我們更新數(shù)據(jù)庫的一條數(shù)據(jù),如果直接找到這條數(shù)據(jù)并更新,可能會耗費比較長的時間。更快更安全的方式是先寫一條Log數(shù)據(jù)到文件中,然后由后臺線程來完成最終數(shù)據(jù)的更新,這條log中通常包含的是一條指令。
etcd通過wal將日志持久化,wal中一條日志的結構如下:

type Record struct {
    Type             int64  `protobuf:"varint,1,opt,name=type" json:"type"`
    Crc              uint32 `protobuf:"varint,2,opt,name=crc" json:"crc"`
    Data             []byte `protobuf:"bytes,3,opt,name=data" json:"data,omitempty"`
    XXX_unrecognized []byte `json:"-"`
}
  • Type:日志的類型
    metadataType:元數(shù)據(jù),代表Data中存的是節(jié)點信息,id、clusterid
    entryType:日志條目,代表Data中是收到的客戶端的日志
    stateType:狀態(tài),每次HardState有變化時會寫一條數(shù)據(jù)到wal中
    crcType:校驗位,讀WAL時會根據(jù)Crc的值校驗防止文件損壞
    snapshotType:Snapshot記錄,每做一次KV的snapshot都會記錄一條wal日志,這是為了節(jié)點重啟時恢復數(shù)據(jù)。

SnapShotter
etcd需要定期對KV做快照,快照的目的一是為了在新的Follower加進來時可以快速復制數(shù)據(jù),二是做完快照后可以清除日志釋放空間,三是重啟時從快照中恢復比從日志中恢復要快的多。

KV
etcd是一個KV數(shù)據(jù)庫,客戶端寫一條數(shù)據(jù)給etcd時,通過復制日志將數(shù)據(jù)發(fā)送到超過半數(shù)節(jié)點,然后寫道KV中才會返給客戶端成功。etcd的KV使用自研的boltDB實現(xiàn),提供了事務和監(jiān)聽Key變化的功能。

總結

簡單總結了etcd中raft相關的模塊,為后面理解raft實現(xiàn)做準備。etcd中對于raft算法的實現(xiàn)都在raft這個struct中,而數(shù)據(jù)的存儲以及集群節(jié)點間的通信通過EtcdServer來完成,很好的做了模塊的解耦,也使其它的產品可以很容易的集成它的raft實現(xiàn)。
整個Server啟動后運行的邏輯如下:

  • EtcdServer定時觸發(fā)raft中的心跳接口,raft根據(jù)自身角色決定是發(fā)送心跳還是檢查是否要發(fā)起選舉
  • EtcdServer調用raft的Ready接口查看是否需要有數(shù)據(jù)處理,包括需持久化的日志,需要發(fā)給Follower的投票、心跳或者日志,需應用到KV的日志等
  • EtcdServer在收到其它節(jié)點的消息后調用raft的Step觸發(fā)raft狀態(tài)機處理
  • EtcdServer在收到客戶端更新請求時,發(fā)送給raft處理,一直到請求在KV中生效,回復客戶端成功

后續(xù)文章通過閱讀源碼來理解etcd中的選舉、日志復制、持久化實現(xiàn)邏輯。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容