從原始碼級別深挖Zookeeper監聽機制

eddieVim發表於2020-12-03

從原始碼級別深挖Zookeeper監聽機制

監聽機制是Zookeeper的一個重要特性,例如:Zookeeper實現的高可用叢集、分散式鎖,就利用到了這一特性。

在Zookeeper被監聽的結點物件/資訊發生了改變,就會觸發監聽機制,通知註冊者。

註冊監聽機制

建立客戶端,建立預設監聽器

在建立zookeeper客戶端例項時,需要下列引數。

new ZooKeeper(String connectString, int sessionTimeout, Watcher watcher)

三個引數分別的含義為:

connectString 服務端地址
sessionTimeout:超時時間
Watcher:監控器

這個 Watcher 將作為整個 ZooKeeper 會話期間的上下文 ,一直被儲存在客戶端 ZKWatchManager 的 defaultWatcher 中,在開啟對某個節點或資訊的監控後,但是並沒有指定額外的監控器,則會預設呼叫這個監控器的方法。

對指定結點進行特殊監聽處理

除此之外,ZooKeeper 客戶端也可以通過 getData、exists 和 getChildren 三個介面來向 ZooKeeper 伺服器註冊 Watcher,從而方便地在不同的情況下新增 Watch 事件:

getData(String path, Watcher watcher, Stat stat)

Zookeeper只能在成功連線上客戶端後,才能使得監控機制起作用;且僅支援4種事件的監聽。

  1. 結點的增加
  2. 結點的刪除
  3. 結點所攜帶資訊的更改
  4. 結點的子結點的更改

底層原理

Zookeeper監聽機制是觀察者模式實現的。

Zookeeper監聽機制

在觀察者模式中,最重要的一個屬性就是需要一個列表用於儲存觀察者。

而在Zookeeper監聽機制中,也實現了這個一個列表,在客戶端和服務端分別維護了ZKWatchManagerWatchManager

客戶端Watch註冊實現過程

在傳送一個Watch事件的會話請求時,Zookeeper客戶端主要做了兩件事

  • 標記該會話是一個帶有 Watch 事件的請求
  • 將 Watch 事件儲存到 ZKWatchManager

以 getData 介面為例。當傳送一個帶有 Watch 事件的請求時,客戶端首先會把該會話標記為帶有 Watch 監控的事件請求,之後通過 DataWatchRegistration 類來儲存 watcher 事件和節點的對應關係:

public byte[] getData(final String path, Watcher watcher, Stat stat){
    ...
    WatchRegistration wcb = null;
    // 如果watcher不為null,即有watcher物件
    if (watcher != null) {
        wcb = new DataWatchRegistration(watcher, clientPath);
    }
    RequestHeader h = new RequestHeader();
    // 標記請求為帶有監聽器的
    request.setWatch(watcher != null);
    ...
    GetDataResponse response = new GetDataResponse();
    ReplyHeader r = cnxn.submitRequest(h, request, response, wcb);
}

之後客戶端向伺服器傳送請求時,是將請求封裝成一個 Packet 物件,並新增到一個等待傳送佇列 outgoingQueue 中:

public Packet queuePacket(RequestHeader h, ReplyHeader r,...) {
    Packet packet = null;
    ...
    packet = new Packet(h, r, request, response, watchRegistration);
    ...
    outgoingQueue.add(packet); 
    ...
    return packet;
}

最後,ZooKeeper 客戶端就會向伺服器端傳送這個請求,完成請求傳送後。呼叫負責處理伺服器響應的 SendThread 執行緒類中的 readResponse 方法接收服務端的回撥,並在最後執行 finishPacket()方法將 Watch 註冊到 ZKWatchManager 中:

private void finishPacket(Packet p) {
    int err = p.replyHeader.getErr();
    if (p.watchRegistration != null) {
        p.watchRegistration.register(err);
    }
    ...
}

服務端 Watch 註冊實現過程

Zookeeper 服務端處理 Watch 事件基本有 2 個過程:

  • 解析收到的請求是否帶有 Watch 註冊事件
  • 將對應的 Watch 事件儲存到 WatchManager

服務端 Watch 事件的觸發過程

以 setData 介面即“節點資料內容發生變更”事件為例。

在 setData 方法內部執行完對節點資料的變更後,會呼叫 WatchManager.triggerWatch 方法觸發資料變更事件。

Set<Watcher> triggerWatch(String path, EventType type...) {
    WatchedEvent e = new WatchedEvent(type,
                                      KeeperState.SyncConnected, path);
    Set<Watcher> watchers;
    synchronized (this) {
        watchers = watchTable.remove(path);
        ...
            for (Watcher w : watchers) {
                Set<String> paths = watch2Paths.get(w);
                if (paths != null) {
                    paths.remove(path);
                }
            }
    }
    for (Watcher w : watchers) {
        if (supress != null && supress.contains(w)) {
            continue;
        }
        w.process(e);
    }
    return watchers;
}
watcherspaths的關係:

雙向繫結關係。

由於zk的監聽機制是一次性的(觸發即銷燬),當path2觸發了監聽事件後,立馬從watchTable中銷燬監聽事件,獲取watchers;並且path2結點的事件已經出發了,所以也要將每個watcher對應的paths中去除path2;然後呼叫watchers中每個watcherprocess()函式完成一次監聽回撥。

客戶端回撥的處理過程

SendThread

此方法是客戶端用於處理服務端的統一請求,replyHdr.getXid()值為-1時,則響應為通知型別的資訊,最後呼叫eventThread.queueEvent()將事件交由eventThread處理。

if (replyHdr.getXid() == -1) {
    ...
    WatcherEvent event = new WatcherEvent();
    event.deserialize(bbia, "response");
    ...
    if (chrootPath != null) {
        String serverPath = event.getPath();
        if(serverPath.compareTo(chrootPath)==0)
            event.setPath("/");
            ...
            event.setPath(serverPath.substring(chrootPath.length()));
            ...
    }
    WatchedEvent we = new WatchedEvent(event);
    ...
    eventThread.queueEvent( we );
}

EventThread

根據觸發的事件型別,去監聽器列表查詢對應的路徑所對應的監聽器,並統一放到集合result中,由於Zookeeper事件是一次觸發即銷燬,所以也要從watchManager中移除監聽器。

public Set<Watcher> materialize(...)
{
	Set<Watcher> result = new HashSet<Watcher>();
	...
	switch (type) {
    ...
	case NodeDataChanged:
	case NodeCreated:
	    synchronized (dataWatches) {
	        addTo(dataWatches.remove(clientPath), result);
	    }
	    synchronized (existWatches) {
	        addTo(existWatches.remove(clientPath), result);
	    }
	    break;
    ....
	}
	return result;
}

完成了對監聽器的取出後,將查詢到Watcher放到對應的waitEvents任務佇列中,呼叫 EventThread 類中的 run 方法對事件進行處理。

而處理事件,無非就是執行我們註冊事件時,寫下的process()函式。

總結

Zookeeper的監聽機制是基於觀察者模式設計的。其方式就是通過在客戶端和服務端都維護一張表(zkWatcherManagerwatcherManager),用於存放監聽器物件。

註冊監聽器過程,就是在呼叫介面的過程,將監聽器進行註冊,首先在本地客戶端進行一個註冊管理,然後傳遞服務端之後,又根據是否含有監聽器,在服務端進行註冊管理。

觸發監聽事件的過程:

  1. 服務端,通過觸發的路徑path,通過watcherManager找到對應的監聽器集合,通過呼叫process()方法將資訊傳送至每個監聽器原來的客戶端;
  2. 客戶端,通過判斷是否是通知事件,通過zkWatcherManager找到對應的監聽器集合,通過呼叫process()方法將執行對應的應答處理。

ZK監聽機制

Watcher的客戶端實現和服務端使用不同的實現

當在監聽事件觸發之後,客戶端和服務端幾乎都做了同樣的事(通過Path找到Watcher然後執行process()),但是他們做的事不同的事,服務端的Watcherprocess()的作用是將path和觸發的event傳送至客戶端,然後再次通過pathevent找到watcher執行process(),這時候,執行的程式碼即為開發者所需要執行的監聽事件對應的應答處理process()

思考 -> 為什麼Zookeeper要維護兩份Watcher清單(zkWatcherManager + WatcherManager)?

使用反證法,來證明這樣設計的優秀之處。

  1. 假設只在客戶端維護Watcher清單,當服務端的事件觸發之後,服務端沒有Watcher清單,不知道是哪幾個客戶端訂閱了這個事件,只能將事件傳送給所有的客戶端,既浪費了頻寬,也浪費了客戶端處理響應的資源。
  2. 假設只在服務端維護Watcher清單,當服務端的事件觸發之後,服務端傳送給訂閱了該事件的客戶端,客戶端確會因為沒有Watcher物件,而無法執行對應的事件應答處理,導致需要服務端將對應的處理方法,通過網路傳遞,則會加重網路的傳輸壓力。

微信公眾號

相關文章