etcd的raft實現之tracker&quorum

1.前言

在閱讀本文之前請現閱讀《etcd的raft實現之log》,筆者的etcd的raft系列通過一個點(就是log)進行展開,能夠讓讀者比較容易的理解etcd的raft實現,解決讀者無從下手的難題。

在開始分析之前,先做一些名詞解釋,在《etcd的raft實現之log》中提到的概念本文就不在重複了:

  1. Peer:原意是同齡人或者同輩份的人,在raft中是參與選舉和投票的節點,寓意raft節點都是“相同的”,這符合raft一致性的特點。
  2. Leader:集羣的領導者,在Peer中通過選舉產生;
  3. Follower:Peer中除了Leader以外的節點
  4. Learner:不參與選舉和投票的節點,這些節點一直從Leader同步日誌,只輸入不輸出,就像一個學生。空白節點加入集羣都是Learner;
  5. Quorum:原意是法定人數,在raft中超過一半以上的人數就是法定人數

2.解析

2.1tracker

tracker是etcd的raft單獨的一個包,其核心類是ProgressTracker。從類名上看是Progress的Tracker,所以raft的tracker是用來跟蹤Progress的,理解了什麼是Progress才能明白ProgressTracker的是幹什麼的。

2.1.1Progress

Progress翻譯成中文叫進度,和所謂進度?先來看看代碼中註釋是怎麼解釋的,翻譯的不好還請見諒:

// Progress represents a follower’s progress in the view of the leader. Leader
// maintains progresses of all followers, and sends entries to the follower
// based on its progress.
//
// NB(tbg): Progress is basically a state machine whose transitions are mostly
// strewn around `*raft.raft`. Additionally, some fields are only used when in a
// certain State. All of this isn't ideal.
Progress代表了Leader視角的Follower進度,Leader擁有所有Follower的進度,並根據其進度向Follower
發送日誌。進度基本上是一個狀態機,其轉換主要散佈在`* raft.raft`周圍(這句話不知道什麼意思)。

從官方註釋來應該能看出一些端倪,筆者一句話概括:Progress是Follower追隨Leader狀態的進度,此處的狀態主要指定的是日誌的狀態。對於raft來說系統狀態的決策者是Leader,其他Follower都是從Leader同步的,確切的說是Leader發送給Follower的。作爲Leader,需要知道所有Peer已經同步到什麼程度了,所以就有了Progress。比如Leader已經有10條日誌,Leader需要把這10條日誌發給所有的Peer,那麼Leader就需要記錄Peer1已經發送到了第3條,Peer2已經發送到了第4條,以此類推。當然,Leader不可能就這麼簡單的跟蹤每個peer的當前日誌的進度,接下來看看Progress的定義:

// 代碼源自go.etcd.io/etcd/raft/tracker/progress.go
type Progress struct {
    // Leader與Follower之間的狀態同步是異步的,Leader將日誌發給Follower,Follower再回復接收
    // 到了哪些日誌。出於效率考慮,Leader不會每條日誌以類似同步調用的方式發送給Follower,而是
    // 只要Leader有新的日誌就發送,Next就是用來記錄下一次發送日誌起始索引。換句話說就是發送給Peer
    // 的最大日誌索引是Next-1,而Match的就是經過Follower確認接收的最大日誌索引,Next-Match-1
    // 就是還在飛行中或者還在路上的日誌數量(Inflights)。Inflights還是比較形象的,下面會有詳細
    // 說明。
    Match, Next uint64
    // 此處順便把StateType這個類型詳細說明一下,StateType的代碼在go.etcd.io/etcd/raft/tracker/state.go
    // Progress一共有三種狀態,分別爲探測(StateProbe)、複製(StateReplicate)、快照(StateSnapshot)
    // 探測:一般是系統選舉完成後,Leader不知道所有Follower都是什麼進度,所以需要發消息探測一下,從
    //    Follower的回覆消息獲取進度。在還沒有收到回消息前都還是探測狀態。因爲不確定Follower是
    //    否活躍,所以發送太多的探測消息意義不大,只發送一個探測消息即可。
    // 複製:當Peer回覆探測消息後,消息中有該節點接收的最大日誌索引,如果回覆的最大索引大於Match,
    //    以此索引更新Match,Progress就進入了複製狀態,開啓高速複製模式。複製制狀態不同於
    //    探測狀態,Leader會發送更多的日誌消息來提升IO效率,就是上面提到的異步發送。這裏就要引入
    //    Inflight概念了,飛行中的日誌,意思就是已經發送給Follower還沒有被確認接收的日誌數據,
    //    後面會有inflight介紹。
    // 快照:快照狀態說明Follower正在複製Leader的快照
    State StateType
    // 在快照狀態時,快照的索引值
    PendingSnapshot uint64
    // 變量名字就能看出來,表示Follower最近是否活躍,只要Leader收到任何一個消息就表示節點是最近
    // 是活躍的。如果新一輪的選舉,那麼新的Leader默認爲都是不活躍的。
    RecentActive bool
    // 探測狀態時纔有用,表示探測消息是否已經發送了,如果發送了就不會再發了,避免不必要的IO。
    ProbeSent bool
    // Inflight前面提到了,在複製狀態有作用,後面有他的代碼解析,此處只需要知道他是個限流的作用即可。
    // Leader不能無休止的向Follower發送日誌,飛行中的日誌量太大對網絡和節點都是負擔。而且一個日誌
    // 丟失其後面的日誌都要重發,所以過大的飛行中的日誌失敗後的重發成本也很大。
    Inflights *Inflights
    // 是否是Learner,對於本文,這個變量作用不大,會在其他文章中使用
    IsLearner bool
}

通過Progress的定義來看,總結如下:

  1. (0, Next)的日誌已經發送給節點了,(0,Match]是節點的已經接收到的日誌。
  2. 探測狀態通過ProbeSent控制探測消息的發送頻率,複製狀態下通過Inflights控制發送流量。
  3. 如果Follower的返回消息中確認接收的日誌索引大於Match,說明Follower開始接收日誌了,那麼就進入了複製狀態。

此處需要解釋一下,raft沒有專門的探測消息,他是藉助於其他消息實現的,比如心跳消息,日誌消息等。任何消息的回覆都算是一種探測,筆者會在其他文章的代碼註釋中提到。接下來再來看看Progress幾個接口函數:

// 代碼源自go.etcd.io/etcd/raft/tracker/progress.go
// Progress進入探測狀態
func (pr *Progress) BecomeProbe() {
    // 代碼註釋翻譯:如果原始狀態是快照,說明快照已經被Peer接收了,那麼Next=pendingSnapshot+1,
    // 意思就是從快照索引的下一個索引開始發送。
    if pr.State == StateSnapshot {
        // 此處用臨時變量的原因是因爲ResetState()會pr.PendingSnapshot=nil
        pendingSnapshot := pr.PendingSnapshot
        pr.ResetState(StateProbe)
        pr.Next = max(pr.Match+1, pendingSnapshot+1)
    } else {
        // ResetState的代碼註釋在下面
        pr.ResetState(StateProbe)
        // 上面的邏輯是Peer接收完快照後再探測一次才能繼續發日誌,而這裏的邏輯是Peer從複製狀態轉
        // 到探測狀態,這在Peer拒絕了日誌、日誌消息丟失的情況會發生,此時Leader不知道從哪裏開始,
        // 倒不如從Match+1開始,因爲Match是節點明確已經收到的。
        pr.Next = pr.Match + 1
    }
}
// Progress進入複製狀態
func (pr *Progress) BecomeReplicate() {
    // 除了復位一下狀態就是調整Next,爲什麼Next也是Match+1?進入複製狀態肯定是收到了探測消息的
    // 反饋,此時Match會被更新,那從Match+1也就理所當然了。
    pr.ResetState(StateReplicate)
    pr.Next = pr.Match + 1
}
// Progress進入快照狀態
func (pr *Progress) BecomeSnapshot(snapshoti uint64) {
    // 除了復位一下狀態就是設置快照的索引,此處爲什麼不需要調整Next?因爲這個狀態無需在發日誌給
    // peer,知道快照完成後才能繼續
    pr.ResetState(StateSnapshot)
    pr.PendingSnapshot = snapshoti
}
// 復位狀態
func (pr *Progress) ResetState(state StateType) {
    // 代碼簡單到無需解釋
    pr.ProbeSent = false
    pr.PendingSnapshot = 0
    pr.State = state
    pr.Inflights.reset()
}

除了以上幾個切換狀態函數,還有幾個函數也很重要:

// 代碼源自go.etcd.io/etcd/raft/tracker/progress.go
// 更新Progress的狀態,爲什麼有個maybe呢?因爲不確定是否更新,raft代碼中有很多maybeXxx系列函數,
// 大多是嘗試性操作,畢竟是分佈式系統,消息重發、網絡分區可能會讓某些操作失敗。這個函數是在節點回復
// 追加日誌消息時被調用的,在反饋消息中節點告知Leader日誌消息中最有一條日誌的索引已經被接收,就是
// 下面的參數n,Leader嘗試更新節點的Progress的狀態。
func (pr *Progress) MaybeUpdate(n uint64) bool {
    var updated bool
    // 只有n比Match大才更新,否則可能是節點的進度根本沒有變化。n小於Match筆者猜可能是過時的消息。
    if pr.Match < n {
        pr.Match = n
        updated = true
        // 這個函數就是把ProbeSent設置爲false,試問爲什麼在這個條件下纔算是確認收到探測包?
        // 這就要從探測消息說起了,raft可以把日誌消息、心跳消息當做探測消息,此處是把日誌消息
        // 當做探測消息的處理邏輯。新的日誌肯定會讓Match更新,只有收到了比Match更大的回覆才
        // 能算是這個節點收到了新日誌消息,其他的反饋都可以視爲過時消息。比如Match=9,新的日誌
        // 索引是10,只有收到了>=10的反饋才能確定節點收到了當做探測消息的日誌。
        pr.ProbeAcked()
    }
    // 這會發生在什麼時候?Next是Leader認爲發送給Peer最大的日誌索引了,Peer怎麼可能會回覆一個
    // 比Next更大的日誌索引呢?這個其實是在系統初始化的時候亦或是每輪選舉完成後,新的Leader
    // 還不知道Leer的接收的最大日誌索引,所以此時的Next還是個初識值。
    if pr.Next < n+1 {
        pr.Next = n + 1
    }
    return updated
}
// 源碼註釋翻譯:Progress狀態,當收到Peer拒絕的消息的時候使用,參數rejected、last是Peer拒絕的
// 和最後的日誌的索引。因爲消息的無序和重複發送可能會造成Peer的拒絕,因爲Progress通過Match記錄
// 了先前Peer已經確認收到的索引,所以這些是不需要調整狀態的.如果拒絕超出了Progress預料,則明智地
// 降低Next。
func (pr *Progress) MaybeDecrTo(rejected, last uint64) bool {
    // 複製狀態下Match是有效的,可以通過Match判斷拒絕的日誌是否已經無效了
    if pr.State == StateReplicate {
        // 拒絕的日誌索引比Match小,可能是重複日誌的回覆,所以可以忽略
        if rejected <= pr.Match {
            return false
        }
        // 源碼註釋:直接把Next調整到Match+1。源碼註釋還有一句是如果last更大爲什麼不用他?
        // last有可能比Match大麼?讓我們分析一下,因爲在複製狀態下Leader會發送多個日誌信息
        // 給Peer再等待Peer的回覆,例如:Match+1,Match+2,Match+3,Match+4,此時如果
        // Match+3丟了,那麼Match+4肯定好會被拒絕,此時last應該是Match+2,Next=last+1
        // 應該更合理。但是從peer的角度看,如果收到了Match+2的日誌就會給leader一次回覆,這個
        // 回覆理論上是早於當前這個拒絕消息的,所以當Leader收到Match+4拒絕消息,此時的Match
        // 已經更新到Match+2,如果Peer回覆的消息也丟包了Match可能也沒有更新。所以Match+1
        // 大概率和last相同,少數情況可能last更好,但是用Match+1做可能更保險一點。
        pr.Next = pr.Match + 1
        return true
    }
    // 源碼註釋翻譯:如果拒絕日誌索引不是Next-1,肯定是陳舊消息這是因爲非複製狀態探測消息一次只
    // 發送一條日誌。這句話是什麼意思呢,讀者需要注意,代碼執行到這裏說明Progress不是複製狀態,
    // 應該是探測狀態。爲了效率考慮,Leader向Peer發送日誌消息一次會帶多條日誌,比如一個日誌消息
    // 會帶有10條日誌。上面Match+1,Match+2,Match+3,Match+4的例子是爲了理解方便假設每個
    // 日誌消息一條日誌。真實的情況是Message[Match,Match+9],Message[Match+10,Match+15],
    // 一個日誌消息如果帶有多條日誌,Peer拒絕的是其中一條日誌。此時用什麼判斷拒絕索引日誌就在剛剛
    // 發送的探測消息中呢?所以探測消息一次只發送一條日誌就能做到了,因爲這個日誌的索引肯定是Next-1。
    if pr.Next-1 != rejected {
        return false
    }
    // 根據Peer的反饋調整Next
    if pr.Next = min(rejected, last+1); pr.Next < 1 {
        pr.Next = 1
    }
    // 因爲節點拒絕了日誌,如果這個日誌是探測消息,那就再探測一次,ProbeSent=true的話,Leader
    // 就不會再發消息了
    pr.ProbeSent = false
    return true
}
// 判斷Progress當前是否暫停,“暫停”這個詞還是不錯的,畢竟Progress是一個動態過程,而暫停即表示
// Leader不能再向Peer發日誌消息了,必須等待Peer回覆打破這個狀態。
func (pr *Progress) IsPaused() bool {
    // 不同狀態下的暫停條件是不同的。
    switch pr.State {
    case StateProbe:
        // 探測狀態下如果已經發送了探測消息Progress就暫停了,意味着不能再發探測消息了,前一個消息
        // 還沒回復呢,如果節點真的不活躍,發再多也沒用。
        return pr.ProbeSent
    case StateReplicate:
        // 複製狀態如果Inflights滿了就是Progress暫停,這個很合理,也很好理解。
        return pr.Inflights.Full()
    case StateSnapshot:
        // 快照狀態Progress就是暫停的,Peer正在複製Leader發送的快照,這個過程是一個相對較大
        // 而且重要的事情,因爲所有的日誌都是基於某一快照基礎上的增量,所以快照不完成其他的都是
        // 徒勞。
        return true
    default:
        panic("unexpected state")
    }
}

以上就是Progress幾乎全部的代碼了,代碼量雖然不多,但是包含的內容確實不少,有些內容需要有一些上下文背景才能理解,筆者在註釋中基本都提及了。

2.1.2Inflights

上一節提到了好多次Inflights,這裏就對他進行一次手術刀式剖析:

// 代碼源自go.etcd.io/etcd/raft/tracker/inflights.go
// 在解釋Inflights前先溫習小學的數據題:有一個水池子,一個入水口,一個出水口,小學題一般會問什麼時候
// 能把池子放滿。Inflights就好像這個池子,當Progress在複製狀態時,Leader向Peer發日誌消息相當於
// 放水,Peer回覆日誌已經收到了相當於出水,當池子滿了就不能放水了,也就是上面提到的暫停。作爲一個
// 容量相對固定的池子,有入水口有出水口,而且需要按照進水的順序出水,這正符合queue的特性。而raft的
// 實現沒有使用queue,而是在一個內存塊上採用循環方式模擬queue的特性,這樣效率會更高。就這麼多了乾貨
// 了,沒有其他更有價值的內容了。
type Inflights struct {
    // 因爲是循環使用內存塊,需要用起始位置+數量的方式表達Inflights中的日誌,start和count就是
    // 這兩個變量。
    start int
    count int
    // size是內存塊的大小
    size int
    // buffer是內存塊,Inflights只記錄日誌的索引值,而不是日誌本身,有索引就足夠了。
    buffer []uint64
}
// 創建Inflights,需要給Inflights的容量
func NewInflights(size int) *Inflights {
    // 有沒有注意到並沒有爲buffer申請內存?size是容量,但是實際運行過程中對於buffer的使用量可能
    // 遠遠低於容量,此時申請size大小的內存明顯是一種浪費,所以設計者採用動態調整buffer大小的方法
    // 這個會在後面的函數中看到。此處來一個附加題,爲什麼實際運行過程中對於buffer的使用量可能遠遠
    // 低於容量?例如,容量是256,但是即使用的量可能只有16。首先,日誌是以消息爲粒度發送的,一個
    // 消息可以攜帶多個日誌;其次,Inflights記錄的是消息中最大日誌的索引,所以它記錄的是飛行中的
    // 消息的數量,那麼折算成飛行中的日誌數量就更多了;第三,正常情況下日誌發送到節點到接收到節點
    // 的回覆是非常快的,幾毫秒到幾十毫秒;第四,使用者在不頻繁執行寫操作的情況下節點間的IO性能基本
    // 能夠滿足寫IO,Inflights的緩衝效果就不明顯了。所以說,在大部分情況下,buffer的使用遠到不
    // 了設置容量。
    return &Inflights{
        size: size,
    }
}
// 向Inflights添加一個日誌索引,就是向池子放水
func (in *Inflights) Add(inflight uint64) {
    // 如果已經滿了是不能再添加的
    if in.Full() {
        panic("cannot add into a Full inflights")
    }
    // 找到新添加的日誌應該在內存塊的位置,因爲是循環使用內存塊,算法也比較簡單:(count) % size
    next := in.start + in.count
    size := in.size
    if next >= size {
        next -= size
    }
    // 這裏有意思了,如果buffer大小不夠了,那就再擴容。前面我們提到了,buffer不是上來就申請內存的
    if next >= len(in.buffer) {
        in.grow()
    }
    // 把日誌索引存儲buffer
    in.buffer[next] = inflight
   in.count++
}
// 爲buffer擴容
func (in *Inflights) grow() {
    // 每次擴上次容量的2倍,不多解釋了
    newSize := len(in.buffer) * 2
    if newSize == 0 {
        newSize = 1
    } else if newSize > in.size {
        newSize = in.size
    }
    // 把以前內存的內容拷貝到新內存上
    newBuffer := make([]uint64, newSize)
    copy(newBuffer, in.buffer)
    in.buffer = newBuffer
}
// 把小於等於to的日誌全部釋放,爲什麼不是把等於to的釋放掉?這個很簡單,如果節點回復的消息丟包了,那麼
// 就會造成部分日誌無法釋放。raft裏日誌是有序的,搜到了節點回復消息的使用爲n,那就說明節點已經收到了
// n以前的全部日誌,所以可以把之前的全部釋放掉。
func (in *Inflights) FreeLE(to uint64) {
    // 沒有日誌或者老舊消息則忽略
    if in.count == 0 || to < in.buffer[in.start] {
        return
    }

    // 找到第一個比to更大的日誌
    idx := in.start
    var i int
    for i = 0; i < in.count; i++ {
        if to < in.buffer[idx] { // found the first large inflight
            break
        }

        // 此處還是循環使用內存的操作
        size := in.size
        if idx++; idx >= size {
            idx -= size
        }
    }
    // 調整start和count
    in.count -= i
    in.start = idx
    // 如果此時沒有日誌了,索性把start也調整到0的位置,我感覺這是coder的強迫症,哈哈~
    if in.count == 0 {
        in.start = 0
    }
}
// 釋放第一個日誌
func (in *Inflights) FreeFirstOne() { in.FreeLE(in.buffer[in.start]) }
// 判斷是否滿了
func (in *Inflights) Full() bool {
    return in.count == in.size
}
// 獲取日誌數量
func (in *Inflights) Count() int { return in.count }
// 復位
func (in *Inflights) reset() {
    in.count = 0
    in.start = 0
}

是不是非常簡單,以至於不用總結什麼。

2.1.3ProgressTracker

有了前兩節的鋪墊,再來理解ProgressTracker就比較容易了。ProgressTracker是Progress的管理者,可以理解爲Leader跟蹤所有Peer的Progress。

老規矩,先看Tracker的代碼定義:

// 代碼源自go.etcd.io/etcd/raft/tracker/tracker.go
type ProgressTracker struct {
    // 這個會在後面的章節說明,此處現忽略
    Voters   quorum.JointConfig
    // 所有的learners
    Learners map[uint64]struct{}
    // 所有的peer的Progress
    Progress map[uint64]*Progress
    // 這個會在後面的章節說明,此處現忽略
    Votes map[uint64]bool
    // 這個就是Inflights的容量。
    MaxInflight int
}

除了和quota相關的內容會在下一章說明,其實就是一個Progress的map,所以Tracker也被不會有太過高深的內容。先來看看ProgressTracker初始化Progress的代碼:

// 代碼源自go.etcd.io/etcd/raft/tracker/tracker.go
// 源碼註釋:初始化給定Follower或Learner的Progress,該節點不能以任何一種形式存在,否則異常。
// ID是Peer的ID,match和next用來初始化Progress的。
func (p *ProgressTracker) InitProgress(id, match, next uint64, isLearner bool) {
    // 完全按照註釋來的,不能重複初始化
    if pr := p.Progress[id]; pr != nil {
        panic(fmt.Sprintf("peer %x already tracked as node %v", id, pr))
    }
    // Follower可以參與選舉和投票,Learner不可以,只要知道這一點就可以了。無論是Follower還是
    // Learner都會有一個Progress,但是他們再次進行了分組管理。
    if !isLearner {
        // 此處爲什麼是Voters[0],這個在quorum解釋,暫時就把他看做一個map好了。
        p.Voters[0][id] = struct{}{}
    } else {
        p.Learners[id] = struct{}{}
    }
    // 初始化Progress需要給定Next、Match、Inflights容量以及是否是learner,其他也沒啥
    // 此處可以劇透一下,raft的代碼初始化的時候Match=0,Next=1.
    p.Progress[id] = &Progress{Next: next, Match: match, Inflights: NewInflights(p.MaxInflight), IsLearner: isLearner}
}

瞭解了Progress的初始化,接下來就是ProgressTracker存在的最大價值了,先來看一個非常簡單的函數:

// 代碼源自go.etcd.io/etcd/raft/tracker/tracker.go
// 源碼註釋:根據投票成員已確認的返回已提交的最大日誌索引。這句話是什麼意思呢?首先需要理解什麼是投票
// 成員,raft中有Follower和Learner,只有Follower纔有權投票,Learner是沒有的,所以投票成員其實
// 就是Follower。最大日誌索引比較好理解,沒什麼需要解釋的,最重要的就是已提交,那什麼纔算是已提交呢?
// raft認爲超過半數以上Follower確認接收的日誌就算是已提交的,Committed()是從整個集羣的角度計算出
// 已提交的最大日誌索引。因爲Leader是通過Progres是跟蹤每個Follower的日誌進度的,Follower之間還
// 存在這個各種差異(比如網絡)使得彼此的進度不同,這就是這個函數存在的必要性。
func (p *ProgressTracker) Committed() uint64 {
    // 此處是用Voters.CommittedIndex()實現的,這也是筆者將tracker和quorum放在一個文章的原因
    // 下面需要了解一下matchAckIndexer,因爲Voters.CommittedIndex()傳入了這個類型的對象。
    return uint64(p.Voters.CommittedIndex(matchAckIndexer(p.Progress)))
}

// matchAckIndexer就是Progress的map,這個和ProgressTracker.Progress本質上是同一個類型,所以
// 在上面的函數傳入的參數matchAckIndexer(p.Progress)是做了一次強制的類型轉換。
type matchAckIndexer map[uint64]*Progress

// matchAckIndexer實現了AckedIndexer(定義在quorum中的接口)的AckedIndex()接口函數,
// 而Voters.CommittedIndex()的參數其實是AckedIndexer類型的對象。AckedIndex()就是返回指定
// ID的Peer接收的最大日誌索引,就是Progress.Match。
func (l matchAckIndexer) AckedIndex(id uint64) (quorum.Index, bool) {
    // 根據ID找到Progress
    pr, ok := l[id]
    if !ok {
        return 0, false
    }
    // 返回Progress.Match,現在我們已經知道了Match就是Peer回覆給Leader確認收到的最大的日誌索引
    // 此處可以想象得到Voters.CommittedIndex()函數會用到每個peer的Progress.Match來計算raft
    // 當前已經提交的最大日誌索引。
    return quorum.Index(pr.Match), true
}

2.2quorum

根據上一章節的註釋自然而然的進入到了quorum部分,筆者順着上一章節的思路順藤摸瓜,最終帶着讀者把quorum搞明白。從quorum的定義可以推測這個模塊做的基本都是跟法定人數(即超過一半以上的人數)有關的功能,比如選舉就需要超過一半以上的Peer支持才能成爲Leader。

2.2.1MajorityConfig

ProgressTracker.Voters的類型是quorum.JointConfig,但是筆者此處先賣個關子,先來看MajorityConfig,因爲JointConfig.CommittedIndex()調用的是MajorityConfig.CommittedIndex()。

// 代碼源自go.etcd.io/etcd/raft/quorum/majority.go
// MajorityConfig的定義其實就是peerID的set,所以MajorityConfig就是記錄了所有Peer
type MajorityConfig map[uint64]struct{}

// 這裏需要注意的是AckedIndexer就是上一節提到的matchAckIndexer,通過matchAckIndexer可以獲取
// 所有節點Progress.Match。
func (c MajorityConfig) CommittedIndex(l AckedIndexer) Index {
    n := len(c)
    if n == 0 {
        // 這裏很有意思,當沒有任何peer的時候返回值居然是無窮大(64位無符號範圍內),如果都沒有任何
        // peer,0不是更合適?其實這跟JoinConfig類型有關,此處先放一放,後面會給出解釋。
        return math.MaxUint64
    }
    // 下面的代碼對理解函數的實現原理沒有多大影響,只是用了一個小技巧,在Peer數量不大於7個的情況下
    // 優先用棧數組,否則通過堆申請內存。因爲raft集羣超過7個的概率不大,用棧效率會更高
    var stk [7]uint64
    srt := uint64Slice(stk[:])
    if cap(srt) < n {
        srt = make([]uint64, n)
    }
    srt = srt[:n]

    {
        // 把所有的Peer.Progress.Match放入srt數組中,看了源碼註釋也沒太弄明白爲什麼從後往前
        // 放,貌似是在排序的時候效率會更高。量一般在個位數的情況下不知道效率會高多少,讀者如果
        // 感興趣可以自行了解,理解了設計目的麻煩告訴筆者。
        i := n - 1
        for id := range c {
            if idx, ok := l.AckedIndex(id); ok {
                srt[i] = uint64(idx)
                i--
            }
        }
    }
    // 插入排序,這裏只需要知道根據所有Peer.Progress.Match進行了排序即可,至於用什麼排序並不重要
    insertionSort(srt)

    // 這句代碼就是整個函數的精髓了,當前srt是按照peer.Progress.Match從小到達排好序了,此時需要
    // 知道一個事情:Peer.Progress.Match代表了[0,Match]的日誌全部被peer確認收到。有了這個前提
    // 就非常容易理解了,可以把srt理解爲按照處理速度升序排序的Peer。n - (n/2 + 1)之後的所有Peer
    // 接收日誌的速度都比它快,而在他之後包括他自己的節點數量正好超過一半,那麼他的Match就是集羣的
    // 提交索引了。換句話說,有少於一半的節點的Match可能小於該節點的Match。
    pos := n - (n/2 + 1)
    return Index(srt[pos])
}

MajorityConfig除了用來計算raft的提交索引,同時也用來做選舉結果統計,接下來看看他是如何實現的:

// 代碼源自go.etcd.io/etcd/raft/quorum/majority.go
// 參數votes是一個map,支持自己成爲leader那麼votes[peerID]=true,所以這個函數就是一個唱票的實現
func (c MajorityConfig) VoteResult(votes map[uint64]bool) VoteResult {
    if len(c) == 0 {
        // 這裏和CommittedIndex()設計方法一樣,揹着在後面解釋
        return VoteWon
    }

    // 統計支持者(nv[1])和反對者(nv[0])的數量
    ny := [2]int{} 
    //  當然還有棄權的,raft的棄權不是peer主動棄權的,而是丟包或者超時造成的
    var missing int
    
    // 統計票數,這個也沒啥好解釋的了
    for id := range c {
        v, ok := votes[id]
        if !ok {
            missing++
            continue
        }
        if v {
            ny[1]++
        } else {
            ny[0]++
        }
    }
    
    // 支持者超過一半代表選舉勝利
    q := len(c)/2 + 1
    if ny[1] >= q {
        return VoteWon
    }
    // 支持者和棄權數量超過一半以上選舉掛起,因爲可能還有一部分票還在路上~
    if ny[1]+missing >= q {
        return VotePending
    }
    // 反對者超過一半以上肯定就失敗了
    return VoteLost
}

以上就是MajorityConfig最核心的兩個函數,他們最核心的思路都是找到大多數,所以用Majority這個單詞還是比較貼切的。

2.2.2JointConfig

千呼萬喚始出來,終於輪到JointConfig上場了,讓我們看看他的真身:

// 代碼源自go.etcd.io/etcd/raft/quorum/joint.go
// 是不是有種想罵街的心情?就是這麼簡單,簡單到讓你有種上當的感覺~這符合joint的定義,由兩個
// MajorityConfig組成,JointConfig和MajorityConfig功能是一樣的,只是JointConfig的做法是
// 根據兩個MajorityConfig的結果做一次融合性操作。
type JointConfig [2]MajorityConfig

// 這個函數的功能應該不需要解釋了
func (c JointConfig) CommittedIndex(l AckedIndexer) Index {
    idx0 := c[0].CommittedIndex(l)
    idx1 := c[1].CommittedIndex(l)
    // 返回的是二者最小的那個,這時候可以理解MajorityConfig.CommittedIndex()爲什麼Peers數
    // 爲0的時候返回無窮大了吧,如果返回0該函數就永遠返回0了。
    if idx0 < idx1 {
        return idx0
    }
      return idx1
}

// 無需解釋這個函數的功能
func (c JointConfig) VoteResult(votes map[uint64]bool) VoteResult {
    r1 := c[0].VoteResult(votes)
    r2 := c[1].VoteResult(votes)

    // 相同的,下里面的判斷邏輯基就可以知道MajorityConfig.VoteResult()在peers數爲0返回選舉
    // 勝利的原因。
    if r1 == r2 {
        return r1
    }
    if r1 == VoteLost || r2 == VoteLost {
        return VoteLost
    }
  
    return VotePending
}

如果此時讀者還在懵逼與爲什麼會有MajorityConfig的peer數量是0,這時候讀者應該回頭看看ProgressTracker.InitProgress()代碼,他只初始化了Voter[0]。筆者懷疑JointConfig是設計者爲未來某個功能提前開發的,至少編寫本文時的代碼僅用到了一個MajorityConfig。

3.總結

至此,還有一個疑問,那就是獲得提交索引用來幹什麼?很簡單,Leader用來把這個值廣播到所有的Peer,這樣Follower就可以把提交的日誌給使用者應用了。因爲此時的日誌已經被超過一半以上的Peer接收了。

現在已經弄明白tracker和quorum在raft中的作用了,總結如下:

  1. 跟蹤所有Peer日誌的進度,根據每個Peer的狀態同步日誌給Peer,目標是讓所有的Peer與Leader的日誌狀態相同;
  2. 統計所有Peer的確認接收的最大日誌索引,然後計算出超過一半以上Peer都確認接收的最大的日誌索引,把這個值廣播到所有Peer,讓日誌進一步被應用;
  3. 選舉階段用於唱票;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章