背景
上一篇日志復制我們分析了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,所以就是要判斷是否過半完成了,邏輯是這樣的:
先創建一個數組matched,長度為集群節點數,我們的例子是3,
然后把matchIndexes的commitIndex 起出來,放到matched中,matched的數據就是[4,3,3]
排序,為啥要排序,因為map 是無序的,下面要通過中間索引的值來判斷是否變化。
然后計算 quorumMatchIndex := matched[(len(matched)-1)/2],這個就是取中間索引下標的值,也是因為這點,需要第三步排序.
比較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是怎么通知到的,后面有時間再單獨開一篇來說明。
目前正在看機會中,關注基礎架構,中間件,架構師,技術經理等相關的機會。