本文作者:HelloGitHub-老荀
Hi,這里是 HelloGitHub 推出的 HelloZooKeeper 系列,免費開源、有趣、入門級的 ZooKeeper 教程,面向有編程基礎的新手。
ZooKeeper 是 Apache 軟件基金會的一個軟件項目,它為大型分布式計算提供開源的分布式配置服務、同步服務和命名注冊。 ZooKeeper 曾經是 Hadoop 的一個子項目,但現在是一個頂級獨立的開源項目。
ZK 在實際開發工作中經常會用見到,算的上是吃飯的家伙了,那可得玩透、用的趁手,要不怎么進階和升職加薪呢?來和 HelloGitHub 一起學起來吧~
本系列教程是從零開始講解 ZooKeeper,內容從最基礎的安裝使用到背后原理和源碼的講解,整個系列希望通過有趣文字、詼諧的氣氛中讓 ZK 的知識“鑽”進你聰明的大腦。本教程是開放式:開源、協作,所以不管你是新手還是老司機,我們都希望你可以加入到本教程的貢獻中,一起讓這個教程變得更好:
- 新手:參與修改文中的錯字、病句、拼寫、排版等問題
- 使用者:參與到內容的討論和問題解答、幫助其他人的事情
- 老司機:參與到文章的編寫中,讓你的名字出現在作者一欄
今天我們會講解下,如何使用 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 秒,證明鎖有效。lockRegistry
的 obtrain
方法字符串參數就是對應的業務場景,例如:訂單號、用戶 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 公眾號 收到第一時間的更新。
還有更多開源項目的介紹和寶藏項目等待你的發掘。