面試官:你給我講一講,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,一個在努力讓自己變得更優秀的普通工程師。自己閱歷有限、學識淺薄,如有發現文章不妥之處,非常歡迎加我提出,我一定細心推敲並加以修改。

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