ETCD背後的Raft一致性算法原理

項目中使用ETCD來實現服務發現和配置信息的存儲,最近我抽空研究了一下ETCD和背後的一致性算法 — Raft算法的邏輯。

ETCD是什麼

  • ETCD是一個go語言實現的高可靠的KV存儲系統,支持HTTP協議的PUT/GET/DELETE操作;
  • 爲了支持服務註冊與發現,支持WATCH接口(通過http long poll實現);
  • 支持KEY持有TTL屬性;
  • CAS(compare and swap)操作;
  • 支持多key的事務操作;
  • 支持目錄操作

簡單的來說,ETCD可以看做是一個no sql的存儲,存的是key-value的node,每個node又可以像樹形結構一樣產生子node。它是集羣化的運行狀態來保證高可用,並且對外提供了一套簡單友好的交互接口。

其實ETCD暫時就想介紹這麼多,本文的重點在於Raft算法,只是我機智的考慮到站內的SEO才加上ETCD的名號:smirk:,以後會陸續寫一些其他與ETCD相關的內容。

一致性的基礎:Raft算法

ETCD實現高可靠的基礎在於Raft算法,也是理解ETCD工作原理最重要的一部分。類似於zookeeper的zab協議(Paxos算法),Raft也是用於保證分佈式環境下多節點數據的一致性,但更易於理解。

看了很多相關Raft算法的技術文章,要麼是介紹的過於簡單,要麼是過於晦澀難懂。最後看了原始的論文In search of an Understandable Consensus Algorithm和infoQ上對應的中文翻譯Raft 一致性算法論文譯文纔對整個邏輯有細緻的理解。

首先來看看Raft大致的原理,這是一個選主(leader selection)思想的算法,集羣總每個節點都有三種可能的角色:

  • leader
    對客戶端通信的入口,對內數據同步的發起者,一個集羣通常只有一個leader節點
  • follower:
    非leader的節點,被動的接受來自leader的數據請求
  • candidate:
    一種臨時的角色,只存在於leader的選舉階段,某個節點想要變成leader,那麼就發起投票請求,同時自己變成candidate。如果選舉成功,則變爲candidate,否則退回爲follower

數據提交的過程

先看前兩種角色,leader扮演的是分佈式事務中的協調者,每次有數據更新的時候產生二階段提交(two-phase commit)。在leader收到數據操作的請求,先不着急更新本地數據(數據是持久化在磁盤上的),而是生成對應的log,然後把生成log的請求廣播給所有的follower。

每個follower在收到請求之後有兩種選擇:一種是聽從leader的命令,也寫入log,然後返回success回去;另一種情況,在某些條件不滿足的情況下,follower認爲不應該聽從leader的命令,返回false。例如下圖,leader收到客戶端的寫請求,我們暫時不考慮請求的具體值,虛線表示leader先寫log,

leader寫log

然後告訴所有的follower準備提交數據,先和我一樣寫log,

同步log

然後回到leader,此時如果超過半數的follower都成功寫了log,那麼leader開始第二階段的提交:正式寫入數據,然後同樣廣播給follower,follower也根據自身情況選擇寫入或者不寫入並返回結果給leader。繼續上面的例子,leader先寫自己的數據,然後告訴follower也開始持久化數據,

leader持久化並同步數據

最終所有節點的數據達成一致,圖中用實線表示已提交的數據。

數據一致

這兩階段中如果任意一個都有超過半數的follower返回false或者根本沒有返回,那麼這個分佈式事務是不成功的。此時雖然不會有回滾的過程,但是由於數據不會真正在多數節點上提交,所以會在之後的過程中被覆蓋掉。

選舉的過程

上面只說了常規時候兩種角色是如何協調工作的,還剩下candidate沒說,對,就是一個follower是如何逆襲成爲leader的。

初始狀態下,大家都是平等的follower,那麼follow誰呢,總要選個老大吧。大家都蠢蠢欲動,每個follower內部都維護了一個隨機的timer。如下圖,

每個follower都有timer

在timer時間到了的時候還沒有人主動聯繫它的話,那它就要變成candidate,同時發出投票請求(RequestVote)給其他人。特殊情況如下圖,S1和S3都變成了candidate,

轉變爲candidate

當然選不選就是人家的事了,原則是

每個follower一輪只能投一次票給一個candidate,

對於相同條件的candidate,follower們採取先來先投票的策略。如果超過半數的follower都認爲他是合適做領導的,那麼恭喜,新的leader產生了,如下圖,S3變成了新一屆的大哥,又可以很開心的像上一節一樣的正常工作了。

所有follower接受candidate的大哥身份

但是如果很不幸,沒有人願意選這個悲劇的candidate,那它只有老老實實的變回小弟的狀態。

選舉完成之後,leader靠什麼來確保小弟是跟着我的呢?答案是定時發送心跳檢測(heart beat)。小弟們也是通過心跳來感知大哥的存在的。如下圖

leader定期發心跳檢測

 

同樣的,如果在timer期間內沒有收到大哥的聯絡,這時很可能大哥已經跪了,如下圖,所有小弟又開始蠢蠢欲動,新的一輪(term)選舉開始了。

新的一輪選舉

好了,Raft算法的大致原理就是這樣了,下面我們來說說一些沒說到的細節問題。

選舉時會產生的問題

之前說過,在選舉階段,每個follower如果在自身的timer到期之後都會變成candidate去參與選舉。所以就這個candidate身份而言,是沒有特別條件的,每個follower都有機會參選。但是,在分佈式的環境裏,每個follower節點存儲的數據是不一樣的,考慮一下下圖的情況,在這些節點經歷了一些損壞和恢復。此時S4想當leader,

不適合的candidate

但是如果S4成功當選的話,根據leader爲上的原則,S4的log在index爲4-7的數據,會覆蓋掉S2和S3的8。如何解決這樣的衝突的問題呢?有兩種方法:第一種是S4在變爲大哥之前,先向所有的小弟拿數據來保證自己數據是最全的;第二種方法是其他小弟遇到這樣資歷不足的大哥想上位的時候,完全不予以理睬。Raft算法認爲第一種策略過於複雜,所以選擇了第二種,保證數據只從leader流向follower。S4在vote請求中會帶上自身數據的描述信息,包括:

  1. term,自身處於的選舉週期
  2. lastLogIndex,log中最新的index值
  3. lastLogTerm,log中最近的index是在哪個term中產生的

S2和S3在收到vote請求時候會和自身的情況進行對比,每個節點保存的數據信息包括:

  1. currentTerm,節點處於的term號
  2. log[ ],自身的log集合
  3. commitIndex,log中最後一個被提交的index值

對比的原則有:

  1. 如果term < currentTerm,也就是說candidate的版本還沒我新,返回 false
  2. 如果已經投票給別的candidate了(votedFor),則返回false
  3. log匹配,如果和自身的log匹配上了,則返回true

這個log匹配原則(Log Matching Property)具體是:

如果在不同日誌中的兩個條目有着相同的索引和任期號,則它們所存儲的命令是相同的。

如果在不同日誌中的兩個條目有着相同的索引和任期號,則它們之間的所有條目都是完全一樣的。

這樣就可以一直等到含有最新數據的candidate被選上,從而保證領導人完全原則(Leader Completeness):

如果一個日誌的index在一個給定term內被提交,那麼這個index一定會出現在所有term號更大的領導人中。

好了,繼續看圖說話。S4的vote請求,

term lastLogIndex lastLogTerm
10 6 7

被無情的拒絕。接下來S3也變成了candidate,

S3變成candidate

一直等到S3變成了candidate,發出vote請求。

term lastLogIndex lastLogTerm
11 6 8

被S4和S10接受,變成新的leader,並初始化兩個數組:

  1. nextIndex[ ],表示需要發給每個follower的下一個日誌條目的索引(初始化爲leader最新log的index+1,因爲leader總是先假定所有的follower和自己是一致的,後面說明當有不一致的時候是如何協商的)
  2. matchIndex[ ],表示已經複製到每個follower的log的最高index值(從0開始遞增)

在這個例子中,S3中的這兩個數組會初始化爲,

  S1 S2 S4 S5
nextIndex 7 7 7 7
matchIndex 0 0 0 0

數據更新的問題

現在新的一屆leader選舉出來了,雖然選舉的過程保證了leader的數據是最新的,但是follower中的數據還是可能存在不一致的情況。比如下圖的S4,這就需要一個補償機制來糾正這個問題。

在正常情況下,S3會給S4發心跳請求(一種名叫AppendEntries請求的特殊格式,entries爲空),其中攜帶一些數據信息,包括,

term prevIndex prevTerm entries commitIndex
11 6 8 [ ] 6

commitIndex之前已經解釋過了,是log中最後一個被提交的index值。prevIndex與lastLogIndex類似,都是最新的日誌的index值,只是屬於不同的請求類型。
prevTerm也與lastLogTerm類似,是prevLogIndex對應的term號。

S4在接收到該請求之後會做一致性的判斷,規則包括,

  1. 如果 term < currentTerm返回 false
  2. 如果在prevLogIndex處的log的term號與prevLogTerm不匹配時,返回 false
  3. 如果一條已經存在的log與新的衝突(index相同但是term號不同),則刪除已經存在的日誌和它之後所有的日誌,返回true
  4. 添加任何在已有的log中不存在的index,返回true
  5. 如果請求中leader的commitIndex > 自身的commitIndex,則比較leader的commitIndex和最新log index,將其中較小的賦給自身的commitIndex

結果與規則2不符合,返回false給S3。這時S3需要做一次退讓,修改保存的nextIndex數組,將S4的nextIndex退化爲6

S4的nextIndex退化爲6

再次發送AppendEntries詢問S4

term prevIndex prevTerm entries commitIndex
11 5 8 [ ] 5

如此循環的退讓,一直到nextIndex減小到4

nextIndex減小到4

S3此時發送的請求爲,

term prevIndex prevTerm entries commitIndex
11 3 3 [ ] 3

S4和自己的log匹配成功,返回true,並告訴leader,當前的matchIndex等於3。S3收到之後更新matchIndex數組,

  S1 S2 S4 S5
nextIndex 7 7 4 7
matchIndex 0 6 3 0

併發送從nextIndex之後的數據(entries),

term prevIndex prevTerm entries commitIndex
11 3 3 [8] 4

S4再根據覆蓋的原則,把自身的數據追平leader,並拋棄之後的數據。

S4的index4同步爲leader的內容

這樣消息往復,數據最終一致。

一些其他的問題

還有一些值得注意的特殊情況,比如log的清理。log是以追加的方式遞增的,隨着系統的不斷運行,log會越來越大。Raft通過log的snapshot方式,可以定期壓縮log爲一個snapshot,並且清除之前的log。壓縮的具體策略可以參考原論文。

還有集羣節點的增減。當網絡發生波動的時候,節點可能需要增減甚至發生網絡分區。具體參考:ETCD系列之二:部署集羣

總結

Raft是一種基於leader選舉的算法,用於保證分佈式數據的一致性。所有節點在三個角色(leader, follower和candidate)之中切換。選舉階段candidate向其他節點發送vote請求,但是隻有包括所有最新數據的節點可以變爲leader。

在數據同步階段,leader通過一些標記(commitIndex,term,prevTerm,prevIndex等等)與follower不斷協商最終達成一致。當有新的數據產生時,採用二階段(twp-phase)提交,先更新log,等大多數節點都做完之後再正式提交數據。

以上的圖片來自github上raft算法的算法動畫的截圖。



作者:11舍的華萊士
鏈接:https://www.jianshu.com/p/5aed73b288f7
來源:簡書
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章