一、ZooKeeper簡介
ZooKeeper是一個分佈式協調服務,提供了諸如數據發佈/訂閱、負載均衡、命名服務、分佈式協調/通知和分佈式鎖等分佈式基礎服務。
1.1、數據結構
ZooKeeper採用znode的樹狀層級結構來存儲信息,znode節點可能包含數據也可能沒有數據,znode存儲數據格式爲字節數組,znode數據結構如下圖示:
ZooKeeper提供瞭如下API:
1、創建znode節點/path幷包含數據data:create/path data
2、刪除節點:delete/path
3、判斷節點是否存在:exists/path
4、設置節點數據:setData/path data
5、獲取節點數據:getData/path
6、獲取節點的子節點:getChildren/path
ZooKeeper讀取或寫入節點數據時不允許局部操作,只允許讀取全部數據或寫入覆蓋全部數據;
1.2、ZooKeeper節點類型
ZooKeeper節點類型包括持久(persistent)節點和臨時(ephemeral)節點
持久節點一旦創建就會持久化,直到通過delete命令刪除節點;
臨時節點創建之後生命週期和客戶端連接生命週期一致,一旦客戶端連接關閉就會自動刪除給客戶端創建的所有臨時節點,當然臨時節點也可以主動刪除。
持久節點可以創建子節點,但是臨時節點不允許創建子節點,因爲臨時節點一旦客戶端連接斷開就會刪除。
有序節點
一個znode還可以設置爲有序(sequential)節點。一個有序znode節 點被分配唯一個單調遞增的整數。當創建有序節點時,一個序號會被追 加到路徑之後。例如,如果一個客戶端創建了一個有序znode節點,
其路徑爲/tasks/task-,那麼ZooKeeper將會分配一個序號,如1,並將這個 數字追加到路徑之後,最後該znode節點爲/tasks/task-1。
有序znode通過 提供了創建具有唯一名稱的znode的簡單方式。同時也通過這種方式可 以直觀地查看znode的創建順序
所以,znode一共有4種類型:持久的(persistent)、臨時的 (ephemeral)、持久有序的(persistent_sequential)和臨時有序的 (ephemeral_sequential)
1.3、監視與通知
客戶端獲取znode信息通常需要遠程調用的方式獲取,但是如果輪訓查詢就會查詢到大量相同的內容或者爲空的數據。所以爲了減少無效的查詢請求,ZooKeeper採用通知機制來替代客戶端輪訓。
客戶端向ZK註冊想要監聽的znode,ZK會對這個znode設置監視點,那麼當被監聽的znode數據發生變化時,ZK會主動通知客戶端。不過監視通知是一次性行爲,也就是通知一次之後就不會再通知,
此時需要再次監視纔會發送新的通知。監視點的類型包括節點數據變化、子節點變化、節點創建和刪除變化等;
由於監視通知爲單次行爲,所以客戶端每次接收更新通知後都需要設置下一次的監視點,這裏就存在一個時間差,在接收到通知到設置新的監視點之間可能數據再一次發生變化,這樣客戶端在設置監視點後就無法獲取
設置監視點之前的更新,所以爲了防止數據丟失,客戶端再每次設置監視點之前都會再一次讀取最新的狀態確保不會丟失更新的數據。
1.4、版本號
ZooKeeper的znode都有一個版本號,每次當znode數據變化版本號都會自增更新,當根據znode進行setData和getData操作時都必須攜帶版本號,只有當版本號一致時纔會操作成功,否則就會操作失敗。
這樣就可以避免併發情況下的數據不一致問題。
1.5、運行模式
ZooKeeper服務器架構模式有獨立模式(Standalone)和仲裁模式(Quorum),也就是單機模式和集羣模式。
單機模式下比較簡單,一臺服務器負責維護所有客戶端讀寫請求並存儲節點數據。
集羣模式下所有節點都複製整個集羣的數據,當集羣數據更新時,只有當超過半數節點更新成功時纔算一次更新成功。
1.6、會話
客戶端和ZK服務器之間的連接叫做會話,客戶端和服務器之間的交互必須建立在會話之上,當會話應該主動關閉或網絡異常斷開時,ZK會刪除此會話創建的臨時節點。同一個會話中的請求是有序的,但是如果一個客戶端
創建多個會話,那麼請求順序是不一定有序的。
客戶端和服務器之間的會話可能有多種狀態,分別爲CONNECTING(連接中)、CONNECTED(已連接)、CLOSED(已關閉)、NOT_CONNECTED(未連接)
會話狀態從NOT_CONNECTED開始,初始化連接時轉爲CONNECTING狀態,連接ZK成功後變成CONNECTED狀態,當和服務器斷開時會轉爲CONNECTING狀態並繼續嘗試連接其他ZK服務器,如果重新連接成功則
轉爲CONENCTED狀態,否則變成CLOSED狀態。
每個會話創建時都會有一個過期時間,如果經過時間t之後服務接收不到這個會話的任何消息,服務就會聲明會話過期。而在客戶端側,如果經過t/3的時間未收到任何消息,客戶端將向服務器發送心跳消息。
在經過2t/3時間後,ZooKeeper客戶端開始尋找其他的服務器,而此時它還有t/3時間去尋找。
tips:
二、ZooKeeper實踐
2.1、ZooKeeper服務啓動
下載zookeeper安裝包解壓並進入bin目錄,執行./zkServer.sh start 命令可以啓動ZooKeeper,客戶端執行./zkCli.sh -server ip:port 連接服務器。
客戶端可執行命令如下:
1 ZooKeeper -server host:port cmd args 2 stat path [watch] 3 set path data [version] 4 ls path [watch] 5 delquota [-n|-b] path 6 ls2 path [watch] 7 setAcl path acl 8 setquota -n|-b val path 9 history 10 redo cmdno 11 printwatches on|off 12 delete path [version] 13 sync path 14 listquota path 15 rmr path 16 get path [watch] 17 create [-s] [-e] path data acl 18 addauth scheme auth 19 quit 20 getAcl path 21 close 22 connect host:port
2.2、Java集成ZooKeeper
<dependency> <groupId>com.101tec</groupId> <artifactId>zkclient</artifactId> </dependency>
測試代碼如下:
1 public static void main(String[] args){ 2 ZkClient zkClient = new ZkClient("localhost:2181"); 3 /** 創建路徑爲first數據爲空的臨時節點*/ 4 zkClient.create("/first", null, CreateMode.EPHEMERAL); 5 while (true){ 6 } 7 }
public static void main(String[] args){ ZkClient zkClient = new ZkClient("localhost:2181"); /** 創建路徑爲first數據爲空的臨時節點*/ zkClient.create("/test", null, CreateMode.PERSISTENT); zkClient.create("/test/first", null, CreateMode.EPHEMERAL); zkClient.create("/test/second", null, CreateMode.EPHEMERAL); zkClient.create("/test/third", null, CreateMode.EPHEMERAL); List<String> subs = zkClient.getChildren("/test"); System.out.println(JSON.toJSONString(subs)); while (true){ } }
上面案例是先創建path爲/test的持久化節點,然後再依次創建/first, /second, /third三個子節點臨時節點,然後通過getChildren方法獲取/test目錄的子節點,打印結果如下:
["third","first","second"]
這裏需要注意,父節點只可以是持久化節點,不可以是臨時節點,只有持久化節點纔可以創建子節點,而臨時節點只能是葉子子節點,不可以在臨時節點下創建子節點。
public static void main(String[] args){ ZkClient zkClient = new ZkClient("localhost:2181"); /** 創建路徑爲first數據爲空的臨時節點*/ zkClient.create("/test/sub", null, CreateMode.EPHEMERAL_SEQUENTIAL); zkClient.create("/test/sub", null, CreateMode.EPHEMERAL_SEQUENTIAL); zkClient.create("/test/sub", null, CreateMode.EPHEMERAL_SEQUENTIAL); List<String> subs = zkClient.getChildren("/test"); System.out.println(JSON.toJSONString(subs)); while (true){ } }
上面案例是在/test目錄下創建三個sub子節點,且子節點爲有序節點,所以子節點名稱會在sub後面增加序號,打印結果如下:
["sub0000000003","third","first","sub0000000004","second","sub0000000005"]
public static void main(String[] args) throws InterruptedException { ZkClient zkClient = new ZkClient("localhost:2181"); zkClient.create("/test/sub", null, CreateMode.EPHEMERAL); /** 訂閱節點數據變化通知 */ zkClient.subscribeDataChanges("/test/sub", new IZkDataListener() { @Override public void handleDataChange(String dataPath, Object data) throws Exception { //監聽數據變化 System.out.println("變化結果爲:" + JSON.toJSONString(data)); } @Override public void handleDataDeleted(String dataPath) throws Exception { } }); Thread.sleep(2000L); new Thread(new Runnable() { @Override public void run() { /** 節點數據寫入 */ zkClient.writeData("/test/sub", "線程:" + Thread.currentThread().getName() + "寫入"); } }).start(); Thread.sleep(5000L); new Thread(new Runnable() { @Override public void run() { /** 節點數據寫入 */ zkClient.writeData("/test/sub", "線程:" + Thread.currentThread().getName() + "寫入"); } }).start(); while (true){ } }
上述案例通過主線程訂閱/test/sub節點數據變化通知,通過IZkDataListener實現類來處理變化結果,然後開啓兩個線程分別調用writeData向/test/sub寫入數據,發現可以通過監聽器獲取到變化的結果,打印結果如下:
變化結果爲:"線程:Thread-1寫入"
變化結果爲:"線程:Thread-2寫入"
三、ZooKeeper實戰
3.1、ZooKeeper實現分佈式鎖
分佈式鎖需要滿足以下功能
1、獲取鎖和釋放鎖
2、鎖同一時間只可以由同一時間獲取
3、鎖被佔用時不可搶佔
4、當佔有鎖的客戶端異常無法獲取鎖時,鎖可以自動釋放
基於分佈式鎖的特性,結合ZooKeeper的節點特性,可以發現基於ZooKeeper就可以實現分佈式鎖
1、通過創建znode和刪除znode實現獲取鎖和釋放鎖,並且znode類型爲臨時節點
2、znode存在時不可用再次創建,保證同一時間只有一個線程獲取鎖,且不可被搶佔
3、當客戶端斷開連接時,創建的臨時節點會自動刪除從而釋放鎖資源
基於ZooKeeper實現分佈式鎖方案如下
方案一:
創建節點進行加鎖,刪除節點釋放鎖,獲取鎖成功的客戶端執行業務邏輯,獲取鎖失敗的客戶端添加監聽器,監聽鎖的狀態變化,當鎖被刪除之後再次嘗試獲取鎖。
如鎖的key爲/test,同時有3個Client嘗試獲取鎖,那麼執行邏輯流程如下:
1、Client1 創建臨時節點 /test 成功,獲取鎖成功;
2、Client2 和 Client 3 創建臨時節點 /test 失敗,獲取鎖失敗;
3、Client2 和 Client 3 開啓監聽器,監聽 /test節點的狀態是否被刪除;
4、Client1 執行業務邏輯,後刪除節點 /test 釋放鎖;
5、Client2 和 Client 3同時監聽到 /test鎖釋放,則再次同時嘗試創建臨時節點/test獲取鎖,此時只有一個Client獲取鎖成功,另一個失敗再次進入監聽等待狀態;
此方案雖然可以滿足分佈式鎖的效果,但是有一個小問題就是當鎖被釋放時,此時可能同時存在大量的等待鎖的客戶端,此時就會同時喚醒大量的客戶端再次獲取鎖,也就是所謂的--驚鴻效應。
大量的客戶端頻繁的嘗試獲鎖失敗又頻繁的進入監聽狀態,浪費系統資源。所以最好的效果是當鎖釋放時,只有1個客戶端嘗試獲取鎖,其他客戶端還是處於監聽狀態,那麼此時就需要將所有等待的客戶端進行排序,
而ZooKeeper的臨時有序節點剛好就可以實現這樣的效果,所以就有了方案二。
方案二:
客戶端根據key創建臨時有序子節點,然後判斷當前創建的子節點的序號是否是所有子節點中最小序號的一個,如果是最小序號的子節點那麼就表示獲取鎖成功,如果不是最小序號的子節點那麼就表示獲取鎖失敗,
此時開啓監聽器監聽上一個節點的刪除事件,只有當上一個節點被刪除了纔會喚醒繼續嘗試獲取鎖,否則就一直處於等待狀態。
所以正常情況下每次當獲取鎖的客戶端釋放鎖刪除子節點後,只會喚醒下一個節點,而其他節點仍然處於監聽狀態,不會被喚醒。這樣就保證了同時只有一個客戶端獲取鎖且每次只會喚醒一個客戶端。
如鎖的key爲 /test,同時有3個Client嘗試獲取鎖,那麼執行邏輯流程如下:
1、Client1、Client2、Client3分別創建臨時節點 /test/0000000001、/test/0000000002、/test/0000000003;
2、Client1判斷當前子節點0000000001是最小的子節點,則獲取鎖成功;
3、Client2 和 Client 3 判斷當前子節點不是最小子節點,那麼開啓監聽器,分別監聽 /test/0000000001和/test/0000000002的刪除事件;
4、Client1執行業務邏輯完成並刪除 /test/0000000001 子節點;
5、Client2 監聽到上一個子節點被刪除,此時再次判斷當前子節點是否爲最小節點,如果是則表示獲取鎖成功;
6、Client2執行業務邏輯完成並刪除 /tests/0000000002 子節點;
7、Client3 監聽到上一個子節點刪除,此時再次判斷當前子節點是否爲最小節點,如果是則表示獲取鎖成功;
總結
基於ZooKeeper實現分佈式鎖,主要是使用了ZooKeeper的臨時有序子節點的特性來實現。臨時節點保證鎖會隨着客戶端斷開而釋放,有序子節點保證獲取鎖的有序性,ZooKeeper創建節點保證線程安全。
另外ZooKeeper的監聽通知機制可以實現等待鎖的客戶端及時感知鎖的釋放事件從而可以及時嘗試獲取鎖。
3.2、ZooKeeper實現註冊中心
註冊中心的核心功能包括服務發佈、服務訂閱、健康檢測以及服務更新推送等功能。
那麼基於ZooKeeper實現註冊中心的整體工作流程如下:
1、服務提供者和服務消費者啓動將本身信息註冊到ZooKeeper;
2、服務提供者發佈服務到ZooKeeper
3、消費者從ZooKeeper訂閱服務,並監聽服務更新事件
4、ZooKeeper感知服務變化,及時推送服務更新事件給消費者
5、ZooKeeper通過檢測客戶端連接狀態來健康檢測
如dubbo分佈式框架,那麼服務發佈和服務訂閱之後,ZooKeeper存儲結果如下:
根目錄下有一個dubbo目錄,dubbo目錄下有元數據metadata目錄和配置config目錄兩個持久節點分別存儲服務元數據和配置,然後針對所有服務分別創建持久子目錄,目錄名稱爲服務全路徑如com.lucky.test.UserService
服務目錄下包含4個持久化子節點,核心是consumers和providers兩個子節點,這兩個子節點下面就是服務消費者和服務提供者對應的臨時節點,分別存儲服務訂閱者列表和服務消費者列表。
工作流程如下:
1、服務提供者發佈服務時在/dubbo/service1/providers節點記錄服務請求地址;
2、服務訂閱者訂閱服務時在/dubbo/service1/consumers節點記錄消費者信息;
3、服務訂閱者獲取/dubbo/service1/providers節點數據得到所有服務提供者對應的請求地址信息;
4、服務訂閱者監聽/dubbo/service1/providers節點的子節點變化通知,當providers子節點信息變化時,zookeeper會發生子節點變化通知給對應的服務消費者;
5、服務訂閱者接收服務提供者變化通知刷新本地緩存;