目錄
1 簡介
1.1 一致性簡介
1.1.1 一致性的描述性定義
一致性是指分佈式系統中的多個節點在數據層面達到一致。多個計算機就某個值達成了一致。比如a=2,所有計算機達成一致之後他們的內存中a就是等於2且不可改變。此決定一旦達成不可更改。由於各個計算機是相互獨立的,故障隨時可能發生,在所有計算機上總是達成一致是不可能的。我們當然可以設計一個所有計算機必須都正常才能達成一致的系統,但是一旦發生故障,這個一致性系統就會變得不可用,抵禦風險的能力比較弱。所以主流的一致性協議都是能夠容忍少數(小於50%)的計算機故障。
1.1.2 一致性和CAP理論
分佈式CAP理論 https://baike.baidu.com/item/CAP#3
對於大部分分佈式系統而言,P(分區容錯)都是要保證,因此權衡一般都在A(可用性)和C(一致性)中做權衡。
由此衍生出三種強弱不同的一致性協議。強一致性協議、弱一致性協議、最終一致性。
強一致性:客戶端往“主”中寫入數據的時候,主再往“從”中寫,直到大部分“從”寫成功,即可返回客戶端寫成功,返回了寫成功,從任意節點讀的內容都是"寫"之後的狀態
弱一致性:寫之後的內容,不一定能立馬讀出來,從不同節點讀的內容也可能不一致。但是一般最終都是一致的。
對於一個系統而言,談及其一致性的時候我們一般說的都是其對外的一致性。
Raft從外部來看,是強一致性。其實其內部各個節點之間並不是在每個時刻都是一致的,很明顯這是絕對做不到的
Gossip協議就是最終一致性。
用戶更新網站頭像,在某個時間點,用戶向主庫發送更新請求,不久之後主庫就收到了請求。在某個時刻,主庫又會將數據變更轉發給自己的從庫。最後,主庫通知用戶更新成功。銀行轉賬購也類似。
如果在返回“更新成功”並使新頭像對其他用戶可見之前,主庫需要等待從庫的確認,確保從庫已經收到寫入操作,那麼複製是同步的,即強一致性。如果主庫寫入成功後,不等待從庫的響應,直接返回“更新成功”,則複製是異步的,即弱一致性。
1.2 一致性協議/算法發展歷程
兩階段提交協議 --> Paxos --> ZAB --> Raft。
協議名稱 |
特點 |
產品代表 |
備註 |
---|---|---|---|
Paxos |
|
|
|
ZAB |
|
ZooKeeper |
|
Raft |
|
Consul K8s(Edct) Redis Cluster TiDB 區塊鏈共識算法 |
|
2 Raft協議原理
2.1 Raft協議簡介
論文的第一句話是:
Raft is a consensus algorithm for managing a replicated log.
這句話說了Raft協議要解決的問題和解決問題的手段(非全部手段,但是是最重要的手段)。
爲了達到一致,Raft做了以下工作,強化Leader角色的功能,Raft請求都要經過Leader,因此保證集羣只有一個Leader非常重要。
Raft 一致性協議相對來說易於實現主要歸結爲以下幾個原因:
-
模塊化的拆分:把一致性協議劃分爲 Leader 選舉、MemberShip 變更、日誌複製、SnapShot 等相對比較解耦的模塊;
-
設計的簡化:比如不允許類似 Paxos 算法的亂序提交、使用 Randomization 算法設計 Leader Election 算法以簡化系統的狀態,只有 Leader、Follower、Candidate 等等。
2.1 Leader選舉
2.1.1 選舉簡述
爲了保證集羣在任何時刻只有一個Leader,選舉策略的設計顯得尤爲重要。Leader選舉涉及到以下問題:
-
哪些節點有被選舉權(參選)/何時發起選舉
-
哪些節點有投票權/何時投票/投給誰
保證只有一個Leader的確切含義是保證只有一個Leader會分發日誌,可能集羣中有不止一隻節點認爲自己是Leader,但是實質上多數認爲是Leader那個纔是真正的Leader)。
Raft將節點有三種狀態/角色:Leader、Follower、Candidate。
http://thesecretlivesofdata.com/raft/
如上圖所示,整個狀態的流轉過程是:
每個節點啓動默認是Follower,同時會給每個Follower一個elect timeout(100ms~300ms)的一個隨機值,哪個節點超時時間到就轉變爲Candidate 狀態(可能有多個參選者),
該Candidate 狀態的節點緊接着會發起投票請求,會有如下兩種狀態變化:
-
如果收到的投票數達到半數以上,即成爲新的Leader
-
反之如果別的Candidate當選Leader,則此Candidate則置爲Follow
Leader如果發現有比自己任期更高的Leader,則降爲Follower角色。
-
在對外正常提供服務的時候只有Leader和Follower兩種狀態;在選舉過程中,有Candidate和Follower兩種狀態。
-
Follower收到心跳或者投票請求,則重新開始計時
-
只有在重新選舉或者初始化的時候會範圍隨機設定這個timeout時間,在其他時候這個隨機值並不改變。
2.1.2 選舉細節
2.1.2.1 數據結構與通信協議
在所有節點持久存在的:(在響應遠程過程調用 RPC 之前穩定存儲的)
名稱 |
描述 |
currentTerm |
服務器最後知道的任期號(從 0 開始遞增) |
votedFor |
在當前任期內收到選票的Candidate Id(如果沒有就爲 null) |
log[] |
日誌條目;每個條目包含狀態機的要執行命令和從Leader處收到日誌時的任期號 |
在所有節點上不穩定存在的:
名稱 |
描述 |
commitIndex |
已知的被提交的最大日誌條目的索引值(從 0 開始遞增) |
lastApplied |
被狀態機執行的最大日誌條目的索引值(從 0 開始遞增) |
在Leader節點上不穩定存在的:(在選舉之後初始化的)
名稱 |
描述 |
nextIndex[] |
對於每一個服務器,記錄需要發給它的下一個日誌條目的索引(初始化爲Leader上一條日誌的索引值 +1) |
matchIndex[] |
對於每一個服務器,記錄已經複製到該服務器的日誌的最高索引值(從 0 開始遞增) |
在整個term只有兩種RPC請求:
功能 |
調用者 |
參數 |
參數值 |
接受者實現邏輯 |
返回值 |
返回內容 |
---|---|---|---|---|---|---|
RequestVote |
Candidate |
term |
Candidate的任期號 |
|
term |
任期號 |
candidateId |
請求投票的Candidate id |
success |
如果其它服務器包含能夠匹配上 prevLogIndex 和 prevLogTerm 的日誌時爲真 |
|||
lastLogIndex |
Candidate最新日誌條目的索引值 |
|
|
|||
lastLogTerm |
Candidate最新日誌條目對應的任期號 |
|
|
|||
AppendEntries |
Leader |
term |
Candidate的任期號 |
|
term |
目前的任期號,用於Candidate更新自己 |
leaderId |
Leader的 id,爲了其他服務器能重定向到客戶端 |
voteGranted |
如果Candidate收到選票爲 true |
|||
prevLogIndex |
Candidate最新日誌條目的索引值 |
|
|
|||
prevLogTerm |
Candidate最新日誌條目對應的任期號 |
|
|
|||
entries[] |
將要存儲的日誌條目(表示 heartbeat 時爲空,有時會爲了效率發送超過一條) |
|
|
|||
leaderCommit |
Leader提交的日誌條目索引值 |
|
|
2.1.2.2 投票細節
以上對選出過程做了簡要描述,但是對細節沒有涉及,我們很明顯有以下疑問:
同時發起投票請求?
在自己成爲Candidate的情況下,收到請求怎麼辦?
在節點已經成爲Leader的情況下,收到投票請求怎麼處理?
一個節點會不會投出多張票?
Raft的投票機制詳細過程:
-
follower遞增自己的term。
-
follower將自己的狀態變爲candidate。
-
投票給自己。
-
向集羣其它機器發起投票請求(RequestVote請求)。
-
當以下情況發生,結束自己的Candidate狀態。
-
超過集羣一半服務器都同意,狀態變爲leader,並且立即向所有服務器發送心跳消息,之後按照心跳間隔時間發送心跳消息。任意一個term中的任意一個服務器只能投一次票,所有的candidate在此term已經投給了自己,那麼需要另外的follower投票才能贏得選舉。
-
在等待期間,發現了其它leader並且這個leader的term不小於自己的term,狀態轉爲follower。否則丟棄消息。
-
沒有服務器贏得選舉,可能是由於網絡超時或者服務器原因沒有leader被選舉,這種情況比較簡單,超時之後重試。有一種情況被稱爲split votes,比如一個有三個服務器的集羣中所有服務器同時發起選舉,那麼就不可能有leader被選舉出來,此時如果超時之後重試很可能所有服務器又同時發起選舉,這樣永遠不可能有leader被選舉出來。Raft處理這種情況是採用上文提到過的random election timeout,隨機超時保證了split votes發生的機率很小。
-
收到投票請求的響應邏輯:
如果term < currentTerm返回 false(5.1 節)
如果votedFor爲空或者與candidateId相同,並且Candidate的日誌和自己的日誌一樣新,則給該Candidate投票(5.2 節 和 5.4 節
如下圖所示,初始時,所有節點都是term=0;現假設同時A和C同時成爲Candidate,發起投票請求(其中附term),B和D關於同一個term投票請求(B本地能做到只處理一個請求)只會投出去一票。
採用了term機制來保證了“一人一票”,在此規則下只會出現如下兩種結果之一:
-
其中一個超過半票,一個沒有,這種超過半票的順利當選
-
如果沒有任何Candidate當選Leader,會重走選舉流程。
2.1.3 實例分析-異常情況
Follow節點之間的“失聯”沒有關係。
異常情況1:
如下圖所示:Leader出了異常(假如當前集羣的term是3),沒有發心跳給FA、FB、FC,這三者就會如上述章節所述,進行選舉,假如選出的新Leader是FA,那麼FA(term=4),發起的選舉是關於term=4選舉,選舉成功後,整個集羣(不包括失聯的前Leader D) Leader是A。
如果一段時間之後,D節點恢復,和其他節點成功連接,此時,D發現集羣內已經有了其他Leader,term比自己高,則將自己的角色修改爲Follow,從新Leader同步term和log。
異常情況2:
比如C和Leader之間網絡斷開,超過心跳間隔沒有收到來自Leader D的心跳包,elect timeout時間到了,C進入Candidate角色,發起關於term=4(原term=3)選舉。按照論文描述,
-
A和B收到C的term比自身大(term=3),而且有比C領先的日誌,則投票結果是拒絕。此時C一直收不到D的心跳,導致C term會一直增大發起選舉。針對這個問題的解決方案是PreVote。
-
A和B收到C的term比自身大(term=3),而且沒有比C領先的日誌(C斷了期間沒有日誌更新),此時會投票給C,Leader易主,換爲C。因爲日誌一樣,此時C還沒有成爲Leader也不會有新日誌,易主成本不大。
這種情況還有一個變種,C和其他節點全部失聯,此時C會一直增加自己的term,這種情況也類似,據說有個長篇論文。pre-candidate
其他的一些異常分析:
網絡分區,term一直增大,反覆選舉https://www.zhihu.com/question/302761390
從異常處理看Raft協議:https://zhuanlan.zhihu.com/p/64405742
2.1.4 選舉問題分析
1 奇數問題
集羣到底是否是必須是奇數,是否推薦使用奇數。
-
沒有絕對必要使用奇數,其實使用偶數也可以
-
選用奇數,是因爲在同樣容錯下,奇數和偶數都是可以的(5個Node容忍2個失敗,6個還是容忍兩個失敗),但是從節省資源角度來說,選用奇數合理;
-
同時選用奇數可以在一定程度上避免"平票的情況"。大部分情況下在某個時刻還是一個節點壞,此時集羣剩餘的就是奇數,選舉很容易選出Leader
2 網絡抖動是否會引發選舉
論文中對接受到請求的節點的響應實現邏輯做出如下描述:
如果term < currentTerm返回 false(5.1 節)
如果votedFor爲空或者與candidateId相同,並且Candidate的日誌和自己的日誌一樣新,則給該Candidate投票(5.2 節 和 5.4 節)
按照這個邏輯,在2.1.2小節中示例的異常情況,則C發起選舉併成爲Leader,那麼會不會網絡抖動一下導致某個節點和Leader失聯就會Leader易主。
在論文中另外有段關於心跳時間和elect timeout的說法有要求,
broadcastTime指的是一臺服務器並行的向集羣中的其他服務器發送 RPC 並且收到它們的響應的平均時間
broadcastTime << electionTimeout << MTBF
broadcastTime和MTBF是由系統決定的性質,但是electionTimeout是我們必須做出選擇的。Raft 的 RPC 需要接收方將信息持久化的保存到穩定存儲中去,所以廣播時間大約是 0.5 毫秒到 20 毫秒,這取決於存儲的技術。因此,electionTimeout一般在 10ms 到 500ms 之間。大多數的服務器的MTBF都在幾個月甚至更長,很容易滿足這個時序需求。
這裏建議的elect timeout比心跳時間大了好多個量級,所以在elect timeout中,有很多次發送心跳的機會。所以並不會因爲網絡抖動而造成Leader易主,但是如果C到D節點之間的通信線路的確出問題了,長時間出問題的確會發生Leader易主了。但是從合理性來說,此時A和B還是知道原Leader的存在,並且發起投票的C節點信息並不領先於D,此時A和B應該拒絕投票給C才合理(據說有些工程就是這樣實現的,沒有驗證)。
3 心跳超時
在Leader成爲網絡孤島時,Leader可以發出心跳、Follower可以收到心跳但是Leader收不到心跳回應,這種情況下Leader此時已經出現網絡異常,但是由於一直可以向外發送心跳包會導致Follower無法切換狀態進行選取,系統陷入停滯。爲了避免第二種情況發生,模塊中設置了心跳超時機制,Leader每次收到心跳回應時會進行相應記錄,一旦一段時間後記錄沒有更新則Leader放棄Leader身份並轉換爲Follower節點。
4 Leader性能瓶頸
所有客戶端的讀寫請求都轉發到Leader上? |
是的,可以請求任意節點,但是都會轉發到Leader節點 |
比如Consul,就是所有的請求都會有Server達到Leader(client只會轉發)。Client知道有哪些Server,依靠的是gossip協議 |
讀寫請求全部壓力都在Leader帶上? |
是的,讀直接讀Leader的本地內容,寫的話需要日誌提交 |
|
讀寫都在Leader上,很容易出現性能瓶頸? |
|
|
5 網絡分區
其實異常情況1已經是一個異常分區了。異常分區有兩種,一種是原Leader在大數的一側;另一種是原Leader在少數一側。
-
當發生網絡分區的時候,在不同分區的節點接收不到leader的心跳,則會開啓一輪選舉,形成不同leader的多個分區集羣。
-
當客戶端給不同leader的發送更新消息時,不同分區集羣中的節點個數小於原先集羣的一半時,更新不會被提交,而節點個數大於集羣數一半時,更新會被提交。
-
當網絡分區恢復後,被提交的更新會同步到其他的節點上,其他節點未提交的日誌會被回滾並匹配新leader的日誌,保證全局的數據是一致的
2.2 日誌複製
2.2.1 日誌複製原理
2.2.1.1 複製狀態機及日誌結構
相同的初識狀態 + 相同的輸入 = 相同的結束狀態。
論文中有一個很重要的詞deterministic,就是說不同節點要以相同且確定性的函數來處理輸入,而不要引入一下不確定的值,比如本地時間等。如何保證所有節點 輸入相同輸出相同,使用replicated log是一個很好的方法,log具有持久化、保序的特點,是大多數分佈式系統的基石。
因此,可以這麼說,在raft中,leader將客戶端請求(command)封裝到一個個log entry,將這些log entries複製(replicate)到所有follower節點,然後大家按相同順序應用(apply)log entry中的command,則狀態肯定是一致的。
複製狀態機
日誌結構
2.2.1.2 日誌複製基本流程
1)Client向Leader提交指令(如:SET 5),Leader收到命令後,將命令追加到本地日誌中。此時,這個命令處於“uncomitted”狀態,複製狀態機不會執行該命令。
(2)然後,Leader將命令(SET 5)併發複製給其他節點,並等待其他其他節點將命令寫入到日誌中,如果此時有些節點失敗或者比較慢,Leader節點會一直重試,知道所有節點都保存了命令到日誌中。之後Leader節點就提交命令(即被狀態機執行命令,這裏是:SET 5),並將結果返回給Client節點。
(3)Leader節點在提交命令後,下一次的心跳包中就帶有通知其他節點提交命令的消息,其他節點收到Leader的消息後,就將命令應用到狀態機中(State Machine),最終每個節點的日誌都保持了一致性。
Leader節點會記錄已經交的最大日誌index,之後後續的heartbeat和日誌複製請求(Append Entries)都會帶上這個值,這樣其他節點就知道哪些命令已經提交了,就可以讓狀態機(State Machine)執行日誌中的命令,使得所有節點的狀態機數據都保持一致。
就A和D倆看,在A複製第一條指令之前,
A中關於D的記錄: nextIndex:1 / matchIndex=0,在給D發送了複製請求(複製請求中LeaderCommitedIndex=0)之後,D收到請求之後(由於收到A的LeaderCommitedIndex=0,不會有任何移動本機commitedIndex的動作)給了A響應,
Leader A收到響應之後,準備下次發送2條日誌給D,則修改關於D的記錄:nextIndex:2 / matchIndex=1。--> 然後開發發送第2、3個日誌(由於D網絡不好,響應慢,在此過程中A的commitedIndex已經到了5),在發送該第2個複製指令的時候帶上LeaderCommitedIndex=5,D在收到該複製指令後,複製2條日誌(此時總有三條了),然後設置本地commitedIndex=3
另外一方面,Leader通過matchIndex發現index=1的log的複製請求已經得到了大部分迴應,下一次心跳帶上LeaderCommitedIndex,讓Follow的commit和Leader同步。如果該Commit發送到Follow失敗也沒有關係,失敗意味着Follow的commit落後於Leader,會在後面根於下面這個規則同步上。
如果leaderCommit > commitIndex,將commitIndex設置爲leaderCommit和最新日誌條目索引號中較小的一個
還有一種情況是D已經追評了A的日誌,兩者都已經commit=8,此時Leader收到請求9,然後發出複製請求(LeaderCommitedIndex=8),然後D響應請求(不會修改CommitedIndex值)表示已經複製,A收到響應修改matchIndex和NextIndex,另外A發現得到了大多數響應,會在下一次心跳包中帶上commit請求,讓Follow修改commit值。
2.2.1.3 一致性檢查
Raft日誌同步保證如下兩點:
-
如果不同日誌中的兩個條目有着相同的索引和任期號,則它們所存儲的命令是相同的。
-
如果不同日誌中的兩個條目有着相同的索引和任期號,則它們之前的所有條目都是完全一樣的。
第一條特性源於Leader在一個term內在給定的一個log index最多創建一條日誌條目,同時該條目在日誌中的位置也從來不會改變。
第二條特性源於 AppendEntries 的一個簡單的一致性檢查:當發送一個 AppendEntries RPC 時,Leader會把新日誌條目緊接着之前的條目的log index和term都包含在裏面。如果Follower沒有在它的日誌中找到log index和term都相同的日誌,它就會拒絕新的日誌條目。
一般情況下,Leader和Followers的日誌保持一致,因此 AppendEntries 一致性檢查通常不會失敗。然而,Leader崩潰可能會導致日誌不一致:舊的Leader可能沒有完全複製完日誌中的所有條目。
上圖闡述了一些Followers可能和新的Leader日誌不同的情況。一個Follower可能會丟失掉Leader上的一些條目,也有可能包含一些Leader沒有的條目,也有可能兩者都會發生。丟失的或者多出來的條目可能會持續多個任期。
比如上述的a,append時候一致性檢查,一致點就在當前點,直接append;比如B一致性檢查一致點在index=4的位置,則Leader從4開始複製日誌給b。
比如d,一直點在index=10,從index=10開始複製。
Leader通過強制Followers複製它的日誌來處理日誌的不一致,Followers上的不一致的日誌會被Leader的日誌覆蓋。
Leader爲了使Followers的日誌同自己的一致,Leader需要找到Followers同它的日誌一致的地方,然後覆蓋Followers在該位置之後的條目。
Leader會從後往前試,每次AppendEntries失敗後嘗試前一個日誌條目,直到成功找到每個Follower的日誌一致位點,然後向後逐條覆蓋Followers在該位置之後的條目。
2.2.2 日誌複製異常分析
-
不管是Follow的日誌多餘、少於、差異於Leader,都是以Leader爲準,進行修復。
-
原Leader日誌領先於大多數Follow(比如Leader發出日誌複製命令A 之後就掛了),但是Candidate的日誌和其他Follow是一樣的,這種情況下Candidate當選爲新Leader,該日誌少於原Leader,原Leader收到的客戶端請求A就作廢了嗎?
2.2.3 日誌複製問題記錄
-
爲什麼不一次請求,直接定位到相同日誌;而是不同往前嘗試。
-
Leader轉化的時候的日誌怎麼處理,是否會丟失。
3 如果在日誌複製的過程中Leader掛了怎麼辦?
4 在Follower已經ack AppendEntities之後,但是還是處於uncommited狀態的時候Follower掛了怎麼辦?
2.3 成員變更
在實際業務場景中,我們會經常更改節點配置。例如更改日誌級別。雖然通過關閉整個集羣,升級配置文件,然後重啓整個集羣也可以解決這個問題,但是這會導致在更改配置的過程中,整個集羣不可用。
另外,如果存在需要手工操作,那麼就會有操作失誤的風險。爲了避免這些問題, Raft 一致性算法中設計了一套自動改變配置並能保證系統一致性的機制。
3 Consul與Raft
Consul使用Serf(基於goisp,https://www.serf.io/)來管理節點,每個節點上都會有一份成員列表。
Gossip協議已經是P2P網絡中比較成熟的協議了。Gossip協議的最大的好處是,即使集羣節點的數量增加,每個節點的負載也不會增加很多,幾乎是恆定的。這就允許Consul管理的集羣規模能橫向擴展到數千個節點。
Consul的每個Agent會利用Gossip協議互相檢查在線狀態,本質上是節點之間互Ping,分擔了服務器節點的心跳壓力。如果有節點掉線,不用服務器節點檢查,其他普通節點會發現,然後用Gossip廣播給整個集羣。
在此基礎上,使用Raft協議來保證整個系統的一致性。
Consul通過serf知道整個集羣有多少個節點,每次添加一個節點之後,就會檢查節點數是否到了bootstrap expect配置的數量。如果到了開始引導 --> 啓動Raft選舉定時器,爲每一個節點範圍隨機賦予elect timeout。然後就進入Raft的選舉過程。
集羣初次啓動只有Consul集羣的節點數量到達了bootstrap expect數,纔會開始進行選舉過程。
只有一個節點:
如下圖,增加了一個節點,serf發現節點,開始觸發MemberJoin事件。
記錄下了第一次的節點列表,嘗試去連接第一次的列表。 如果是第一次是隻有達到3個纔開始選舉。
0 參考資料
[官方演示器] https://raft.github.io/
[動畫, 簡明易懂] http://thesecretlivesofdata.com/raft/
[raft論文的中文版] https://github.com/maemual/raft-zh_cn || https://linux-network-programming.readthedocs.io/zh_CN/latest/translations/raft-paper.html
[raft詳解]http://www.solinx.co/archives/415
[一致性詳解] https://zhuanlan.zhihu.com/p/67949045
[PreVote] https://www.jianshu.com/p/1496228df9a9
[Follow失聯] https://www.zhihu.com/question/329076128
[Raft客戶端] https://juejin.im/post/5af14cd2f265da0b863633e4
[stale read] https://www.cnblogs.com/williamjie/p/11137118.html
[gossip] https://toutiao.io/posts/0syf8m/preview
[Consul原理]http://ljchen.net/2019/01/04/consul%E5%8E%9F%E7%90%86%E8%A7%A3%E6%9E%90/
[在各種Failure情況下如何達成一致,有非常詳盡的分析]http://zenlife.tk/raft-fault-tolerance.md
[Raft及共識算法] http://tinylcy.me/2018/Understanding-the-Raft-consensus-algorithm-One/
[Raft實現小結] https://feilengcui008.github.io/post/raft%E5%AE%9E%E7%8E%B0%E5%B0%8F%E7%BB%93/
[日誌複製] https://bloodhunter.github.io/2019/03/31/raft-xie-yi-zhi-ri-zhi-fu-zhi/
[Raft協議方面的疑問] https://www.zhihu.com/question/54997169
[細節理解] https://www.jianshu.com/p/80fb90fff5ba
[深入共識算法] https://shuwoom.com/?p=826