搞懂 ZooKeeper 叢集的資料同步

削微寒發表於2021-03-25

本文作者:HelloGitHub-老荀

Hi,這裡是 HelloGitHub 推出的 HelloZooKeeper 系列,免費開源、有趣、入門級的 ZooKeeper 教程,面向有程式設計基礎的新手。

專案地址:https://github.com/HelloGitHub-Team/HelloZooKeeper

前一篇文章我們介紹了 ZK 是如何進行持久化的,這章我們將正式學習 Follower 或 Observer 是如何在選舉之後和 Leader 進行資料同步的。

一、選舉完成

經歷了選舉之後,我們的馬果果榮耀當選當前辦事處叢集的 Leader,所以現在假設各個辦事處的關係圖是這樣:

我們現在就來說說馬小云馬小騰是如何同馬果果進行資料同步的。

結束了累人的選舉後,馬小云馬小騰以微弱的優勢輸掉了競爭,只能委屈成為 Follower。整理完各自的情緒後,他們要做的第一件事情就是通過話務員上報自己的資訊給馬果果,使用了專門的暗號 FOLLOWERINFO, 資料主要有自己的 epoch 和 myid:

然後是馬果果這邊,他收到 FOLLOWERINFO 之後也會進行統計,直到達到半數以上後,綜合各個 Follower 給的資訊會計算出新的 epoch,然後將這個新的 epoch 隨著暗號 LEADERINFO 回發給其他 Follower

然後再回到馬小云馬小騰這邊,收到 LEADERINFO 之後將新的 epoch 記錄下來,然後回覆給馬果果一個 ACKEPOCH 暗號並帶上自己這邊的最大 zxid,表示剛剛的 LEADERINFO 收到了

然後馬果果這邊也會等待半數以上的 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 的順序發給他們

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

假設現在三個辦事處是這樣的

馬果果的寫請求佇列在預設配置下記錄了 277 至 777 的寫請求,又假設現在的場景不滿足上面 1.1.2 的情況,馬果果就知道當前需要通過 SNAP 的情況進行同步了。

馬果果會先傳送一個 SNAP 的請求給馬小云馬小騰讓他們準備起來

緊接著就會當前記憶體中的資料整個序列化(和 snapshot 檔案是一樣的)然後一起傳送給馬小云馬小騰

馬小云馬小騰收到馬果果發來的整個 snapshot 之後會先清空自己當前的資料庫的所有資訊,接著直接將收到的 snapshot 反序列化就完成了整個記憶體資料的恢復。

1.3 TRUNC

最後一種策略的場景假設是這樣:

假設馬小騰是上一個 Leader,但是經歷了停電以後恢復重新以 Follower 的身份加入叢集,但是他的 zxid 要比 max 還大,這個時候馬果果就會給馬小騰傳送 TRUNC,(至於圖中為什麼馬小云不舉例為 TRUNC,因為如果馬小云的 zxid 也比馬果果要大的話,馬果果在當前場景下就不可能當選 Leader 了)。

馬果果就會傳送 TRUNC 給馬小騰(這裡忽略馬小云

假設馬小騰的本地 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 的資訊給他們

馬小云馬小騰在收到 NEWLEADER 之後,若之前是通過 SNAP 方式同步資料的話,這裡會強制快照一份新的 snapshot 檔案在自己這裡。然後會回覆給馬果果一個 ACK 的訊息,告訴他自己的同步資料已經完成了

然後馬果果同樣會等待半數一樣的 ACK 接收完成後,再傳送一個 UPTODATE 給其他兩馬,告訴他們現在辦事處資料已經都一致了,可以開始對外提供服務了

然後馬小云馬果果收到 UPTODATE 之後會再回復一個 ACK 給馬果果,但是這次馬果果收到這次的 ACK 之後不會做處理,所以在 UPTODATE 之後,各個辦事處就已經算可以正式對外提供服務了。


上面說了這麼多,但是馬小云馬小騰都是 Follower,如果是 Observer 呢?怎麼用上面的步驟同步呢?

區別就在第一步,Follower 傳送的是 FOLLOWERINFO,而 Observer 傳送的是 OBSERVERINFO 除此之外沒有任何區別,和 Follower 是一樣的步驟進行資料同步。

二、繼續深挖

現在把其中的一些細節再用猿話說明一下,三種不同的資料同步策略,Leader 在傳送 Follower 的時候採用的具體方法是不太相同的

2.1 三種策略傳送方式

如果採用的是 DIFF 或者 TRUNC 的同步方法的話,Leader 其實不是在找到有差異資料的時候傳送過去的,而是按照順序先放入一個佇列,最後再統一啟動一個執行緒去一個個傳送的

DIFF :

TRUNC:

但是以 SNAP 方式同步的話就不會放入該佇列,無論是 SNAP 訊息還是之後整個序列化後的記憶體快照 snapshot 都會直接通過服務端間的 socket 直接寫入。

2.2 上帝視角

讓我們把三種策略訊息互動的全過程再看一遍,這裡就以馬小云舉例了

2.2.1 DIFF

2.2.2 TRUNC

2.2.3 SNAP


可以看到首尾是一樣的,就是中間的請求根據不同的策略會有不同的請求傳送。差不多到這裡關於 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,這個我拖了很久沒解釋的坑。

三、沒有規矩,不成方圓

先帶大家重拾記憶,之前建立節點程式碼片段中的 ZooDefs.Ids.OPEN_ACL_UNSAFE 就是 ACL 的引數

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 種,分別是 ipworlddigestsuper,但是其實就是兩大類,一種是針對 IP 地址的 ip,另一種是使用類似“使用者名稱:密碼”的 worlddigestsuper。其實整個 ACL 是分三個部分的,scheme:id:perms ,id 的取值取決於 scheme 的種類,這裡是 ip 所以 id 的取值就是具體的 IP 地址,而 perms 則是我上一小節介紹的 RWCDA。

這三部分的前兩部分 scheme:id 相當於告訴服務端 “我是誰?”,而最後的部分 perms 則是代表了 “我能做什麼?”,這兩個問題,任意一個問題出錯都會導致服務端丟擲 NoAuthException 的異常,告訴客戶端許可權不夠。

3.2.1 IP

我們先來直接看一段程式碼,其中的 IP 10.11.12.13 我是隨便寫的

ZooKeeper client = new ZooKeeper("127.0.0.1:2181", 3000, null);
List<ACL> aclList = new ArrayList<>();
aclList.add(new ACL(ZooDefs.Perms.ALL, new Id("ip", "10.11.12.13")));
String path = client.create("/abc", "test".getBytes(), aclList, CreateMode.PERSISTENT);
System.out.println(path); // 輸出 /abc
client.close();

可以看到 /abc 是可以被正確輸出的,而且通過檢視 / 的子節點列表是可以看到 /abc 節點的

ZooKeeper client = new ZooKeeper("127.0.0.1:2181", 3000, null);
List<String> children = client.getChildren("/", false);
System.out.println(children); // 輸出 [abc, zookeeper]
client.close();

但是現在如果去訪問該節點的資料的話就會得到報錯

ZooKeeper client = new ZooKeeper("127.0.0.1:2181", 3000, null);
byte[] data = client.getData("/abc", false, null);
System.out.println(new String(data));
client.close();
Exception in thread "main" org.apache.zookeeper.KeeperException$NoAuthException: KeeperErrorCode = NoAuth for /abc

讀者可以試試把上面的 IP 改成 127.0.0.1 重新建立節點,之後就能正常訪問了,一般生產環境中 IP 模式用的不多(也可能是我用的不多),如果要用 IP 控制訪問的話,通過防火牆白名單之類的手段即可,這個層面我認為不需要 ZK 去管。

3.2.2 World

這個模式應該是最常用的(手動狗頭)

我們還是來看一段程式碼

ZooKeeper client = new ZooKeeper("127.0.0.1:2181", 3000, null);
List<ACL> aclList = new ArrayList<>();
aclList.add(new ACL(ZooDefs.Perms.READ, new Id("world", "anyone"))); // 區別是這行
String path = client.create("/abc", "test".getBytes(), aclList, CreateMode.PERSISTENT);
System.out.println(path); // 輸出 /abc
client.close();

我把 scheme 改成了 World 模式,而 World 模式的 id 取值就是固定的 anyone 不能用其他值,而且我還設定了 perms 為 R,所以這個節點只能讀資料,但不能做其他操作,如果使用 setData 對其進行資料修改的話也會得到許可權的錯誤

ZooKeeper client = new ZooKeeper("127.0.0.1:2181", 3000, null);
Stat stat = client.setData("/abc", "newData".getBytes(), -1); // NoAuth for /abc

現在再回頭看之前的 ZooDefs.Ids.OPEN_ACL_UNSAFE,其實就是 ZK 提供的常用的靜態常量,代表不校驗許可權

Id ANYONE_ID_UNSAFE = new Id("world", "anyone");
ArrayList<ACL> OPEN_ACL_UNSAFE = new ArrayList<ACL>(Collections.singletonList(new ACL(Perms.ALL, ANYONE_ID_UNSAFE)));

3.2.3 Digest

這個就是我們熟悉的使用者名稱密碼了,還是先上程式碼

ZooKeeper client = new ZooKeeper("127.0.0.1:2181", 3000, null);

List<ACL> aclList = new ArrayList<>();
aclList.add(new ACL(ZooDefs.Perms.ALL, 
  new Id("digest", DigestAuthenticationProvider.generateDigest("laoxun:kaixin")))); // 1

String path = client.create("/abc", "test".getBytes(), aclList, CreateMode.PERSISTENT);
System.out.println(path);
client.close();

這個寫法中必須要注意的是 1 處的 username:password 的字串必須通過 DigestAuthenticationProvider.generateDigest 的方法包裝一下,用這個方法會對傳入的字串進行編碼。

包裝完後 laoxun:kaixin 其實變成了 laoxun:/xQjqfEf7WHKtjj2csJh1/aEee8=,這個過程如下:

  • laoxun:kaixin 對整個字串先進行 SHA1 加密
  • 對加密後的結果進行 Base64 編碼
  • 將使用者名稱和編碼後的結果拼接

上面的程式碼還有一種寫法如下,使用 addAuthInfo 在客戶端上下文中新增許可權資訊

ZooKeeper client = new ZooKeeper("127.0.0.1:2181", 3000, null);
client.addAuthInfo("digest", "laoxun:kaixin".getBytes()); // 1. 
List<ACL> aclList = new ArrayList<>();
aclList.add(new ACL(ZooDefs.Perms.ALL, new Id("auth", ""))); // 2. 這裡的 Id 是固定寫法
String path = client.create("/abc", "test".getBytes(), aclList, CreateMode.PERSISTENT);
System.out.println(path);
client.close();

這裡有兩個改動,在 1 處使用 addAuthInfo 的方法可以在當前客戶端的會話中新增 auth 資訊,Digest 的 id 取值為 username:password 直接用明文即可,無論是 username 還是 password 都是自定義的。

然後是查詢程式碼

ZooKeeper client = new ZooKeeper("127.0.0.1:2181", 3000, null);
client.addAuthInfo("digest", "laoxun:kaixin".getBytes()); // 這行如果註釋的話就會報錯
byte[] data = client.getData("/abc", false, null);
System.out.println(new String(data)); // test

不管建立的時候是何種寫法,查詢的時候都要使用 addAuthInfo 在會話中新增許可權資訊,才能對該節點進行查詢

3.2.4 Super

聽名字就知道這個模式是管理員的模式了,因為之前建立的那些節點,如果設定了使用者名稱密碼,其他客戶端是無法訪問的,如果該客戶端自己退出了,這些節點就無法去操作了,所以需要管理員這一個角色來對其進行降維打擊。

首先 Super 模式是要開啟的,我這裡假設管理員的使用者名稱為 HelloZooKeeper,密碼為 niubi,經過編碼後就是 HelloZooKeeper:PT8Sb6Exg9YyPCS7fYraLCsqzR8=, 然後需要在服務端啟動的環境中指定 zookeeper.DigestAuthenticationProvider.superDigest 配置,引數就是 HelloZooKeeper:PT8Sb6Exg9YyPCS7fYraLCsqzR8= 即可。

建立節點假設還是以 laoxun:kaixin 的模式,然後通過管理員的密碼也能進行正常的訪問

ZooKeeper client = new ZooKeeper("127.0.0.1:2181", 3000, null);
client.addAuthInfo("digest", "HelloZooKeeper:niubi".getBytes()); // 1.
byte[] data = client.getData("/abc", false, null);
System.out.println(new String(data)); // test
client.close();

這裡可以看到 1 處的 Super 模式本質上還是 Digest,指定的 scheme 為 digest,然後之後的 id 取值採用的是明文,而非編碼後的格式,切記!

3.3 Permission 彙總表格

我這裡列出大部分服務端提供的操作對應的 Permission 許可權:

操作 所需許可權 描述
create 父節點的 CREATE 建立節點
create2 父節點的 CREATE 建立節點,同時返回節點資料
createContainer 父節點的 CREATE 建立容器節點
createTTL 父節點的 CREATE 建立帶超時時間的節點
delete 父節點的 DELETE 刪除節點
setData 當前節點的 WRITE 設定節點資料
setACL 當前節點的 ADMIN 設定節點的許可權資訊
reconfig 當前節點的 WRITE 重新設定一些配置(之後有機會介紹)
getData 當前節點的 READ 查詢節點資料
getChildren 當前節點的 READ 獲取子節點列表
getChildren2 當前節點的 READ 獲取子節點列表
getAllChildrenNumber 當前節點的 READ 獲取所有子節點(包含孫子節點)數量
getACL 當前節點的 ADMIN 或 READ 獲取節點的許可權資訊

可以看到刪除和建立節點看的是父節點的許可權,只有讀寫才是看的自己本身的許可權。另外如果表格中沒有出現的操作可以認為不需要 ACL 許可權校驗,其他要麼是隻需要客戶端是一個合法的 session 或者本身是一些比較特殊的功能,例如:createSession、closeSession 等。至於關於 session 的更多內容,留到下一篇再講吧~哈哈

3.4 ACL 背後的原理

我們剛剛花了一點篇幅介紹了 ACL 是什麼,怎麼用?現在深入瞭解下 ACL 在 ZK 的服務端底層是怎麼去實現的吧~為了節約篇幅,這次就直接進入猿話講解了。

首先祭出之前的一張圖,喚醒下大家的記憶

圖中許可權部分(藍色字型)之前的文章直接省略跳過了,沒有進行解釋,今天我們就好好講講這個許可權欄位。

從圖上也能看到許可權這個欄位是直接以數字(long 型別,64 位的整型數字)的方式儲存在服務端的節點中的,而 -1 是一個特殊的值代表不進行許可權的校驗對應的就是之前的 OPEN_ACL_UNSAFE 常量。

而 ACL 許可權無論是建立節點時提供的(ACL 引數是一個 List),還是通過 addAuth 方法提供的(這個方法可以被呼叫多次),這兩種設計都表示一個客戶端是可以擁有多種許可權的,比如:多個使用者名稱密碼,多個 IP 地址等等。

ACL 我之前講過是由 3 個部分組成的,即 scheme:id:perms 為了簡潔的表示我會在之後使用該形式去表示一個 ACL。

服務端會使用兩個雜湊表把目前接收到的 ACL 列表和其對應的數字雙向的關係儲存起來,類似這樣(圖中的 ACL 取值是我隨意編造的):

ZK 服務端會維護一個從 1 開始的數字,收到一個新的 ACL 會同時放入這兩個雜湊表(原始碼中對應的就是兩個 Map,一個是 Map<List<ACL>, Long>,一個是 Map<Long, List<ACL>>),除了這兩個雜湊表以外,ZK 服務端還為每一個客戶端都維護了一個會話中的許可權資訊,該許可權資訊就是客戶端通過 addAuth 新增的,但是這個客戶端的許可權資訊只儲存了 scheme:id 部分,所以結合以下三個資訊就可以對客戶端的本次操作進行許可權校驗了:

  • 兩個雜湊表表示的節點的資訊 scheme:id:perms,可以有多個
  • 客戶端會話上下文中的許可權資訊僅 id:perms ,可以有多個
  • 本次操作對應的許可權要求,即 3.3 表格中列出的所需許可權

校驗的流程如下:

這裡額外提一下,校驗器是可以自定義的,使用者可以自定義自己的 scheme 以及自己的校驗邏輯,需要在服務端的環境變數中配置以 zookeeper.authProvider. 開頭的配置,對應的值則對應一個 class 類全路徑,這個類必須實現 org.apache.zookeeper.server.auth.AuthenticationProvider 介面,而且這個類必須能被 ZK 服務端載入到,這樣就可以解析自定義的 scheme 控制整個校驗邏輯了,這個功能比較高階,我也沒用過,大家就當補充知識瞭解下~


今天我們瞭解了 Follower 和 Observer 是如何同 Leader 進行資料同步的,以及 ZK 提供的許可權管理 ACL 究竟是怎麼回事,下一篇我們將聊聊 ZK 的 session 管理,客戶端和服務端之間是怎麼保持會話的,以及服務端不同節點之間的心跳又是怎麼保持的?

最後給文章點個贊吧~什麼?你說不想點?

老規矩,如果你有任何對文章中的疑問也可以是建議或者是對 ZK 原理部分的疑問,歡迎來倉庫中提 issue 給我們,或者來語雀話題討論。

地址:https://www.yuque.com/kaixin1002/yla8hz

相關文章