ZooKeeper 核心通識

作者:mosun,騰訊 PCG 後臺開發工程師

文章分三部分展開陳述:ZooKeeper 核心知識、ZooKeeper 的典型應用實現原理、ZooKeeper 在中間件的落地案例。

爲了應對大流量,現代應用 / 中間件通常採用分佈式部署,此時不得不考慮 CAP 問題。ZooKeeper(後文簡稱 ZK)是面向 CP 設計的一個開源的分佈式協調框架,將那些複雜且容易出錯的分佈式一致性服務封裝起來,構成一個高效可靠的原語集,並以一系列簡單易用的接口提供給用戶使用,分佈式應用程序可以基於它實現諸如 數據發佈 / 訂閱、負載均衡、命名服務、集羣管理、Master 選舉、分佈式鎖、分佈式隊列 等功能。ZK 之所以能夠提供上述一套分佈式數據一致性解決方案,核心在於其設計精妙的數據結構、watcher 機制、Zab 一致性協議等,下面將依次剖析。

數據結構

ZK 在內存中維護了一個類似文件系統的樹狀數據結構實現命名空間(如下),樹中的節點稱爲 znode

然而,znode 要比文件系統的路徑複雜,既可以通過路徑訪問,又可以存儲數據。znode 具有四個屬性 data、acl、stat、children,如下

public class DataNode implements Record {
    byte data[];
    Long acl;
    public StatPersisted stat;
    private Set<String> children = null;
}

注意:znode 的數據操作具有原子性,讀操作將獲取與節點相關的所有數據,寫操作也將替換掉節點的所有數據。znode 可存儲的最大數據量是 1MB ,但實際上我們在 znode 的數據量應該儘可能小,因爲數據過大會導致 zk 的性能明顯下降。每個 ZNode 都對應一個唯一的路徑

事物 ID:Zxid

Zxid 由 Leader 節點生成。當有新寫入事件時,Leader 節點生成新的 Zxid,並隨提案一起廣播。Zxid 的生成規則如下:

zxid 是遞增的,所以誰的 zxid 越大,就表示誰的數據是最新的。每個節點都保存了當前最近一次事務的 Zxid。Zxid 對於 ZK 的數據一致性以及選主都有着重要意義,後邊在介紹相關知識時會重點講解其作用原理。

znode 類型

節點根據生命週期的不同可以將劃分爲持久節點臨時節點。持久節點的存活時間不依賴於客戶端會話,只有客戶端在顯式執行刪除節點操作時,節點才消失;臨時節點的存活時間依賴於客戶端會話,當會話結束,臨時節點將會被自動刪除(當然也可以手動刪除臨時節點)。注意:臨時節點不能擁有子節點

節點類型是在創建時進行制定,後續不能改變。如create /n1 node1創建了一個數據爲”node1” 的持久節點 / n1;在上述指令基礎上加上參數 - e:create -e /n1/n3 node3,則創建了一個數據爲”node3” 的臨時節點 /n1/n3。

create 命令還有一個可選參數 -s 用於指定創建的節點是否具有順序特性。創建順序節點時,zk 會在路徑後面自動追加一個 遞增的序列號 ,這個序列號可以保證在同一個父節點下是唯一的,利用該特性我們可以實現分佈式鎖 等功能。

基於 znode 的上述兩組特性,兩兩組合後可構建 4 種類型的節點:

Watcher 監聽機制

Watcher 監聽機制是 ZK 非常重要的一個特性。ZK 允許 Client 端在指定節點上註冊 Watcher,監聽節點數據變更、節點刪除、子節點狀態變更等事件,當特定事件發生時,ZK 服務端會異步通知註冊了相應 Watcher 的客戶端,通過該機制,我們可以利用 ZK 實現數據的發佈和訂閱等功能。

Watcher 監聽機制由三部分協作完成:ZK 服務端、ZK 客戶端、客戶端的 WatchManager 對象。工作時,客戶端首先將 Watcher 註冊到服務端,同時將 Watcher 對象保存到客戶端的 Watch 管理器中。當 ZK 服務端監聽的數據狀態發生變化時,服務端會主動通知客戶端,接着客戶端的 Watch 管理器會觸發相關 Watcher 來回調相應處理邏輯。

注意

ZK 集羣

爲了確保服務的高可用性,ZK 採用集羣化部署,如下:

ZK 集羣服務器有三種角色:Leader、Follower 和 Observer

“早期的 ZooKeeper 集羣服務運行過程中,只有 Leader 服務器和 Follow 服務器。隨着集羣規模擴大,follower 變多,ZK 在創建節點和選主等事務性請求時,需要一半以上節點 AC,所以導致性能下降寫入操作越來越耗時,follower 之間通信越來越耗時。爲了解決這個問題,就引入了觀察者,可以處理讀,但是不參與投票。既保證了集羣的擴展性,又避免過多服務器參與投票導致的集羣處理請求能力下降。”

ZK 集羣中通常有很多服務器,那麼如何區分不同的服務器的角色呢?可以通過服務器的狀態進行區分

ZK 集羣是一主多從的結構,所有的所有的寫操作必須要通過 Leader 完成,Follower 可直接處理並返回客戶端的讀請求。那麼如何保證從 Follower 服務器讀取的數據與 Leader 寫入的數據的一致性呢?Leader 萬一由於某些原因崩潰了,如何選出新的 Leader,如何保證數據恢復?Leader 是怎麼選出來的?

Zab 一致性協議

ZK 專門設計了 ZAB 協議 (Zookeeper Atomic Broadcast) 來保證主從節點數據的一致性。下面分別從 client 向 Leader 和 Follower 寫數據場景展開陳述。

寫 Leader 場景數據一致性

  1. 客戶端向 Leader 發起寫請求

  2. Leader 將寫請求以 Proposal 的形式發給所有 Follower 並等待 ACK

  3. Follower 收到 Leader 的 Proposal 後返回 ACK

  4. Leader 得到過半數的 ACK(Leader 對自己默認有一個 ACK)後向所有的 Follower 和 Observer 發送 Commmit

  5. Leader 將處理結果返回給客戶端

注意

寫 Follower 場景數據一致性

  1. 客戶端向 Follower 發起寫請求, Follower 將寫請求轉發給 Leader 處理;

  2. 其它流程與直接寫 Leader 無任何區別

注意:Observer 與 Follower 寫流程相同

最終一致性

Zab 協議消息廣播使用兩階段提交的方式,達到主從數據的最終一致性。爲什麼是最終一致性呢?從上文可知數據寫入過程核心分成下面兩階段:

根據寫入過程的兩階段的描述,可以知道 ZooKeeper 保證的是最終一致性,即 Leader 向客戶端返回寫入成功後,可能有部分 Follower 還沒有寫入最新的數據,所以是最終一致性。ZooKeeper 保證的最終一致性也叫順序一致性,即每個結點的數據都是嚴格按事務的發起順序生效的。ZooKeeper 集羣的寫入是由 Leader 結點協調的,真實場景下寫入會有一定的併發量,那 Zab 協議的兩階段提交是如何保證事務嚴格按順序生效的呢?ZK 事物的順序性是藉助上文中的 Zxid 實現的。Leader 在收到半數以上 ACK 後會將提案生效並廣播給所有 Follower 結點,Leader 爲了保證提案按 ZXID 順序生效,使用了一個 ConcurrentHashMap,記錄所有未提交的提案,命名爲 outstandingProposals,key 爲 ZXID,Value 爲提案的信息。對 outstandingProposals 的訪問邏輯如下:

  1. Leader 每發起一個提案,會將提案的 ZXID 和內容放到 outstandingProposals 中,作爲待提交的提案;

  2. Leader 收到 Follower 的 ACK 信息後,根據 ACK 中的 ZXID 從 outstandingProposals 中找到對應的提案,對 ACK 計數;

  3. 執行 tryToCommit 嘗試將提案提交:判斷流程是,先判斷當前 ZXID 之前是否還有未提交提案,如果有,當前提案暫時不能提交;再判斷提案是否收到半數以上 ACK,如果達到半數則可以提交;如果可以提交,將當前 ZXID 從 outstandingProposals 中清除並向 Followers 廣播提交當前提案;

Leader 是如何判斷當前 ZXID 之前是否還有未提交提案的呢?由於前提是保證順序提交的,所以 Leader 只需判斷 outstandingProposals 裏,當前 ZXID 的前一個 ZXID 是否存在。代碼如下:

所以 ZooKeeper 是通過兩階段提交保證數據的最終一致性,並且通過嚴格按照 ZXID 的順序生效提案保證其順序一致性的。

選主原理

ZK 中默認的並建議使用的 Leader 選舉算法是:基於 TCP 的 FastLeaderElection。在分析選舉原理前,先介紹幾個重要的參數。

ZK 的 leader 選舉存在兩類,一個是服務器啓動時 leader 選舉,另一個是運行過程中服務器宕機時的 leader 選舉,下面依次展開介紹。以下兩節引自從 0 到 1 詳解 ZooKeeper 的應用場景及架構

服務器啓動時的 leader 選舉

1、各自推選自己:ZooKeeper 集羣剛啓動時,所有服務器的 logicClock 都爲 1,zxid 都爲 0。各服務器初始化後,先把第一票投給自己並將它存入自己的票箱,同時廣播給其他服務器。此時各自的票箱中只有自己投給自己的一票,如下圖所示:

2、更新選票:第一步中各個服務器先投票給自己,並把投給自己的結果廣播給集羣中的其他服務器,這一步其他服務器接收到廣播後開始更新選票操作,以 Server1 爲例流程如下:

(1)Server1 收到 Server2 和 Server3 的廣播選票後,由於 logicClock 和 zxid 都相等,此時就比較 myid;

(2)Server1 收到的兩張選票中 Server3 的 myid 最大,此時 Server1 判斷應該遵從 Server3 的投票決定,將自己的票改投給 Server3。接下來 Server1 先清空自己的票箱 (票箱中有第一步中投給自己的選票),然後將自己的新投票(1->3) 和接收到的 Server3 的 (3->3) 投票一起存入自己的票箱,再把自己的新投票決定 (1->3) 廣播出去, 此時 Server1 的票箱中有兩票:(1->3),(3->3);

(3)同理,Server2 收到 Server3 的選票後也將自己的選票更新爲(2->3)並存入票箱然後廣播。此時 Server2 票箱內的選票爲 (2->3),(3->3);

(4)Server3 根據上述規則,無須更新選票,自身的票箱內選票仍爲(3->3);

(5)Server1 與 Server2 重新投給 Server3 的選票廣播出去後,由於三個服務器最新選票都相同,最後三者的票箱內都包含三張投給服務器 3 的選票。

3、根據選票確定角色:根據上述選票,三個服務器一致認爲此時 Server3 應該是 Leader。因此 Server1 和 Server2 都進入 FOLLOWING 狀態,而 Server3 進入 LEADING 狀態。之後 Leader 發起並維護與 Follower 間的心跳。

運行時 Follower 重啓選舉

本節討論 Follower 節點發生故障重啓或網絡產生分區恢復後如何進行選舉。

1、Follower 重啓投票給自己:Follower 重啓,或者發生網絡分區後找不到 Leader,會進入 LOOKING 狀態併發起新的一輪投票。

2、發現已有 Leader 後成爲 Follower:Server3 收到 Server1 的投票後,將自己的狀態 LEADING 以及選票返回給 Server1。Server2 收到 Server1 的投票後,將自己的狀態 FOLLOWING 及選票返回給 Server1。此時 Server1 知道 Server3 是 Leader,並且通過 Server2 與 Server3 的選票可以確定 Server3 確實得到了超過半數的選票。因此服務器 1 進入 FOLLOWING 狀態。

運行時 Leader 重啓選舉

Follower 發起新投票:Leader(Server3)宕機後,Follower(Server1 和 2)發現 Leader 不工作了,因此進入 LOOKING 狀態併發起新的一輪投票,並且都將票投給自己,同時將投票結果廣播給對方。

2、更新選票:(1)Server1 和 2 根據外部投票確定是否要更新自身的選票,這裏跟之前的選票 PK 流程一樣,比較的優先級爲:logicLock > zxid > myid,這裏 Server1 的參數 (L=3, M=1, Z=11) 和 Server2 的參數 (L=3, M=2, Z=10),logicLock 相等,zxid 服務器 1 大於服務器 2,因此服務器 2 就清空已有票箱,將(1->1) 和(2->1)兩票存入票箱,同時將自己的新投票廣播出去 (2)服務器 1 收到 2 的投票後,也將自己的票箱更新。

3、重新選出 Leader:此時由於只剩兩臺服務器,服務器 1 投票給自己,服務器 2 投票給 1,所以 1 當選爲新 Leader。

4、舊 Leader 恢復發起選舉:之前宕機的舊 Leader 恢復正常後,進入 LOOKING 狀態併發起新一輪領導選舉,並將選票投給自己。此時服務器 1 會將自己的 LEADING 狀態及選票返回給服務器 3,而服務器 2 將自己的 FOLLOWING 狀態及選票返回給服務器 3。

5、舊 Leader 成爲 Follower:服務器 3 瞭解到 Leader 爲服務器 1,且根據選票瞭解到服務器 1 確實得到過半服務器的選票,因此自己進入 FOLLOWING 狀態。

腦裂

對於一主多從類的集羣應用,通常要考慮腦裂問題,腦裂會導致數據不一致。那麼,什麼是腦裂?簡單點來說,就是一個集羣有兩個 master。通常腦裂產生原因如下:

  1. 假死:由於心跳超時(網絡原因導致的)認爲 Leader 死了,但其實 Leader 還存活着。

  2. 腦裂:由於假死會發起新的 Leader 選舉,選舉出一個新的 Leader,但舊的 Leader 網絡又通了,導致出現了兩個 Leader ,有的客戶端連接到老的 Leader,而有的客戶端則連接到新的 Leader。

通常解決腦裂問題有 Quorums(法定人數)方式、Redundant communications(冗餘通信)方式、仲裁、磁盤鎖等方式。ZooKeeper 採用 Quorums 這種方式來防止 “腦裂” 現象,只有集羣中超過半數節點投票才能選舉出 Leader

典型應用場景

數據發佈 / 訂閱

我們可基於 ZK 的 Watcher 監聽機制實現數據的發佈與訂閱功能。ZK 的發佈訂閱模式採用的是推拉結合的方式實現的,實現原理如下:

  1. 當集羣中的服務啓動時,客戶端向 ZK 註冊 watcher 監聽特定節點,並從節點拉取數據獲取配置信息;

  2. 當發佈者變更配置時,節點數據發生變化,ZK 會發送 watcher 事件給各個客戶端;客戶端在接收到 watcher 事件後,會從該節點重新拉取數據獲取最新配置信息。

注意:Watch 具有一次性,所以當獲得服務器通知後要再次添加 Watch 事件。

負載均衡

利用 ZK 的臨時節點、watcher 機制等特性可實現負載均衡,具體思路如下:

把 ZK 作爲一個服務的註冊中心,基本流程:

  1. 服務提供者 server 啓動時在 ZK 進行服務註冊(創建臨時文件);

  2. 服務消費者 client 啓動時,請求 ZK 獲取最新的服務存活列表並註冊 watcher,然後將獲得服務列表保存到本地緩存中;

  3. client 請求 server 時,根據自己的負載均衡算法,從服務器列表選取一個進行通信。

  4. 若在運行過程中,服務提供者出現異常或人工關閉不能提供服務,臨時節點失效,ZK 探測到變化更新本地服務列表並異步通知到服務消費者,服務消費者監聽到服務列表的變化,更新本地緩存

注意:服務發現可能存在延遲,因爲服務提供者掛掉到緩存更新大約需要 3-5s 的時間(根據網絡環境不同還需仔細測試)。爲了保證服務的實時可用,client 請求 server 發生異常時,需要根據服務消費報錯信息,進行重負載均衡重試等。

命名服務

命名服務是指通過指定的名字來獲取資源或者服務的地址、提供者等信息。以 znode 的路徑爲名字,znode 存儲的數據爲值,可以很容易構建出一個命名服務。例如 Dubbo 使用 ZK 來作爲其命名服務,如下

集羣管理

基於 ZK 的臨時節點和 watcher 監聽機制可實現集羣管理。集羣管理通常指監控集羣中各個主機的運行時狀態、存活狀況等信息。如下圖所示,主機向 ZK 註冊臨時節點,監控系統註冊監聽集羣下的臨時節點,從而獲取集羣中服務的狀態等信息。

Master 選舉

ZK 中某節點同一層子節點,名稱具有唯一性,所以,多個客戶端創建同一節點時,只會有一個客戶端成功。利用該特性,可以實現 maser 選舉,具體如下:

  1. 多個客戶端同時競爭創建同一臨時節點 / master-election/master,最終只能有一個客戶端成功。這個成功的客戶端成爲 Master,其它客戶端置爲 Slave。

  2. Slave 客戶端都向這個臨時節點的父節點 / master-election 註冊一個子節點列表的 watcher 監聽。

  3. 一旦原 Master 宕機,臨時節點就會消失,zk 服務器就會向所有 Slave 發送子節點變更事件,Slave 在接收到事件後會競爭創建新的 master 臨時子節點。誰創建成功,誰就是新的 Master。

分佈式鎖

基於 ZK 的臨時順序節點和 Watcher 機制可實現公平分佈式鎖。下面具體看下多客戶端獲取及釋放 zk 分佈式鎖的整個流程及背後的原理。下面過程引自七張圖徹底講清楚 ZooKeeper 分佈式鎖的實現原理【石杉的架構筆記】

假如說客戶端 A 先發起請求,就會搞出來一個順序節點,大家看下面的圖,Curator 框架大概會弄成如下的樣子:

這一大坨長長的名字都是 Curator 框架自己生成出來的。然後,因爲客戶端 A 是第一個發起請求的,所以給他搞出來的順序節點的序號是 "1"。接着客戶端 A 會查一下 "my_lock" 這個鎖節點下的所有子節點,並且這些子節點是按照序號排序的,這個時候大概會拿到這麼一個集合:

接着客戶端 A 會走一個關鍵性的判斷:唉!兄弟,這個集合裏,我創建的那個順序節點,是不是排在第一個啊?如果是的話,那我就可以加鎖了啊!因爲明明我就是第一個來創建順序節點的人,所以我就是第一個嘗試加分佈式鎖的人啊!bingo!加鎖成功!大家看下面的圖,再來直觀的感受一下整個過程。

假如說客戶端 A 加完鎖完後,客戶端 B 過來想要加鎖,這個時候它會幹一樣的事兒:先是在 "my_lock" 這個鎖節點下創建一個臨時順序節點,因爲是第二個來創建順序節點的,所以 zk 內部會維護序號爲 "2"。接着客戶端 B 會走加鎖判斷邏輯,查詢 "my_lock" 鎖節點下的所有子節點,按序號順序排列,此時看到的類似於:

同時檢查自己創建的順序節點,是不是集合中的第一個?明顯不是,此時第一個是客戶端 A 創建的那個順序節點,序號爲 "01" 的那個。所以加鎖失敗!加鎖失敗了以後,客戶端 B 就會通過 ZK 的 API 對他的順序節點的上一個順序節點加一個監聽器, 即對客戶端 A 創建的那個順序節加監聽器!如下

接着,客戶端 A 加鎖之後,可能處理了一些代碼邏輯,然後就會釋放鎖。那麼,釋放鎖是個什麼過程呢?

其實很簡單,就是把自己在 zk 裏創建的那個順序節點,也就是:

這個節點被刪除。

刪除了那個節點之後,zk 會負責通知監聽這個節點的監聽器,也就是客戶端 B 之前加的那個監聽器,說:兄弟,你監聽的那個節點被刪除了,有人釋放了鎖。

此時客戶端 B 的監聽器感知到了上一個順序節點被刪除,也就是排在他之前的某個客戶端釋放了鎖。

此時,就會通知客戶端 B 重新嘗試去獲取鎖,也就是獲取 "my_lock" 節點下的子節點集合,此時爲:

集合裏此時只有客戶端 B 創建的唯一的一個順序節點了!

然後呢,客戶端 B 判斷自己居然是集合中的第一個順序節點,bingo!可以加鎖了!直接完成加鎖,運行後續的業務代碼即可,運行完了之後再次釋放鎖。

注意:利用 ZK 實現分佈式鎖時要避免出現驚羣效應。上述策略中,客戶端 B 通過監聽比其節點順序小的那個臨時節點,解決了驚羣效應問題。

分佈式隊列

基於 ZK 的臨時順序節點和 Watcher 機制可實現簡單的 FIFO 分佈式隊列。ZK 分佈式隊列和上節中的分佈式鎖本質是一樣的,都是基於對上一個順序節點進行監聽實現的。具體原理如下:

  1. 利用順序節點的有序性,爲每個數據在 / FIFO 下創建一個相應的臨時子節點;且每個消費者均在 / FIFO 註冊一個 watcher;

  2. 消費者從分佈式隊列獲取數據時,首先嚐試獲取分佈式鎖,獲取鎖後從 / FIFO 獲取序號最小的數據,消費成功後,刪除相應節點;

  3. 由於消費者均監聽了父節點 / FIFO,所以均會收到數據變化的異步通知,然後重複 2 的過程,嘗試消費隊列數據。依此循環,直到消費完畢。

中間件落地案例

Kafka

ZK 在 Kafka 集羣中扮演着極其重要的角色。Kafka 中很多信息都在 ZK 中維護,如 broker 集羣信息、consumer 集羣信息、 topic 相關信息、 partition 信息等。Kafka 的很多功能也是基於 ZK 實現的,如 partition 選主、broker 集羣管理、consumer 負載均衡等,限於篇幅本文將不展開陳述,這裏先附一張網上截圖大家感受下,詳情將在 Kafka 專題中細聊。

Dubbo

Dubbo 使用 Zookeeper 用於服務的註冊發現和配置管理,詳情見上文 “命名服務”。

參考文獻

https://mp.weixin.qq.com/s/tiAQQXbh7Tj45_1IQmQqZg

https://www.jianshu.com/p/68b45694026c

https://time.geekbang.org/column/article/239261

https://blog.csdn.net/lihao21/article/details/51810395

https://zhuanlan.zhihu.com/p/378018463

https://juejin.cn/post/6974737393324654628

https://blog.csdn.net/liuao107329/article/details/78936160

https://blog.csdn.net/en_joker/article/details/78799737

https://blog.51cto.com/u_15077535/4199740

https://juejin.cn/post/6844903729406148622

https://blog.csdn.net/Saintmm/article/details/124110149

https://www.wumingx.com/linux/zk-kafka.html

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/uustNtBOGvPOx4Q3oPFmSw