面試官:你給我講一講,Dubbo暴力停機,消費者是如何感知服務下線的?
本文首發於公眾號【看點程式碼再上班】,建議關注公眾號,及時閱讀最新文章。
大家好,我是tin,這是我的第21篇原創文章
上一篇我們講到了Dubbo服務正常重啟下線時是如何優雅停機的,其中有一個環節就非常重要:
通知註冊中心下線服務。
重啟的服務因為是主動關閉Spring容器,所以有時間也有主動權去告知註冊中心“我要下線了”。
但是,對於暴力停機,比如kill -9或者機器宕機,Dubbo服務又是如何通知到註冊中心的呢?
要想知道真正原因,得從註冊中心的心跳機制聊起。今天就結合zk註冊中心一起看一看,先上一個目錄:
目錄
一、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
類似檔案系統的目錄結構進行儲存,目錄樹中的每個節點被稱為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×tamp=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,一個在努力讓自己變得更優秀的普通工程師。自己閱歷有限、學識淺薄,如有發現文章不妥之處,非常歡迎加我提出,我一定細心推敲並加以修改。
你的正反饋是我堅持輸出的最強大動力,謝謝!