Consul Raft 協議源碼分析下篇——日志提交

背景

上一篇日志復制我們分析了consul leader 接受一個key value的put請求,leader經過一頓操作,把日志都發給了follower,但是還沒有提交,插入的go routine 也還wait在哪里等是否成功commit了,即應用到狀態機,那我們這篇文章就來說一說consul 是怎么判斷過半日志復制成功的和怎么應用到狀態機的。

過半提交

raft協議要求寫操作,只有超過一半才能算成功,才能應用到狀態機FSM, 客戶端才能讀到這個數據,這個過半是leader自己也算在里面的,也就是前面一篇文章我們提到的,leader在持久化log后,就標記自己寫成功了,我們沒有分析,現在我們來分析下這個邏輯,因為follower 處理完日志復制后,也是有這個邏輯處理的。

//這里很重要,好就才看明白,這個是log 復制成功后,最終應用到狀態機的一個機制
//這里是記錄下leader自己的結果,因為過半leader也算一份。
r.leaderState.commitment.match(r.localID, lastIndex)

我們上篇文章只是在這里做了一個注釋,并沒有分析里面怎么實現的,我們就是要搞懂到底怎么實現的,下面是match的代碼:

// Match is called once a server completes writing entries to disk: either the
// leader has written the new entry or a follower has replied to an
// AppendEntries RPC. The given server's disk agrees with this server's log up
// through the given index.
func (c *commitment) match(server ServerID, matchIndex uint64) {
   c.Lock()
   defer c.Unlock()
   if prev, hasVote := c.matchIndexes[server]; hasVote && matchIndex > prev {
      c.matchIndexes[server] = matchIndex
      c.recalculate()
   }
}

注釋也基本說明了這個方法的作用,就是我們上面說的,我們就不再重復了,要理解這個邏輯,先了解下這個數據結構matchIndexes,matchIndexes 是一個map,key就是server id,就是consul 集群每個節點有一個id,value就是上次應用log到狀態機的編號commitIndex,我們舉一個例子來說明下recalculate的邏輯:

假如集群三個節點,server id分別為1,2,3,上次寫log的編號是3,就是leader和follower都成功了,這個matchIndexes的數據如下:

1(leader) --> 3

2(follower) --> 3

3(follower) --> 3

假如這個時候新來一個put請求,leader本地持久化成功,就要更新這個數據結構了matchIndexes了, 因為leader是先更新,再并發請求follower的,所以這個時候matchIndexes數據如下,因為一個log,所以logIndex是加1。

1(leader) --> 4

2(follower) --> 3

3(follower) --> 3

因為leader本地完成和follower遠程完成一樣,都要通過這個邏輯來判斷是否commit 該log 請求,即是否應用到FSM,所以就是要判斷是否過半完成了,邏輯是這樣的:

  1. 先創建一個數組matched,長度為集群節點數,我們的例子是3,

  2. 然后把matchIndexes的commitIndex 起出來,放到matched中,matched的數據就是[4,3,3]

  3. 排序,為啥要排序,因為map 是無序的,下面要通過中間索引的值來判斷是否變化。

  4. 然后計算 quorumMatchIndex := matched[(len(matched)-1)/2],這個就是取中間索引下標的值,也是因為這點,需要第三步排序.

  5. 比較quorumMatchIndex 是否大于當前的commitIndex,如果大于,說明滿足過半的條件,則更新,然后應用到狀態機。

通過上面5步,來實現了一個過半的邏輯,我們再以兩個場景來理下,

假如一個follower失敗了,一個成功,成功的follower會更新matched的數據是[4,3,4],或者是[4,4,3],排序后為都是[4,4,3], 第4步計算的結果是4大于3,就可以提交了,經過上面的詳細,再看下面的代碼就好理解了:

// Internal helper to calculate new commitIndex from matchIndexes.
// Must be called with lock held.
func (c *commitment) recalculate() {
   if len(c.matchIndexes) == 0 {
      return
   }
   matched := make([]uint64, 0, len(c.matchIndexes))
   for _, idx := range c.matchIndexes {
      matched = append(matched, idx)
   }
   //這個排序是降序,才能保證下面取中間索引位置的值來判斷是否過半已經復制成功。
   sort.Sort(uint64Slice(matched))
   quorumMatchIndex := matched[(len(matched)-1)/2]
   //如果超過一半的follower成功了,則開始commit,即應用到狀態機
   if quorumMatchIndex > c.commitIndex && quorumMatchIndex >= c.startIndex {
      c.commitIndex = quorumMatchIndex
      //符合條件,觸發commit,通知leader執行apply log
      asyncNotifyCh(c.commitCh)
   }
}

失敗的處理情況

網絡失敗:

則本次寫就失敗了,就失敗了,就返回了,這里也沒有看到怎么通知前面的future.wait 退出的。

數據不一致:

如果是log和follower的有gap,比如follower停了一段時間,重新加入集群,這個時候follower的log 編號很多事和leader有差距的,對這種情況,就是日志一致性的保證。

follower會響應leader自己當前的logindex 編號,leader獲取到對應的編號時,會更新發送logNext,也就是從這里開始發生日志給follower,就進入重試的的流程,重新發日志。

這里consul 根據raft協議做了一個優化,raft協議描述的是每次遞減一個logindex 編號,來回確認,直到找到follower匹配的編號,再開始發日志,這樣性能就很差,所有基本上沒有那個分布式系統是那樣實現落地的。

Commit Log

只要超過一半的日志 復制成功,consul 就進入日志commit階段,也就是將修改應用到狀態機,通過recalculate 方法給leader監聽的commitCh 發一個消息,通知leader開始執行apply log 到FSM, leader 的代碼如下:

case <-r.leaderState.commitCh:
   // Process the newly committed entries
   //上次執行commit log index
   oldCommitIndex := r.getCommitIndex()
   //新的log需要commit的log index,在判斷是過半時,會更新commitindex
   commitIndex := r.leaderState.commitment.getCommitIndex()
   r.setCommitIndex(commitIndex)

     ....
   start := time.Now()
   var groupReady []*list.Element
   var groupFutures = make(map[uint64]*logFuture)
   var lastIdxInGroup uint64

   // Pull all inflight logs that are committed off the queue.
   for e := r.leaderState.inflight.Front(); e != nil; e = e.Next() {
      commitLog := e.Value.(*logFuture)
      idx := commitLog.log.Index
      //idx 大于commitIndex,說明是后面新寫入的,還沒有同步到follower的日志。
      if idx > commitIndex {
         // Don't go past the committed index
         break
      }

      // Measure the commit time
      metrics.MeasureSince([]string{"raft", "commitTime"}, commitLog.dispatch)
      groupReady = append(groupReady, e)
      groupFutures[idx] = commitLog
      lastIdxInGroup = idx
   }

   // Process the group
   if len(groupReady) != 0 {
      //應用的邏輯在這里。groupFutures 就是寫入go routine wait的future
      r.processLogs(lastIdxInGroup, groupFutures)
      //清理inflight集合中已經commit過的log,防止重復commit
      for _, e := range groupReady {
         r.leaderState.inflight.Remove(e)
      }
   }

這里比較簡單,就是從leaderState.inflight 中取出log,就是我們之前寫入的,循環判斷,如果log的編號大于commitIndex,說明是后面新寫入的log,還沒有同步到follower的log,不能提交。這里應該是有序的,lastIdxInGroup 應該就是需要commit的log的最大的一個編號。

processLogs的邏輯就是支持分批提交支持,發給consul 的runFSM的go routine,consul raft專門有一個go routine來負責commit log到狀態機,支持批量和一個一個commit,我們看下單個commit的情況,代碼如下:

commitSingle := func(req *commitTuple) {
   // Apply the log if a command or config change
   var resp interface{}
   // Make sure we send a response
   defer func() {
      // Invoke the future if given
      if req.future != nil {
         req.future.response = resp
         req.future.respond(nil)
      }
   }()

   switch req.log.Type {
   case LogCommand:
      start := time.Now()
      //將日志應用到FSM的關鍵在這里。 
      resp = r.fsm.Apply(req.log)
      metrics.MeasureSince([]string{"raft", "fsm", "apply"}, start)
    ....
   
   }

   // Update the indexes
   lastIndex = req.log.Index
   lastTerm = req.log.Term
}

主要就是三點,應用log 到fsm,然后跟新下fsm的logindex和任期,最后就是要通知還在wait的go routine。

log有序

這里還補充下上篇日志復制一個重要點沒有說明,就是最后發起日志復制的時候,因為raft要嚴格保證log的順序性,所以發送日志除了當前新產生的log 的編號和任期,還需要帶上該log的上一條log的編號和任期,follower需要檢查這個,因為follower只要保證上一條也和leader的上一條匹配,即log 編號和任期term都相同,才可以,如果有一個不相同,就是follower和leader出現了gap,或者中間有其他leader寫了數據,需要告訴leader直接覆蓋自己本地log。

總結

到這里基本上把consul log提交的整個流程摸了一遍。我們大致盤清楚了consul raft log 從log 復制到log commit的整個過程,這中間還有一種情況沒有理清楚,就是兩個follower都掛了的情況,前面的寫go routine 的wait的future是怎么通知到的,后面有時間再單獨開一篇來說明。

目前正在看機會中,關注基礎架構,中間件,架構師,技術經理等相關的機會。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容