可能不少 Java 程序員對 TCP 的理解只有一個三次握手,四次握手的認識,我以爲這樣的緣由主要在於 TCP 協議自己稍微有點抽象(相比較於應用層的 HTTP 協議);其次,非框架開發者不太須要接觸到 TCP 的一些細節。其實我我的對 TCP 的不少細節也並無徹底理解,這篇文章主要針對微信交流羣裏有人提出的長鏈接,心跳問題,作一個統一的整理。java
在 Java 中,使用 TCP 通訊,大機率會涉及到 Socket、Netty,本文將借用它們的一些 API 和設置參數來輔助介紹。nginx
TCP 自己並無長短鏈接的區別,長短與否,徹底取決於咱們怎麼用它。程序員
短鏈接和長鏈接的優點,分別是對方的劣勢。想要圖簡單,不追求高性能,使用短鏈接合適,這樣咱們就不須要操心鏈接狀態的管理;想要追求性能,使用長鏈接,咱們就須要擔憂各類問題:好比端對端鏈接的維護,鏈接的保活。算法
長鏈接還經常被用來作數據的推送,咱們大多數時候對通訊的認知仍是 request/response 模型,但 TCP 雙工通訊的性質決定了它還能夠被用來作雙向通訊。在長鏈接之下,能夠很方便的實現 push 模型,長鏈接的這一特性在本文並不會進行探討,有興趣的同窗能夠專門去搜索相關的文章。bootstrap
短鏈接沒有太多東西能夠講,因此下文咱們將目光聚焦在長鏈接的一些問題上。純講理論未免有些過於單調,因此下文我藉助一些 RPC 框架的實踐來展開 TCP 的相關討論。服務器
前面已經提到過,追求性能時,必然會選擇使用長鏈接,因此藉助 Dubbo 能夠很好的來理解 TCP。咱們開啓兩個 Dubbo 應用,一個 server 負責監聽本地 20880 端口(衆所周知,這是 Dubbo 協議默認的端口),一個 client 負責循環發送請求。執行 lsof -i:20880
命令能夠查看端口的相關使用狀況:微信
*:20880 (LISTEN)
說明了 Dubbo 正在監聽本地的 20880 端口,處理髮送到本地 20880 端口的請求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 能夠理解爲主動創建鏈接的一方,實際上兩端的地位能夠理解爲是對等的。socket
這個話題就有的聊了,會牽扯到比較多的知識點。首先須要明確一點,爲何須要鏈接的保活?當雙方已經創建了鏈接,但由於網絡問題,鏈路不通,這樣長鏈接就不能使用了。須要明確的一點是,經過 netstat,lsof 等指令查看到鏈接的狀態處於 ESTABLISHED
狀態並非一件很是靠譜的事,由於鏈接可能已死,但沒有被系統感知到,更不用提假死這種疑難雜症了。若是保證長鏈接可用是一件技術活。
首先想到的是 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 機制是在網絡層面保證了鏈接的可用性,但站在應用框架層面咱們認爲這還不夠。主要體如今三個方面:
/etc/sysctl.conf
配置中,這對於應用來講不夠靈活。ESTABLISHED
,這時會發生什麼?天然會走 TCP 重傳機制,要知道默認的 TCP 超時重傳,指數退避算法也是一個至關長的過程。咱們已經爲應用層面的鏈接保活作了足夠的鋪墊,下面就來一塊兒看看,怎麼在應用層作鏈接保活。
終於點題了,文題中提到的心跳即是一個本文想要重點強調的另外一個重要的知識點。上一節咱們已經解釋過了,網絡層面的 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 協議,天然也須要作心跳的支持,若是將心跳請求識別爲正常流量,會形成服務端的壓力問題,干擾限流等諸多問題。
其中 Flag 表明了 Dubbo 協議的標誌位,一共 8 個地址位。低四位用來表示消息體數據用的序列化工具的類型(默認 hessian),高四位中,第一位爲1表示是 request 請求,第二位爲 1 表示雙向傳輸(即有返回response),第三位爲 1 表示是心跳事件。
心跳請求應當和普通請求區別對待。
這壓根是兩個概念。
啓用 TCP KeepAlive 的應用程序,通常能夠捕獲到下面幾種類型錯誤
ETIMEOUT 超時錯誤,在發送一個探測保護包通過 (tcp_keepalive_time + tcp_keepalive_intvl * tcp_keepalive_probes)時間後仍然沒有接收到 ACK 確認狀況下觸發的異常,套接字被關閉
java.io.IOException: Connection timed out
複製代碼
EHOSTUNREACH host unreachable(主機不可達)錯誤,這個應該是 ICMP 彙報給上層應用的。
java.io.IOException: No route to host
複製代碼
連接被重置,終端可能崩潰死機重啓以後,接收到來自服務器的報文,然物是人非,前朝往事,只能報以無奈重置宣告之。
java.io.IOException: Connection reset by peer
複製代碼
有三種使用 KeepAlive 的實踐方案:
各個框架的設計都有所不一樣,例如 Dubbo 使用的是方案三,但阿里內部的 HSF 框架則沒有設置 TCP 的 KeepAlive,僅僅由應用心跳保活。和心跳策略同樣,這和框架總體的設計相關。
歡迎關注個人微信公衆號:「Kirito的技術分享」