1. 程式人生 > >ETCD原理詳細解析

ETCD原理詳細解析

從etcd的架構開始,深入到原始碼中解析etcd

1 架構

從etcd的架構圖中我們可以看到,etcd主要分為四個部分。

  • HTTP Server: 用於處理使用者傳送的API請求以及其它etcd節點的同步與心跳資訊請求。
  • Store:用於處理etcd支援的各類功能的事務,包括資料索引、節點狀態變更、監控與反饋、事件處理與執行等等,是etcd對使用者提供的大多數API功能的具體實現。
  • Raft:Raft強一致性演算法的具體實現,是etcd的核心。
  • WAL:Write Ahead Log(預寫式日誌),是etcd的資料儲存方式。除了在記憶體中存有所有資料的狀態以及節點的索引以外,etcd就通過WAL進行持久化儲存。WAL中,所有的資料提交前都會事先記錄日誌。Snapshot是為了防止資料過多而進行的狀態快照;Entry表示儲存的具體日誌內容。

通常,一個使用者的請求傳送過來,會經由HTTP Server轉發給Store進行具體的事務處理

如果涉及到節點的修改,則交給Raft模組進行狀態的變更、日誌的記錄,然後再同步給別的etcd節點以確認資料提交

最後進行資料的提交,再次同步。

2 新版etcd重要變更列表

  • 獲得了IANA認證的埠,2379用於客戶端通訊,2380用於節點通訊,與原先的(4001 peers / 7001 clients)共用。
  • 每個節點可監聽多個廣播地址。監聽的地址由原來的一個擴充套件到多個,使用者可以根據需求實現更加複雜的叢集環境,如一個是公網IP,一個是虛擬機器(容器)之類的私有IP。
  • etcd可以代理訪問leader節點的請求,所以如果你可以訪問任何一個etcd節點,那麼你就可以無視網路的拓撲結構對整個叢集進行讀寫操作。
  • etcd叢集和叢集中的節點都有了自己獨特的ID。這樣就防止出現配置混淆,不是本叢集的其他etcd節點發來的請求將被遮蔽。
  • etcd叢集啟動時的配置資訊目前變為完全固定,這樣有助於使用者正確配置和啟動。
  • 執行時節點變化(Runtime Reconfiguration)。使用者不需要重啟 etcd 服務即可實現對 etcd 叢集結構進行變更。啟動後可以動態變更叢集配置。
  • 重新設計和實現了Raft演算法,使得執行速度更快,更容易理解,包含更多測試程式碼。
  • Raft日誌現在是嚴格的只能向後追加、預寫式日誌系統,並且在每條記錄中都加入了CRC校驗碼。
  • 啟動時使用的_etcd/* 關鍵字不再暴露給使用者
  • 廢棄叢集自動調整功能的standby模式,這個功能使得使用者維護叢集更困難。
  • 新增Proxy模式,不加入到etcd一致性叢集中,純粹進行代理轉發。
  • ETCD_NAME(-name)引數目前是可選的,不再用於唯一標識一個節點。
  • 摒棄通過配置檔案配置 etcd 屬性的方式,你可以用環境變數的方式代替。
  • 通過自發現方式啟動叢集必須要提供叢集大小,這樣有助於使用者確定叢集實際啟動的節點數量。

3 etcd概念詞彙表

  • Raft:etcd所採用的保證分散式系統強一致性的演算法。
  • Node:一個Raft狀態機例項。
  • Member: 一個etcd例項。它管理著一個Node,並且可以為客戶端請求提供服務。
  • Cluster:由多個Member構成可以協同工作的etcd叢集。
  • Peer:對同一個etcd叢集中另外一個Member的稱呼。
  • Client: 向etcd叢集傳送HTTP請求的客戶端。
  • WAL:預寫式日誌,etcd用於持久化儲存的日誌格式。
  • snapshot:etcd防止WAL檔案過多而設定的快照,儲存etcd資料狀態。
  • Proxy:etcd的一種模式,為etcd叢集提供反向代理服務。
  • Leader:Raft演算法中通過競選而產生的處理所有資料提交的節點。
  • Follower:競選失敗的節點作為Raft中的從屬節點,為演算法提供強一致性保證。
  • Candidate:當Follower超過一定時間接收不到Leader的心跳時轉變為Candidate開始競選。
  • Term:某個節點成為Leader到下一次競選時間,稱為一個Term。
  • Index:資料項編號。Raft中通過Term和Index來定位資料。

4 叢集化應用實踐

etcd作為一個高可用鍵值儲存系統,天生就是為叢集化而設計的。

由於Raft演算法在做決策時需要多數節點的投票,所以etcd一般部署叢集推薦奇數個節點,推薦的數量為3、5或者7個節點構成一個叢集。

4.1 叢集啟動

etcd有三種叢集化啟動的配置方案,分別為靜態配置啟動、etcd自身服務發現、通過DNS進行服務發現。

通過配置內容的不同,你可以對不同的方式進行選擇。值得一提的是,這也是新版etcd區別於舊版的一大特性,它摒棄了使用配置檔案進行引數配置的做法,轉而使用命令列引數或者環境變數的做法來配置引數。

4.1.1. 靜態配置

這種方式比較適用於離線環境,在啟動整個叢集之前,你就已經預先清楚所要配置的叢集大小,以及叢集上各節點的地址和埠資訊。那麼啟動時,你就可以通過配置initial-cluster引數進行etcd叢集的啟動。

在每個etcd機器啟動時,配置環境變數或者新增啟動引數的方式如下。

1

2

ETCD_INITIAL_CLUSTER="infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380"

ETCD_INITIAL_CLUSTER_STATE=new

引數方法:

1

2

3

-initial-cluster

infra0=http://10.0.1.10:2380,http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \

-initial-cluster-state new

值得注意的是

  -initial-cluster引數中配置的url地址必須與各個節點啟動時設定的initial-advertise-peer-urls引數相同。

  (initial-advertise-peer-urls引數表示節點監聽其他節點同步訊號的地址)

  如果你所在的網路環境配置了多個etcd叢集,為了避免意外發生,最好使用-initial-cluster-token引數為每個叢集單獨配置一個token認證。這樣就可以確保每個叢集和叢集的成員都擁有獨特的ID。

綜上所述,如果你要配置包含3個etcd節點的叢集,那麼你在三個機器上的啟動命令分別如下所示。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

$ etcd -name infra0 -initial-advertise-peer-urls http://10.0.1.10:2380 \

-listen-peer-urls http://10.0.1.10:2380 \

-initial-cluster-token etcd-cluster-1 \

-initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \

-initial-cluster-state new

$ etcd -name infra1 -initial-advertise-peer-urls http://10.0.1.11:2380 \

-listen-peer-urls http://10.0.1.11:2380 \

-initial-cluster-token etcd-cluster-1 \

-initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \

-initial-cluster-state new

$ etcd -name infra2 -initial-advertise-peer-urls http://10.0.1.12:2380 \

-listen-peer-urls http://10.0.1.12:2380 \

-initial-cluster-token etcd-cluster-1 \

-initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \

-initial-cluster-state new

在初始化完成後,etcd還提供動態增、刪、改etcd叢集節點的功能,這個需要用到etcdctl命令進行操作。

4.1.2. etcd自發現模式

通過自發現的方式啟動etcd叢集需要事先準備一個etcd叢集。如果你已經有一個etcd叢集,首先你可以執行如下命令設定叢集的大小,假設為3.

1

$ curl -X PUT http://myetcd.local/v2/keys/discovery/6c007a14875d53d9bf0ef5a6fc0257c817f0fb83/_config/size -d value=3

然後你要把這個url地址http://myetcd.local/v2/keys/discovery/6c007a14875d53d9bf0ef5a6fc0257c817f0fb83作為-discovery引數來啟動etcd。

節點會自動使用http://myetcd.local/v2/keys/discovery/6c007a14875d53d9bf0ef5a6fc0257c817f0fb83目錄進行etcd的註冊和發現服務。

所以最終你在某個機器上啟動etcd的命令如下。

1

2

3

$ etcd -name infra0 -initial-advertise-peer-urls http://10.0.1.10:2380 \

-listen-peer-urls http://10.0.1.10:2380 \

-discovery http://myetcd.local/v2/keys/discovery/6c007a14875d53d9bf0ef5a6fc0257c817f0fb83

如果你本地沒有可用的etcd叢集,etcd官網提供了一個可以公網訪問的etcd儲存地址。你可以通過如下命令得到etcd服務的目錄,並把它作為-discovery引數使用。

1

2

$ curl http://discovery.etcd.io/new?size=3

http://discovery.etcd.io/3e86b59982e49066c5d813af1c2e2579cbf573de

同樣的,當你完成了叢集的初始化後,這些資訊就失去了作用。

當你需要增加節點時,需要使用etcdctl來進行操作。

為了安全,請務必每次啟動新etcd叢集時,都使用新的discovery token進行註冊。

另外,如果你初始化時啟動的節點超過了指定的數量,多餘的節點會自動轉化為Proxy模式的etcd。

4.1.3. DNS自發現模式

etcd還支援使用DNS SRV記錄進行啟動。關於DNS SRV記錄如何進行服務發現,可以參閱RFC2782,所以,你要在DNS伺服器上進行相應的配置。

(1) 開啟DNS伺服器上SRV記錄查詢,並新增相應的域名記錄,使得查詢到的結果類似如下。

1

2

3

4

$ dig +noall +answer SRV _etcd-server._tcp.example.com

_etcd-server._tcp.example.com. 300 IN   SRV 0 0 2380 infra0.example.com.

_etcd-server._tcp.example.com. 300 IN   SRV 0 0 2380 infra1.example.com.

_etcd-server._tcp.example.com. 300 IN   SRV 0 0 2380 infra2.example.com.

(2) 分別為各個域名配置相關的A記錄指向etcd核心節點對應的機器IP。使得查詢結果類似如下。

1

2

3

4

$ dig +noall +answer infra0.example.com infra1.example.com infra2.example.com

infra0.example.com. 300 IN  A   10.0.1.10

infra1.example.com. 300 IN  A   10.0.1.11

infra2.example.com. 300 IN  A   10.0.1.12

(3) 做好了上述兩步DNS的配置,就可以使用DNS啟動etcd叢集了。配置DNS解析的url引數為-discovery-srv,其中某一個節點地啟動命令如下。

1

2

3

4

5

6

7

8

$ etcd -name infra0 \

-discovery-srv example.com \

-initial-advertise-peer-urls http://infra0.example.com:2380 \

-initial-cluster-token etcd-cluster-1 \

-initial-cluster-state new \

-advertise-client-urls http://infra0.example.com:2379 \

-listen-client-urls http://infra0.example.com:2379 \

-listen-peer-urls http://infra0.example.com:2380

  當然,你也可以直接把節點的域名改成IP來啟動。

4.2 關鍵部分原始碼解析

etcd的啟動是從主目錄下的main.go開始的,然後進入etcdmain/etcd.go,載入配置引數。

  如果被配置為Proxy模式,則進入startProxy函式,否則進入startEtcd,開啟etcd服務模組和http請求處理模組。

在啟動http監聽時,為了保持與叢集其他etcd機器(peers)保持連線,都採用的transport.NewTimeoutListener啟動方式,這樣在超過指定時間沒有獲得響應時就會出現超時錯誤。

  而在監聽client請求時,採用的是transport.NewKeepAliveListener,有助於連線的穩定。

etcdmain/etcd.go中的setupCluster函式可以看到,根據不同etcd的引數,啟動叢集的方法略有不同,但是最終需要的就是一個IP與埠構成的字串。

在靜態配置的啟動方式中,叢集的所有資訊都已經在給出,所以直接解析用逗號隔開的叢集url資訊就好了。

DNS發現的方式類似,會預先發送一個tcp的SRV請求,先檢視etcd-server-ssl._tcp.example.com下是否有叢集的域名資訊,如果沒有找到,則去檢視etcd-server._tcp.example.com

  根據找到的域名,解析出對應的IP和埠,即叢集的url資訊。

較為複雜是etcd式的自發現啟動。

  首先就用自身單個的url構成一個叢集,然後在啟動的過程中根據引數進入discovery/discovery.go原始碼的JoinCluster函式。

    因為我們事先是知道啟動時使用的etcd的token地址的,裡面包含了叢集大小(size)資訊。

    在這個過程其實是個不斷監測與等待的過程。

  啟動的第一步就是在這個etcd的token目錄下注冊自身的資訊,然後再監測token目錄下所有節點的數量,如果數量沒有達標,則迴圈等待。

  當數量達到要求時,才結束,進入正常的啟動過程。

配置etcd過程中通常要用到兩種url地址容易混淆

  一種用於etcd叢集同步資訊並保持連線,通常稱為peer-urls;

  另外一種用於接收使用者端發來的HTTP請求,通常稱為client-urls。

  • peer-urls:通常監聽的埠為2380(老版本使用的埠為7001),包括所有已經在叢集中正常工作的所有節點的地址。
  • client-urls:通常監聽的埠為2379(老版本使用的埠為4001),為適應複雜的網路環境,新版etcd監聽客戶端請求的url從原來的1個變為現在可配置的多個。這樣etcd可以配合多塊網絡卡同時監聽不同網路下的請求。

4.3 執行時節點變更

etcd叢集啟動完畢後,可以在執行的過程中對叢集進行重構,包括核心節點的增加、刪除、遷移、替換等。

執行時重構使得etcd叢集無須重啟即可改變叢集的配置,這也是新版etcd區別於舊版包含的新特性。

只有當叢集中多數節點正常的情況下,你才可以進行執行時的配置管理。

  因為配置更改的資訊也會被etcd當成一個資訊儲存和同步,如果叢集多數節點損壞,叢集就失去了寫入資料的能力。

  所以在配置etcd叢集數量時,強烈推薦至少配置3個核心節點。

4.3.1. 節點遷移、替換

當你節點所在的機器出現硬體故障,或者節點出現如資料目錄損壞等問題,導致節點永久性的不可恢復時,就需要對節點進行遷移或者替換。

當一個節點失效以後,必須儘快修復,因為etcd叢集正常執行的必要條件是叢集中多數節點都正常工作

遷移一個節點需要進行四步操作:

  • 暫停正在執行著的節點程式程序
  • 把資料目錄從現有機器拷貝到新機器
  • 使用api更新etcd中對應節點指向機器的url記錄更新為新機器的ip
  • 使用同樣的配置項和資料目錄,在新的機器上啟動etcd。

4.3.2. 節點增加

增加節點可以讓etcd的高可用性更強。

  舉例來說,如果你有3個節點,那麼最多允許1個節點失效;當你有5個節點時,就可以允許有2個節點失效。

同時,增加節點還可以讓etcd叢集具有更好的讀效能。

  因為etcd的節點都是實時同步的,每個節點上都儲存了所有的資訊,所以增加節點可以從整體上提升讀的吞吐量。

增加一個節點需要進行兩步操作:

  • 在叢集中新增這個節點的url記錄,同時獲得叢集的資訊。
  • 使用獲得的叢集資訊啟動新etcd節點。

4.3.3. 節點移除

有時你不得不在提高etcd的寫效能和增加叢集高可用性上進行權衡。

  Leader節點在提交一個寫記錄時,會把這個訊息同步到每個節點上,當得到多數節點的同意反饋後,才會真正寫入資料。

  所以節點越多,寫入效能越差。

  在節點過多時,你可能需要移除一個或多個。

移除節點非常簡單,只需要一步操作,就是把叢集中這個節點的記錄刪除。然後對應機器上的該節點就會自動停止。

4.3.4. 強制性重啟叢集

當叢集超過半數的節點都失效時,就需要通過手動的方式,強制性讓某個節點以自己為Leader,利用原有資料啟動一個新叢集。

此時你需要進行兩步操作。

  • 備份原有資料到新機器。
  • 使用-force-new-cluster加備份的資料重新啟動節點

注意:強制性重啟是一個迫不得已的選擇,它會破壞一致性協議保證的安全性(如果操作時叢集中尚有其它節點在正常工作,就會出錯),所以在操作前請務必要儲存好資料。

5 Proxy模式

Proxy模式也是新版etcd的一個重要變更,etcd作為一個反向代理把客戶的請求轉發給可用的etcd叢集。

  這樣,你就可以在每一臺機器都部署一個Proxy模式的etcd作為本地服務,如果這些etcd Proxy都能正常執行,那麼你的服務發現必然是穩定可靠的。

所以Proxy並不是直接加入到符合強一致性的etcd叢集中,也同樣的,Proxy並沒有增加叢集的可靠性,當然也沒有降低叢集的寫入效能。

5.1 Proxy取代Standby模式的原因

那麼,為什麼要有Proxy模式而不是直接增加etcd核心節點呢?

  實際上etcd每增加一個核心節點(peer),都會增加Leader節點一定程度的包括網路、CPU和磁碟的負擔,因為每次資訊的變化都需要進行同步備份。

  增加etcd的核心節點可以讓整個叢集具有更高的可靠性,但是當數量達到一定程度以後,增加可靠性帶來的好處就變得不那麼明顯,反倒是降低了叢集寫入同步的效能。

  因此,增加一個輕量級的Proxy模式etcd節點是對直接增加etcd核心節點的一個有效代替。

熟悉0.4.6這個舊版本etcd的使用者會發現,Proxy模式實際上是取代了原先的Standby模式。

  Standby模式除了轉發代理的功能以外,還會在核心節點因為故障導致數量不足的時候,從Standby模式轉為正常節點模式。

  而當那個故障的節點恢復時,發現etcd的核心節點數量已經達到的預先設定的值,就會轉為Standby模式。

但是新版etcd中,只會在最初啟動etcd叢集時,發現核心節點的數量已經滿足要求時,自動啟用Proxy模式,反之則並未實現。主要原因如下。

  • etcd是用來保證高可用的元件,因此它所需要的系統資源(包括記憶體、硬碟和CPU等)都應該得到充分保障以保證高可用。任由叢集的自動變換隨意地改變核心節點,無法讓機器保證效能。所以etcd官方鼓勵大家在大型叢集中為執行etcd準備專有機器叢集。
  • 因為etcd叢集是支援高可用的,部分機器故障並不會導致功能失效。所以機器發生故障時,管理員有充分的時間對機器進行檢查和修復。
  • 自動轉換使得etcd叢集變得複雜,尤其是如今etcd支援多種網路環境的監聽和互動。在不同網路間進行轉換,更容易發生錯誤,導致叢集不穩定。

基於上述原因,目前Proxy模式有轉發代理功能,而不會進行角色轉換。

5.2 關鍵部分原始碼解析

從程式碼中可以看到,Proxy模式的本質就是起一個HTTP代理伺服器,把客戶發到這個伺服器的請求轉發給別的etcd節點。

etcd目前支援讀寫皆可和只讀兩種模式。

  預設情況下是讀寫皆可,就是把讀、寫兩種請求都進行轉發。

  只讀模式只轉發讀的請求,對所有其他請求返回501錯誤。

值得注意的是,除了啟動過程中因為設定了proxy引數會作為Proxy模式啟動。

  在etcd叢集化啟動時,節點註冊自身的時候監測到叢集的實際節點數量已經符合要求,那麼就會退化為Proxy模式。

6 資料儲存

etcd的儲存分為記憶體儲存和持久化(硬碟)儲存兩部分

  記憶體中的儲存除了順序化的記錄下所有使用者對節點資料變更的記錄外,還會對使用者資料進行索引、建堆等方便查詢的操作。

  持久化則使用預寫式日誌(WAL:Write Ahead Log)進行記錄儲存。

在WAL的體系中,所有的資料在提交之前都會進行日誌記錄。

  在etcd的持久化儲存目錄中,有兩個子目錄。

    一個是WAL,儲存著所有事務的變化記錄;

    另一個則是snapshot,用於儲存某一個時刻etcd所有目錄的資料。

    通過WAL和snapshot相結合的方式,etcd可以有效的進行資料儲存和節點故障恢復等操作。

既然有了WAL實時儲存了所有的變更,為什麼還需要snapshot呢?

  隨著使用量的增加,WAL儲存的資料會暴增,為了防止磁碟很快就爆滿,etcd預設每10000條記錄做一次snapshot,經過snapshot以後的WAL檔案就可以刪除。

  而通過API可以查詢的歷史etcd操作預設為1000條。

首次啟動時,etcd會把啟動的配置資訊儲存到data-dir引數指定的資料目錄中。

  配置資訊包括本地節點的ID、叢集ID和初始時叢集資訊。

使用者需要避免etcd從一個過期的資料目錄中重新啟動,因為使用過期的資料目錄啟動的節點會與叢集中的其他節點產生不一致(如:之前已經記錄並同意Leader節點儲存某個資訊,重啟後又向Leader節點申請這個資訊)。

  所以,為了最大化叢集的安全性,一旦有任何資料損壞或丟失的可能性,你就應該把這個節點從叢集中移除,然後加入一個不帶資料目錄的新節點。

6.1 預寫式日誌(WAL)

WAL(Write Ahead Log)最大的作用是記錄了整個資料變化的全部歷程。

  在etcd中,所有資料的修改在提交前,都要先寫入到WAL中。

  使用WAL進行資料的儲存使得etcd擁有兩個重要功能。

    • 故障快速恢復: 當你的資料遭到破壞時,就可以通過執行所有WAL中記錄的修改操作,快速從最原始的資料恢復到資料損壞前的狀態。
    • 資料回滾(undo)/重做(redo):因為所有的修改操作都被記錄在WAL中,需要回滾或重做,只需要方向或正向執行日誌中的操作即可。

WAL與snapshot在etcd中的命名規則

在etcd的資料目錄中,WAL檔案以$seq-$index.wal的格式儲存。

  最初始的WAL檔案是0000000000000000-0000000000000000.wal,表示是所有WAL檔案中的第0個,初始的Raft狀態編號為0。

  執行一段時間後可能需要進行日誌切分,把新的條目放到一個新的WAL檔案中。

  假設,當叢集執行到Raft狀態為20時,需要進行WAL檔案的切分時,下一份WAL檔案就會變為0000000000000001-0000000000000021.wal

    如果在10次操作後又進行了一次日誌切分,那麼後一次的WAL檔名會變為0000000000000002-0000000000000031.wal

    可以看到-符號前面的數字是每次切分後自增1,而-符號後面的數字則是根據實際儲存的Raft起始狀態來定。

snapshot的儲存命名則比較容易理解,以$term-$index.wal格式進行命名儲存。

  term和index就表示儲存snapshot時資料所在的raft節點狀態,當前的任期編號以及資料項位置資訊。

6.2 關鍵部分原始碼解析

從程式碼邏輯中可以看到,WAL有兩種模式,讀模式(read)和資料新增(append)模式,兩種模式不能同時成立。

  一個新建立的WAL檔案處於append模式,並且不會進入到read模式。

  一個本來存在的WAL檔案被開啟的時候必然是read模式,並且只有在所有記錄都被讀完的時候,才能進入append模式,進入append模式後也不會再進入read模式。這樣做有助於保證資料的完整與準確。

叢集在進入到etcdserver/server.goNewServer函式準備啟動一個etcd節點時,會檢測是否存在以前的遺留WAL資料。

  檢測的第一步是檢視snapshot資料夾下是否有符合規範的檔案,若檢測到snapshot格式是v0.4的,則呼叫函式升級到v0.5。

  從snapshot中獲得叢集的配置資訊,包括token、其他節點的資訊等等

  然後載入WAL目錄的內容,從小到大進行排序。根據snapshot中得到的term和index,找到WAL緊接著snapshot下一條的記錄,然後向後更新,直到所有WAL包的entry都已經遍歷完畢,Entry記錄到ents變數中儲存在記憶體裡。

  此時WAL就進入append模式,為資料項新增進行準備。

當WAL檔案中資料項內容過大達到設定值(預設為10000)時,會進行WAL的切分,同時進行snapshot操作。

  這個過程可以在etcdserver/server.gosnapshot函式中看到。

  所以,實際上資料目錄中有用的snapshot和WAL檔案各只有一個,預設情況下etcd會各保留5個歷史檔案。

7 Raft

新版etcd中,raft包就是對Raft一致性演算法的具體實現。

  關於Raft演算法的講解,網上已經有很多文章,有興趣的讀者可以去閱讀一下Raft演算法論文非常精彩。

  本文則不再對Raft演算法進行詳細描述,而是結合etcd,針對演算法中一些關鍵內容以問答的形式進行講解。

  有關Raft演算法的術語如果不理解,可以參見概念詞彙表一節。

7.1 Raft常見問答一覽

  • Raft中一個Term(任期)是什麼意思?
    • Raft演算法中,從時間上,一個任期講即從一次競選開始到下一次競選開始。
    • 從功能上講,如果Follower接收不到Leader節點的心跳資訊,就會結束當前任期,變為Candidate發起競選,有助於Leader節點故障時叢集的恢復。
    • 發起競選投票時,任期值小的節點不會競選成功。
    • 如果叢集不出現故障,那麼一個任期將無限延續下去。
    • 而投票出現衝突也有可能直接進入下一任再次競選。

  • Raft狀態機是怎樣切換的?
    • Raft剛開始執行時,節點預設進入Follower狀態,等待Leader發來心跳資訊。
    • 若等待超時,則狀態由Follower切換到Candidate進入下一輪term發起競選,等到收到叢集多數節點的投票時,該節點轉變為Leader。
    • Leader節點有可能出現網路等故障,導致別的節點發起投票成為新term的Leader,此時原先的老Leader節點會切換為Follower。
    • Candidate在等待其它節點投票的過程中如果發現別的節點已經競選成功成為Leader了,也會切換為Follower節點。

  • 如何保證最短時間內競選出Leader,防止競選衝突?
    • 在Raft狀態機一圖中可以看到,在Candidate狀態下, 有一個times out,這裡的times out時間是個隨機值,也就是說,每個機器成為Candidate以後,超時發起新一輪競選的時間是各不相同的,這就會出現一個時間差。
    • 在時間差內,如果Candidate1收到的競選資訊比自己發起的競選資訊term值大(即對方為新一輪term),
    • 並且新一輪想要成為Leader的Candidate2包含了所有提交的資料,那麼Candidate1就會投票給Candidate2。
    • 這樣就保證了只有很小的概率會出現競選衝突。
  • 如何防止別的Candidate在遺漏部分資料的情況下發起投票成為Leader?
    • Raft競選的機制中,使用隨機值決定超時時間,第一個超時的節點就會提升term編號發起新一輪投票,一般情況下別的節點收到競選通知就會投票。
    • 但是,如果發起競選的節點在上一個term中儲存的已提交資料不完整,節點就會拒絕投票給它。
    • 通過這種機制就可以防止遺漏資料的節點成為Leader。
  • Raft某個節點宕機後會如何?
    • 通常情況下,如果是Follower節點宕機,如果剩餘可用節點數量超過半數,叢集可以幾乎沒有影響的正常工作。
    • 如果是Leader節點宕機,那麼Follower就收不到心跳而超時,發起競選獲得投票,成為新一輪term的Leader,繼續為叢集提供服務。
    • 需要注意的是;etcd目前沒有任何機制會自動去變化整個叢集總共的節點數量,即如果沒有人為的呼叫API,etcd宕機後的節點仍然被計算為總節點數中,任何請求被確認需要獲得的投票數都是這個總數的半數以上。

  • 為什麼Raft演算法在確定可用節點數量時不需要考慮拜占庭將軍問題?
    • 拜占庭問題中提出,允許n個節點宕機還能提供正常服務的分散式架構,需要的總節點數量為3n+1,而Raft只需要2n+1就可以了。
    • 其主要原因在於,拜占庭將軍問題中存在資料欺騙的現象,而etcd中假設所有的節點都是誠實的。
    • etcd在競選前需要告訴別的節點自身的term編號以及前一輪term最終結束時的index值,這些資料都是準確的,其他節點可以根據這些值決定是否投票。
    • 另外,etcd嚴格限制Leader到Follower這樣的資料流向保證資料一致不會出錯。
  • 使用者從叢集中哪個節點讀寫資料?
    • Raft為了保證資料的強一致性,所有的資料流向都是一個方向,從Leader流向Follower,也就是所有Follower的資料必須與Leader保持一致,如果不一致會被覆蓋。即所有使用者更新資料的請求都最先由Leader獲得,然後存下來通知其他節點也存下來,等到大多數節點反饋時再把資料提交。
    • 一個已提交的資料項才是Raft真正穩定儲存下來的資料項,不再被修改,最後再把提交的資料同步給其他Follower。
    • 因為每個節點都有Raft已提交資料準確的備份(最壞的情況也只是已提交資料還未完全同步),所以讀的請求任意一個節點都可以處理。
  • etcd實現的Raft演算法效能如何?
    • 單例項節點支援每秒1000次資料寫入。
    • 節點越多,由於資料同步涉及到網路延遲,會根據實際情況越來越慢,而讀效能會隨之變強,因為每個節點都能處理使用者請求。

7.2 關鍵部分原始碼解析

在etcd程式碼中,Node作為Raft狀態機的具體實現,是整個演算法的關鍵,也是瞭解演算法的入口

在etcd中,對Raft演算法的呼叫如下,你可以在etcdserver/raft.go中的startNode找到:

1

2

storage := raft.NewMemoryStorage()

n := raft.StartNode(0x01, []int64{0x02, 0x03}, 3, 1, storage)

通過這段程式碼可以瞭解到,Raft在執行過程記錄資料和狀態都是儲存在記憶體中,而程式碼中raft.StartNode啟動的Node就是Raft狀態機Node。

啟動了一個Node節點後,Raft會做如下事項。

1. 首先,你需要把從叢集的其他機器上收到的資訊推送到Node節點,你可以在etcdserver/server.go中的Process函式看到。

1

2

3

4

5

6

func (s *EtcdServer) Process(ctx context.Context, m raftpb.Message) error {

if m.Type == raftpb.MsgApp {

s.stats.RecvAppendReq(types.ID(m.From).String(), m.Size())

}

return s.node.Step(ctx, m)

}

  在檢測發來請求的機器是否是叢集中的節點,自身節點是否是Follower,把發來請求的機器作為Leader,具體對Node節點資訊的推送和處理則通過node.Step()函式實現。

2. 其次,你需要把日誌項儲存起來,在你的應用中執行提交的日誌項,然後把完成訊號傳送給叢集中的其它節點,再通過node.Ready()監聽等待下一次任務執行。

  有一點非常重要,你必須確保在你傳送完成訊息給其他節點之前,你的日誌項內容已經確切穩定的儲存下來了。

3. 最後,你需要保持一個心跳訊號Tick()

  Raft有兩個很重要的地方用到超時機制:心跳保持和Leader競選。

  需要使用者在其raft的Node節點上週期性的呼叫Tick()函式,以便為超時機制服務。

綜上所述,整個raft節點的狀態機迴圈類似如下所示:

1

2

3

4

5

6

7

8

9

10

11

12

13

for {

select {

case <-s.Ticker:

n.Tick()

case rd := <-s.Node.Ready():

saveToStorage(rd.State, rd.Entries)

send(rd.Messages)

process(rd.CommittedEntries)

s.Node.Advance()

case <-s.done:

return

}

}

  而這個狀態機真實存在的程式碼位置為etcdserver/server.go中的run函式。

對狀態機進行狀態變更(如使用者資料更新等)則是呼叫n.Propose(ctx, data)函式,在儲存資料時,會先進行序列化操作。

  獲得大多數其他節點的確認後,資料會被提交,存為已提交狀態。

之前提到etcd叢集的啟動需要藉助別的etcd叢集或者DNS,而啟動完畢後這些外力就不需要了,etcd會把自身叢集的資訊作為狀態儲存起來。

  所以要變更自身叢集節點數量實際上也需要像使用者資料變更那樣新增資料條目到Raft狀態機中。這一切由n.ProposeConfChange(ctx, cc)實現。

  當叢集配置資訊變更的請求同樣得到大多數節點的確認反饋後,再進行配置變更的正式操作,程式碼如下。

1

2

3

var cc raftpb.ConfChange

cc.Unmarshal(data)

n.ApplyConfChange(cc)

    注意:一個ID唯一性的表示了一個叢集,所以為了避免不同etcd叢集訊息混亂,ID需要確保唯一性,不能重複使用舊的token資料作為ID

8 Store

Store這個模組顧名思義,就像一個商店把etcd已經準備好的各項底層支援加工起來,為使用者提供五花八門的API支援,處理使用者的各項請求。

要理解Store,只需要從etcd的API入手即可。開啟etcd的API列表,我們可以看到有如下API是對etcd儲存的鍵值進行的操作,亦即Store提供的內容。

API中提到的目錄(Directory)和鍵(Key),上文中也可能稱為etcd節點(Node)。

  • 為etcd儲存的鍵賦值

1

2

3

4

5

6

7

8

9

10

curl http://127.0.0.1:2379/v2/keys/message -XPUT -d value="Hello world"

{

"action": "set",

"node": {

"createdIndex": 2,

"key": "/message",

"modifiedIndex": 2,

"value": "Hello world"

}

}

  反饋的內容含義如下:

    • action: 剛剛進行的動作名稱。
    • node.key: 請求的HTTP路徑。etcd使用一個類似檔案系統的方式來反映鍵值儲存的內容。
    • node.value: 剛剛請求的鍵所儲存的內容。
    • node.createdIndex: etcd節點每次有變化時都會自增的一個值,除了使用者請求外,etcd內部執行(如啟動、叢集資訊變化等)也會對節點有變動而引起這個值的變化。  
    • node.modifiedIndex: 類似node.createdIndex,能引起modifiedIndex變化的操作包括set, delete, update, create, compareAndSwap and compareAndDelete。
  • 查詢etcd某個鍵儲存的值

1

curl http://127.0.0.1:2379/v2/keys/message

  • 修改鍵值:與建立新值幾乎相同,但是反饋時會有一個prevNode值反應了修改前儲存的內容。

1

curl http://127.0.0.1:2379/v2/keys/message -XPUT -d value="Hello etcd"

  • 刪除一個值

1

curl http://127.0.0.1:2379/v2/keys/message -XDELETE

  • 對一個鍵進行定時刪除:etcd中對鍵進行定時刪除,設定一個TTL值,當這個值到期時鍵就會被刪除。反饋的內容會給出expiration項告知超時時間,ttl項告知設定的時長。

1

curl http://127.0.0.1:2379/v2/keys/foo -XPUT -d value=bar -d ttl=5

  • 取消定時刪除任務

1

curl http://127.0.0.1:2379/v2/keys/foo -XPUT -d value=bar -d ttl= -d prevExist=true

  • 對鍵值修改進行監控:etcd提供的這個API讓使用者可以監控一個值或者遞迴式的監控一個目錄及其子目錄的值,當目錄或值發生變化時,etcd會主動通知。

1

curl http://127.0.0.1:2379/v2/keys/foo?wait=true

  • 對過去的鍵值操作進行查詢:類似上面提到的監控,只不過監控時加上了過去某次修改的索引編號,就可以查詢歷史操作。預設可查詢的歷史記錄為1000條。

1

curl 'http://127.0.0.1:2379/v2/keys/foo?wait=true&waitIndex=7'

  • 自動在目錄下建立有序鍵。在對建立的目錄使用POST引數,會自動在該目錄下建立一個以createdIndex值為鍵的值,這樣就相當於以建立時間先後嚴格排序了。這個API對分散式佇列這類場景非常有用。

1

2

3

4

5

6

7

8

9

10

curl http://127.0.0.1:2379/v2/keys/queue -XPOST -d value=Job1

{

"action": "create",

"node": {

"createdIndex": 6,

"key": "/queue/6",

"modifiedIndex": 6,

"value": "Job1"

}

}

  • 按順序列出所有建立的有序鍵。

1

curl -s 'http://127.0.0.1:2379/v2/keys/queue?recursive=true&sorted=true'

  • 建立定時刪除的目錄:就跟定時刪除某個鍵類似。如果目錄因為超時被刪除了,其下的所有內容也自動超時刪除。

1

curl http://127.0.0.1:2379/v2/keys/dir -XPUT -d ttl=30 -d dir=true

  • 重新整理超時時間

1

curl http://127.0.0.1:2379/v2/keys/dir -XPUT -d ttl=30 -d dir=true -d prevExist=true

  • 自動化CAS(Compare-and-Swap)操作:etcd強一致性最直觀的表現就是這個API,通過設定條件,阻止節點二次建立或修改。即使用者的指令被執行當且僅當CAS的條件成立。條件有以下幾個。
    • prevValue 先前節點的值,如果值與提供的值相同才允許操作。
    • prevIndex 先前節點的編號,編號與提供的校驗編號相同才允許操作。
    • prevExist 先前節點是否存在。如果存在則不允許操作。這個常常被用於分散式鎖的唯一獲取。
      • 假設先進行了如下操作:設定了foo的值。

      1

      curl http://127.0.0.1:2379/v2/keys/foo -XPUT -d value=one

      • 然後再進行操作:

      1

      curl http://127.0.0.1:2379/v2/keys/foo?prevExist=false -XPUT -d value=three

      • 就會返回建立失敗的錯誤。
  • 條件刪除(Compare-and-Delete):與CAS類似,條件成立後才能刪除。

1

curl http://127.0.0.1:2379/v2/keys/dir -XPUT -d dir=true

  • 建立目錄

1

curl http://127.0.0.1:2379/v2/keys/

  • 列出目錄下所有的節點資訊,最後以/結尾。還可以通過recursive引數遞迴列出所有子目錄資訊。

1

curl http://127.0.0.1:2379/v2/keys/

  • 刪除目錄:預設情況下只允許刪除空目錄,如果要刪除有內容的目錄需要加上recursive=true引數。

1

curl 'http://127.0.0.1:2379/v2/keys/foo_dir?dir=true' -XDELETE

  • 建立一個隱藏節點:命名時名字以下劃線_開頭預設就是隱藏鍵。

1

curl http://127.0.0.1:2379/v2/keys/_message -XPUT -d value="Hello hidden world"

相信看完這麼多API,讀者已經對Store的工作內容基本瞭解了。

  它對etcd下儲存的資料進行加工,創建出如檔案系統般的樹狀結構供使用者快速查詢。

  它有一個Watcher用於節點變更的實時反饋,還需要維護一個WatcherHub對所有Watcher訂閱者進行通知的推送。

  同時,它還維護了一個由定時鍵構成的小頂堆,快速返回下一個要超時的鍵。

最後,所有這些API的請求都以事件的形式儲存在事件佇列中等待處理。

9 總結

通過從應用場景到原始碼分析的一系列回顧,我們瞭解到etcd並不是一個簡單的分散式鍵值儲存系統。

它解決了分散式場景中最為常見的一致性問題,為服務發現提供了一個穩定高可用的訊息註冊倉庫,為以微服務協同工作的架構提供了無限的可能。

相信在不久的將來,通過etcd構建起來的大型系統會越來越多。