面試官:你給我講一講,Dubbo暴力停機,消費者是如何感知服務下線的?

語言: CN / TW / HK

本文首發於公眾號【看點代碼再上班】,建議關注公眾號,及時閲讀最新文章。

一定要讀的原文:面試官:你給我講一講,Dubbo暴力停機,消費者是如何感知服務下線的?

大家好,我是tin,這是我的第21篇原創文章

上一篇我們講到了Dubbo服務正常重啟下線時是如何優雅停機的,其中有一個環節就非常重要:

通知註冊中心下線服務。

重啟的服務因為是主動關閉Spring容器,所以有時間也有主動權去告知註冊中心“我要下線了”。

但是,對於暴力停機,比如kill -9或者機器宕機,Dubbo服務又是如何通知到註冊中心的呢?

要想知道真正原因,得從註冊中心的心跳機制聊起。今天就結合zk註冊中心一起看一看,先上一個目錄:

目錄

一、zookeeper配置參數

二、zookeeper的數據存儲結構

三、zookeeper與dubbo的心跳感應

1. client定時發送ping

2. server定時檢測client

四、zk server摘除宕機client節點

五、dubbo消費者摘除宕機client節點

六、結語


一、zookeeper配置參數

我們先來看一看zookeeper的配置參數。還記得如何安裝zookeeper麼?這裏有一份攻略(都大同小異):

https://zhuanlan.zhihu.com/p/466902641

zookeeper的安裝文件目錄結構也比較簡單,如下圖:

​ - bin目錄:zookeeper支持的運行命令集合 ​ 這些命令集合分為windows系統命令和linux系統命令。

以 .cmd 結尾的命令,即是在windows環境上運行使用的命令,以.sh 結尾的命令,即是在linux環境上運行使用的命令。 - conf目錄:存放配置文件,包括日誌配置、zookeeper啟動配置等 ​ 在運行zk之前,必須配置 .cfg。注意,默認是沒有zoo.cfg文件的,但是有zoo_sample.cfg文件,需要重命名為zoo.cfg。

zoo.cfg文件比較重要,zookeeper的心跳間隔參數就在此中,下面是此文件中一些主要參數説明:

1.tickTime: client和server間通信心跳時間,單位是毫秒

zookeeper 客户端與服務器之間建立長連接,並通過心跳機制保持通信,每個 tickTime 時間間隔就會發送一個心跳。tickTime以毫秒為單位,默認是2000毫秒。

tickTime=2000

2.initLimit: LF初始通信時限

這是一個zookeeper集羣參數,表示集羣中的follower服務器(F)與leader服務器(L)之間初始連接時能容忍的最多心跳數(tickTime的數量)。

initLimit=5

3.syncLimit: LF同步通信時限

這是一個zookeeper集羣參數,表示集羣中的follower服務器與leader服務器之間請求和應答之間能容忍的最多心跳數(tickTime的數量)。

syncLimit=2

4.dataDir: zookeeper用於保存內存數據庫的快照的目錄

zookeeper保存數據的目錄,默認情況下,zookeeper將寫數據的事務日誌文件也保存在這個目錄裏。

dataDir=/tmp/zookeeper

5.clientPort: 客户端連接的socket端口

客户端連接server的端口,即對外服務端口,一般設置為2181。

clientPort=2181

  • docs目錄:幫助文檔

​ 包括一些官方説明文檔,比如zookeeperStarted.html文件,打開可以看到zookeeper的安裝啟動向導:

​ - lib目錄:zookeeper需要依賴的jar包

因為zookeeper是用Java開發的,zookeeper依賴的jar包都會放在這個目錄中。

二、zookeeper的數據存儲結構

zookeeper存儲數據的核心數據結構是一個DataTree,源碼是採用一個Map數據結構,其中key是path,DataNode是真正保存數據的核心數據結構。

類似文件系統的目錄結構進行存儲,目錄樹中的每個節點被稱為Znode,Znode既可以存儲數據,也可以擁有自己的子節點。

比如保存dubbo服務信息的結構如下:

​ 總共分為四層,從上到下,分別為group分組、服務全限定名接口、分類、服務節點地址。

上圖右側即是節點Znode的關係依賴,在最底層的是服務節點url地址,格式形如dubbo://com.xx.xx:xxxx?xx=xx。

比如我本地起的服務,最終保存到zookeeper的服務提供者節點信息如下:

dubbo://192.168.31.19:30058/com.tin.example.dubbo.demo.facade.GiftFacade?anyhost=true&application=demo-provider&class=com.tin.example.dubbo.demo.impl.GiftFacadeImpl&deprecated=false&dubbo=2.0.2&dynamic=true&generic=false&interface=com.tin.example.dubbo.demo.facade.GiftFacade&mapping-type=metadata&mapping.type=metadata&metadata-type=remote&methods=give&pid=25334&release=2.7.15&serialization=jackson&service.name=ServiceBean:/com.tin.example.dubbo.demo.facade.GiftFacade&side=provider&telnet=ls,ps,cd,pwd,trace,count,invoke,select,status,log,help,clear,exit,shutdown&timeout=500&timestamp=1650163947650

當然zookeeper保存的url是進行encode編碼後的url(以上是我decode後的結果),如下:

三、zookeeper與dubbo的心跳感應

前面配置説明有説到zookeeper的心跳參數tickTime,不錯,它是zookeeper在server端檢測client存活的時間間隔。

我們把zookeeper源碼下載下來一看究竟。因為我的dubbo版本是2.7.15,依賴的zookeeper版本是3.4.13。

​ 所以我下載zookeeper3.4.13版本。

zookeeper源碼地址,有需自取:https://github.com/apache/zookeeper/tree/branch-3.4.13

在zookeeper的client端(比如集成到dubbo內的zookeeper-client),則定時向zookeeper的server發送ping包,上報自身的健康狀態。

注意,這裏的client端的ping包發送和server端間隔tickTime進行存活檢測不一樣。

client端的ping包發送是輪詢隔一段時間後向server端發送ping請求,其意義是告訴server端,我還活着。

而server端的tickTime間隔時間進行存活檢測意義是檢測client連接對象是否已經無效,如果已經無效則將連接對象進行清除關閉。

1. client定時發送ping

重點邏輯在org.apache.zookeeper.ClientCnxn.SendThread#run中。

SendThread是ClientCnxn的一個內部類,一個SendThread也即是一個線程,它繼承了ZookeeperThread,而ZookeeperThread繼承了java.lang.Thread。

​ 很多框架源碼都很喜歡使用內部類,JDK源碼也經常看到這樣的用法。在我看來,這種用法用好了一定程度上更忠於面向對象編程。

現在的我寫業務代碼也時常會使用內部類,因為它讓邏輯更內聚到一個類內。

ClientCnxn是一個非常重要的一個類,它在zk client啟動的時候生成,主責管理客户端的套接字io,且它維護着可用的zk服務器列表。

​ 回到SendThread的run方法,其內部是一個while循環。我們進入到源碼state.isConnected()片段,如下: ​ 一開始看①處的註釋説避免當讀超時值設置過小時ping發送過於頻繁的問題,但一直不明白是如何做到的。

既然是為了避免readTimeout設置過小導致頻繁發送ping包,為什麼還要readTimeout/2?

認真看了很久才恍然大悟,其實是以下這行代碼的功勞:((clientCnxnSocket.getIdleSend() > 1000) ? 1000 : 0)。

②處代碼是①處的一個補充,就是當客户端超過MAX_SEND_PING_INTERVAL時間(默認10s)沒有發送讀寫請求時,也會向server端發送ping包。

③的sendPing()方法就是向服務端發送一個空包:

④重置last send時間this.lastSend = now。

2. server定時檢測client

在zk的安裝包目錄下,有一個包bin。前面也有講過,這個目錄都是一些命令文件,其中就有一個zkServer.sh文件。

打開zkServer.sh文件,找到zk服務器啟動類。

可以看到,org.apache.zookeeper.server.quorum.QuorumPeerMain類是zk服務器啟動類,它裏面有一個main方法。

接着是調用org.apache.zookeeper.server.quorum.QuorumPeerMain#initializeAndRun方法,

initializeAndRun()方法內又分為集羣模式和單機模式啟動,分別對應①和②處,因為我們本節只為討論server是如何和client保持連接的,所以我們看②處代碼,當然①處代碼也是一樣的。

ZookeeperServerMain最終會實例化一個ZookeeperServer,最後也即是一個zk服務器進程對應一個ZookeeperServer,如下圖:

new出來的ZookeeperServer由zk的一個啟動工廠類負責啟動:

工廠類統一調用ZookeeperServer的startup方法:

org.apache.zookeeper.server.ZooKeeperServer#startupWithServerState方法邏輯很清晰,其中第一步就是初始化SessionTrackerImpl類,見下圖①方法內部實現:

SessionTrackerImpl是一個線程類,它繼承了Thread。

最後tickTime參數傳到ExpireQueue內,別名也即expirationInterval屬性。

SessionTrackerImpl是一個線程類,自然最重要的邏輯也就是在run()方法內了:

在while循環內,①處是不停地取出wait_time,而這個wait_time則和tickTime(也即是expirationTime)有關,如下:

expirationTime是一個long型的包裝類,其value即是由expirationInterval計算而來,如下:

接下來,經過一定的waitTime後,在②處,逐個取出過期的session(zk客户端)並刪除。這就是server定時檢測client的原理。

四、zk server摘除宕機client節點

zk server摘除宕機的client分為兩部分。

第一部分是刪除和client之間的session連接。

第二部分是刪除zk臨時節點。

經過上一節的介紹和源碼分析,在我們看到SessionTrackerImpl類的run()方法時,已經看到zk Server處理過期session的邏輯,如下:

上圖②處代碼即是刪除過期session的源碼,我們先看看sessionExpiryQueue是怎麼管理這些客户端的。

其內部是一個hash數據結構:

  • key是expirationTime;
  • value是一個Set,存放對應此時間過期的session。

ExpiryQueue會定期更新expiryMap,當client的心跳ping包傳輸過來的時候會再更新對應的Session的expirationTime。

所以,zk是通過remove expiryMap中key值為expirationTime的value,即達到刪除過期session的目的。

除了關閉session,還要清除zk node。

打開expirer.expire(s)方法,一直看進去,在close方法提交了一個關閉session的事務請求:

提交close session請求後,這個請求就進入zk的處理鏈中。最後到達FinalRequestProcessor這個處理器。

在處理鏈後半段比較重要的類是DataTree,delete node的源碼即在其內。

看下這個方法:

org.apache.zookeeper.server.DataTree#processTxn(org.apache.zookeeper.txn.TxnHeader, org.apache.jute.Record, boolean)

在killSession內最終會調用DataTree.deleteNode方法,刪除node節點。

到此zk server完成了臨時節點的刪除。

五、dubbo消費者摘除宕機client節點

dubbo服務提供者在zk上註冊了臨時節點,消費者監聽該臨時節點。一旦臨時節點有修改,zk就會通知消費者,消費者進行處理。

對於服務提供者宕機的情況,上文已説明zk server自動刪除臨時節點的邏輯,接下來我們看看消費者又是如何做到摘除client節點的。

分兩部分,第一部分是client啟動時向對應的臨時節點註冊監聽器;第二部分是zk節點有變更時,client接收對應的event並做出相應的處理動作。

dubbo啟動時,會初始化一個zk註冊器ZookeeperRegistry(dubbo源碼),用於服務提供者服務註冊和服務消費者服務訂閲。

下面着重講服務訂閲部分,對應doSubscribe方法源碼。

public void doSubscribe(final URL url, final NotifyListener listener) { if (Constants.ANY_VALUE.equals(url.getServiceInterface())) { // ① 全量service訂閲邏輯 } else { // ② 部分類別訂閲邏輯 } }

以上①處是服務治理(dubbo-admin)需要用到,訂閲所有的service,因為我們是消費者,那麼只需要進入②,如下圖:

②處代碼作用是為當前分類節點添加“子節點列表變更的”watcher監聽,zkListener是RegistryChildListenerImpl,通過debug也證明了這點: 

RegistryChildListenerImpl的listener屬性最終值類型是RegistryDirectory。

所以,當zk節點有變更時,最終會回調到RegistryDirectory#notify方法。

RegistryDirectory#notify源碼如下:

以上④即實現consumer本地摘除提供者節點!同理,摘除宕機client節點也就在這裏啦!

我們做一個測試,比如我本地啟動一個provider,同時啟動一個consumer。

那麼,在我的zk服務器上是可以查得到這兩個節點的:

同樣,在本地通過jps命令也可以看到:

接下來,我通過執行kill -9,把ProviderApplication這個進程刪除:

然後,本地consumer在RegistryDirectory#notify打個斷點,等待一會後,代碼執行到斷點處了:

因為我的服務只有一個provider,斷點最終進入到org.apache.dubbo.registry.integration.RegistryDirectory#destroyAllInvokers方法,其內部實現destroy invoker,最終實現摘除provider節點,如下:

好啦,現在consumer也走完摘除client節點邏輯啦~

六、結語

我是tin,一個在努力讓自己變得更優秀的普通工程師。自己閲歷有限、學識淺薄,如有發現文章不妥之處,非常歡迎加我提出,我一定細心推敲並加以修改。

你的正反饋是我堅持輸出的最強大動力,謝謝!