長連線的心跳及重連設計

crossoverJie發表於2019-03-02

前言

說道“心跳”這個詞大家都不陌生,當然不是指男女之間的心跳,而是和長連線相關的。

顧名思義就是證明是否還活著的依據。

什麼場景下需要心跳呢?

目前我們接觸到的大多是一些基於長連線的應用需要心跳來“保活”。

由於在長連線的場景下,客戶端和服務端並不是一直處於通訊狀態,如果雙方長期沒有溝通則雙方都不清楚對方目前的狀態;所以需要傳送一段很小的報文告訴對方“我還活著”

同時還有另外幾個目的:

  • 服務端檢測到某個客戶端遲遲沒有心跳過來可以主動關閉通道,讓它下線。
  • 客戶端檢測到某個服務端遲遲沒有響應心跳也能重連獲取一個新的連線。

正好藉著在 cim有這樣兩個需求來聊一聊。

心跳實現方式

心跳其實有兩種實現方式:

  • TCP 協議實現(keepalive 機制)。
  • 應用層自己實現。

由於 TCP 協議過於底層,對於開發者來說維護性、靈活度都比較差同時還依賴於作業系統。

所以我們這裡所討論的都是應用層的實現。


長連線的心跳及重連設計

如上圖所示,在應用層通常是由客戶端傳送一個心跳包 ping 到服務端,服務端收到後響應一個 pong 表明雙方都活得好好的。

一旦其中一端延遲 N 個時間視窗沒有收到訊息則進行不同的處理。

客戶端自動重連

先拿客戶端來說吧,每隔一段時間客戶端向服務端傳送一個心跳包,同時收到服務端的響應。

常規的實現應當是:

  • 開啟一個定時任務,定期傳送心跳包。
  • 收到服務端響應後更新本地時間。
  • 再有一個定時任務定期檢測這個“本地時間”是否超過閾值。
  • 超過後則認為服務端出現故障,需要重連。

這樣確實也能實現心跳,但並不友好。

在正常的客戶端和服務端通訊的情況下,定時任務依然會傳送心跳包;這樣就顯得沒有意義,有些多餘。

所以理想的情況應當是客戶端收到的寫訊息空閒時才傳送這個心跳包去確認服務端是否健在。

好訊息是 Netty 已經為我們考慮到了這點,自帶了一個開箱即用的 IdleStateHandler 專門用於心跳處理。

來看看 cim 中的實現:

長連線的心跳及重連設計

pipeline 中加入了一個 10秒沒有收到寫訊息的 IdleStateHandler,到時他會回撥 ChannelInboundHandler 中的 userEventTriggered 方法。

長連線的心跳及重連設計

所以一旦寫超時就立馬向服務端傳送一個心跳(做的更完善應當在心跳傳送失敗後有一定的重試次數);

這樣也就只有在空閒時候才會傳送心跳包。

但一旦間隔許久沒有收到服務端響應進行重連的邏輯應當寫在哪裡呢?

先來看這個示例:

當收到服務端響應的 pong 訊息時,就在當前 Channel 上記錄一個時間,也就是說後續可以在定時任務中取出這個時間和當前時間的差額來判斷是否超過閾值。

超過則重連。

長連線的心跳及重連設計
長連線的心跳及重連設計

同時在每次心跳時候都用當前時間和之前服務端響應繫結到 Channel 上的時間相減判斷是否需要重連即可。

也就是 heartBeatHandler.process(ctx); 的執行邏輯。

虛擬碼如下:

@Override
public void process(ChannelHandlerContext ctx) throws Exception {

    long heartBeatTime = appConfiguration.getHeartBeatTime() * 1000;
    
    Long lastReadTime = NettyAttrUtil.getReaderTime(ctx.channel());
    long now = System.currentTimeMillis();
    if (lastReadTime != null && now - lastReadTime > heartBeatTime){
        reconnect();
    }

}
複製程式碼

IdleStateHandler 誤區

一切看起來也沒毛病,但實際上卻沒有這樣實現重連邏輯。

最主要的問題還是對 IdleStateHandler 理解有誤。

我們假設下面的場景:

  1. 客戶端通過登入連上了服務端並保持長連線,一切正常的情況下雙方各發心跳包保持連線。
  2. 這時服務端突入出現 down 機,那麼理想情況下應當是客戶端遲遲沒有收到服務端的響應從而 userEventTriggered 執行定時任務。
  3. 判斷當前時間 - UpdateWriteTime > 閾值 時進行重連。

但卻事與願違,並不會執行 2、3兩步。

因為一旦服務端 down 機、或者是與客戶端的網路斷開則會回撥客戶端的 channelInactive 事件。

IdleStateHandler 作為一個 ChannelInbound 也重寫了 channelInactive() 方法。

長連線的心跳及重連設計
長連線的心跳及重連設計

這裡的 destroy() 方法會把之前開啟的定時任務都給取消掉。

所以就不會再有任何的定時任務執行了,也就不會有機會執行這個重連業務

靠譜實現

因此我們得有一個單獨的執行緒來判斷是否需要重連,不依賴於 IdleStateHandler

於是 cim 在客戶端感知到網路斷開時就會開啟一個定時任務:

長連線的心跳及重連設計

之所以不在客戶端啟動就開啟,是為了節省一點執行緒消耗。網路問題雖然不可避免,但在需要的時候開啟更能節省資源。

長連線的心跳及重連設計

長連線的心跳及重連設計

在這個任務重其實就是執行了重連,限於篇幅具體程式碼就不貼了,感興趣的可以自行查閱。

同時來驗證一下效果。

啟動兩個服務端,再啟動客戶端連線上一臺並保持長連線。這時突然手動關閉一臺服務,客戶端可以自動重連到可用的那臺服務節點。

長連線的心跳及重連設計
長連線的心跳及重連設計

啟動客戶端後服務端也能收到正常的 ping 訊息。

利用 :info 命令檢視當前客戶端的連結狀態發現連的是 9000埠。

長連線的心跳及重連設計

:info 是一個新增命令,可以檢視一些客戶端資訊。

這時我關掉連線上的這臺節點。

kill -9 2142
複製程式碼

長連線的心跳及重連設計
長連線的心跳及重連設計

這時客戶端會自動重連到可用的那臺節點。 這個節點也收到了上線日誌以及心跳包。

服務端自動剔除離線客戶端

現在來看看服務端,它要實現的效果就是延遲 N 秒沒有收到客戶端的 ping 包則認為客戶端下線了,在 cim 的場景下就需要把他踢掉置於離線狀態。

訊息傳送誤區

這裡依然有一個誤區,在呼叫 ctx.writeAndFlush() 傳送訊息獲取回撥時。

其中是 isSuccess 並不能作為訊息傳送成功與否的標準。

長連線的心跳及重連設計

也就是說即便是客戶端直接斷網,服務端這裡傳送訊息後拿到的 success 依舊是 true

這是因為這裡的 success 只是告知我們訊息寫入了 TCP 緩衝區成功了而已。

和我之前有著一樣錯誤理解的不在少數,這是 Netty 官方給的回覆。

長連線的心跳及重連設計

相關 issue

github.com/netty/netty…

同時感謝 95老徐以及閃電俠的一起排查。

所以我們不能依據此來關閉客戶端的連線,而是要像上文一樣判斷 Channel 上繫結的時間與當前時間只差是否超過了閾值。

長連線的心跳及重連設計
長連線的心跳及重連設計
長連線的心跳及重連設計

以上則是 cim 服務端的實現,邏輯和開頭說的一致,也和 Dubbo 的心跳機制有些類似。

於是來做個試驗:正常通訊的客戶端和服務端,當我把客戶端直接斷網時,服務端會自動剔除客戶端。

長連線的心跳及重連設計
長連線的心跳及重連設計

總結

這樣就實現了文初的兩個要求。

  • 服務端檢測到某個客戶端遲遲沒有心跳過來可以主動關閉通道,讓它下線。
  • 客戶端檢測到某個服務端遲遲沒有響應心跳也能重連獲取一個新的連線。

同時也踩了兩個誤區,坑一個人踩就可以了,希望看過本文的都有所收穫避免踩坑。

本文所有相關程式碼都在此處,感興趣的可以自行檢視:

github.com/crossoverJi…

如果本文對你有所幫助還請不吝轉發。

長連線的心跳及重連設計

相關文章