從 0 到 1 詳解 ZooKeeper 的應用場景及架構

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

一、背景

1 後臺系統由集中式發展爲分佈式

隨着計算機系統的規模越來越大,業務量的迅速提升和互聯網的爆炸式增長,集中式系統採用大型主機單機部署帶來了一系列問題:系統大而複雜、難於維護、發生單點故障引起雪崩、擴展性差等。這些都使業務面臨巨大的壓力和嚴重的風險,爲了解決集中式系統架構面臨的痛點,分佈式系統架構逐步走上舞臺。分佈式系統是一個硬件或軟件組件分佈在不同的網絡計算機上,彼此之間僅僅通過消息傳遞進行通信和協調的系統,可以很好的解決系統擴容、可用性以及降低成本。

2 分佈式系統架構引入的新問題

“天下沒有免費的午餐”,分佈式系統架構帶來了優點的同時,也提出了一系列的挑戰:

(1)由於多節點甚至多地部署,節點之間的數據一致性如何保證?

(2)在併發場景下如何保證任務只被執行一次?

(3)一個節點掛掉不能提供服務時如何被集羣知曉並由其他節點接替任務?

(4)存在資源共享時,資源的安全性和互斥性如何保證?

以上列舉了分佈式系統中面臨的一些挑戰,需要一個協調機制來解決分佈式集羣中的問題,使得開發者更專注於應用本身的邏輯而不是關注分佈式系統處理。

3 分佈式協調組件

爲解決分佈式系統中面臨的這些問題,開發者們通過工程實踐創造了很多非常優秀的分佈式系統協調組件,這些組件可以在分佈式環境下,保證分佈式系統的數據一致性和容錯性等。其中爲我們熟知的有:ZooKeeper、ETCD、Consul 等。ZooKeeper 作爲 Apache 的頂級開源項目,基於 Google Chubby 開源實現,在 Hadoop,Hbase,Kafka 等技術中充當核心組件的角色。雖然歷史悠久,但就像陳釀一樣,其設計思想和實現不論何時還是值得仔細學習和品味。

二、ZooKeeper

1 ZooKeeper 是什麼

從理論概念角度解釋:ZooKeeper 是一個分佈式的,開源的分佈式應用程序協調服務,它是一個爲分佈式應用提供一致性服務的軟件,提供的功能包括:配置維護、域名服務、分佈式同步、組服務等。

從數據讀寫角度解釋:ZooKeeper 是一個分佈式的開源協調服務,用於分佈式系統。ZooKeeper 允許你讀取、寫入數據和發現數據更新。數據按層次結構組織在文件系統中,並複製到 ensemble(一個 ZooKeeper 服務器的集合)中所有的 ZooKeeper 服務器。對數據的所有操作都是原子的和順序一致的。ZooKeeper 通過 Zab 一致性協議在 ensemble 的所有服務器之間複製一個狀態機來確保這個特性。

2 ZooKeeper 的安裝與使用

“紙上得來終覺淺,絕知此事要躬行”,學習一個新的組件,我們先通過安裝使用,對配置、API 等有一個直觀的認識,也爲後面動手實現一些功能部署好開發環境基礎。

2.1 ZooKeeper 下載與安裝

(1)ZooKeeper 使用 JAVA 語言開發,使用前需要先安裝 JDK(讀者自行安裝),安裝 JDK 後可在終端命令行中使用 java -version 命令查看版本(注意:本文均在 Linux 環境下指導演示)。

(2)ZooKeeper 下載:https://zookeeper.apache.org/releases.html 

在下載頁面分爲最新的 Release 版本和最近的穩定 Release 版本,這裏生產環境使用推薦穩定版本,點擊下載並上傳 apache-zookeeper-3.7.0-bin.tar.gz 到 Linux 服務器上。

(3)ZooKeeper 安裝:ZooKeeper 安裝分爲集羣安裝和單機安裝,生產環境一般爲集羣安裝。此處作爲演示,使用一臺服務器來做模擬集羣,也稱僞集羣安裝 (通過三個不同的文件夾 zk1/zk2/zk3,模擬真實環境中的三臺服務器實例)。

  1. 本篇中我們將要在本地開發機上安裝三個 zk 實例(可以認爲在生產集羣模式中,這是三臺不同的服務器),其安裝位置分別如下:

    /Users/newboy/ZooKeeper/zk1
    /Users/newboy/ZooKeeper/zk2
    /Users/newboy/ZooKeeper/zk3
  2. 將上文中下載的 ZooKeeper 安裝包 apache-zookeeper-3.7.0-bin.tar.gz 上傳到第一個實例 zk1 文件夾下,並使用如下命令進行解壓:

    tar -xzvf apache-zookeeper-3.7.0-bin.tar.gz
  3. 解壓完成後在 zk1 文件夾下創建 data 和 log 目錄,分別用於存儲當前 zk 實例數據和日誌:

    mkdir data logs

    此時 zk1 文件夾目錄結構如下所示:

  4. 創建 myid 文件 在 zk1 的 data 目錄下,創建 myid 文件,此文件記錄節點 id,每個 zookeeper 節點都需要一個 myid 文件來記錄節點在集羣中的 id,此文件中只能有一個數字,這裏 zk1 實例 myid 中寫入一個 1 即可:

    echo "1" >> /Users/newboy/ZooKeeper/zk1/data/myid  // 實例zk1的myid賦值爲1
    echo "2" >> /Users/newboy/ZooKeeper/zk2/data/myid  // 實例zk2的myid賦值爲2
    echo "3" >> /Users/newboy/ZooKeeper/zk3/data/myid  // 實例zk3的myid賦值爲3
  5. 進入 zk1 文件夾下 apache-zookeeper-3.7.0-bin/conf / 目錄,將配置文件 zoo_sample.cfg 重命名爲 zoo.cfg,打開 zoo.cfg 進行配置,具體配置如下:

    tickTime=2000  # 單位時間,其他時間都是以這個倍數來表示
    initLimit=10   # 節點初始化時間,10倍單位時間(即十倍tickTime)
    syncLimit=5    # 心跳最大延遲週期
    dataDir=/Users/newboy/ZooKeeper/zk1/data     # 該實例對應的數據目錄(上文步驟3創建)
    dataLogDir=/Users/newboy/ZooKeeper/zk1/logs  # 該實例對應的日誌目錄(上文步驟3創建)
    clientPort=2181                              # 端口(每個實例不同)
    // 集羣配置
    server.1=127.0.0.1:8881:7771                 # server.id=host:port:port
    server.2=127.0.0.1:8882:7772                 # server.id=host:port:port
    server.3=127.0.0.1:8883:7773                 # server.id=host:port:port

    集羣配置中模版爲 server.id=host:port:port,id 是上面 myid 文件中配置的 id;ip 是節點的 ip,第一個 port 是節點之間通信的端口,第二個 port 用於選舉 leader 節點(在真正的集羣模式下,不同服務器可以共用同一個 port,這裏單機上演示爲了避免端口衝突,選擇不同的端口)。

  6. zk2 和 zk3 的實例配置與 zk1 類似,爲了方便我們可以直接拷貝 zk1 的配置到 zk2 和 zk3 文件夾,然後修改各自的 zoo.cfg 和 data 目錄下的 myid 即可。拷貝命令:

    cp -R zk1 zk2
    cp -R zk1 zk3

    zk2 對應的 zoo.cfg:

    tickTime=2000  # 單位時間,其他時間都是以這個倍數來表示
    initLimit=10   # 節點初始化時間,10倍單位時間(即十倍tickTime)
    syncLimit=5    # 心跳最大延遲週期
    dataDir=/Users/newboy/ZooKeeper/zk2/data     # 該實例對應的數據目錄(上文步驟3創建)
    dataLogDir=/Users/newboy/ZooKeeper/zk2/logs  # 該實例對應的日誌目錄(上文步驟3創建)
    clientPort=2182                              # 端口(每個實例不同)
    // 集羣配置
    server.1=127.0.0.1:8881:7771                 # server.id=host:port:port
    server.2=127.0.0.1:8882:7772                 # server.id=host:port:port
    server.3=127.0.0.1:8883:7773                 # server.id=host:port:port

    zk3 對應的 zoo.cfg:

    tickTime=2000  # 單位時間,其他時間都是以這個倍數來表示
    initLimit=10   # 節點初始化時間,10倍單位時間(即十倍tickTime)
    syncLimit=5    # 心跳最大延遲週期
    dataDir=/Users/newboy/ZooKeeper/zk3/data     # 該實例對應的數據目錄(上文步驟3創建)
    dataLogDir=/Users/newboy/ZooKeeper/zk3/logs  # 該實例對應的日誌目錄(上文步驟3創建)
    clientPort=2183                              # 端口(每個實例不同)
    // 集羣配置
    server.1=127.0.0.1:8881:7771                 # server.id=host:port:port
    server.2=127.0.0.1:8882:7772                 # server.id=host:port:port
    server.3=127.0.0.1:8883:7773                 # server.id=host:port:port

    至此 zk 僞集羣模式的安裝配置已經完成,整體目錄結構縱覽如下:

    .
    ├── zk1
    │   ├── data
    │   │     └── myid
    │   ├── logs
    │   └── apache-zookeeper-3.7.0-bin
    ├── zk2
    │   ├── data
    │   │     └── myid
    │   ├── logs
    │   └── apache-zookeeper-3.7.0-bin
    └── zk3
    │   ├── data
    │   │     └── myid
        ├── logs
        └── apache-zookeeper-3.7.0-bin

    (4)ZooKeeper 實例啓動及使用客戶端交互:

  7. 啓動剛剛創建的三個 zk 實例 (1) 啓動 zk1 實例,命令行運行下面命令:

    // 啓動命令
    /Users/newboy/ZooKeeper/zk1/apache-zookeeper-3.7.0-bin/bin/zkServer.sh start
    // 啓動結果
    ZooKeeper JMX enabled by default
    Using config: /Users/newboy/ZooKeeper/zk1/apache-zookeeper-3.7.0-bin/bin/../conf/zoo.cfg
    Starting zookeeper ... STARTED

    (2) 同樣啓動 zk2 和 zk3 實例,命令行運行下面命令:

    // 啓動zk2命令
    /Users/newboy/ZooKeeper/zk2/apache-zookeeper-3.7.0-bin/bin/zkServer.sh start
    // 啓動zk3命令
    /Users/newboy/ZooKeeper/zk3/apache-zookeeper-3.7.0-bin/bin/zkServer.sh start
  8. 連接實例 所有實例全部啓動過後,選擇任一實例進行連接,這裏選擇實例 zk2,命令行輸入如下命令:

    /Users/newboy/ZooKeeper/zk2/apache-zookeeper-3.7.0-bin/bin/zkCli.sh -server 127.0.0.1:2182
  9. 創建節點 連接之後,可以在當前實例上創建節點,類似於創建一個 kv 值或者文件夾(ZK 的命令和可選參數讀者可以自行查看用戶手冊)

    // 創建節點 create表示創建命令,/zk-demo爲節點名稱 123爲節點值
    [zk: 127.0.0.1:2181(CONNECTED) 1] create /zk-demo 123
    Created /zk-demo
    // 獲取節點值 get表示獲取 /zk-demo爲需要獲取的節點名稱
    [zk: 127.0.0.1:2181(CONNECTED) 2] get /zk-demo
    123
  10. 在其他實例上獲取 zk2 實例創建的節點 由於 zk 會將節點寫入的值同步到集羣中每個節點,從而保證數據的一致性,那麼其他節點理論上也可以訪問到剛剛 zk2 創建的值。下面連接 zk1 來驗證下:

    // 連接zk1
    /Users/newboy/ZooKeeper/zk1/apache-zookeeper-3.7.0-bin/bin/zkCli.sh -server 127.0.0.1:2181
    // 獲取zk2上創建的節點/zk-demo
    [zk: 127.0.0.1:2183(CONNECTED) 0] get /zk-demo
    123

    可以看到,我們成功的在實例 zk1 上獲取到了實例 zk2 創建的節點,說明數據寫入 zk2 後,在各個節點間同步並實現了一致,zk 的下載、安裝和基本命令操作也就講完了。

3 ZooKeeper 能做什麼

前文中,我們瞭解了 ZooKeeper 出現的背景,它是分佈式系統中非常重要的中間件,分佈式應用程序可以基於 ZooKeeper 實現:

可以看到 ZooKeeper 可以實現非常多的功能,之所以能夠實現各種不同的能力,源於 ZooKeeper 底層的數據結構和數據模型。

4 ZooKeeper 的數據結構和數據模型

1 Znode 數據節點

ZooKeeper 的數據節點可以視爲樹狀結構,樹中的各節點被稱爲 Znode(即 ZooKeeper node),一個 Znode 可以有多個子節點,ZooKeeper 中的所有存儲的數據是由 znode 組成,並以 key/value 形式存儲數據。整體結構類似於 linux 文件系統的模式以樹形結構存儲,其中根路徑以 / 開頭:

如上圖所示,在根目錄下我們創建 Dog 和 Cat 兩個不同的數據節點,Cat 節點下有 TomCat 這個數據存儲節點,整個 ZooKeeper 的樹形存儲結構就是這樣的 Znode 構成,並存儲在內存中。

命令行下使用 ZooKeeper 客戶端工具創建節點的過程如下:首先連接一個 zk 實例:

// 連接zk1
/Users/newboy/ZooKeeper/zk1/apache-zookeeper-3.7.0-bin/bin/zkCli.sh -server 127.0.0.1:2181

創建節點:

[zk: 127.0.0.1:2181(CONNECTED) 5] create /Dog
Created /Dog
[zk: 127.0.0.1:2181(CONNECTED) 6] create /Cat
Created /Cat
[zk: 127.0.0.1:2181(CONNECTED) 7] create /Cat/TomCat
Created /Cat/TomCat

使用 ls 命令查看各個目錄下的節點數據:

[zk: 127.0.0.1:2181(CONNECTED) 8] ls /
[Cat, Dog, zk-demo, zookeeper]
[zk: 127.0.0.1:2181(CONNECTED) 10] ls /Cat
[TomCat]

Znode 節點類似於 Unix 文件系統,但也有自己的特性:

(1)Znode 兼具文件和目錄特點 既像文件一樣維護着數據、信息、時間戳等數據,又像目錄一樣可以作爲路徑標識的一部分,並可以具有子 Znode。用戶對 Znode 具有增、刪、改、查等操作;

(2)Znode 具有原子性操作 讀操作將獲取與節點相關的所有數據,寫操作也將替換掉節點的所有數據;

(3) Znode 存儲數據大小有限制 每個 Znode 的數據大小至多 1M,但是常規使用中應該遠小於此值;

(4)Znode 通過路徑引用 如同 Unix 中的文件路徑。路徑必須是絕對的,因此他們必須由斜槓字符來開頭。除此以外,他們必須是唯一的,也就是說每一個路徑只有一個表示,因此這些路徑不能改變。

2 Znode 節點類型

Znode 有兩種,分別爲臨時節點和永久節點,節點的類型在創建時即被確定,並且不能改變。

臨時節點:該節點的生命週期依賴於創建它們的會話。一旦會話結束,臨時節點將被自動刪除,當然可以也可以手動刪除。臨時節點不允許擁有子節點。

永久節點:該節點的生命週期不依賴於會話,並且只有在客戶端顯式執行刪除操作的時候,才能被刪除。

Znode 還有一個序列化的特性,如果創建的時候指定的話,該 Znode 的名字後面會自動追加一個遞增的序列號。序列號對於此節點的父節點來說是唯一的,這樣便會記錄每個子節點創建的先後順序。因此組合之後,Znode 有四種節點類型:

爲了對節點類型有更清楚的認識,在命令行下來模擬創建一個臨時節點:(1)首先連接 zk1 實例:

// 連接zk1
/Users/newboy/ZooKeeper/zk1/apache-zookeeper-3.7.0-bin/bin/zkCli.sh -server 127.0.0.1:2181

(2)創建一個臨時節點:

// -e 表示該節點爲臨時節點
[zk: 127.0.0.1:2181(CONNECTED) 12] create -e /Dog/Puppy 123
Created /Dog/Puppy

(3)連接 zk2 實例,查看該臨時節點是否同步:

// 連接zk2
/Users/newboy/ZooKeeper/zk2/apache-zookeeper-3.7.0-bin/bin/zkCli.sh -server 127.0.0.1:2182
// 查詢/Dog/Puppy節點值
[zk: 127.0.0.1:2182(CONNECTED) 2] get /Dog/Puppy
123

(4)斷開 zk1 實例的會話

[zk: 127.0.0.1:2181(CONNECTED) 16] quit
WATCHER::
WatchedEvent state:Closed type:None path:null
2022-03-15 15:39:55,807 [myid:] - INFO  [main:ZooKeeper@1232] - Session: 0x1000c3279ae0000 closed
2022-03-15 15:39:55,807 [myid:] - INFO  [main-EventThread:ClientCnxn$EventThread@570] - EventThread shut down for session: 0x1000c3279ae0000
2022-03-15 15:39:55,810 [myid:] - ERROR [main:ServiceUtils@42] - Exiting JVM with code 0

(5)在 zk2 上查看該節點

[zk: 127.0.0.1:2182(CONNECTED) 3] get /Dog/Puppy
org.apache.zookeeper.KeeperException$NoNodeExceptionKeeperErrorCode = NoNode for /Dog/Puppy

可以看到 / Dog/Puppy 臨時節點隨着 zk1 實例會話的退出消失了,這就是臨時節點的特性,zk1 創建的臨時節點會隨着 zk1 實例連接的退出而消失,永久節點則只能通過 delete /Dog(節點名) 刪除纔會消失。

3 ZooKeeper 的 Znode Watcher 機制

ZooKeeper 可以用來做數據的發佈和訂閱,一個典型的發佈 / 訂閱模型系統定義了一種一對多的訂閱關係,能夠讓多個訂閱者同時監聽某一個主題對象,當這個主題對象自身狀態變化時,會通知所有訂閱者,使它們能夠做出相應的處理。在 ZooKeeper 中,引入了 Watcher 機制實現這種分佈式的通知功能

ZooKeeper 允許 ZK 客戶端向服務端註冊一個 Watcher 監聽,當服務端的一些指定事件觸發了這個 Watcher,那麼就會向指定客戶端發送一個事件通知。例如 ZK 客戶端監聽臨時節點 / Cat, 當該臨時節點消失時,則會由服務端觸發調用客戶端 WatchManager,客戶端從 WatchManager 中取出對應的 Watcher 對象來進行處理邏輯。

(1)客戶端首先將 Watcher 註冊到服務端,同時將 Watcher 對象保存到客戶端的 Watch 管理器中;(2)當 ZooKeeper 服務端監聽的數據狀態發生變化時,服務端會主動通知客戶端;(3) 接着客戶端的 Watch 管理器會觸發相關 Watcher 來回調相應處理邏輯,從而完成整體的數據發佈 / 訂閱流程。

4 經典案例:基於 Znode 臨時順序節點 + Watcher 機制實現公平分佈式鎖

1、臨時順序節點:在介紹 Znode 節點時,我們提到過 Znode 節點有 “臨時節點” 這個類型,它會隨着客戶端連接的斷開而消失,同時節點類型可以選擇順序性,組合起來就是“臨時順序節點”,如下圖所示:

在根目錄 “/” 下創建分佈式鎖 “/Lock” 節點目錄,/Lock 節點本身可以是永久節點,用於存放客戶端搶佔創建的臨時順序節點。此時假設有兩個 ZK 客戶端 A 和 B 同時調用 Create 函數,在 "/Lock" 節點下創建臨時順序節點,A 比 B 網絡延時更小,先創建,ZK 分配節點名稱爲 "/Lock/Seq0001", B 晚於 A 創建成功,ZK 分配節點名爲 "/Lock/Seq0002",ZK 負責維護這個遞增的順序節點名。

2、分佈式鎖實現的具體流程 (1)如下圖,客戶端 A、B 同時在 "/Lock" 節點下創建臨時順序子節點,可以理解爲同時搶佔分佈式鎖,A 先於 B 創建成功,此時分配的節點爲 “/Lock/seq-0000001”,由於 A 創建成功,並且臨時順序節點的順序值序號最小,代表它是最先獲取到該鎖,此時加鎖成功。

(2)如下圖,(紅色虛線)客戶端 B 晚於 A 創建臨時順序節點,此時 ZK 分配的節點順序值爲 “/Lock/seq-0000002”,B 創建成功之後,它的順序值大於 A 的順序值,不是最小順序值,此時說明 A 已經搶佔到分佈式鎖,這個時候 B 就使用 Watcher 監聽機制,監聽次小於自己的臨時順序節點 A 的狀態變化。

(3)如下圖,當 A 客戶端因宕機或者完成處理邏輯而斷開鏈接時,A 創建的臨時順序節點會隨之消失,此時由於客戶端 B 已經監聽了 A 臨時順序節點的狀態變化,當消失事件發生時,Watcher 監聽器邏輯會回調客戶端 B,B 重新開始獲取鎖。注意此時不是 B 再次創建節點,而是獲取 "/Lock" 下的臨時順序節點,發現自己的順序值最小,那麼就加鎖成功。

如果有 C、D 甚至更多的客戶端同時搶佔,原理都是一致的,他們會依次排隊,監聽自己之前 (節點順序值次小於自己) 的節點,等待他們的狀態發生變化時,再去重新獲取鎖。

這裏使用臨時順序節點和 Watcher 機制實現了一個公平分佈式鎖,還有很多其他用法,如只使用臨時節點實現非公平分佈式鎖,篇幅所限,讀者可以自行探索。

三、深入 ZooKeeper 一致性協議原理

上圖是 ZooKeeper 的整體架構,ZooKeeper Service 是服務端集羣,也是整個組件的核心,客戶端的讀寫請求都是它來處理。ZK 下載安裝章節模擬的 zk1/zk2/zk3 就可以認爲是一個 ZK 服務端集羣,我們在 zk2 中寫入的節點值,在 zk1 和 zk3 實例中也能讀到這個節點值,zk2 會話退出後臨時節點在其他服務器上也同樣消失了,ZK 服務端是通過什麼機制實現數據在各個節點之間的同步,從而保證一致性?當有節點出現故障時又是如何保證正常提供對外服務?這就涉及到 ZooKeeper 的核心 - 分佈式一致性原理。

1 ZooKeeper 服務端角色

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

2 一致性協議 - ZAB

ZooKeeper 爲了保證集羣中各個節點讀寫數據的一致性和可用性,設計並實現了 ZAB 協議,ZAB 全稱是 ZooKeeper Atomic Broadcast,也就是 ZooKeeper 原子廣播協議。這種協議支持崩潰恢復,並基於主從模式,同一時刻只有一個 Leader,所有的寫操作都由 Leader 節點主導完成,而讀操作可通過任意節點完成,因此 ZooKeeper 讀性能遠好於寫性能,更適合讀多寫少的場景。

一旦 Leader 節點宕機,ZAB 協議的崩潰恢復機制能自動從 Follower 節點中重新選出一個合適的替代者,即新的 Leader,該過程即爲領導選舉。領導選舉過程,是 ZAB 協議中最爲重要和複雜的過程。

3. ZAB 協議讀寫流程

3.1 ZAB 寫流程
3.1.1 寫 Leader

由上圖可見,通過 Leader 進行寫操作,主要分爲五步:

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

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

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

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

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

注意 Leader 並不需要得到 Observer 的 ACK,即 Observer 無投票權

Leader 不需要得到所有 Follower 的 ACK,只要收到過半的 ACK 即可,同時 Leader 本身對自己有一個 ACK。上圖中有 4 個 Follower,只需其中兩個返回 ACK 即可,因爲 (2+1) / (4+1) > 1/2

Observer 雖然無投票權,但仍須同步 Leader 的數據從而在處理讀請求時可以返回儘可能新的數據 `

3.1.2 寫 Follower

從上圖可見:

3.2 ZAB 讀流程

Leader/Follower/Observer 都可直接處理讀請求,從本地內存中讀取數據並返回給客戶端即可。由於處理讀請求不需要服務器之間的交互,Follower/Observer 越多,整體可處理的讀請求量越大,也即讀性能越好。ZooKeeper官方文檔數據,Client數量1000時,讀寫性能比10:1

4 ZooKeeper Leader 選舉算法

4.1 選舉算法

ZooKeeper 中默認的並建議使用的 Leader 選舉算法是:基於 TCP 的 FastLeaderElection,其他選舉算法被廢棄。集羣模式下 zoo.cfg 配置文件中有參數可配選舉算法:

4.2 FastLeaderElection 選舉參數解析

(1)選舉算法參數 myid:每個 ZooKeeper 服務器,都需要在數據文件夾下創建一個名爲 myid 的文件,該文件包含整個 ZooKeeper 集羣唯一的 ID(整數)。例如,我們第二章中部署的 zk1/zk2/zk3 三個實例,其 myid 分別爲 1、2 和 3,在配置文件中其 ID 與 hostname 必須一一對應,如下所示。在該配置文件中,server. 後面的 id 即爲 myid。該參數在選舉時如果無法通過其他判斷條件選擇 Leader,那麼將該 ID 的大小來確定優先級。

// 集羣配置
server.1=127.0.0.1:8881:7771                 # server.id=host:port:port
server.2=127.0.0.1:8882:7772                 # server.id=host:port:port
server.3=127.0.0.1:8883:7773                 # server.id=host:port:port

zxid:用於標識一次更新操作的 ID。爲了保證順序性,該 zxid 必須單調遞增,因此 ZooKeeper 使用一個 64 位的數來表示,高 32 位是 Leader 的 epoch,從 1 開始,每次選出新的 Leader,epoch 加一。低 32 位爲該 epoch 內的序號,每次有寫操作低 32 位加一,每次 epoch 變化,都將低 32 位的序號重置。這樣保證了 zxid 的全局遞增性。之前看到過有博主使用中國古代的年號來解釋這個字段,非常形象:萬曆十五年,萬曆是 epoch,十五年是序號選票數據結構,每個服務器在進行選舉時,發送的選票包含如下關鍵信息:

struct Vote {
    logicClock  // 邏輯時鐘,表示該服務器發起的第多少輪投票
    state       // 當前服務器的狀態 (LOOKING-不確定Leader狀態 FOLLOWING-跟隨者狀態 LEADING-領導者狀態 OBSERVING-觀察者狀態)
    self_myid     // 當前服務器的myid
    self_zxid   // 當前服務器上所保存的數據的最大zxid
    vote_myid     // 被推舉的服務器的myid
    vote_zxid   // 被推舉的服務器上所保存的數據的最大zxid
}

節點服務器狀態,每個服務器所處的狀態時下面狀態中的一種:

4.3 選舉投票流程

每個服務器的一次選舉流程:

  1. 自增選舉輪次:即 logicClock 加一,ZooKeeper 規定所有有效的投票都必須在同一輪次中。每個服務器在開始新一輪投票時,會先對自己維護的 logicClock 進行自增操作。

  2. 初始化選票:每個服務器在開始進行新一輪的投票之前,會將自己的投票箱清空,然後初始化自己的選票。在初始化階段,每臺服務器都會將自己推選爲 Leader,也就是將票都投給自己。例如:服務器 1、2、3 都投票給自己 (1->1), (2->2),(3->3)。

  3. 發送初始化選票:每個服務器通過廣播將初始化投給自己的票廣播出去,讓其他服務器接收。

  4. 接收外部投票:服務器會嘗試從其它服務器獲取投票,並記入自己的投票箱內。如果無法獲取任何外部投票,則會確認自己是否與集羣中其它服務器保持着有效連接。如果是,則再次發送自己的投票;如果否,則馬上與之建立連接。

  5. 判斷選舉輪次:收到外部投票後,首先會根據投票信息中所包含的 logicClock 來進行不同處理。(1)如果大於當前服務的選票中的選舉次數,那麼則會更新當前服務的 logicClock,並且清空所有收到的選票,再次拿選票和外部投票進行選票的比較,確定是否真的要更改自身的選票,然後重新發送選票信息;(2)如果外部選票的選舉次數小於當前服務實例的選舉次數,那麼直接無視掉這個選票信息,並且繼續發送自身的選票出去;(3)如果外部選票和自身服務實例的選舉次數一致,那麼就需要進入選票之間的比較操作。

  6. 選票 PK:選票 PK 是基於 (self_myid, self_zxid) 與(vote_myid, vote_zxid)的對比。

    (1)外部投票的 logicClock 大於自己的 logicClock,則將自己的 logicClock 及自己的選票的 logicClock 變更爲收到的 logicClock;

    (2)若 logicClock 一致,則對比二者的 vote_zxid,若外部投票的 vote_zxid 比較大,則將自己的票中的 vote_zxid 與 vote_myid 更新爲收到的票中的 vote_zxid 與 vote_myid 並廣播出去,另外將收到的票及自己更新後的票放入自己的票箱。如果票箱內已存在 (self_myid, self_zxid) 相同的選票,則直接覆蓋;

    (3)若二者 vote_zxid 一致,則比較二者的 vote_myid,若外部投票的 vote_myid 比較大,則將自己的票中的 vote_myid 更新爲收到的票中的 vote_myid 並廣播出去,另外將收到的票及自己更新後的票放入自己的票箱。

  7. 統計選票:如果已經確定有過半服務器認可了自己的投票(可能是更新後的投票),則終止投票。否則繼續接收其它服務器的投票。

  8. 更新服務器狀態:投票終止後,服務器開始更新自身狀態。若過半的票投給了自己,則將自己的服務器狀態更新爲 LEADING,否則將自己的狀態更新爲 FOLLOWING。

同時還需要注意的一點是,即使選票超過半數了,選出 Leader 服務實例了,也不是立刻結束,而是等待 200ms,確保沒有丟失其他服務的更優的選票。

5 ZooKeeper 集羣啓動選舉流程圖解

5.1 集羣啓動領導選舉

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

2、更新選票:第一步中各個服務器先投票給自己,並把投給自己的結果廣播給集羣中的其他服務器,這一步其他服務器接收到廣播後開始更新選票操作(如果對此規則不熟悉,可以對照 4.3 選舉投票流程小節),以 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 間的心跳。

5.2 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 狀態。

5.3 Leader 宕機重啓選舉

1、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 狀態。

6 commit 過的數據不丟失

ZK 的數據寫入都是通過 Leader,一條數據寫入過程中,ZK 服務集羣中只有超過一半的服務器返回給 Leader ACK 後,Leader 服務器纔會 Commit 這條消息,同步到每一個節點。已經被過 Leader commit,也就是被過半節點同步過的消息,在 Leader 宕機之後,重新選舉出 Leader 這個消息也不會丟失。但是未被 commit 也就是未被過半節點複製到的消息則會丟失。

四、參考文獻 && 鳴謝

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