搞懂 ZooKeeper 叢集的資料同步
阿新 • • 發佈:2021-03-25
![](https://img2020.cnblogs.com/blog/759200/202101/759200-20210124161622816-1605238160.png)
本文作者:HelloGitHub-老荀
Hi,這裡是 HelloGitHub 推出的 HelloZooKeeper 系列,**免費開源、有趣、入門級的 ZooKeeper 教程**,面向有程式設計基礎的新手。 > 專案地址:https://github.com/HelloGitHub-Team/HelloZooKeeper [前一篇文章](https://mp.weixin.qq.com/s/jV4WJmbZoFUC_4ZOjAwbtA)我們介紹了 ZK 是如何進行持久化的,這章我們將正式學習 Follower 或 Observer 是如何在選舉之後和 Leader 進行資料同步的。 ![](https://img2020.cnblogs.com/blog/759200/202103/759200-20210325013304738-814385578.png) ## 一、選舉完成 經歷了選舉之後,我們的**馬果果**榮耀當選當前辦事處叢集的 Leader,所以現在假設各個辦事處的關係圖是這樣: ![](https://img2020.cnblogs.com/blog/759200/202103/759200-20210325005726451-629151783.png) 我們現在就來說說**馬小云**和**馬小騰**是如何同**馬果果**進行資料同步的。 結束了累人的選舉後,**馬小云**和**馬小騰**以微弱的優勢輸掉了競爭,只能委屈成為 Follower。整理完各自的情緒後,他們要做的第一件事情就是通過話務員上報自己的資訊給**馬果果**,使用了專門的暗號 FOLLOWERINFO, 資料主要有自己的 epoch 和 myid: ![](https://img2020.cnblogs.com/blog/759200/202103/759200-20210325005732638-1716897543.png) 然後是**馬果果**這邊,他收到 FOLLOWERINFO 之後也會進行統計,直到達到半數以上後,綜合各個 Follower 給的資訊會計算出新的 epoch,然後將這個新的 epoch 隨著暗號 LEADERINFO 回發給其他 Follower ![](https://img2020.cnblogs.com/blog/759200/202103/759200-20210325005804359-681935800.png) 然後再回到**馬小云**和**馬小騰**這邊,收到 LEADERINFO 之後將新的 epoch 記錄下來,然後回覆給**馬果果**一個 ACKEPOCH 暗號並帶上自己這邊的最大 zxid,表示剛剛的 LEADERINFO 收到了 ![](https://img2020.cnblogs.com/blog/759200/202103/759200-20210325005809383-170113884.png) 然後**馬果果**這邊也會等待半數以上的 ACKEPOCH 的通知,收到之後會根據各個 Follower 的資訊給出不同的同步策略。關於不同的同步策略,這裡我先入為主的給大家介紹一下: - DIFF,如果 Follower 的記錄和 Leader 的記錄相差的不多,使用增量同步的方式將一個一個寫請求傳送給 Follower - TRUNC,這個情況的出現代表 Follower 的 zxid 是領先於當前的 Leader 的(可能是以前的 Leader),需要 Follower 自行把多餘的部分給截斷,降級到和 Leader 一致 - SNAP,如果 Follower 的記錄和當前 Leader 相差太多,Leader 直接將自己的整個記憶體資料傳送給 Follower 至於採用哪一種策略,是如何進行判斷的,接下來一一進行講解。 ### 1.1 DIFF 每一個 ZK 節點在收到寫請求後,會維護一個寫請求佇列(預設是 500 大小,通過 `zookeeper.commitLogCount` 配置),將寫請求記錄在其中,這個佇列中的最早進入的寫請求當時的 zxid 就是 minZxid(以下簡稱 min),最後一個進入的寫請求的 zxid 就是 maxZxid(以下簡稱 max),達到上限後,會移除最早進入的寫請求,知道了這兩個值之後,我們來看看 DIFF 是怎麼判斷的。 #### 1.1.1 從記憶體中的寫請求佇列恢復 一種情況就是如果當 Follower 通過 ACKEPOCH 上報的 zxid 是在 min 和 max 之間的話,就採用 DIFF 策略進行資料同步。 我們的例子中 Leader 的 zxid 是 99,說明這個儲存 500 個寫請求的佇列根本沒有放滿,所以 min 是 1 max 是 99,很顯然 77 以及 88 是在這個區間內的,那馬果果就會為另外兩位 Follower 找到他們各自所需要的區間,先發送一個 DIFF 給 Follower,然後將一條條的寫請求包裝成 PROPOSAL 和 COMMIT 的順序發給他們 ![](https://img2020.cnblogs.com/blog/759200/202103/759200-20210325005836567-849633774.png) #### 1.1.2 從磁碟檔案 log 恢復 另一種情況是如果 Follower 的 zxid 不在 min 和 max 的區間內時,但當 `zookeeper.snapshotSizeFactor` 配置大於 0 的話(預設是 0.33),會嘗試使用 log 進行 DIFF,但是需要同步的 log 檔案的總大小不能超過當前最新的 snapshot 檔案大小的三分之一(以預設 0.33 為例)的話,才可以通過讀取 log 檔案中的寫請求記錄進行 DIFF 同步。同步的方法也和上面一樣,先發送一個 DIFF 給 Follower 然後從 log 檔案中找到該 Follower 的區間,再一條條的傳送 PROPOSAL 和 COMMIT。 而 Follower 收到 PROPOSAL 的暗號訊息後,就會像處理客戶端請求那樣去一條條處理,慢慢就會將資料恢復成和 Leader 是一致的。 ### 1.2 SNAP 假設現在三個辦事處是這樣的 ![](https://img2020.cnblogs.com/blog/759200/202103/759200-20210325005846021-991174817.png) **馬果果**的寫請求佇列在預設配置下記錄了 277 至 777 的寫請求,又假設現在的場景不滿足上面 1.1.2 的情況,**馬果果**就知道當前需要通過 SNAP 的情況進行同步了。 **馬果果**會先發送一個 SNAP 的請求給**馬小云**和**馬小騰**讓他們準備起來 ![](https://img2020.cnblogs.com/blog/759200/202103/759200-20210325005851638-185523095.png) 緊接著就會當前記憶體中的資料整個序列化(和 snapshot 檔案是一樣的)然後一起傳送給**馬小云**和**馬小騰**。 而**馬小云**和**馬小騰**收到**馬果果**發來的整個 snapshot 之後會先清空自己當前的資料庫的所有資訊,接著直接將收到的 snapshot 反序列化就完成了整個記憶體資料的恢復。 ### 1.3 TRUNC 最後一種策略的場景假設是這樣: ![](https://img2020.cnblogs.com/blog/759200/202103/759200-20210325005857050-771068292.png) 假設**馬小騰**是上一個 Leader,但是經歷了停電以後恢復重新以 Follower 的身份加入叢集,但是他的 zxid 要比 max 還大,這個時候**馬果果**就會給**馬小騰**傳送 TRUNC,(至於圖中為什麼**馬小云**不舉例為 TRUNC,因為如果**馬小云**的 zxid 也比**馬果果**要大的話,**馬果果**在當前場景下就不可能當選 Leader 了)。 **馬果果**就會發送 TRUNC 給**馬小騰**(這裡忽略**馬小云**) ![](https://img2020.cnblogs.com/blog/759200/202103/759200-20210325005905424-390035441.png) 假設**馬小騰**的本地 log 檔案目錄下是這樣的: ``` /tmp └── zookeeper └── log └── version-2 └── log.0 └── log.500 └── log.800 ``` 而**馬小騰**收到 TRUNC 之後,會找到本地 log 檔案中所有大於 777 的 log 檔案刪除,即這裡的 `log.800` ,然後會在 `log.500` 這個檔案找到 777 這個 zxid 記錄並且把當前檔案的讀寫指標修改至 777 的位置,之後針對該檔案的讀寫操作就會從 777 開始,這樣就會把之後的那些記錄給覆蓋了。 --- 而**馬果果**這邊當判斷完同步策略併發送給另外兩馬之後,便會發送一個 NEWLEADER 的資訊給他們 ![](https://img2020.cnblogs.com/blog/759200/202103/759200-20210325005912384-2140163227.png) 而**馬小云**和**馬小騰**在收到 NEWLEADER 之後,若之前是通過 SNAP 方式同步資料的話,這裡會強制快照一份新的 snapshot 檔案在自己這裡。然後會回覆給**馬果果**一個 ACK 的訊息,告訴他自己的同步資料已經完成了 ![](https://img2020.cnblogs.com/blog/759200/202103/759200-20210325005916788-2092426584.png) 然後**馬果果**同樣會等待半數一樣的 ACK 接收完成後,再發送一個 UPTODATE 給其他兩馬,告訴他們現在辦事處資料已經都一致了,可以開始對外提供服務了 ![](https://img2020.cnblogs.com/blog/759200/202103/759200-20210325005923547-85104595.png) 然後**馬小云**和**馬果果**收到 UPTODATE 之後會再回復一個 ACK 給**馬果果**,但是這次**馬果果**收到這次的 ACK 之後不會做處理,所以在 UPTODATE 之後,各個辦事處就已經算可以正式對外提供服務了。 --- 上面說了這麼多,但是**馬小云**和**馬小騰**都是 Follower,如果是 Observer 呢?怎麼用上面的步驟同步呢? 區別就在第一步,Follower 傳送的是 FOLLOWERINFO,而 Observer 傳送的是 OBSERVERINFO 除此之外沒有任何區別,和 Follower 是一樣的步驟進行資料同步。 ## 二、繼續深挖 現在把其中的一些細節再用猿話說明一下,三種不同的資料同步策略,Leader 在傳送 Follower 的時候採用的具體方法是不太相同的 ### 2.1 三種策略傳送方式 如果採用的是 DIFF 或者 TRUNC 的同步方法的話,Leader 其實不是在找到有差異資料的時候傳送過去的,而是按照順序先放入一個佇列,最後再統一啟動一個執行緒去一個個傳送的 DIFF : ![](https://img2020.cnblogs.com/blog/759200/202103/759200-20210325005934116-2058245508.png) TRUNC: ![](https://img2020.cnblogs.com/blog/759200/202103/759200-20210325005939514-1610815872.png) 但是以 SNAP 方式同步的話就不會放入該佇列,無論是 SNAP 訊息還是之後整個序列化後的記憶體快照 snapshot 都會直接通過服務端間的 socket 直接寫入。 ### 2.2 上帝視角 讓我們把三種策略訊息互動的全過程再看一遍,這裡就以**馬小云**舉例了 #### 2.2.1 DIFF ![](https://img2020.cnblogs.com/blog/759200/202103/759200-20210325005945192-1886520908.png) #### 2.2.2 TRUNC ![](https://img2020.cnblogs.com/blog/759200/202103/759200-20210325005956135-1331330008.png) #### 2.2.3 SNAP ![](https://img2020.cnblogs.com/blog/759200/202103/759200-20210325010000745-1870934725.png) --- 可以看到首尾是一樣的,就是中間的請求根據不同的策略會有不同的請求傳送。差不多到這裡關於 Follower 或 Observer 是如何同 Leader 同步訊息,整體的邏輯都介紹完了。 ### 2.3 小結 - Follower 和 Observer 同步資料的方式一共有三種:DIFF、SNAP、TRUNC - DIFF 需要 Follower 或 Observer 和 Leader 的資料相差在 min 和 max 範圍內,或者配置了允許從 log 檔案中恢復 - TRUNC 是當 Follower 或 Observer 的 zxid 比 Leader 還要大的時候,該節點需要主動刪除多餘 zxid 相關的資料,降級至 Leader 一致 - SNAP 作為最後的資料同步手段,由 Leader 直接將記憶體資料整個序列化完併發送給 Follower 或 Observer,以達到恢復資料的目的 我看了下文章的字數還行,決定加一點料,開一個小篇講一下 ACL,這個我拖了很久沒解釋的坑。 ![](https://img2020.cnblogs.com/blog/759200/202103/759200-20210325010027748-1233887669.gif) ## 三、沒有規矩,不成方圓 先帶大家重拾記憶,之前建立節點程式碼片段中的 `ZooDefs.Ids.OPEN_ACL_UNSAFE` 就是 ACL 的引數 ```java client.create("/更新視訊/跳舞/20201101", "這是Data,既可以記錄一些業務資料也可以隨便寫".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT); ``` 首先如果配置了 `zookeeper.skipACL` 該引數為 `yes`(注意大小寫),表示當前節點放棄 ACL 校驗,預設是 `no` 那這個 ACL 是怎麼規定的,有哪些許可權,又是怎麼在服務端體現的呢?首先 ACL 整體分為 Permission 和 Scheme 兩部分,Permission 是針對操作的許可權,而 Scheme 是指定使用哪一種鑑權模式,下面我們一起來了解下。 ### 3.1 許可權 Permission 介紹 首先 ZK 將許可權分為 5 種: - READ(以下簡稱 R),獲取節點資料或者獲取子節點列表 - WRITE(以下簡稱 W),設定節點資料 - CREATE(以下簡稱 C),建立節點 - DELETE(以下簡稱 D),刪除節點 - ADMIN(以下簡稱 A),設定節點的 ACL 許可權 然後該 5 種許可權在程式碼層面就是簡單的 int 資料,而判斷是否有許可權只需要用 & 操作即可,和目標許可權 & 完結果只要不等於 0 就說明擁有該許可權,細節如下: ``` int binary R 1 00001 W 2 00010 C 4 00100 D 8 01000 A 16 10000 ``` 假設現在的客戶端許可權為 RWC,對應的數值就是各個許可權相加 `1 + 2 + 4 = 7` ``` int binary RWC 7 00111 ``` 對任意有 R、W、C 許可權需求的節點,求 & 的結果都不為 0,所以就能判斷該客戶端是擁有 RWC 這 3 個許可權的。 但是如果當該客戶端對目標節點進行刪除時,做 & 判斷許可權的話,可以得到結果為 0,表示該客戶端不具備刪除的許可權,就會返回給客戶端許可權錯誤 ``` int binary RWC 7 00111 D 8 & 01000 ------------------ 結果 0 00000 ``` ### 3.2 Scheme 介紹 Scheme 有 4 種,分別是 `ip`、`world`、`digest`、`super`,但是其實就是兩大類,一種是針對 IP 地址的 ip,另一種是使用類似“使用者名稱:密碼”的 `world`、`digest`、`super`。其實整個 ACL 是分三個部分的,`scheme:id:perms` ,id 的取值取決於 scheme 的種類,這裡是 ip 所以 id 的取值就是具體的 IP 地址,而 perms 則是我上一小節介紹的 RWCDA。 這三部分的前兩部分 `scheme:id` 相當於告訴服務端 “我是誰?”,而最後的部分 `perms` 則是代表了 “我能做什麼?”,這兩個問題,任意一個問題出錯都會導致服務端丟擲 `NoAuthException` 的異常,告訴客戶端許可權不夠。 #### 3.2.1 IP 我們先來直接看一段程式碼,其中的 IP `10.11.12.13` 我是隨便寫的 ```java ZooKeeper client = new ZooKeeper("127.0.0.1:2181", 3000, nul