聊聊 TCP 長連線和心跳那些事

Kirito的部落格發表於2019-01-09

前言

可能很多 Java 程式設計師對 TCP 的理解只有一個三次握手,四次握手的認識,我覺得這樣的原因主要在於 TCP 協議本身稍微有點抽象(相比較於應用層的 HTTP 協議);其次,非框架開發者不太需要接觸到 TCP 的一些細節。其實我個人對 TCP 的很多細節也並沒有完全理解,這篇文章主要針對微信交流群裡有人提出的長連線,心跳問題,做一個統一的整理。

在 Java 中,使用 TCP 通訊,大概率會涉及到 Socket、Netty,本文將借用它們的一些 API 和設定引數來輔助介紹。

長連線與短連線

TCP 本身並沒有長短連線的區別,長短與否,完全取決於我們怎麼用它。

  • 短連線:每次通訊時,建立 Socket;一次通訊結束,呼叫 socket.close()。這就是一般意義上的短連線,短連線的好處是管理起來比較簡單,存在的連線都是可用的連線,不需要額外的控制手段。
  • 長連線:每次通訊完畢後,不會關閉連線,這樣可以做到連線的複用。長連線的好處是省去了建立連線的耗時。

短連線和長連線的優勢,分別是對方的劣勢。想要圖簡單,不追求高效能,使用短連線合適,這樣我們就不需要操心連線狀態的管理;想要追求效能,使用長連線,我們就需要擔心各種問題:比如端對端連線的維護,連線的保活

長連線還常常被用來做資料的推送,我們大多數時候對通訊的認知還是 request/response 模型,但 TCP 雙工通訊的性質決定了它還可以被用來做雙向通訊。在長連線之下,可以很方便的實現 push 模型,長連線的這一特性在本文並不會進行探討,有興趣的同學可以專門去搜尋相關的文章。

短連線沒有太多東西可以講,所以下文我們將目光聚焦在長連線的一些問題上。純講理論未免有些過於單調,所以下文我藉助一些 RPC 框架的實踐來展開 TCP 的相關討論。

服務治理框架中的長連線

前面已經提到過,追求效能時,必然會選擇使用長連線,所以藉助 Dubbo 可以很好的來理解 TCP。我們開啟兩個 Dubbo 應用,一個 server 負責監聽本地 20880 埠(眾所周知,這是 Dubbo 協議預設的埠),一個 client 負責迴圈傳送請求。執行 lsof -i:20880 命令可以檢視埠的相關使用情況:

image-20190106203341694

  • *:20880 (LISTEN) 說明了 Dubbo 正在監聽本地的 20880 埠,處理髮送到本地 20880 埠的請求
  • 後兩條資訊說明請求的傳送情況,驗證了 TCP 是一個雙向的通訊過程,由於我是在同一個機器開啟了兩個 Dubbo 應用,所以你能夠看到是本地的 53078 埠與 20880 埠在通訊。我們並沒有手動設定 53078 這個客戶端埠,它是隨機的。通過這兩條資訊,闡釋了一個事實:即使是傳送請求的一方,也需要佔用一個埠
  • 稍微說一下 FD 這個引數,他代表了檔案控制程式碼,每新增一條連線都會佔用新的檔案控制程式碼,如果你在使用 TCP 通訊的過程中出現了 open too many files 的異常,那就應該檢查一下,你是不是建立了太多連線,而沒有關閉。細心的讀者也會聯想到長連線的另一個好處,那就是會佔用較少的檔案控制程式碼。

長連線的維護

因為客戶端請求的服務可能分佈在多個伺服器上,客戶端自然需要跟對端建立多條長連線,我們遇到的第一個問題就是如何維護長連線。

// 客戶端
public class NettyHandler extends SimpleChannelHandler {

    private final Map<String, Channel> channels = new ConcurrentHashMap<String, Channel>(); // <ip:port, channel>
}
// 服務端
public class NettyServer extends AbstractServer implements Server {
    private Map<String, Channel> channels; // <ip:port, channel>
}
複製程式碼

在 Dubbo 中,客戶端和服務端都使用 ip:port 維護了端對端的長連線,Channel 便是對連線的抽象。我們主要關注 NettyHandler 中的長連線,服務端同時維護一個長連線的集合是 Dubbo 的額外設計,我們將在後面提到。

這裡插一句,解釋下為什麼我認為客戶端的連線集合要重要一點。TCP 是一個雙向通訊的協議,任一方都可以是傳送者,接受者,那為什麼還抽象了 Client 和 Server 呢?因為建立連線這件事就跟談念愛一樣,必須要有主動的一方,你主動我們就會有故事。Client 可以理解為主動建立連線的一方,實際上兩端的地位可以理解為是對等的。

連線的保活

這個話題就有的聊了,會牽扯到比較多的知識點。首先需要明確一點,為什麼需要連線的保活?當雙方已經建立了連線,但因為網路問題,鏈路不通,這樣長連線就不能使用了。需要明確的一點是,通過 netstat,lsof 等指令檢視到連線的狀態處於 ESTABLISHED 狀態並不是一件非常靠譜的事,因為連線可能已死,但沒有被系統感知到,更不用提假死這種疑難雜症了。如果保證長連線可用是一件技術活。

連線的保活:KeepAlive

首先想到的是 TCP 中的 KeepAlive 機制。KeepAlive 並不是 TCP 協議的一部分,但是大多數作業系統都實現了這個機制(所以需要在作業系統層面設定 KeepAlive 的相關引數)。KeepAlive 機制開啟後,在一定時間內(一般時間為 7200s,引數 tcp_keepalive_time)在鏈路上沒有資料傳送的情況下,TCP 層將傳送相應的 KeepAlive 探針以確定連線可用性,探測失敗後重試 10(引數 tcp_keepalive_probes)次,每次間隔時間 75s(引數 tcp_keepalive_intvl),所有探測失敗後,才認為當前連線已經不可用。

在 Netty 中開啟 KeepAlive:

bootstrap.option(ChannelOption.SO_KEEPALIVE, true)
複製程式碼

Linux 作業系統中設定 KeepAlive 相關引數,修改 /etc/sysctl.conf 檔案:

net.ipv4.tcp_keepalive_time=90
net.ipv4.tcp_keepalive_intvl=15
net.ipv4.tcp_keepalive_probes=2
複製程式碼

KeepAlive 機制是在網路層面保證了連線的可用性,但站在應用框架層面我們認為這還不夠。主要體現在三個方面:

  • KeepAlive 的開關是在應用層開啟的,但是具體引數(如重試測試,重試間隔時間)的設定卻是作業系統級別的,位於作業系統的 /etc/sysctl.conf 配置中,這對於應用來說不夠靈活。
  • KeepAlive 的保活機制只在鏈路空閒的情況下才會起到作用,假如此時有資料傳送,且物理鏈路已經不通,作業系統這邊的鏈路狀態還是 ESTABLISHED,這時會發生什麼?自然會走 TCP 重傳機制,要知道預設的 TCP 超時重傳,指數退避演算法也是一個相當長的過程。
  • KeepAlive 本身是面向網路的,並不面向於應用,當連線不可用,可能是由於應用本身的 GC 頻繁,系統 load 高等情況,但網路仍然是通的,此時,應用已經失去了活性,連線應該被認為是不可用的。

我們已經為應用層面的連線保活做了足夠的鋪墊,下面就來一起看看,怎麼在應用層做連線保活。

連線的保活:應用層心跳

終於點題了,文題中提到的心跳便是一個本文想要重點強調的另一個重要的知識點。上一節我們已經解釋過了,網路層面的 KeepAlive 不足以支撐應用級別的連線可用性,本節就來聊聊應用層的心跳機制是實現連線保活的。

如何理解應用層的心跳?簡單來說,就是客戶端會開啟一個定時任務,定時對已經建立連線的對端應用傳送請求(這裡的請求是特殊的心跳請求),服務端則需要特殊處理該請求,返回響應。如果心跳持續多次沒有收到響應,客戶端會認為連線不可用,主動斷開連線。不同的服務治理框架對心跳,建連,斷連,拉黑的機制有不同的策略,但大多數的服務治理框架都會在應用層做心跳,Dubbo/HSF 也不例外。

應用層心跳的設計細節

以 Dubbo 為例,支援應用層的心跳,客戶端和服務端都會開啟一個 HeartBeatTask,客戶端在 HeaderExchangeClient 中開啟,服務端將在 HeaderExchangeServer 開啟。文章開頭埋了一個坑:Dubbo 為什麼在服務端同時維護 Map<String,Channel> 呢?主要就是為了給心跳做貢獻,心跳定時任務在發現連線不可用時,會根據當前是客戶端還是服務端走不同的分支,客戶端發現不可用,是重連;服務端發現不可用,是直接 close。

// HeartBeatTask
if (channel instanceof Client) {
    ((Client) channel).reconnect();
} else {
    channel.close();
}
複製程式碼

Dubbo 2.7.x 相比 2.6.x 做了定時心跳的優化,使用 HashedWheelTimer 更加精準的控制了只在連線閒置時傳送心跳。

再看看 HSF 的實現,並沒有設定應用層的心跳,準確的說,是在 HSF2.2 之後,使用 Netty 提供的 IdleStateHandler 更加優雅的實現了應用的心跳。

ch.pipeline()
        .addLast("clientIdleHandler", new IdleStateHandler(getHbSentInterval(), 0, 0));
複製程式碼

處理 userEventTriggered 中的 IdleStateEvent 事件

@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
    if (evt instanceof IdleStateEvent) {
        callConnectionIdleListeners(client, (ClientStream) StreamUtils.streamOfChannel(ctx.channel()));
    } else {
        super.userEventTriggered(ctx, evt);
    }
}
複製程式碼

對於客戶端,HSF 使用 SendHeartbeat 來進行心跳,每次失敗累加心跳失敗的耗時,當超過最大限制時斷開亂接;對於服務端 HSF 使用 CloseIdle 來處理閒置連線,直接關閉連線。一般來說,服務端的閒置時間會設定的稍長。

熟悉其他 RPC 框架的同學會發現,不同框架的心跳機制真的是差距非常大。心跳設計還跟連線建立,重連機制,黑名單連線相關,還需要具體框架具體分析。

除了定時任務的設計,還需要在協議層面支援心跳。最簡單的例子可以參考 nginx 的健康檢查,而針對 Dubbo 協議,自然也需要做心跳的支援,如果將心跳請求識別為正常流量,會造成服務端的壓力問題,干擾限流等諸多問題。

dubbo protocol

其中 Flag 代表了 Dubbo 協議的標誌位,一共 8 個地址位。低四位用來表示訊息體資料用的序列化工具的型別(預設 hessian),高四位中,第一位為1表示是 request 請求,第二位為 1 表示雙向傳輸(即有返回response),第三位為 1 表示是心跳事件

心跳請求應當和普通請求區別對待。

注意和 HTTP 的 KeepAlive 區別對待

  • HTTP 協議的 KeepAlive 意圖在於連線複用,同一個連線上序列方式傳遞請求-響應資料
  • TCP 的 KeepAlive 機制意圖在於保活、心跳,檢測連線錯誤。

這壓根是兩個概念。

KeepAlive 常見錯誤

啟用 TCP KeepAlive 的應用程式,一般可以捕獲到下面幾種型別錯誤

  1. ETIMEOUT 超時錯誤,在傳送一個探測保護包經過 (tcp_keepalive_time + tcp_keepalive_intvl * tcp_keepalive_probes)時間後仍然沒有接收到 ACK 確認情況下觸發的異常,套接字被關閉

    java.io.IOException: Connection timed out
    複製程式碼
  2. EHOSTUNREACH host unreachable(主機不可達)錯誤,這個應該是 ICMP 彙報給上層應用的。

    java.io.IOException: No route to host
    複製程式碼
  3. 連結被重置,終端可能崩潰當機重啟之後,接收到來自伺服器的報文,然物是人非,前朝往事,只能報以無奈重置宣告之。

    java.io.IOException: Connection reset by peer
    複製程式碼

總結

有三種使用 KeepAlive 的實踐方案:

  1. 預設情況下使用 KeepAlive 週期為 2 個小時,如不選擇更改,屬於誤用範疇,造成資源浪費:核心會為每一個連線都開啟一個保活計時器,N 個連線會開啟 N 個保活計時器。 優勢很明顯:
    • TCP 協議層面保活探測機制,系統核心完全替上層應用自動給做好了
    • 核心層面計時器相比上層應用,更為高效
    • 上層應用只需要處理資料收發、連線異常通知即可
    • 資料包將更為緊湊
  2. 關閉 TCP 的 KeepAlive,完全使用應用層心跳保活機制。由應用掌管心跳,更靈活可控,比如可以在應用級別設定心跳週期,適配私有協議。
  3. 業務心跳 + TCP KeepAlive 一起使用,互相作為補充,但 TCP 保活探測週期和應用的心跳週期要協調,以互補方可,不能夠差距過大,否則將達不到設想的效果。

各個框架的設計都有所不同,例如 Dubbo 使用的是方案三,但阿里內部的 HSF 框架則沒有設定 TCP 的 KeepAlive,僅僅由應用心跳保活。和心跳策略一樣,這和框架整體的設計相關。

歡迎關注我的微信公眾號:「Kirito的技術分享」

關注微信公眾號

相關文章