### 前言
可能很多 Java 程序員對 TCP 的理解只有一個三次握手,四次握手的認識,我覺得這樣的原因主要在于 TCP 協議本身稍微有點抽象(相比較于應用層的 HTTP 協議);其次,非框架開發者不太需要接觸到 TCP 的一些細節。其實我個人對 TCP 的很多細節也并沒有完全理解,這篇文章主要針對微信交流群里有人提出的長連接,心跳問題,做一個統一的整理。
在 Java 中,使用 TCP 通信,大概率會涉及到 Socket、Netty,本文將借用它們的一些 API 和設置參數來輔助介紹。
### [](https://www.cnkirito.moe/tcp-talk/#%E9%95%BF%E8%BF%9E%E6%8E%A5%E4%B8%8E%E7%9F%AD%E8%BF%9E%E6%8E%A5 "長連接與短連接")長連接與短連接
**TCP 本身并沒有長短連接的區別**,長短與否,完全取決于我們怎么用它。
* 短連接:每次通信時,創建 Socket;一次通信結束,調用 socket.close()。這就是一般意義上的短連接,短連接的好處是管理起來比較簡單,存在的連接都是可用的連接,不需要額外的控制手段。
* 長連接:每次通信完畢后,不會關閉連接,這樣可以做到連接的復用。**長連接的好處是省去了創建連接的耗時。**
短連接和長連接的優勢,分別是對方的劣勢。想要圖簡單,不追求高性能,使用短連接合適,這樣我們就不需要操心連接狀態的管理;想要追求性能,使用長連接,我們就需要擔心各種問題:比如**端對端連接的維護,連接的保活**。
長連接還常常被用來做數據的推送,我們大多數時候對通信的認知還是 request/response 模型,但 TCP 雙工通信的性質決定了它還可以被用來做雙向通信。在長連接之下,可以很方便的實現 push 模型,長連接的這一特性在本文并不會進行探討,有興趣的同學可以專門去搜索相關的文章。
短連接沒有太多東西可以講,所以下文我們將目光聚焦在長連接的一些問題上。純講理論未免有些過于單調,所以下文我借助一些 RPC 框架的實踐來展開 TCP 的相關討論。
### [](https://www.cnkirito.moe/tcp-talk/#%E6%9C%8D%E5%8A%A1%E6%B2%BB%E7%90%86%E6%A1%86%E6%9E%B6%E4%B8%AD%E7%9A%84%E9%95%BF%E8%BF%9E%E6%8E%A5 "服務治理框架中的長連接")服務治理框架中的長連接
前面已經提到過,追求性能時,必然會選擇使用長連接,所以借助 Dubbo 可以很好的來理解 TCP。我們開啟兩個 Dubbo 應用,一個 server 負責監聽本地 20880 端口(眾所周知,這是 Dubbo 協議默認的端口),一個 client 負責循環發送請求。執行`lsof -i:20880`命令可以查看端口的相關使用情況:
[](https://kirito.iocoder.cn/image-20190106203341694.png "image-20190106203341694")image-20190106203341694
* `*:20880 (LISTEN)`說明了 Dubbo 正在監聽本地的 20880 端口,處理發送到本地 20880 端口的請求
* 后兩條信息說明請求的發送情況,驗證了 TCP 是一個雙向的通信過程,由于我是在同一個機器開啟了兩個 Dubbo 應用,所以你能夠看到是本地的 53078 端口與 20880 端口在通信。我們并沒有手動設置 53078 這個客戶端端口,它是隨機的。通過這兩條信息,闡釋了一個事實:**即使是發送請求的一方,也需要占用一個端口**。
* 稍微說一下 FD 這個參數,他代表了**文件句柄**,每新增一條連接都會占用新的文件句柄,如果你在使用 TCP 通信的過程中出現了`open too many files`的異常,那就應該檢查一下,你是不是創建了太多連接,而沒有關閉。細心的讀者也會聯想到長連接的另一個好處,那就是會占用較少的文件句柄。
### [](https://www.cnkirito.moe/tcp-talk/#%E9%95%BF%E8%BF%9E%E6%8E%A5%E7%9A%84%E7%BB%B4%E6%8A%A4 "長連接的維護")長連接的維護
因為客戶端請求的服務可能分布在多個服務器上,客戶端自然需要跟對端創建多條長連接,我們遇到的第一個問題就是如何維護長連接。
```
// 客戶端
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 可以理解為主動建立連接的一方,實際上兩端的地位可以理解為是對等的。
### [](https://www.cnkirito.moe/tcp-talk/#%E8%BF%9E%E6%8E%A5%E7%9A%84%E4%BF%9D%E6%B4%BB "連接的保活")連接的保活
這個話題就有的聊了,會牽扯到比較多的知識點。首先需要明確一點,為什么需要連接的保活?當雙方已經建立了連接,但因為網絡問題,鏈路不通,這樣長連接就不能使用了。需要明確的一點是,通過 netstat,lsof 等指令查看到連接的狀態處于`ESTABLISHED`狀態并不是一件非常靠譜的事,因為連接可能已死,但沒有被系統感知到,更不用提假死這種疑難雜癥了。如果保證長連接可用是一件技術活。
### [](https://www.cnkirito.moe/tcp-talk/#%E8%BF%9E%E6%8E%A5%E7%9A%84%E4%BF%9D%E6%B4%BB%EF%BC%9AKeepAlive "連接的保活:KeepAlive")連接的保活: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 高等情況,但網絡仍然是通的,此時,應用已經失去了活性,連接應該被認為是不可用的。
我們已經為應用層面的連接保活做了足夠的鋪墊,下面就來一起看看,怎么在應用層做連接保活。
### [](https://www.cnkirito.moe/tcp-talk/#%E8%BF%9E%E6%8E%A5%E7%9A%84%E4%BF%9D%E6%B4%BB%EF%BC%9A%E5%BA%94%E7%94%A8%E5%B1%82%E5%BF%83%E8%B7%B3 "連接的保活:應用層心跳")連接的保活:應用層心跳
終于點題了,文題中提到的**心跳**便是一個本文想要重點強調的另一個重要的知識點。上一節我們已經解釋過了,網絡層面的 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`更加優雅的實現了應用的心跳。
處理`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 協議,自然也需要做心跳的支持,如果將心跳請求識別為正常流量,會造成服務端的壓力問題,干擾限流等諸多問題。
[](https://kirito.iocoder.cn/359310b9-b980-3254-aed6-78aa6c482e53.png "dubbo protocol")dubbo protocol
其中 Flag 代表了 Dubbo 協議的標志位,一共 8 個地址位。低四位用來表示消息體數據用的序列化工具的類型(默認 hessian),高四位中,第一位為 1 表示是 request 請求,第二位為 1 表示雙向傳輸(即有返回 response),**第三位為 1 表示是心跳事件**。
> 心跳請求應當和普通請求區別對待。
### [](https://www.cnkirito.moe/tcp-talk/#%E6%B3%A8%E6%84%8F%E5%92%8C-HTTP-%E7%9A%84-KeepAlive-%E5%8C%BA%E5%88%AB%E5%AF%B9%E5%BE%85 "注意和 HTTP 的 KeepAlive 區別對待")注意和 HTTP 的 KeepAlive 區別對待
* HTTP 協議的 KeepAlive 意圖在于連接復用,同一個連接上串行方式傳遞請求 - 響應數據
* TCP 的 KeepAlive 機制意圖在于保活、心跳,檢測連接錯誤。
這壓根是兩個概念。
### [](https://www.cnkirito.moe/tcp-talk/#KeepAlive-%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF "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
```
### [](https://www.cnkirito.moe/tcp-talk/#%E6%80%BB%E7%BB%93 "總結")總結
有三種使用 KeepAlive 的實踐方案:
1. 默認情況下使用 KeepAlive 周期為 2 個小時,如不選擇更改,屬于誤用范疇,造成資源浪費:內核會為每一個連接都打開一個保活計時器,N 個連接會打開 N 個保活計時器。 優勢很明顯:
* TCP 協議層面保活探測機制,系統內核完全替上層應用自動給做好了
* 內核層面計時器相比上層應用,更為高效
* 上層應用只需要處理數據收發、連接異常通知即可
* 數據包將更為緊湊
2. 關閉 TCP 的 KeepAlive,完全使用應用層心跳保活機制。由應用掌管心跳,更靈活可控,比如可以在應用級別設置心跳周期,適配私有協議。
3. 業務心跳 + TCP KeepAlive 一起使用,互相作為補充,但 TCP 保活探測周期和應用的心跳周期要協調,以互補方可,不能夠差距過大,否則將達不到設想的效果。
各個框架的設計都有所不同,例如 Dubbo 使用的是方案三,但阿里內部的 HSF 框架則沒有設置 TCP 的 KeepAlive,僅僅由應用心跳保活。和心跳策略一樣,這和框架整體的設計相關。
- 前言
- 服務器開發設計
- Reactor模式
- 一種心跳,兩種設計
- 聊聊 TCP 長連接和心跳那些事
- 學習TCP三次握手和四次揮手
- Linux基礎
- Linux的inode的理解
- 異步IO模型介紹
- 20個最常用的GCC編譯器參數
- epoll
- epoll精髓
- epoll原理詳解及epoll反應堆模型
- epoll的坑
- epoll的本質
- socket的SO_REUSEADDR參數全面分析
- 服務器網絡
- Protobuf
- Protobuf2 語法指南
- 一種自動反射消息類型的 Protobuf 網絡傳輸方案
- 微服務
- RPC框架
- 什么是RPC
- 如何科學的解釋RPC
- RPC 消息協議
- 實現一個極簡版的RPC
- 一個基于protobuf的極簡RPC
- 如何基于protobuf實現一個極簡版的RPC
- 開源RPC框架
- thrift
- grpc
- brpc
- Dubbo
- 服務注冊,發現,治理
- Redis
- Redis發布訂閱
- Redis分布式鎖
- 一致性哈希算法
- Redis常見問題
- Redis數據類型
- 緩存一致性
- LevelDB
- 高可用
- keepalived基本理解
- keepalived操做
- LVS 學習
- 性能優化
- Linux服務器程序性能優化方法
- SRS性能(CPU)、內存優化工具用法
- centos6的性能分析工具集合
- CentOS系統性能工具 sar 示例!
- Linux性能監控工具集sysstat
- gdb相關
- Linux 下如何產生core文件(core dump設置)