坐下坐下,基本操作(ZooKeeper 操作篇)

削微寒發表於2021-02-04

本文作者:HelloGitHub-老荀

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

ZooKeeper 是 Apache 軟體基金會的一個軟體專案,它為大型分散式計算提供開源的分散式配置服務、同步服務和命名註冊。 ZooKeeper 曾經是 Hadoop 的一個子專案,但現在是一個頂級獨立的開源專案。

ZK 在實際開發工作中經常會用見到,算的上是吃飯的傢伙了,那可得玩透、用的趁手,要不怎麼進階和升職加薪呢?來和 HelloGitHub 一起學起來吧~

本系列教程是從零開始講解 ZooKeeper,內容從最基礎的安裝使用到背後原理和原始碼的講解,整個系列希望通過有趣文字、詼諧的氣氛中讓 ZK 的知識“鑽”進你聰明的大腦。本教程是開放式:開源、協作,所以不管你是新手還是老司機,我們都希望你可以加入到本教程的貢獻中,一起讓這個教程變得更好

  • 新手:參與修改文中的錯字、病句、拼寫、排版等問題
  • 使用者:參與到內容的討論和問題解答、幫助其他人的事情
  • 老司機:參與到文章的編寫中,讓你的名字出現在作者一欄

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

今天我們會講解下,如何使用 Java 程式碼客戶端去操作 ZK。

一、基本操作

1.1 馬果果的新規定

老規矩,在開始實戰之前呢,我還是講一個小故事(故事中的人物,純屬虛構,請勿對號入座,如有雷同,純屬巧合)。

馬果果自從擔任了辦事處的負責人後,每天那是忙的不可開交,村民有事都來找他,他的小本子上已經密密麻麻記了一大堆:

特別是雞太美,儼然已經成為了日更 UP 主,每天的頻繁更新讓馬果果倍感力不從心,他想,如果再這樣毫無章法的記下去,不但以後自己會越來越累,等自己退休後,別人來交接也會無從下手,那還不得在背後說我管理不當,對著我指指點點。要晚節不保啊,到時候怕不是要給全村人民謝罪。

於是辦事處出臺了新的規定,每次過來登記的村民必須對自己要登記的事務進行分類,而馬果果則根據這些分類去進行記錄,所以馬果果的筆記(以下簡稱:小紅本)就變成了這樣:

但是執行了規定一段時間以後,以雞太美馬小云為首的村民代表又向馬果果提出了:“我們都是老熟人了,每次來都得自報家門,能不能做點便民措施?你這辦事處的宗旨難道不就是服務我們人民群眾的嗎?”

馬果果聽完也覺得很有道理,於是給每一個老熟人都建立了一個標籤。比如以後雞太美過來建立的記錄,都直接放到雞太美的標籤下,這樣雞太美只需要關心自己具體想要記哪些東西就行了,所以筆記本最後變成了這樣:

而對於需要接收到通知到村民也是一樣,馬果果會在需要通知的事務旁備註下,比如雞太美的頭號粉絲坤坤,對雞太美的跳舞視訊十分感興趣,所以在雞太美的跳舞事務旁備註下:

然後拿出另一本本子(以下簡稱:小黃本)把需要通知誰給記下來:

隨著時間推移雞太美的人氣與日俱增,現在連馬小云東東都成了她的粉絲,紛紛都要關注她的更新:

所以現在馬果果當記錄完小紅本後,會看看當前的事務是不是有別人訂閱了通知,如果有的話,會再拿出小黃本去找到對應需要通知的村民,一個個打電話通知他們。

馬果果對自己的這次出臺的規定非常滿意,當面對記者採訪的時候得意的說道,這是自己退休後堅持學習計算機,從計算機的檔案目錄中得到的靈感,人果然還是要「活到老學到老」啊!


小故事講完了,下面用猿話翻譯一下:

ZK 定義了每一個記錄必須有一個對應路徑,這個路徑就是對應小故事中的辦事處規定的分類,而整個記錄的結構的確和 Linux 中的檔案樹類似,有一個根節點 /,節點間有父子關係,路徑用 / 分割,比如:

/雞太美/更新視訊/跳舞/20201101

而故事中的標籤,其實就是客戶端中指定的 chroot,實際上是由客戶端維護的,服務端並不知道。

1.2 程式碼實戰

特別說明接下來的實戰是用官方的 Java 客戶端作為演示的,新建一個空白的 Maven 專案,然後引入 ZK 的依賴:

<dependency>
  <groupId>org.apache.zookeeper</groupId>
  <artifactId>zookeeper</artifactId>
  <version>3.6.2</version>
</dependency>

要操作 ZK 首先得先建立一個客戶端物件,我們以雞太美為例

ZooKeeper client = new ZooKeeper("127.0.0.1:2181/雞太美", 3000, null);

ZooKeeper第一個字串就是連線的服務端地址,/ 後面就是 chroot, 就是小故事裡的標籤,之後該客戶端所有的操作都會以/雞太美作為頂層路徑去處理。

最後當客戶端退出的時候,記得要關閉客戶端噢

client.close();

1.2.1 建立路徑

這裡需要提醒的是,官方的客戶端是沒有遞迴建立的功能的,所以在建立多級路徑的時候,客戶端需要自己確保路徑中的父級節點是存在的!

下面的方法,直接執行是會報錯的,所以需要逐級建立 20201101 的父路徑,最終才能成功,這裡主要是演示結構,而之後的 ZooDefs.Ids.OPEN_ACL_UNSAFE 是一種 ACL 的許可權,意思就是不會進行許可權校驗,關於許可權,之後會有篇幅介紹,這裡直接跳過。

client.create("/更新視訊/跳舞/20201101", "這是Data,既可以記錄一些業務資料也可以隨便寫".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);

最後的 CreateMode.PERSISTENT 代表當前節點是一個持久型別的節點,3.6.2 中一共有 7 種型別,下面列出並且給出簡單解釋:

PERSISTENT											// 持久節點,一旦建立成功不會被刪除,除非客戶端主動發起刪除請求
PERSISTENT_SEQUENTIAL 					// 持久順序節點,會在使用者路徑後面拼接一個不會重複的字增數字字尾,其他同上
EPHEMERAL												// 臨時節點,當建立該節點的客戶端連結斷開後自動被刪除
EPHEMERAL_SEQUENTIAL						// 臨時順序節點,基本同上,也是增加一個數字字尾
CONTAINER												// 容器節點,一旦子節點被刪除完就會被服務端刪除
PERSISTENT_WITH_TTL							// 帶過期時間的持久節點,帶有超時時間的節點,如果超時時間內沒有子節點被建立,就會被刪除
PERSISTENT_SEQUENTIAL_WITH_TTL	// 帶過期時間的持久順序節點,基本同上,多了一個數字字尾

大家可能比較熟悉前四種,對後三種不太熟悉,特別是最後兩種帶 TTL 的型別,這兩種型別在 ZK 預設配置下還是不支援的,需要在 zoo.cfg 配置中新增 extendedTypesEnabled=true 啟用擴充套件功能,否則的話就會收到 Unimplemented for 的錯誤。

示例中路徑建立完就會是這樣:

雞太美
	|--更新視訊
  	|--跳舞
    	|--20201101

1.2.2 刪除路徑

官方的客戶端也不支援遞迴刪除,需要確保刪除的節點是葉子節點,否則就會收到錯誤,我們這裡把 20201101 給刪除:

client.delete("/更新視訊/跳舞/20201101", -1);

-1 是一個 version 欄位,相當於 ZK 提供的樂觀鎖機制,如果是 -1 的話就是無視節點的版本資訊。

刪除完就是這樣:

雞太美
	|--更新視訊
  	|--跳舞

1.2.3 設定資料

每一個節點都可以擁有自己的資料,既可以通過建立的時候指定,也可以在之後通過設定的方式指定。

client.setData("/更新視訊/跳舞", "這是Data,可以寫一些關於業務的引數".getBytes(), -1);

-1 的含義和刪除路徑中是一樣的,也是無視版本資訊。

1.2.4 判斷路徑是否存在

由於建立和刪除都不支援遞迴,所以需要對目標路徑進行判斷是否存在來決定是否進行下一步

Stat stat = client.exists("/更新視訊", false);
System.out.println(stat != null ? "存在" : "不存在"); // 存在

false 意思是不進行訂閱,關於訂閱之後會一起說。

1.2.5 獲取資料

能設定資料,必然也能獲取資料,所以 ZK 可以偶爾客串一下資料儲存的角色

byte[] data = client.getData("/更新視訊/跳舞", false, null);
System.out.println(new String(data)); // 這是Data,可以寫一些關於業務的引數

1.2.6 獲取子節點列表

前面說了 ZK 是一個樹形的結構,有父子節點概念,所以可以查詢某一個節點下面的所有子節點

List<String> children = client.getChildren("/更新視訊", false);
System.out.println(children); // [跳舞]

1.2.7 設定訂閱

上面介紹的三個方法:判斷路徑是否存在、獲取資料、獲取子節點列表,這三種方法(包括他們的過載方法),都可以對路徑進行訂閱,訂閱的方式有兩種:

  • 傳遞一個 boolean 值,如果使用此方式的話,回撥物件就是建立 ZooKeeper 時的第三個引數 defaultWatcher,只不過之前示例中是 null
  • 直接在方法中傳入一個 Watcher 的實現類,此實現類會作為此路徑之後的回撥物件(推薦)

下面分別演示下:

// 方式1
ZooKeeper client = new ZooKeeper("127.0.0.1:2181/雞太美", 3000, new Watcher() {
  // 這個就是 defaultWatcher 引數,是當前客戶端預設的回撥實現
  @Override
  public void process(WatchedEvent event) {
    System.out.println("這是本客戶端全域性的預設回撥物件");
	}
});
// exists
client.exists("/更新視訊", true);
// getData
client.getData("/更新視訊/跳舞", true, null);
// getChildren
client.getChildren("/更新視訊", true);
// 方式2
// exists
client.exists("/更新視訊", new Watcher() {
  @Override
  public void process(WatchedEvent event) {
    System.out.println("我是回撥物件的實現");
  }
});
// getData
client.getData("/更新視訊/跳舞", new Watcher() {
  @Override
  public void process(WatchedEvent event) {
    System.out.println("我是回撥物件的實現");
  }
}, null);
// getChildren
client.getChildren("/更新視訊", new Watcher() {
  @Override
  public void process(WatchedEvent event) {
    System.out.println("我是回撥物件的實現"); 
  }
});

至於回撥是怎麼每次能觸發到對應的方法的,這裡就賣個關子,之後會有文章詳細解釋。

關於 ZK 客戶端的操作大致就這麼幾種,限於篇幅我也無法一一舉例,本系列文章目的也不是作為官方文件的翻譯,重要的還是能激發出大家對於技術的熱情,剩下的那些使用情況就當我給大家的課後練習題吧~關於 ZK 的基本操作就講完了。

二、進階操作

整完了基本操作,我們們再來整點高階的。

2.1 雞太美的籤售會

我們繼續先說說動物村發生的故事。

隨著直播的人氣和關注數的日益增長,雞太美儼然已經成為了動物村的大明星,都出專輯了,所以準備回饋下粉絲辦場籤售會。

決定在馬果果的辦事處前佈置場地,由身強體壯的太極宗師馬果果擔任保安保證現場的秩序

馬果果說了想要進去和雞太美一對一粉絲見面會的,需要拿走我手中的憑證,在簽完名後趕緊出來,還要把這個憑證歸還給我。

對不起,放錯圖了

雞太美的粉絲們聽到可以和明星一對一見面,都瘋了,都火急火燎的趕到了辦事處的門口,不知道誰在人群中大喊了一聲:“搶啊!先到先得啊!”,都像餓虎撲食一般把馬果果撲倒在地,場面相當混亂!

最後是由雞太美的鐵桿粉絲坤坤拔得頭籌,搶下了馬果果手中的唯一憑證,換到了和明星偶像一對一的機會

坤坤捧著手中心愛的專輯,心滿意足的回去了。

重新拿回憑證的馬果果,看著眼前這一群餓狼

頓時明白了自己接下來要面對的...

( 四小時以後 )

終於,所有的粉絲都拿著手中還熱乎的專輯高高興興的回家去了。忙碌了一天的馬果果心想下次可不能這樣,要不是我老當益壯怕不是要被抬進醫院!

2.2 雞太美的演唱會

雞太美的粉絲數量終於突破了 100w !是時候找個理由再營銷自己一波了,於是就和經紀公司商量能不能辦一個演唱會,現場賣票,既可以為自己造勢也可以滿足下粉絲見面的要求。這次同樣的找到了馬果果,希望馬果果能繼續幫忙組織下現場的秩序,高風亮節的馬果果本來不想再接這些雜活了,但是聽到了經紀公司開出的價錢後...

但是自己畢竟年事已高,可經不起上次的那樣折騰了,於是決定這次出臺一個新的規定來應對之後粉絲瘋狂的行為,新場地佈置成這樣:

每一個粉絲都得先去馬果果那裡拿一個從小到大的號碼,馬果果每次從 1 開始發,一邊發一邊還要叫,從最小的號碼開始叫,叫到號碼的粉絲才能進售票處買票,每一個粉絲拿到號之後就要關注下排在自己前面一位的情況,如果他買好了,自己趕緊要準備起來,因為下一個就會輪到自己。

就這樣,整個售賣現場井井有條,大家紛紛都誇獎馬果果高超的管理技巧。


小故事又又講完了,下面用猿話翻譯一下:

這兩個小故事講的就是 ZK 分散式鎖的大致原理,並且基本對應了非公平鎖和公平鎖的兩種情況,雖然實際情況和故事中會有出入,但是通過故事希望給大家能有一個感性的認識。

非公平鎖的缺點在故事中也體現了,就是當前一個持有鎖的程式釋放之後,其他所有等待鎖的程式都會被通知,這個就是經常在面試題中提到的“羊群效應”,從而再去爭搶該鎖,但是因為又只有一個程式能搶到鎖,其他的程式會重新繼續等待迴圈下去,所以在應對高併發場景的情況下該方案有較嚴重的效能問題,極大的增大了服務端的壓力。

而公平鎖的話,每一個沒有獲取到鎖的程式往往只需要關心排在它的前一個程式的情況,每次也只有一個程式會被喚醒,所以如果採用 ZK 作為分散式鎖的中介軟體的話,建議採用公平鎖的方式。

2.3 程式碼實戰

下面用簡單的(偽)程式碼演示下,如何使用 ZK 來編寫分散式鎖的邏輯

2.3.1 非公平鎖

假設我現在要鎖的物件是雞太美的演唱會

ZooKeeper client = new ZooKeeper("127.0.0.1:2181", 3000, null);
try {
  // 之前有提過必須保證 雞太美 的路徑存在
  client.create("/雞太美/演唱會", "Data 沒有用隨便寫".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
  // 建立成功的話就是獲取到鎖了, 之後執行業務邏輯
  System.out.println("我是拿到鎖以後的業務邏輯");
  ...
  // 處理完業務記得一定要刪除該節點,表示釋放鎖,實際場景中這一步刪除應該是在 finally 塊中
  client.delete("/雞太美/演唱會", -1);
} catch (KeeperException.NodeExistsException e) {
  // 如果報 NodeExistsException 就是沒獲取到鎖
  System.out.println("鎖被別人獲取了");
  // 對這個節點進行監聽
  client.exists("/雞太美/演唱會", new Watcher() {
    @Override
    public void process(WatchedEvent event) {
      if (event.getType().equals(Event.EventType.NodeDeleted)) {
        // 如果監聽到了刪除事件就是上一個程式釋放了鎖, 嘗試重新獲取鎖
        // 這裡就牽涉到這次再獲取失敗要繼續監聽的遞迴過程, 其實需要一個封裝好的類似 lock 方法,虛擬碼這裡就不繼續演示了
        client.create("/雞太美/演唱會", "Data 沒有用隨便寫".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
        ...
      }
    }
  });
}
...

ZK 的非公平鎖用到了相同路徑無法重複建立加上臨時節點的特性,用臨時節點是因為如果當獲取鎖的程式崩潰後,沒來得及釋放鎖的話會造成死鎖,但臨時節點會在客戶端的連線斷開後自動刪除,所以規避了死鎖的這個風險。

2.3.2 公平鎖

同樣的演唱會,這次換成公平鎖來試試

ZooKeeper client = new ZooKeeper("127.0.0.1:2181", 3000, null);
String currentPath = client.create("/雞太美/演唱會", "Data 沒有用隨便寫".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
// 因為有序號的存在,所以一定會建立成功
// 然後就是獲取父節點下的所有子節點的名稱
List<String> children = client.getChildren("/雞太美", false);
// 先排序
Collections.sort(children);
if (children.get(0).equals(currentPath)) {
  // 當前路徑是最小的那個節點,獲取鎖成功
  System.out.println("我是拿到鎖以後的業務邏輯");
  ...
  // 同樣記得業務處理完一定要刪除該節點
  client.delete(currentPath, -1);
} else {
  // 不是最小節點,獲取鎖失敗
  // 根據當前節點路徑在所有子節點中獲取序號相比自己小 1 的那個節點
  String preNode = getPreNode(currentPath, children);
  // 對該節點進行監聽
  client.exists(preNode, new Watcher() {
    @Override
    public void process(WatchedEvent event) {
      if (event.getType().equals(Event.EventType.NodeDeleted)) {
        // 和非公平鎖一樣,再次嘗試獲取鎖,由於順序節點的緣故,所以此次獲取鎖應該是不會失敗的
        ...
      }
    }
  });
}
...

ZK 的公平鎖用到了臨時順序節點,序號無法重複的特性,當前的最小子節點才視為獲取鎖成功。

2.3.3 Curator Recipes

你們肯定會問上面這兩段程式碼都沒法直接用,如果我想在專案中使用的話怎麼辦呢?

噹噹噹當~這種活開源社群早就有人替我們幹啦

<dependency>
  <groupId>org.apache.curator</groupId>
  <artifactId>curator-recipes</artifactId>
  <version>5.1.0</version>
</dependency>

下面給出簡單例子

RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
CuratorFramework client = CuratorFrameworkFactory.newClient("127.0.0.1:2181", retryPolicy);
client.start();
InterProcessMutex lock = new InterProcessMutex(client, "/lock");
try (Locker locker = new Locker(lock)) {
  // 使用 try-with-resources 語法糖自動釋放鎖
  System.out.println("獲取到鎖後的業務邏輯");
}
client.close();

Curator 內建了幾種鎖給我們使用,並且都可以通過 Locker 包裝使用

  • InterProcessMultiLock 可以同時對幾個路徑加鎖,釋放也是同時的
  • InterProcessMutex 可重入排他鎖
  • InterProcessReadWriteLock 讀寫鎖
  • InterProcessSemaphoreMutex 不可重入排他鎖

如果想看看優秀的 ZK 分散式鎖如何寫的話,直接翻 curator-recipes 它的原始碼吧~

Curator 提供的還不止是分散式鎖,它還提供了分散式佇列,分散式計數器,分散式屏障,分散式原子類等,厲害吧~開源牛逼~

2.3.4 和 Spring Boot 整合

有沒有比上面 Curator 更簡單的呢?當然!

我已經為你準備好了一個示範專案:

事先說明,該專案僅僅只是用作演示如何將 Curator 整合進 Spring Boot,一切都是從簡配置,並且也只是演示了分散式鎖這一項功能,其他高階功能,如果有需要,讀者可以自行前往瞭解!

常規 Spring Boot 的依賴我就不展示了,我就列下和 Curator 相關的:

專案使用 Maven 作為依賴管理工具

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-integration</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.integration</groupId>
  <artifactId>spring-integration-zookeeper</artifactId>
</dependency>

一個 @Configuration 物件,用於建立對應的 Bean

@Configuration
public class ZookeeperLockConfiguration {
    @Value("${zookeeper.host:127.0.0.1:2181}")
    private String zkUrl;

    @Bean
    public CuratorFrameworkFactoryBean curatorFrameworkFactoryBean() {
        return new CuratorFrameworkFactoryBean(zkUrl);
    }

    @Bean
    public ZookeeperLockRegistry zookeeperLockRegistry(CuratorFramework curatorFramework) {

        return new ZookeeperLockRegistry(curatorFramework, "/HG-lock");
    }
}

兩個測試用的介面

// 在需要使用鎖的 bean 中直接注入
@Resource
private LockRegistry lockRegistry;

@GetMapping("/lock10")
public String lock10() {
  System.out.println("lock10 start " + System.currentTimeMillis());
  final Lock lock = lockRegistry.obtain("lock");
  try {
    lock.lock();
    System.out.println("lock10 get lock success " + System.currentTimeMillis());
    TimeUnit.SECONDS.sleep(10);
  } catch (Exception e) {
  } finally {
    lock.unlock();
  }
  return "OK";
}

@GetMapping("/immediate")
public String immediate() {
  System.out.println("immediate start " + System.currentTimeMillis());
  final Lock lock = lockRegistry.obtain("lock");
  try {
    lock.lock();
    System.out.println("immediate get lock success " + System.currentTimeMillis());
  } finally {
    lock.unlock();
  }
  return "immediate return";
}

邏輯我稍微講一下,我是先呼叫 lock10 這個介面的,然後再呼叫 immediate 介面。lock10 這個介面獲取鎖後會 sleep 10 秒,而同時 immediate 也會嘗試獲取鎖,但是不會 sleep,假設分散式鎖有效的話,對應的也會等 10 秒,所以可以從控制檯的時間戳看到兩個介面幾乎是同時請求的,但是獲取鎖的時間大概差了 10 秒,證明鎖有效。lockRegistryobtrain 方法字串引數就是對應的業務場景,例如:訂單號、使用者 ID 等,字串相同的話就可以認為是同一把鎖。

另外有那麼一點不嚴謹的地方是我本地的測試只啟動了一個 java 程式,如果讀者需要測試分散式環境的話,只需要修改配置檔案中的啟動埠,即可啟動多個程式用來模擬分散式環境~

lock10 start 1607417328823
lock10 get lock success 1607417328855
immediate start 1607417329943
immediate get lock success 1607417338872

而我在 sleep 的時間,去 ZK 上查了下發現框架會在我們指定的節點下建立兩個臨時節點來控制併發,和我們之前演示的差不多,但具體的細節等待作為讀者的你去挖掘了~(或者自挖一坑?)

/
|--zookeeper
|--HG-lock
  |--lock
     |--_c_41d75f28-2346-4cf2-89e8-accccce9ad1a-lock-0000000000
     |--_c_98549447-0ee4-4c93-8194-4ed428225f75-lock-0000000001

更多關於示例的細節(其實也沒什麼細節,是個特別簡單的專案),可以直接訪問上面的專案地址檢視原始碼。

三、總結

本文使用故事和實戰講解了下,ZK 的基本操作和一部分進階操作。下一篇就會進入原理篇了,我會介紹 ZK 的服務端是如何處理每次的請求。


關注 HelloGitHub 公眾號 收到第一時間的更新。

還有更多開源專案的介紹和寶藏專案等待你的發掘。