項目中使用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,
然后告訴所有的follower準備提交數據,先和我一樣寫log,
然后回到leader,此時如果超過半數的follower都成功寫了log,那么leader開始第二階段的提交:正式寫入數據,然后同樣廣播給follower,follower也根據自身情況選擇寫入或者不寫入并返回結果給leader。繼續上面的例子,leader先寫自己的數據,然后告訴follower也開始持久化數據,
最終所有節點的數據達成一致,圖中用實線表示已提交的數據。
這兩階段中如果任意一個都有超過半數的follower返回false或者根本沒有返回,那么這個分布式事務是不成功的。此時雖然不會有回滾的過程,但是由于數據不會真正在多數節點上提交,所以會在之后的過程中被覆蓋掉。
選舉的過程
上面只說了常規時候兩種角色是如何協調工作的,還剩下candidate沒說,對,就是一個follower是如何逆襲成為leader的。
初始狀態下,大家都是平等的follower,那么follow誰呢,總要選個老大吧。大家都蠢蠢欲動,每個follower內部都維護了一個隨機的timer。如下圖,
在timer時間到了的時候還沒有人主動聯系它的話,那它就要變成candidate,同時發出投票請求(RequestVote)給其他人。特殊情況如下圖,S1和S3都變成了candidate,
當然選不選就是人家的事了,原則是
每個follower一輪只能投一次票給一個candidate,
對于相同條件的candidate,follower們采取先來先投票的策略。如果超過半數的follower都認為他是合適做領導的,那么恭喜,新的leader產生了,如下圖,S3變成了新一屆的大哥,又可以很開心的像上一節一樣的正常工作了。
但是如果很不幸,沒有人愿意選這個悲劇的candidate,那它只有老老實實的變回小弟的狀態。
選舉完成之后,leader靠什么來確保小弟是跟著我的呢?答案是定時發送心跳檢測(heart beat)。小弟們也是通過心跳來感知大哥的存在的。如下圖
同樣的,如果在timer期間內沒有收到大哥的聯絡,這時很可能大哥已經跪了,如下圖,所有小弟又開始蠢蠢欲動,新的一輪(term)選舉開始了。
好了,Raft算法的大致原理就是這樣了,下面我們來說說一些沒說到的細節問題。
選舉時會產生的問題
之前說過,在選舉階段,每個follower如果在自身的timer到期之后都會變成candidate去參與選舉。所以就這個candidate身份而言,是沒有特別條件的,每個follower都有機會參選。但是,在分布式的環境里,每個follower節點存儲的數據是不一樣的,考慮一下下圖的情況,在這些節點經歷了一些損壞和恢復。此時S4想當leader,
但是如果S4成功當選的話,根據leader為上的原則,S4的log在index為4-7的數據,會覆蓋掉S2和S3的8。如何解決這樣的沖突的問題呢?有兩種方法:第一種是S4在變為大哥之前,先向所有的小弟拿數據來保證自己數據是最全的;第二種方法是其他小弟遇到這樣資歷不足的大哥想上位的時候,完全不予以理睬。Raft算法認為第一種策略過于復雜,所以選擇了第二種,保證數據只從leader流向follower。S4在vote請求中會帶上自身數據的描述信息,包括:
- term,自身處于的選舉周期
- lastLogIndex,log中最新的index值
- lastLogTerm,log中最近的index是在哪個term中產生的
S2和S3在收到vote請求時候會和自身的情況進行對比,每個節點保存的數據信息包括:
- currentTerm,節點處于的term號
- log[ ],自身的log集合
- commitIndex,log中最后一個被提交的index值
對比的原則有:
- 如果term < currentTerm,也就是說candidate的版本還沒我新,返回 false
- 如果已經投票給別的candidate了(votedFor),則返回false
- 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,發出vote請求。
term | lastLogIndex | lastLogTerm |
---|---|---|
11 | 6 | 8 |
被S4和S10接受,變成新的leader,并初始化兩個數組:
- nextIndex[ ],表示需要發給每個follower的下一個日志條目的索引(初始化為leader最新log的index+1,因為leader總是先假定所有的follower和自己是一致的,后面說明當有不一致的時候是如何協商的)
- 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在接收到該請求之后會做一致性的判斷,規則包括,
- 如果 term < currentTerm返回 false
- 如果在prevLogIndex處的log的term號與prevLogTerm不匹配時,返回 false
- 如果一條已經存在的log與新的沖突(index相同但是term號不同),則刪除已經存在的日志和它之后所有的日志,返回true
- 添加任何在已有的log中不存在的index,返回true
- 如果請求中leader的commitIndex > 自身的commitIndex,則比較leader的commitIndex和最新log index,將其中較小的賦給自身的commitIndex
結果與規則2不符合,返回false給S3。這時S3需要做一次退讓,修改保存的nextIndex數組,將S4的nextIndex退化為6
再次發送AppendEntries詢問S4
term | prevIndex | prevTerm | entries | commitIndex |
---|---|---|---|---|
11 | 5 | 8 | [ ] | 5 |
如此循環的退讓,一直到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,并拋棄之后的數據。
這樣消息往復,數據最終一致。
一些其他的問題
還有一些值得注意的特殊情況,比如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算法的算法動畫的截圖。
(完)