Etcd 源碼閱讀
本文是 etcd-raft 庫源碼的閱讀筆記。希望通過閱讀 etcd-raft 庫的源碼,學習工業場景下對 raft 算法的設計和實現,加深對 raft 的理解。閱讀的 etcd 版本為 v3.4.7,在閱讀過程中加入了部分注釋,同時為了搞清楚一些實現的細節考量,查閱了很多相關 pr,并在源碼中標注了鏈接,地址為:etcd-raft 源碼注釋 - v3.4.7
【文章尚未完成,持續更新中...】
一、從 raftexample 開始
etcd-raft 的特點在于其設計屏蔽了網絡、存儲等其他模塊,只提供接口交由上層應用者來實現,這樣的設計思路使其具有很高的可定制性和可測試性。這也決定了想要使用 etcd,用戶需要自己補全核心協議之外的部分。raftexample 是 etcd 官方提供的一個示例程序,簡單展示了 etcd-raft 的使用方式。通過大概預覽示例,縷清 etcd-raft 模塊之間的協作方式,為深入學習 raft 協議的實現奠定基礎。
入口
func main() {
//------------初始化參數:cluster,id,kvport,join-------------//
//--------------建立應用層和raft之間的通信通道 --------------//
proposeC := make(chan string)
defer close(proposeC)
confChangeC := make(chan raftpb.ConfChange)
defer close(confChangeC)
// raft provides a commit stream for the proposals from the http api
// ---------------啟動 raft ------------------------//
var kvs *kvstore
getSnapshot := func() ([]byte, error) { return kvs.getSnapshot() }
commitC, errorC, snapshotterReady := newRaftNode(*id, strings.Split(*cluster, ","), *join, getSnapshot, proposeC, confChangeC)
//--------------------啟動 kv存儲服務---------------------//
kvs = newKVStore(<-snapshotterReady, proposeC, commitC, errorC)
// the key-value http handler will propose updates to raft
//-------------------啟動 http 對外服務-------------------//
serveHttpKVAPI(kvs, *kvport, confChangeC, errorC)
}
由此可知,example 中搭建的服務總共由3個部分組成:
- kvstore:持久化模塊
- raftnode:核心 raft 組件
- httpKVAPI:http對外服務
服務之間主要通過 3 個通道來進行交互:
- proposeC:用于日志更新
- confChangeC:提交配置變更信息
- commitC:用于將 entries 提交到持久化模塊中
httpKVAPI
我們從 httpKVAPI 開始看起,該服務是外界與 etcd 實例交互的接口,通過實現 ServeHTTP
接口來啟動網絡服務。提供的服務有:
- PUT 方法,通過 proposeC 通道向持久化模塊提交鍵值對變更信息;
- 鍵值對提交后不需要等待 raft 的響應,返回狀態碼 204
- GET 方法,對持久化模塊發起查詢操作;
- POST方法和 DELETE 方法,通過 confChangeC 通道向 raft 組件提交配置變更信息,信息使用 pb 協議進行壓縮;
- 信息提交后不需要等待 raft 響應,返回狀態碼 204
httpKVAPI 通過對外提供 http 服務與用戶進行交互,對內,通過 proposeC 和 confChangeC 兩個通道與 etcd 核心模塊進行交互,而對于只讀的 GET 操作,直接訪問本地數據庫獲取結果
kvstore
接下來我們來看一看持久化存儲部分。
調用 newKVStore 初始化持久化模塊時,會首先從 raft 中讀取快照,然后再后臺啟動一個 goroutine 持續監聽 commitC 通道。
func (s *kvstore) readCommits(commitC <-chan *string, errorC <-chan error) {
for data := range commitC {
if data == nil { // data 為 nil,重放快照
// done replaying log; new data incoming
// OR signaled to load snapshot
snapshot, err := s.snapshotter.Load()
//-----錯誤處理-----
if err := s.recoverFromSnapshot(snapshot.Data); err != nil {
log.Panic(err)
}
continue
}
// 否則將收到的數據進行持久化
var dataKv kv
dec := gob.NewDecoder(bytes.NewBufferString(*data))
if err := dec.Decode(&dataKv); err != nil {
log.Fatalf("raftexample: could not decode message (%v)", err)
}
s.mu.Lock()
s.kvStore[dataKv.Key] = dataKv.Val
s.mu.Unlock()
}
// .......
}
- RaftNode 中如果有 entry 需要提交,則會寫入 commitC 通道。kvstore 從通道中讀取要持久化的值,通過 gob 壓縮編碼后存入數據庫中;
- 如果寫入 nil,則說明要求該節點的 kvstore 加載快照
除此之外,該模塊還提供了對數據庫的查詢和提交的 API 接口:Lookup(key string)
和 Propse(k, v string)
raftnode
最后是 newRaftNode 部分。
初始化節點
func newRaftNode(id int, peers []string, join bool, getSnapshot func() ([]byte, error), proposeC <-chan string,
confChangeC <-chan raftpb.ConfChange) (<-chan *string, <-chan error, <-chan *snap.Snapshotter)
- 初始化節點時傳入了以下參數:
- id:當前節點的 id
- peers:集群中所有節點的 URL 地址
- join:是否加入集群
- getSnapshot:函數類型的參數,支持自定義獲取快照的方式
- proposeC 和 confChangeC:與其余兩個組件交互的通道
- 在初始化節點時,創建了一個內部的提交通道 commitC,用于提交待持久化的 entries
- commitC 通道與 raft 節點的生命周期互相綁定,停止服務時,從內部關閉 commitC 通道
- 除此之外,該模塊還維護了與快照,底層 raft 模塊,WAL日志相關的變量,這里暫不展開
- 節點初始化的最后一步是以 goroutine 的形式調用 startRaft 方法,繼續完成后續的初始化工作
啟動 Raft
func (rc *raftNode) startRaft() {
// 創建 snapshotter,通過 snapshotterReady 通道返回創建的實例,用于管理快照
if !fileutil.Exist(rc.snapdir) {
if err := os.Mkdir(rc.snapdir, 0750); err != nil {
log.Fatalf("raftexample: cannot create dir for snapshot (%v)", err)
}
}
rc.snapshotter = snap.New(zap.NewExample(), rc.snapdir)
rc.snapshotterReady <- rc.snapshotter
// 創建 WAL
oldwal := wal.Exist(rc.waldir)
rc.wal = rc.replayWAL() // 加載快照,并重放WAL
//--------創建 raft.config-------------------
// 判斷當前節點是首次啟動還是重新啟動
if oldwal {
rc.node = raft.RestartNode(c)
} else {
startPeers := rpeers
if rc.join {
startPeers = nil
}
rc.node = raft.StartNode(c, startPeers)
}
// 創建并啟動 transport 實例,該實例負責節點之間的網絡通信
rc.transport = &rafthttp.Transport{
Logger: zap.NewExample(),
ID: types.ID(rc.id),
ClusterID: 0x1000,
Raft: rc,
ServerStats: stats.NewServerStats("", ""),
LeaderStats: stats.NewLeaderStats(zap.NewExample(), strconv.Itoa(rc.id)),
ErrorC: make(chan error),
}
rc.transport.Start()
// 與集群中的其他節點建立連接
for i := range rc.peers {
if i+1 != rc.id {
rc.transport.AddPeer(types.ID(i+1), []string{rc.peers[i]})
}
}
go rc.serveRaft()
go rc.serveChannels()
}
serveRaft
func (rc *raftNode) serveRaft() {
//-----------獲取當前節點的 url-------------
//--------------創建了一個 listener---------------------
ln, err := newStoppableListener(url.Host, rc.httpstopc)
//-------------------開啟連接----------------------
err = (&http.Server{Handler: rc.transport.Handler()}).Serve(ln)
select {
case <-rc.httpstopc:
default:
log.Fatalf("raftexample: Failed to serve rafthttp (%v)", err)
}
close(rc.httpdonec)
}
serveRaft 用于監聽當前節點的指定端口,處理與其他節點的網絡連接
-
newStoppableListener()
方法返回的是一個自定義的結構體,該結構體實現了 Accept 接口,同時封裝了一個停止通道,用于結束連接,該用法值的學習type stoppableListener struct { *net.TCPListener stopc <-chan struct{} } func newStoppableListener(addr string, stopc <-chan struct{}) (*stoppableListener, error) { ln, err := net.Listen("tcp", addr) //---------- 錯誤處理 --------------- return &stoppableListener{ln.(*net.TCPListener), stopc}, nil } func (ln stoppableListener) Accept() (c net.Conn, err error) { connc := make(chan *net.TCPConn, 1) errc := make(chan error, 1) go func() { tc, err := ln.AcceptTCP() if err != nil { errc <- err return } connc <- tc }() select { case <-ln.stopc: return nil, errors.New("server stopped") case err := <-errc: return nil, err case tc := <-connc: tc.SetKeepAlive(true) tc.SetKeepAlivePeriod(3 * time.Minute) return tc, nil } }
serveChannels
該函數主要分為兩大部分:
第一部分負責監聽 proposeC 和 confChangeC 兩個通道,將從通道傳來的信息傳給底層 raft 處理
第二部分負責處理底層 raft 返回的數據,這些數據被封裝在 Ready 結構體中,包括了已經準備好讀取、持久化、提交或者發送給 follower 的 entries 和 messages 等信息
-
配置了一個定時器,定時器到期時調用節點的 Tick() 方法推動 raft 邏輯時鐘前進
func (rc *raftNode) serveChannels() { //...... ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() //...... for { select { case <-ticker.C: // 定時器到期,調用 Tick() 方法推動 raft 邏輯時鐘前進 rc.node.Tick() //...... } } }
小結
通過以上概覽,我們可以得到 raftexample 實現的總體結構為:
- raftnode 居中協調,其余組件通過 channel 與 raftnode 進行交互
- 用戶可以自定義實現各個組件,底層核心庫 etcd-raft 由 etcd 負責維護,這樣每個 etcd-raft 都是一個獨立的狀態機,極大的增強了應用的靈活性
- 不同模塊分開實現,增強了程序的可測試性
- 底層的 etcd-raft 只實現了狀態轉移,整個應用的性能由用戶來決定
二、etcd-raft
由之前的 raftexample 分析可知:etcd 的 raft 核心庫與用戶的數據傳輸實際上是通過 readyC 通道中傳遞的 Ready 結構體來承載的。Ready 結構體定義在 raft/node.go
文件中,除了 Ready 結構體之外,該文件中還定義了一個 Node 接口。如其名字所示,該接口代表了 raft 集群中的一個節點,其實就是用戶與 raft 狀態機交互的接口。
由此,etcd-raft 的實現可以看成是兩個主要部分組成:
- raft 算法由 raft/raft.go 來實現,其中的 raft 結構體維護了相關的狀態,實現了算法的細節;
- 用戶通過調用 node 接口及其實現提供的方法,與底層 raft 狀態機進行交互。
接下來就分別對這兩部分內容進行學習。
Node 接口
-
node 接口定義在
raft/node.go
文件中,通過調用 raft 提供的方法來推進狀態機狀態的變更,包含了以下方法:type Node interface { // raft 的邏輯時鐘,替代真實時間 Tick() // 節點調用 campaign,從 follower 轉為 candidate,發起選舉 Campaign(ctx context.Context) error // 調用 propose 來發起一個新提案 Propose(ctx context.Context, data []byte) error // 調用 ProposeConfChange 來提交一次集群成員變更的提案,特別注意,除非 leader 可以確定在集群成員變更 entries 之前 // 的所有日志條目都已經被提交,否則該條 entries 有可能被丟棄(覆寫) ProposeConfChange(ctx context.Context, cc pb.ConfChangeI) error // 將 message 運用到狀態機 Step(ctx context.Context, msg pb.Message) error // 與應用層交互的通道 Ready() <-chan Ready // 在從 Ready 通道中讀取完值后調用,用來通知底層 raft 可以繼續下一步操作 Advance() // 提交配置變更 ApplyConfChange(cc pb.ConfChangeI) *pb.ConfState // 用于 leadership transfer TransferLeadership(ctx context.Context, lead, transferee uint64) // 調用 ReadIndex 來發起一個只讀請求 ReadIndex(ctx context.Context, rctx []byte) error // 返回狀態機當前的狀態 Status() Status ReportUnreachable(id uint64) ReportSnapshot(id uint64, status SnapshotStatus) Stop() }
-
用戶通過調用
func StartNode(c *Config, peers []Peer) Node
方法,傳入配置選項和集群中節點的信息,即可創建一個 Node 實例func StartNode(c *Config, peers []Peer) Node { if len(peers) == 0 { panic("no peers given; use RestartNode instead") } // 生成一個 rawNode,rawNode 直接與 raft 底層庫交互,是線程不安全的 rn, err := NewRawNode(c) if err != nil { panic(err) } // 狀態轉換為 follower,發送配置變更 RPC rn.Bootstrap(peers) // 用 node 封裝一個 rawnode,node 是線程安全的 n := newNode(rn) go n.run() // 后臺啟動 run 方法,監聽各類事件 return &n }
-
由代碼可知,在 StartNode 通過調用
newNode()
創建了 node 實例,該實例實際上是通過 rawNode 與 raft 進行交互的- rawNode 是線程不安全的 Node,用來實現 multiraft,node 通過封裝 rawNode 后,通過各種 channel 將整個處理流程串行化
-
node 實例的成員實際上是一系列的通道
func newNode(rn *RawNode) node { return node{ // node 的所有成員都是 channel propc: make(chan msgWithResult), recvc: make(chan pb.Message), confc: make(chan pb.ConfChangeV2), confstatec: make(chan pb.ConfState), readyc: make(chan Ready), advancec: make(chan struct{}), // tickc 是buffer channel,用于解決潛在的活鎖的情況 tickc: make(chan struct{}, 128), done: make(chan struct{}), stop: make(chan struct{}), status: make(chan chan Status), rn: rn, // 封裝 rawNode } }
-
通過調用 run 方法,在后臺單線程中對這些所有的通道進行監聽
func (n *node) run() { //-------定義變量--------- for { if advancec != nil { // 不為空,說明還在等待應用調用 Advance 接口通知應用已經完成之前 ready 的數據 readyc = nil } else if n.rn.HasReady() { // 如果存在一個未被處理的 Ready,為其創建一個 Ready 結構體 rd = n.rn.readyWithoutAccept() readyc = n.readyc // 設置 readyc 不為空,用于后續接收消息 } //------處理中途leader變更的情況 // 監聽各通道 select { case pm := <-propc: // 處理本地提交的信息 case m := <-n.recvc: // 處理其他節點發送過來的消息 case cc := <-n.confc: // 處理配置變更的消息 case <-n.tickc: // 處理 tick n.rn.Tick() // 調用 raft.tick() 方法驅動邏輯時鐘前進一次 case readyc <- rd: // 處理未處理的 Ready 信息 n.rn.acceptReady(rd) // 標記 Ready 消息已經處理完成 advancec = n.advancec // 用節點的 advancec 初始化 advancec,后續可以從 advancec 接收消息 case <-advancec: // 收到 advancec 通道傳來的信息,說明之前的 Ready 已經被處理,通知 raft 可以進行下一輪處理 n.rn.Advance(rd) rd = Ready{} // 重置 rd 為空 advancec = nil // 重置 advancec case c := <-n.status: // 處理請求狀態的信息 case <-n.stop: // 處理 stop 信號 } } }
- 由代碼可知,所有的消息都是通過封裝的 rawNode 傳遞給 raft 底層狀態機的
-
對于接口中的其他方法,通過調用封裝的
func (n *node) Step(ctx context.Context, m pb.Message) error
方法發送對應的消息給狀態機,實現推動狀態機狀態的變更
raft 算法的實現
對于該部分內容的學習將結合 raft 大論文來進行
初始化:newRaft()
創建一個新的 raft 節點,主要做了以下工作:
- 初始化各項屬性;
- 檢查 log 相關的配置,確定是否需要使用已有數據進行恢復,修正 applyIndex 等;
- 設置狀態為 follower;
節點的狀態
狀態匯總
定義了以下幾種狀態:
const (
StateFollower StateType = iota
StateCandidate
StateLeader
StatePreCandidate
numStates
)
- 常規狀態三種:Follower,Candidate,Leader
- 優化:PreCandidate,用于預選舉
狀態轉換
- 通過
becomeFollower,becomeCandidate,becomePreCandidate 和 becomeLeader
幾個方法來實現。這些函數的主要邏輯基本是:- 更新 state 切換到對應狀態;
- 更新 tick 函數檢查是否需要觸發不同的事件;
- 調用
reset()
重置超時和設置相關狀態(如任期); - 更新 step 函數處理不同狀態的消息;
邏輯時鐘的實現
在之前介紹的 Node 接口部分我們提到過,Node 接口實現了 Tick()
方法,在節點的 tickc
通道中有消息觸發時,會調用底層 rawNode
的 Tick()
方法推動邏輯時鐘前進:
// 調用 Tick() 方法會向 tickc 通道發送一個信號,在信號處理端執行驅動 tick 的方法調用
func (n *node) Tick() {
select {
case n.tickc <- struct{}{}:
case <-n.done:
//......
}
}
// Tick advances the internal logical clock by a single tick.
func (rn *RawNode) Tick() {
rn.raft.tick()
}
type raft struct {
//......
tick func()
//......
}
- 實際調用的是 raft 的
tick
成員,該成員是一個函數類型的變量,對應不同的節點狀態,可以設置為以下兩個函數- 對于 Candidate,preCandidate 和 Follower:
func (r *raft) tickElection()
- 對于 Leader:
func (r *raft) tickHeartbeat()
- 對于 Candidate,preCandidate 和 Follower:
- 設置
tick
成員的時機是在節點身份變更的一系列becomeXXX
方法中完成的,即節點的身份變更后,將其計時器更新為對應身份的計時器
而 Node 接口的 Tick()
方法則是由用戶設置了一個定時器,在定時器到期后觸發的,具體實例見 raftexample 解析的 serveChannels
部分 。在 raft 結構體中維護了以下幾個成員變量:
type raft struct {
//......
electionElapsed int
heartbeatElapsed int
heartbeatTimeout int
electionTimeout int
randomizedElectionTimeout int
//......
}
-
heartbeatTimeout
、electionTimeout
和randomizedElectionTimeout
三個成員變量指定了邏輯時鐘的超時值,該值是通過Config
來設置的type Config struct { //...... // ElectionTick is the number of Node.Tick invocations that must pass between // elections. That is, if a follower does not receive any message from the // leader of current term before ElectionTick has elapsed, it will become // candidate and start an election. ElectionTick must be greater than // HeartbeatTick. We suggest ElectionTick = 10 * HeartbeatTick to avoid // unnecessary leader switching. ElectionTick int // HeartbeatTick is the number of Node.Tick invocations that must pass between // heartbeats. That is, a leader sends heartbeat messages to maintain its // leadership every HeartbeatTick ticks. HeartbeatTick int //...... }
每次調用 Tick() 函數都會觸發一次邏輯時鐘的推進,用
electionElapsed
和heartbeatElapsed
兩個變量來記錄 Tick() 函數調用的次數,當調用次數超過了邏輯時鐘的超時值時,即觸發超時-
每次節點的身份轉換時都會通過調用
reset()
方法來重置超時時間func (r *raft) reset(term uint64) { //...... r.electionElapsed = 0 r.heartbeatElapsed = 0 r.resetRandomizedElectionTimeout() // 重置選舉超時 //...... } func (r *raft) resetRandomizedElectionTimeout() { r.randomizedElectionTimeout = r.electionTimeout + globalRand.Intn(r.electionTimeout) }
消息的處理
etcd-raft 中將所有的操作都封裝到一個消息結構體中,通過 RPC 來在不同的節點之間傳輸。
消息的類型和結構體定義在 raft/raftpb/raft.proto 文件中,消息處理的入口是 func (r *raft) Step(m pb.Message) error
方法。
消息的處理過程與節點的狀態密切相關:
- 不同的節點狀態可以處理不同類型的消息;
- 同一類型的消息在不同的節點狀態中處理邏輯也可能不同;
消息的類型可以分為兩類:驅動節點狀態變更(選舉相關)和其他業務處理。etcd-raft 將消息的處理過程分為兩部分:
- 在入口函數
Step()
中處理狀態變更的相關消息,用以確定節點當前的狀態; - 后續的消息根據角色的狀態不同,通過調用
r.step()
來繼續執行;
消息的預處理
func (r *raft) Step(m pb.Message) error {
switch {
case m.Term == 0:
// ......
case m.Term > r.Term:
// ......
case m.Term < r.Term:
// ......
}
switch m.Type{
case pb.MsgHup:
// ......
case pb.MsgVote, pb.MsgPreVote:
// ......
default:
// ......
}
return nil
}
- 該方法的實現邏輯由兩部分組成:
- 第一部分先對任期進行檢查,這是因為任期在 raft 中充當著邏輯時鐘的角色,任期影響節點的狀態,而不同的狀態對應了執行特定操作(處理不同類型消息)的權限,故而在進一步處理消息前需要先對任期進行檢查。檢查后的結果為:
- 當前節點任期為0,繼續第二部分;
- 當前節點任期大于收到的消息的任期:直接返回,這也是論文中對 server 行為的要求。除此以外,針對以下兩種特別場景,etcd 進行了優化;
- 當前節點任期小于收到的消息的任期:按照論文要求變為 follower 后繼續第二部分;
- 注意,由于 etcd 加入了 quorum、預投票和只讀請求三個可選優化,導致開啟不同優化選項時可能會出現問題,故而對任期檢查的邏輯進行了修正,這部分內容在優化部分進行分析;
- 第二部分則是分離狀態變更消息和其他業務消息的處理邏輯:
- MsgHup:用于本節點發起選舉
- 針對 learner 的情況作了優化
- 調用
r.campaign()
方法觸發選舉
- MsgVote 和 MsgPreVote:用于發起投票
- 投票的規則基于論文的描述,由于加入了預投票以及其他 bug 的修正,當前的投票條件為:
- 之前已經為同一節點投過票
- 或還沒有投過票且不知道當前的 leader 是誰
- 或收到的是預投票消息且消息的任期大于節點的任期
- 并且請求方的日志至少和當前節點一樣新
- 驗證可以投票后,調用
r.send()
方法發起投票; - 如果投票成功,則重置計時器,并記錄下是為哪個節點投的票
- 投票的規則基于論文的描述,由于加入了預投票以及其他 bug 的修正,當前的投票條件為:
- 對于其他類型的消息,調用
r.step()
方法來進行處理
- MsgHup:用于本節點發起選舉
- 第一部分先對任期進行檢查,這是因為任期在 raft 中充當著邏輯時鐘的角色,任期影響節點的狀態,而不同的狀態對應了執行特定操作(處理不同類型消息)的權限,故而在進一步處理消息前需要先對任期進行檢查。檢查后的結果為:
消息處理的實現
通過預處理過程之后,節點會根據自身的狀態調用對應的方法來處理收到的消息。etcd-raft 通過回調函數 r.step()
來實現這一點:
type stepFunc func(r *raft, m pb.Message) error
type raft struct {
// ......
// 回調函數,對應 stepFollower, stepCandidate 和 stepLeader 三個具體方法,
// 用于不同狀態的節點對各種 Msg 進行處理
step stepFunc
//......
}
func (r *raft) Step(m pb.Message) error {
//--------- 任期檢查 --------------
switch m.Type{
//-------------選舉和投票相關消息處理----------
default:
err := r.step(r, m) // 調用回調函數處理其他消息
if err != nil {
return err
}
}
return nil
}
選舉
選舉流程
etcd-raft 中使用的是邏輯時鐘而不是真實的時間
-
選舉是由 Follower 或者 Candidate 發起的,發起選舉的條件是:選舉超時到期。
func (r *raft) tickElection() { r.electionElapsed++ // 如果可以被提升為 leader,同時也滿足選舉超時,發送一個 MsgHup 消息觸發選舉 if r.promotable() && r.pastElectionTimeout() { //fmt.Printf("trigger, electionElapsed is: %d, random timeout is: %d\n", // r.electionElapsed, r.randomizedElectionTimeout) r.electionElapsed = 0 r.Step(pb.Message{From: r.id, Type: pb.MsgHup}) // 選舉超時是給自己發送 MsgHup 消息 } }
-
之后在
Step
方法中處理 MsgHup 消息:case pb.MsgHup: // 用于本節點發起選舉 if r.state != StateLeader { if !r.promotable() { // 該部分用于確保 learner 不會發起選舉 return nil } // 取出還未提交到狀態機的信息 [applied + 1, committed] ents, err := r.raftLog.slice(r.raftLog.applied+1, r.raftLog.committed+1, noLimit) if err != nil { r.logger.Panicf("unexpected error getting unapplied entries (%v)", err) } // 在調用 Campaign 之前,需要保證所有之前的配置變更都已經被應用 if n := numOfPendingConf(ents); n != 0 && r.raftLog.committed > r.raftLog.applied { return nil } // 開始選舉 if r.preVote { // 對于預投票,調用預投票選舉 r.campaign(campaignPreElection) } else { r.campaign(campaignElection) } } else { r.logger.Debugf("%x ignoring MsgHup because already leader", r.id) } // .....
-
最終的選舉過程是在
campaign
方法中實現的,大概流程為:-
切換到 preCandidate/Candidate 狀態,設置任期,更新 tick 函數為
r.tickElection
,更新 Vote 變量為自己的 id,更新狀態為 StatePreCandidate/StateCandidate- 預投票狀態是論文中提到的一種優化方式,后文有詳細講解
-
調用
r.poll()
方法為自己投票- 如果是單節點集群,則立刻成為 leader
-
向集群中的其他節點發送 MsgPreVote/MsgVote 消息
- 發送的消息中包括了一些額外的字段:
- 當前任期,當前節點的最后一條日志索引(lastIndex),最后一條日志索引對應的任期(lastTerm),附加的Context(用于 leadership transfer)
- 發送的消息中包括了一些額外的字段:
-
之后進入集票部分,原理是收到 majority 的票數,則選舉成功轉為 leader,具體代碼注釋參見預投票部分
func (r *raft) campaign(t CampaignType) { //...... // 根據選舉的類型更新節點的狀態 if t == campaignPreElection { r.becomePreCandidate() voteMsg = pb.MsgPreVote // PreVote RPCs are sent for the next term before we've incremented r.Term. term = r.Term + 1 } else { r.becomeCandidate() voteMsg = pb.MsgVote term = r.Term } // 調用 poll() 方法給自己投票,如果是單節點系統,則立刻成為 leader,如果是預投票,則發起投票 if _, _, res := r.poll(r.id, voteRespMsgType(voteMsg), true); res == quorum.VoteWon { // We won the election after voting for ourselves (which must mean that // this is a single-node cluster). Advance to the next state. if t == campaignPreElection { r.campaign(campaignElection) } else { r.becomeLeader() } return } //...... for _, id := range ids { if id == r.id { // 跳過自己 continue } var ctx []byte if t == campaignTransfer { // 支持 leadership transfer ctx = []byte(t) } // 向其他節點發送請求投票 r.send(pb.Message{Term: term, To: id, Type: voteMsg, Index: r.raftLog.lastIndex(), LogTerm: r.raftLog.lastTerm(), Context: ctx}) } }
-
-
當其他節點收到投票請求后,會對是否投票進行判斷:
func (r *raft) Step(m pb.Message) error { //............ // 首先進行任期判斷 case m.Term > r.Term: // 消息任期大于當前節點的任期 // -----------優化部分,避免不必要的干擾-------------- //-------------優化部分,單獨判斷預投票的流程------------- // 當前節點因為收到一條更高任期的消息需要變為 follower,此時需要更新 r.leader,為了讓更新更明確,在知曉消息是由當前 leader 發來時,可以直接更新 r.leader 為 r.From,這類消息是:AppendEntries, heartbeat 以及快照相關,對于其他類型的消息,因為不知道發送方是否是 leader,將 r.leader 更新為 none if m.Type == pb.MsgApp || m.Type == pb.MsgHeartbeat || m.Type == pb.MsgSnap { r.becomeFollower(m.Term, m.From) } else { r.becomeFollower(m.Term, None) } } case m.Term < r.Term: //-------------優化部分-------------------- // 消息任期小于當前任期,直接返回 return nil } // 可以繼續執行第二部分的情況: // - 消息任期和節點任期相同 // - 消息任期大于節點任期:預投票場景或者當前節點轉為 follower 之后繼續 switch m.Type { //.................... case pb.MsgVote, pb.MsgPreVote: // We can vote if this is a repeat of a vote we've already cast... canVote := r.Vote == m.From || // ...we haven't voted and we don't think there's a leader yet in this term... (r.Vote == None && r.lead == None) || // ...or this is a PreVote for a future term... (m.Type == pb.MsgPreVote && m.Term > r.Term) // 確定是否可以投票: // - 之前已經投過票的,再次收到了同一節點的請求投票 // - 還沒有投過票且不知道當前的 leader 是誰 // - 收到的是預投票消息且消息的任期大于節點的任期 // 滿足以上三條中的任意一條,則 canVote = true if canVote && r.raftLog.isUpToDate(m.Index, m.LogTerm) { // isUpToDate 中進行日志比較 // 滿足投票條件 // 注意,響應 MsgPreVote 時的任期使用的是來自消息的任期,而不是本地的任期 r.send(pb.Message{To: m.From, Term: m.Term, Type: voteRespMsgType(m.Type)}) if m.Type == pb.MsgVote { // 投票成功,清空計時器,并保存為哪個節點投票 // Only record real votes. r.electionElapsed = 0 r.Vote = m.From // 同一任期內只會給一個節點進行投票 } } else { // 拒絕投票 r.send(pb.Message{To: m.From, Term: r.Term, Type: voteRespMsgType(m.Type), Reject: true}) } //...... }
- 除去額外的優化部分,具體的判定規則與 raft 論文中描述的規則一致;
-
請求投票完成后,Candidate 開始集票
func stepCandidate(r *raft, m pb.Message) error { // Only handle vote responses corresponding to our candidacy (while in // StateCandidate, we may get stale MsgPreVoteResp messages in this term from // our pre-candidate state). var myVoteRespType pb.MessageType if r.state == StatePreCandidate { myVoteRespType = pb.MsgPreVoteResp } else { myVoteRespType = pb.MsgVoteResp } // ...... switch m.Type { // ...... case myVoteRespType: // 處理投票 gr, rj, res := r.poll(m.From, m.Type, !m.Reject) // 計算當前收到多少投票 r.logger.Infof("%x has received %d %s votes and %d vote rejections", r.id, gr, m.Type, rj) switch res { case quorum.VoteWon: // 如果quorum 都選擇了投票 if r.state == StatePreCandidate { r.campaign(campaignElection) // 預投票發起正式投票 } else { r.becomeLeader() // 變成 leader r.bcastAppend() // 發送 AppendEntries RPC } case quorum.VoteLost: // 集票失敗,轉為 follower // pb.MsgPreVoteResp contains future term of pre-candidate // m.Term > r.Term; reuse r.Term r.becomeFollower(r.Term, None) // 注意,此時任期沒有改變 } // ...... }
投票成功后,調用 r.becomeLeader 執行身份轉換,然后立刻發送 AppendEntries RPC
心跳檢測
維護了 heartbeatElapsed 和 electionElapsed 兩個計時器,主要完成以下功能:
- 當 electionTimeout 超時:(實現論文中提到的 quorum 和 leadership transfer 兩個優化)
- 如果開啟了 quorum 模式,則給自己發送一條 MsgCheckQuorum 消息,檢測是否處在網絡隔離狀態中
- 如果當前正處在 leadership transfer 過程中,則中斷執行,自己重新變為 leader
- 當 heartbeatTimeout 超時:
- 發起心跳檢測
MsgBeat 消息的處理
-
心跳計時超時后,leader 重置計時器,給自己發送一個心跳包
func (r *raft) tickHeartbeat() { // ...... if r.heartbeatElapsed >= r.heartbeatTimeout { // 如果心跳計時超時,重置計時器,發送心跳包 r.heartbeatElapsed = 0 r.Step(pb.Message{From: r.id, Type: pb.MsgBeat}) } }
-
調用
r.bcastHeartbeat()
將心跳包發給其余節點,該方法中判定了是否有額外的上下文需要追加(用于標記只讀請求),最終是調用r.sendHeartbeat()
來構建心跳包并發送func (r *raft) sendHeartbeat(to uint64, ctx []byte) { // Attach the commit as min(to.matched, r.committed). // When the leader sends out heartbeat message, // the receiver(follower) might not be matched with the leader // or it might not have all the committed entries. // The leader MUST NOT forward the follower's commit to // an unmatched index. // 心跳包中附帶了 follower 的 matchIndex 索引和 master 的 commitIndex 中的最小值 commit := min(r.prs.Progress[to].Match, r.raftLog.committed) m := pb.Message{ To: to, Type: pb.MsgHeartbeat, Commit: commit, Context: ctx, } r.send(m) }
-
心跳包的處理:
-
心跳包的接受同樣是從
Step()
方法開始,然后調用對應的回調函數stepFollower()
和stepCandidate()
,也就是說,對于心跳包,除 leader 外的其余節點只做任期檢查,而不進行日志的 up-to-date 比對- 這是由 raft 的規則決定的:每個任期內只能有一個有效 leader
- 對于日志的操作,Leader 永遠不會刪除自己的已有日志,而 Follower 則是根據 Leader 的日志情況修正自身日志
-
對于 Candidate 和 preCandidate:
case pb.MsgHeartbeat: // 收到心跳,說明當前有leader,轉為 follower r.becomeFollower(m.Term, m.From) // always m.Term == r.Term r.handleHeartbeat(m)
-
對于 Follower:
case pb.MsgHeartbeat: // 收到心跳檢測,重置選舉超時 r.electionElapsed = 0 r.lead = m.From r.handleHeartbeat(m)
-
構建響應
func (r *raft) handleHeartbeat(m pb.Message) { r.raftLog.commitTo(m.Commit) // 在心跳消息中傳入 committedId,使用該值來更新當前節點的 committedId // 要將攜帶的Context原樣發回,實現 readOnly r.send(pb.Message{To: m.From, Type: pb.MsgHeartbeatResp, Context: m.Context}) }
- 檢查并根據檢查結果更新當前節點的提交索引
- 響應 leader
-
-
leader 收到心跳包響應后的處理:
case pb.MsgHeartbeatResp: // 處理心跳響應 pr.RecentActive = true // quorum 機制,收到來自follower的心跳包響應后,將其 RecentActive 設置為 true //---------- Progress 流控機制的處理----------- // 心跳包中附帶了對 follower 日志的檢查,如果 follower 的日志落后于 leader,進行補全 if pr.Match < r.raftLog.lastIndex() { r.sendAppend(m.From) } //-------------- 只讀模式優化部分------------------
基本選舉策略存在的問題
由于網絡的問題存在 partition 的情況,導致:
- 同時存在多個 leader:舊 leader 被隔離后,majority 選出新的 leader,而舊 leader 因為無法收到新 leader 的消息依舊以為自己還是 leader。可能會導致客戶端訪問到舊 leader 接收到過期信息的情況;
- 節點重新加入集群,或者由于集群配置變更等導致正常工作的 leader 因為不合時宜的 RequestVote RPC 而 step down;
- 解法:引入 quorum 和預投票機制進行優化,見優化部分。
日志復制
Storage 接口
etcd 只負責核心 raft 模塊的維護,將存儲等其他部分交由用戶自行實現。Storage 接口提供了查詢持久化數據的相關方法,用戶通過實現這些接口來操作持久化數據
type Storage interface {
// 返回保存的 HardState 和 ConfState 信息
InitialState() (pb.HardState, pb.ConfState, error)
// 范圍查詢 [lo, hi),注意有 maxSize 限制
Entries(lo, hi, maxSize uint64) ([]pb.Entry, error)
// 返回指定索引對應條目的任期
Term(i uint64) (uint64, error)
// 返回最后一條 Entry 的索引
LastIndex() (uint64, error)
// 返回第一條數據的索引
FirstIndex() (uint64, error)
// 返回最近一次快照的數據
Snapshot() (pb.Snapshot, error)
}
unstable
用于保存還未被用戶層持久化的數據
type unstable struct {
// the incoming unstable snapshot, if any.
snapshot *pb.Snapshot
// all entries that have not yet been written to storage.
entries []pb.Entry
// entries 數組中第一條 entry 的實際 index
// 因為日志壓縮和快照機制,執行快照后,包含在快照中的 entry 會被清理掉,但是 log 中 entry 的索引是單調遞增的,所以用 offset 來記錄下之前被清理掉的最后一條 entry 的 index
offset uint64
logger Logger
}
- 因為存在快照截斷日志,所以引入了偏移量 offset;
- 當前 unstable.entries 中的第 i 條日志,對應到 raft 日志中應為 i + offset
raftLog
etcd-raft 中日志相關的操作
type raftLog struct {
// 保存最近一次快照后提交的數據
storage Storage
// 保存還未持久化的記錄和快照
unstable unstable
// 已提交記錄的最高索引
committed uint64
// Invariant: applied <= committed
// 被狀態機使用的記錄的最高索引
applied uint64
logger Logger
// maxNextEntsSize is the maximum number aggregate byte size of the messages
// returned from calls to nextEnts.
// 根據 https://github.com/etcd-io/etcd/pull/9982,maxNextEntsSize 參數是用來防止一次提交的 raft 日志過大導致OOM
maxNextEntsSize uint64
Progress
etcd 將 raft 設計為純狀態機模型。從 leader 的視角來看,其余所有的節點都在預定好的幾個狀態之間不停切換。Progress 結構用于管理節點的狀態。論文中的日志復制部分也是通過 Progress 機制來完成的
-
節點狀態:共有三類節點狀態
const ( // StateProbe indicates a follower whose last index isn't known. Such a // follower is "probed" (i.e. an append sent periodically) to narrow down // its last index. In the ideal (and common) case, only one round of probing // is necessary as the follower will react with a hint. Followers that are // probed over extended periods of time are often offline. StateProbe StateType = iota // StateReplicate is the state steady in which a follower eagerly receives // log entries to append to its log. StateReplicate // StateSnapshot indicates a follower that needs log entries not available // from the leader's Raft log. Such a follower needs a full snapshot to // return to StateReplicate. StateSnapshot )
-
StateProbe:當節點拒絕了最近一次 AppendEntries RPC 時,就會進入探測狀態。leader 通過探測狀態來確定節點的日志情況,找到匹配條目和沖突條目。
- 新當選的 leader 會將所有 follower 的狀態設置為 StateProbe,更新 match = 0,next = 該 leader 的最后一條日志的索引;
- 在探測狀態中,leader 在每個心跳間隔內至多發送一條 AppendEntries RPC,用以確定節點的日志情況;
- 當 follower 不再拒絕 MsgApp 時,意味著已經匹配到了正確的日志條目,則該 follower 轉換至 StateReplicate 狀態;
-
StateReplicate:復制狀態,leader 將自己的日志復制給 follower。采用快速復制的方法,直接更新 follower 的 next 索引至 leader 發送的最新條目。
- 當 follower 拒絕了 msgApp 或者鏈路層報告 follower 不可達時,將該 follower 的轉態調整為 StateProbe;
-
StateSnapshot:當 leader 發現該 follower 的日志遠落后于當前日志時,轉入快照狀態加載快照;
- 快照狀態下,leader 不會發送任何 AppendEntries 到該 follower;
- 在收到 follower 應用了快照的響應后,將該 follower 的狀態轉為 StateProbe;
-
狀態轉換圖:
狀態轉換.png
-
集群成員變更
理論基礎
- 將集群從一個配置直接轉為另一個配置會導致不安全:因為各個節點應用轉換的時間點不同,總會存在一個時間點,使得同一任期內出現兩個 leader,一個由舊配置節點選出,另一個由新配置節點選出
-
raft 論文中提出的解決方式是:限制一次只允許增加或者刪除單個節點。只有上一輪的配置變更完成后,才能繼續下一輪。
-
該方法能確保安全性的原因是:只增刪單個節點,新舊配置中 majority 的部分必然有重疊
raft配置變更.png
-
-
raft 的實現方式:將配置變更的指令放在日志條目中,當成特殊條目來處理。優點:
- 沿用了之前的日志同步提交機制,無需額外增加系統的復雜度,同時保證了安全性
- 整個過程可以繼續處理客戶端請求
-
存在的問題:
- 節點配置需要能夠回退,因為未提交的條目有可能被覆蓋
- TODO:這里有疑問,我理解的場景是:如果發生網絡隔離,出現了A,B兩個分組,各自選主后執行了不同的配置變更,再次合并后達成一致,有一組成員的數據會被修改
- 要處理不在當前配置內的節點的消息,即 servers 處理收到的 RPC 請求時不會去分辨發送方是否還在當前配置內:
- 對于不在當前集群內的 leader 發起的 AppendEntries RPC,如果不處理其 RPC,則新加入的節點將永遠無法加入集群
- 要支持給非集群內成員節點投票
- 如:當前有3個節點,leader 宕機后,新加入一個節點,需要新節點加入投票才能完成選舉
- 被移除的節點在停機前可能會發起新的請求投票,影響當前集群的穩定,使用 check quorum 和 Pre-Vote 來處理
- 節點配置需要能夠回退,因為未提交的條目有可能被覆蓋
-
新增節點還會帶來一個問題:如果新增節點的 log 與原集群中節點的 log 數相差過大,則需要時間來補全新節點的 log,這個過程稱為 catching up。而在這個過程中如果發生故障,則可能會降低集群的容錯性,因為需要更長的時間來達成一致。
-
raft 的實現方式:
對于新加入的節點,增加一個階段,不將該節點加入到投票或提交條目的 majority 中,而是由 leader 為其復制日志
leader 通過多輪同步的方式來為新節點復制日志,這期間新加入到 leader 中的條目也可以在后續的輪次中完成復制
catchup.png- 使用多輪復制時為了降低應用出現不穩定的可能性,同時設置固定的同步輪數,如果最近后一次同步持續時間超過選舉超時,則返回 false,客戶端將在之后重試
-
-
移除當前集群的 leader 節點:
- 兩種實現方式:
- 方案一:leader 需要管理不包含自己的集群,直到修改配置的條目被提交之后,再下線,之后通過選舉超時來選出新集群的 leader
- 方案二:使用 leadership transfer
- 兩種實現方式:
優化
預投票
該部分的內容在 《CONSENSUS BRIDGING THEORY AND PRACTICE》這篇論文的 9.6 節中描述,etcd-raft 在 pr #6624(https://github.com/etcd-io/etcd/pull/6624) 中實現,通過設置 raft.preVote = true 來開啟該優化。
- 背景:節點進入分隔狀態后,重新加入集群會擾亂集群的穩定性
- 算法:節點只有收到 majority 的投票后,才會增加任期進入正常選舉
- 節點進入分隔狀態后,由于沒法收到 majority 的投票,不會增加自己的任期
- 節點重新加入集群后,由于大部分的節點都能正常收到心跳,所以不會發起投票,在收到來自當前 leader 的常規心跳檢查后,變為 follower,更新自己的任期
etcd 的實現:
在任期判斷時,對于預投票的消息,如果當前節點收到任期高于自己的消息,不會更新自己的任期
-
開始選舉時,如果開啟了預投票,則先進行預投票
func (r *raft) Step(m pb.Message) error { // ----- 任期判斷 ------ switch m.Type { case pb.MsgHup: if r.preVote { r.campaign(campaignPreElection) } else { r.campaign(campaignElection) } // ...... }
-
節點狀態變為 preCandidate
func (r *raft) becomePreCandidate() { // TODO(xiangli) remove the panic when the raft implementation is stable if r.state == StateLeader { panic("invalid transition [leader -> pre-candidate]") } // Becoming a pre-candidate changes our step functions and state, // but doesn't change anything else. In particular it does not increase // r.Term or change r.Vote. // 注意:預投票不更新任期 r.step = stepCandidate r.prs.ResetVotes() r.tick = r.tickElection r.lead = None r.state = StatePreCandidate r.logger.Infof("%x became pre-candidate at term %d", r.id, r.Term) }
-
之后的投票流程和正常投票相同,區別在于其他節點收到 MsgPreVote 時不會改變自己的狀態
- 注意:可以給多個預投票的 candidate 投票
-
在收集到 majority 的預投同意后,轉到正常投票流程
func stepCandidate(r *raft, m pb.Message) error { //...... case myVoteRespType: // 處理投票 gr, rj, res := r.poll(m.From, m.Type, !m.Reject) // 計算當前收到多少投票 switch res { case quorum.VoteWon: // 如果quorum 都選擇了投票 if r.state == StatePreCandidate { r.campaign(campaignElection) // 預投票發起正式投票 } else { r.becomeLeader() // 變成 leader r.bcastAppend() // 發送 AppendEntries RPC } case quorum.VoteLost: // 集票失敗,轉為 follower // pb.MsgPreVoteResp contains future term of pre-candidate // m.Term > r.Term; reuse r.Term r.becomeFollower(r.Term, None) // 注意,此時任期沒有改變 } // ...... }
- 預投失敗會轉換為 follower,任期沒有改變
-
注意:
-
預投票不會增加任期,但是發送 MsgPreVote 時填入的任期是競選的任期
func (r *raft) campaign(t CampaignType) { // ...... // 根據選舉的類型更新節點的狀態 if t == campaignPreElection { r.becomePreCandidate() voteMsg = pb.MsgPreVote // PreVote RPCs are sent for the next term before we've incremented r.Term. term = r.Term + 1 } // ......
-
響應 MsgPreVote 時發送的任期是來自消息的任期,而不是本地節點的信息。兩個原因:
一個是因為本地節點可能會被隔離,本地任期可能不正確;
-
第二個是因為 PreCandidate 沒有修改自身的任期,只是使用競選任期來填入 MsgPreVote 中,而節點會忽略落后的消息
// When responding to Msg{Pre,}Vote messages we include the term // from the message, not the local term. To see why, consider the // case where a single node was previously partitioned away and // it's local term is now out of date. If we include the local term // (recall that for pre-votes we don't update the local term), the // (pre-)campaigning node on the other end will proceed to ignore // the message (it ignores all out of date messages). // The term in the original message and current local term are the // same in the case of regular votes, but different for pre-votes. // 注意,響應 MsgPreVote 時的任期使用的是來自消息的任期,而不是本地的任期 r.send(pb.Message{To: m.From, Term: m.Term, Type: voteRespMsgType(m.Type)})
預投票成功也無法保證節點會成為 leader,因為預投的下一步是進入正常投票,有可能會面臨同樣的集票失敗的問題(選票瓜分,集群成員變化等)
-
Quorum
etcd-raft 在pr #3917(https://github.com/etcd-io/etcd/pull/3917)中實現
- 背景:如果出現 Partition 的情況,可能會導致出現多個 leader,而此時過期的 leader 可能還維持著和客戶端的請求,就可能會出現延遲客戶端請求的情況,因為無法將對應的日志復制給其他節點,達成 majority 一致的條件進行提交。為了避免該問題,etcd-raft支持開啟 CheckQuorum 選項,在 election timeout 時發送一個 MsgCheckQuorum 信息來判斷自己當前是否還處于 majority 的集群中;如果檢查發現自己不在集群中,則 step down。
etcd 的實現:
-
election timout,給自己發送 MsgCheckQuorum
func (r *raft) tickHeartbeat() { r.heartbeatElapsed++ r.electionElapsed++ if r.electionElapsed >= r.electionTimeout { // 如果選舉計時超時 r.electionElapsed = 0 // 重置計時器 if r.checkQuorum { // 給自己發送一條 MsgCheckQuorum 消息,檢測是否出現網絡隔離 r.Step(pb.Message{From: r.id, Type: pb.MsgCheckQuorum}) } // ...... }
-
處理 MsgCheckQuorum:
func stepLeader(r *raft, m pb.Message) error { // These message types do not require any progress for m.From. switch m.Type { //--------------------其他消息處理---------------------- case pb.MsgCheckQuorum: // 將 leader 自己的 RecentActive 狀態設置為 true if pr := r.prs.Progress[r.id]; pr != nil { pr.RecentActive = true } if !r.prs.QuorumActive() { // 如果當前 leader 發現其不滿足 quorum 的條件,則說明該 leader 有可能處于隔離狀態,step down r.logger.Warningf("%x stepped down to follower since quorum is not active", r.id) r.becomeFollower(r.Term, None) } // Mark everyone (but ourselves) as inactive in preparation for the next // CheckQuorum. r.prs.Visit(func(id uint64, pr *tracker.Progress) { if id != r.id { pr.RecentActive = false } }) return nil // ......
Quorum VS 預投票
- Quorum 解決的是由于網絡分隔造成存在多個 leader 導致可能延遲客戶端請求的情況;
- 預投票解決的是節點由于不同原因重新加入網絡后發起不恰當的 Request Vote 導致當前可用 leader step down 的問題;
Leadership transfer
該部分的內容在 《CONSENSUS BRIDGING THEORY AND PRACTICE》這篇論文的 3.10 節中描述,etcd 在 pr #5809(https://github.com/etcd-io/etcd/pull/5809) 中實現.
- 背景:Leadership transfer 在以下兩種類型的場景中可能有用:
- leader 必須關停,如需要維護,或者需要從當前集群中移除
- 當有更適合的節點來承當 leader 角色的時候
- 主動的移交 leader 角色有助于增強集群的魯棒性,避免由于選舉造成的時間和性能上的額外開銷
- 算法:
- 前任 leader 停止接收新的客戶端請求,使用常規日志復制機制發送它的日志條目到目標服務器;
- 前任 leader 發送一個 TimeoutNow 請求給目標服務器,目標服務器發起選舉;
- 前任服務器保證目標服務器有其任期內所有的日志條目,選舉投票保證了集群的安全性和可維護性;
- 存在的問題:
- 目標服務器可能在 leadership transfer 完成前失效:
- 如果在 election timeout 時間內未完成 leadership transfer,前任 leader 終止轉換,并恢復接收客戶端請求;
- 目標服務器可能在 leadership transfer 完成前失效:
etcd 的實現:
-
leader 在收到 leadership transfer 的消息后,在執行請求前先進行檢查:
case pb.MsgTransferLeader: // 收到 leadership transfer if pr.IsLearner { r.logger.Debugf("%x is learner. Ignored transferring leadership", r.id) return nil } leadTransferee := m.From lastLeadTransferee := r.leadTransferee if lastLeadTransferee != None { // 如果當前已經設置了 transfer 對象 if lastLeadTransferee == leadTransferee { // 如果相同的節點已經在執行 transfer 了,直接返回 return nil } r.abortLeaderTransfer() // 中斷之前的 transfer } if leadTransferee == r.id { return nil } // Transfer leadership should be finished in one electionTimeout, so reset r.electionElapsed. r.electionElapsed = 0 // 重置 electionElapsed,用于檢測 transfer 超時 r.leadTransferee = leadTransferee if pr.Match == r.raftLog.lastIndex() { // 判斷如果日志已經完成復制,則發起 sendTimeoutNow r.sendTimeoutNow(leadTransferee) } else { r.sendAppend(leadTransferee) // 否則先完成日志復制 }
-
在此期間,leader 不再接受來自客戶端的新請求
case pb.MsgProp: // 處理用戶提交到 raft 的建議數據(propose) // ...... if r.leadTransferee != None { // 說明正在執行 leadership transferring,不能處理該消息 return ErrProposalDropped } // ......
-
目標節點收到來自 leader 的 MsgTimeoutNow 信息后,調用
campaign
方法開始選舉,加入上下文來表示該輪選舉是 leader trasferring,強制進行投票func stepFollower(r *raft, m pb.Message) error { switch m.Type { //...... case pb.MsgTimeoutNow: if r.promotable() { // 如果當前節點超時,則發起選舉 // 判斷 r.promotable 的原因:如果一個被踢出集群的節點收到了 MsgTimeoutNow,會增加任期,發起選舉,擾亂集群的穩定 // Refer to:https://github.com/etcd-io/etcd/pull/6815 r.logger.Infof("%x [term %d] received MsgTimeoutNow from %x and starts an election to get leadership.", r.id, r.Term, m.From) // Leadership transfers never use pre-vote even if r.preVote is true; we // know we are not recovering from a partition so there is no need for the // extra round trip. // 常規選舉超時應該通過 tickElection 觸發 MsgHup,然后在 Step 函數中轉為 candidate 狀態并集票 // 這里是強制的 leadership transfer r.campaign(campaignTransfer) } else { r.logger.Infof("%x received MsgTimeoutNow from %x but is not promotable", r.id, m.From) }
func (r *raft) campaign(t CampaignType) { //-------------校驗----------- // 實現 leadership transfer var ctx []byte if t == campaignTransfer { ctx = []byte(t) } r.send(pb.Message{Term: term, To: id, Type: voteMsg, Index: r.raftLog.lastIndex(), LogTerm: r.raftLog.lastTerm(), Context: ctx}) } }
-
如果 leadership transfer 超時,則 leader 中斷執行,自己重新成為 leader
func (r *raft) tickHeartbeat() { r.heartbeatElapsed++ r.electionElapsed++ if r.electionElapsed >= r.electionTimeout { // 如果選舉計時超時 r.electionElapsed = 0 // 重置計時器 // ...... // leadership transfer 執行超時,中斷執行,自己重現變為 leader if r.state == StateLeader && r.leadTransferee != None { r.abortLeaderTransfer() } } // ...... }
只讀請求
該部分的內容在 《CONSENSUS BRIDGING THEORY AND PRACTICE》這篇論文的 3.10 節中描述,etcd 在 pr #6275(https://github.com/etcd-io/etcd/pull/6275) 中實現;
- 背景:應用中只讀請求占了很大的比例。與寫日志相比,讀請求不會導致服務器狀態機狀態的改變,因此可以繞過寫日志這一步,從而獲得大量的性能提升。
- 存在的問題:
- 繞過寫日志部分可能會導致讀取到過期的結果
- 算法:
- 如果 leader 在當前任期內還未提交過日志,則要先等待 leader 至少執行一次提交:
- Leader完整性只保證了leader必須擁有所有已經提交的日志,但是當某個節點剛剛成為 leader 時,它并不知道它的日志中到底有哪些是已經提交了的。因此必須執行一次提交來確認已提交日志的索引,同時也會將之前未提交的待提交日志進行提交;
- raft 通過給新 leader 提交一個空的 no-op entry 來完成提交索引的確認;
- 保存當前 commitIndex 為 readIndex;
- leader 給其他節點發送心跳檢測,通過收到 majority 的成功響應來確認自己仍然是有效的 leader,則此時的 readIndex 依舊是整個集群中最大的提交索引
- leader 等待狀態機執行提交的條目,直到 applied index >= readIndex,這保證了滿足 linearizability
- leader 將結果返回到客戶端
- 如果 leader 在當前任期內還未提交過日志,則要先等待 leader 至少執行一次提交:
etcd 的實現
-
以下結構用于保存 ReadIndex 的相關狀態:
type readOnly struct { // 讀請求的執行類型 option ReadOnlyOption // 保存所有未執行完成讀請求的狀態,鍵是請求的具體內容,值是請求的相關信息 pendingReadIndex map[string]*readIndexStatus // 保存未完成的讀請求,讀請求的隊列,每一個讀請求都會被加入到隊列中 readIndexQueue []string } type readIndexStatus struct { req pb.Message // 請求的原始信息 index uint64 // 收到讀請求時狀態機的提交索引 acks map[uint64]bool // 對每個請求的最終響應 }
-
客戶端發送讀請求:
func (n *node) ReadIndex(ctx context.Context, rctx []byte) error { return n.step(ctx, pb.Message{Type: pb.MsgReadIndex, Entries: []pb.Entry{{Data: rctx}}}) }
- rctx 是對該讀請求的唯一標識,等價于 uid
-
在 StepLeader() 中處理 MsgReadIndex:
case pb.MsgReadIndex: // 處理只讀請求 // ...... // Reject read only request when this leader has not committed any log entry at its term. if !r.committedEntryInCurrentTerm() { // leader 要先發送一條日志來確定最新 commitIndex return nil } // ...... // If more than the local vote is needed, go through a full broadcast. case ReadOnlySafe: r.readOnly.addRequest(r.raftLog.committed, m) // 保存 readIndex // The local node automatically acks the request. r.readOnly.recvAck(r.id, m.Entries[0].Data) // 對于 leader 節點,由于已經添加過讀請求,所以這一步會自動響應 ack r.bcastHeartbeatWithCtx(m.Entries[0].Data) // 帶上讀請求的上下文通過心跳廣播給其他節點 // ......
-
在 StepLeader() 中處理收到的心跳廣播:
case pb.MsgHeartbeatResp: // ...... if r.prs.Voters.VoteResult(r.readOnly.recvAck(m.From, m.Context)) != quorum.VoteWon { return nil } // 返回 m 對應的 readIndex 之前的所有未響應的只讀請求的結果,并更新readOnly結構體 rss := r.readOnly.advance(m) // 響應客戶端 for _, rs := range rss { if resp := r.responseToReadIndexReq(rs.req, rs.index); resp.To != None { r.send(resp) } } // ......
調用 recvAck 是為了確定當前 leader 仍舊是集群的 leader,這意味著當前消息 m 對應的 readIndex 之前的所有消息都是集群確認過的。
-
r.readOnly.advance(m)
返回的是當前消息 m 對應的 readIndex 之前的所有未響應的讀請求的結果,同時會更新 readOnly 相關的結構體// advance advances the read only request queue kept by the readonly struct. // It dequeues the requests until it finds the read only request that has // the same context as the given `m`. // advance 會將當前m對應的 readIndex 之前的所有未響應的消息都一次性返回 func (ro *readOnly) advance(m pb.Message) []*readIndexStatus { var ( i int found bool ) ctx := string(m.Context) rss := []*readIndexStatus{} for _, okctx := range ro.readIndexQueue { // 遍歷隊列 i++ rs, ok := ro.pendingReadIndex[okctx] if !ok { panic("cannot find corresponding read state from pending map") } rss = append(rss, rs) // 將遍歷到的所有讀請求加入到 rss 中 if okctx == ctx { // 一直讀取到當前請求的 ctx,退出遍歷 found = true break } } if found { ro.readIndexQueue = ro.readIndexQueue[i:] // 更新隊列 for _, rs := range rss { // 刪除已經處理過的讀請求 delete(ro.pendingReadIndex, string(rs.req.Entries[0].Data)) } return rss } return nil }
-
響應客戶端:
// ReadState provides state for read only query. // It's caller's responsibility to call ReadIndex first before getting // this state from ready, it's also caller's duty to differentiate if this // state is what it requests through RequestCtx, eg. given a unique id as // RequestCtx type ReadState struct { Index uint64 RequestCtx []byte } // responseToReadIndexReq constructs a response for `req`. If `req` comes from the peer // itself, a blank value will be returned. func (r *raft) responseToReadIndexReq(req pb.Message, readIndex uint64) pb.Message { if req.From == None || req.From == r.id { // 當前節點是 leader,讀請求直接發給 leader,則 leader 直接添加響應 r.readStates = append(r.readStates, ReadState{ Index: readIndex, RequestCtx: req.Entries[0].Data, }) return pb.Message{} } // 當前節點是 leader,讀請求發給了follower,由follower轉到 leader,則 leader 應該響應follower,由follower添加對客戶端的響應 return pb.Message{ Type: pb.MsgReadIndexResp, To: req.From, Index: readIndex, Entries: req.Entries, } }
-
發給 follower 的讀請求:
case pb.MsgReadIndex: // 發送給客戶端的讀請求,轉發給 leader 來獲取最新數據 if r.lead == None { return nil } m.To = r.lead r.send(m) // 轉發給 leader case pb.MsgReadIndexResp: // leader 處理完由 follower 轉發的讀請求,將響應交給 follower,follower 響應客戶端 if len(m.Entries) != 1 { return nil } r.readStates = append(r.readStates, ReadState{Index: m.Index, RequestCtx: m.Entries[0].Data}) }
- 根據 raft 的論文,讀請求的前 3 步依舊要由 leader 來處理,所以客戶端先將請求轉發給 leader,leader執行完前 3 步后,將結果交給 follower,由 follower 執行后續步驟
租約
在《CONSENSUS BRIDGING THEORY AND PRACTICE》這篇論文中出現過兩次租約的概念,一次在集群配置變更時防止被移出配置的節點影響新集群中的節點,另一次則用于只讀請求的優化
集群成員變更中投票規則優化
該部分內容在《CONSENSUS BRIDGING THEORY AND PRACTICE》的 4.2.3 節中描述,etcd 在引入 Lease Read 優化時,為保障 Lease Read 的正確執行而引入該部分優化
-
背景:
節點因為配置變更被移除新集群時,因為無法知曉自己已經被移除集群,被移除節點在下線前可能因為收不到心跳而發起請求投票,導致當前 leader step down,雖然最終新集群中的節點會被選為 leader,但是無疑浪費了很多時間和系統資源,這種情況是應該避免的
-
采用預投票的方式來確認自己是否有資格參與選舉,這種辦法無法徹底解決問題,因為存在這樣一種情況:待下線的節點滿足日志 up-to-date 的條件
集群成員變更的干擾.png- 如圖所示,舊集群成員為 S1 - S4,S4 是 leader;
- 集群成員變更完成后,S1被移出新集群,S4當選為新集群的 leader,此時 S4 收到一個新條目,還未來得及提交,S1 也還未下線。S1 選舉超時后發起選舉,增加任期后發送 Request RPC,強制 S4 step down
算法:修改請求投票的判定機制,如果 leader 能夠維持心跳,則說明該 leader 是合法的,不允許發起新的選舉。即如果在minimum election timeout 時間內還收到來自當前 leader 的心跳檢查,則拒絕投票
-
存在的問題:與 leadership transfer 的情況沖突
- 解決方式:在請求中加上上下文(special flag),用于在 RequestVote RPC 處理時進行判斷
加入租約機制的只讀請求
該部分的內容在 《CONSENSUS BRIDGING THEORY AND PRACTICE》這篇論文的 6.4.1 節中描述,etcd 在 pr #5468(https://github.com/etcd-io/etcd/pull/5468) 中對該優化方式進行了討論和實現,在 pr #8525(https://github.com/etcd-io/etcd/pull/8525) 中對其進行了完善。
- 背景:之前對只讀請求的優化繞過了寫日志的磁盤操作,但是對每一條讀請求都需要與 quorum 進行一次通信,增加了請求的延遲。通過在心跳機制中引入租約的概念來實現在一次心跳間隔內執行多條只讀請求;
- 算法:
- 通過開啟 quorum,一旦 leader 的心跳包被集群內的 majority 節點響應,則 leader 可以假設,在同一個 election timeout 間隔內,其他節點都不可能成為 leader。因此 leader 可以在心跳間隔內直接響應只讀請求而不需要額外的通信;
- 存在的問題:
- 非常依賴時鐘,不能保證線性一致;
- 收到心跳的節點可以保證在 election timeout 時間內不發起選舉,但是沒有收到的節點仍然有可能超時發起選舉;
etcd 的實現
-
在加入租約機制之前,etcd 在
Step()
函數中,如果收到消息的任期小于當前節點的任期,則該消息會被直接丟棄。但是在實現了 quorum 和 Lease Read 之后,如果沒有實現或開啟預投票,會出現以下問題:- 如果某個節點因為網絡分隔后任期增加,其重新加入集群后會發起 RequestVote RPC,此時如果沒有實現或開啟預投票,則投票 RPC 會打破 Lease Read 中心跳間隔內其余節點不可能成為 leader 的前提條件;
- 為解決該問題,變更了投票規則,引入了上一條中描述的請求投票的修正
func (r *raft) Step(m pb.Message) error { // ...... case m.Term > r.Term: if m.Type == pb.MsgVote || m.Type == pb.MsgPreVote { // 判斷是否滿足強制發起選舉的條件(用于 leader transfer) force := bytes.Equal(m.Context, []byte(campaignTransfer)) inLease := r.checkQuorum && r.lead != None && r.electionElapsed < r.electionTimeout if !force && inLease { // If a server receives a RequestVote request within the minimum election timeout // of hearing from a current leader, it does not update its term or grant its vote return nil } } // -------其他 m.Term > r.Term 時需執行的邏輯 case m.Term < r.Term: // ignore other cases r.logger.Infof("%x [term: %d] ignored a %s message with lower term from %x [term: %d]", r.id, r.Term, m.Type, m.From, m.Term) } // ......
-
修改請求投票判定機制后的實現依舊存在問題:當前實現中,如果收到消息的任期小于當前節點的任期,則該消息會被拋棄;
存在的問題為:某個節點在 partition 后重新加入集群,此時該節點擁有一個更高的任期,因此會忽略任何來自當前 leader 的消息(m.Term < r.Term),而對于開啟了 quorum 和 Lease Reade 的其他節點,inLease 機制會使他們忽略來自 partition 節點的請求投票信息,故該節點會被永久的 stuck 住。
另外一個問題在 https://github.com/etcd-io/etcd/issues/8501 中描述,可能會產生集群死鎖,選不出 leader;
-
為解決上述問題,etcd 對消息任期小于當前節點任期的情況的判定機制也進行了修正
case m.Term < r.Term: if (r.checkQuorum || r.preVote) && (m.Type == pb.MsgHeartbeat || m.Type == pb.MsgApp) { // 節點收到任期比自己小的節點的 heartbeat 或者 Append 消息,可能是自己因為網絡(延遲或者分區)問題 r.send(pb.Message{To: m.From, Type: pb.MsgAppResp}) } else if m.Type == pb.MsgPreVote { // Before Pre-Vote enable, there may have candidate with higher term, // but less log. After update to Pre-Vote, the cluster may deadlock if // we drop messages with a lower term. r.send(pb.Message{To: m.From, Term: r.Term, Type: pb.MsgPreVoteResp, Reject: true}) } else { // ignore other cases } // 其他情況下,消息任期小于當前任期,直接返回 return nil
-
有了以上機制的保障后,在處理只讀請求時就可以使用基于租約的只讀請求模式:
case ReadOnlyLeaseBased: if resp := r.responseToReadIndexReq(m, r.raftLog.committed); resp.To != None { r.send(resp) } }
學習到的技巧和方法匯總
該部分總結了在學習過程中學到的方法和技巧(包括自己未使用過,或者曾經使用過但是沒有很好掌握的)
設計思路
- etcd-raft 對不同狀態的節點處理不同類型消息的實現方式非常值得學習;
- 引入入口函數:
- 整個 raft 被設計為一個大的狀態機,任期是狀態變更的關鍵屬性,而不同的狀態決定了處理消息的具體邏輯。即:任期判斷是所有后續執行的前提;
- 入口函數的作用有兩個:節點狀態的判定和兩類消息(狀態變更相關消息和其他業務消息)處理邏輯的分離,其中節點狀態變更又是由任期變化來驅動的,故而有了兩階段的設計,先判斷任期,再處理消息;
- 這種設計思路有效的將代碼按功能進行分塊,按邏輯進行編排,避免了重復代碼,也便于后續修改
- 使用回調函數實現不同角色對各類消息的處理邏輯:
- 復用了入口函數的部分,將所有前置判斷交給入口函數來做,自己只需實現自己消息處理的部分;
- 引入入口函數:
常用方法
-
對于配置類,可以為該類添加一個 validate 方法,在方法中既可以完成默認值的設置,也可以完成參數的校驗檢查
type Config struct {} func (c *Config)validate() error {} func newRaft(c *Config) *raft { if err := c.validate(); err != nil { // c.validate() 中執行校驗 panic(err.Error()) } }
-
枚舉類型的使用:
type StateType uint64 const ( StateFollower StateType = iota StateCandidate StateLeader StatePreCandidate numStates ) var stmap = [...]string { "StateFollower", "StateCandidate", "StateLeader", "StatePreCandidate", } func (st StateType) String() { return stmap[uint64(st)] }
-
字符串的構造:
var buf strings.Builder for _, id := range ids { fmt.Fprintf(&buf, "%d: %s\n", id, m[id]) } return buf.String()
-
實現優雅的關停節點:
func (n *node) Stop() { select { case n.stop <- struct{}{}: // Not already stopped, so trigger it case <-n.done: // Node has already been stopped - no need to do anything return } // Block until the stop has been acknowledged by run() <-n.done } // run 方法中監聽各個通道發來的信息 func (n *node) run() { //...... for { select { //-------監聽其他通道------- case <- n.stop: close(n.done) return } } } // 關閉 n.done,會同時廣播到其他監聽 done 通道的地方 func (n *node) Tick() { select { //------監聽其他通道------ case <-n.done: }
-
全局隨機數的實現:
// lockedRand is a small wrapper around rand.Rand to provide // synchronization among multiple raft groups. Only the methods needed // by the code are exposed (e.g. Intn). type lockedRand struct { mu sync.Mutex rand *rand.Rand } func (r *lockedRand) Intn(n int) int { r.mu.Lock() v := r.rand.Intn(n) r.mu.Unlock() return v } var globalRand = &lockedRand{ rand: rand.New(rand.NewSource(time.Now().UnixNano())), }
- 單例模式,線程安全
參考資料
《CONSENSUS: BRIDGING THEORY AND PRACTICE》
《In Search of an Understandable Consensus Algorithm》
Raft 筆記