面試官:換人!換人!TCP 這幾個引數都不懂,也來面試?

熬夜不加班發表於2021-05-18

前言

TCP 效能的提升不僅考察 TCP 的理論知識,還考察了對於作業系統提供的核心引數的理解與應用。

TCP 協議是由作業系統實現,所以作業系統提供了不少調節 TCP 的引數。

image
image

如何正確有效的使用這些引數,來提高 TCP 效能是一個不那麼簡單事情。我們需要針對 TCP 每個階段的問題來對症下藥,而不是病急亂投醫。

接下來,將以三個角度來闡述提升 TCP 的策略,分別是:

  • TCP 三次握手的效能提升;

  • TCP 四次揮手的效能提升;

  • TCP 資料傳輸的效能提升;

image
image

正文

01 TCP 三次握手的效能提升

TCP 是面向連線的、可靠的、雙向傳輸的傳輸層通訊協議,所以在傳輸資料之前需要經過三次握手才能建立連線。

image
image

那麼,三次握手的過程在一個 HTTP 請求的平均時間佔比 10% 以上,在網路狀態不佳、高併發或者遭遇 SYN 攻擊等場景中,如果不能有效正確的調節三次握手中的引數,就會對效能產生很多的影響。

如何正確有效的使用這些引數,來提高 TCP 三次握手的效能,這就需要理解「三次握手的狀態變遷」,這樣當出現問題時,先用 netstat 命令檢視是哪個握手階段出現了問題,再來對症下藥,而不是病急亂投醫。

image
image

客戶端和服務端都可以針對三次握手最佳化效能。主動發起連線的客戶端最佳化相對簡單些,而服務端需要監聽埠,屬於被動連線方,其間保持許多的中間狀態,最佳化方法相對複雜一些。

所以,客戶端(主動發起連線方)和服務端(被動連線方)最佳化的方式是不同的,接下來分別針對客戶端和服務端最佳化。

客戶端最佳化

三次握手建立連線的首要目的是「同步序列號」。

只有同步了序列號才有可靠傳輸,TCP 許多特性都依賴於序列號實現,比如流量控制、丟包重傳等,這也是三次握手中的報文稱為 SYN 的原因,SYN 的全稱就叫 Synchronize Sequence Numbers(同步序列號)。

image
image

SYN_SENT 狀態的最佳化

客戶端作為主動發起連線方,首先它將傳送 SYN 包,於是客戶端的連線就會處於 SYN_SENT 狀態。

客戶端在等待服務端回覆的 ACK 報文,正常情況下,伺服器會在幾毫秒內返回 SYN+ACK ,但如果客戶端長時間沒有收到 SYN+ACK 報文,則會重發 SYN 包,重發的次數由 tcp_syn_retries 引數控制,預設是 5 次:

image
image

通常,第一次超時重傳是在 1 秒後,第二次超時重傳是在 2 秒,第三次超時重傳是在 4 秒後,第四次超時重傳是在 8 秒後,第五次是在超時重傳 16 秒後。沒錯,每次超時的時間是上一次的 2 倍。

當第五次超時重傳後,會繼續等待 32 秒,如果服務端仍然沒有回應 ACK,客戶端就會終止三次握手。

所以,總耗時是 1+2+4+8+16+32=63 秒,大約 1 分鐘左右。

image
image

你可以根據網路的穩定性和目標伺服器的繁忙程度修改 SYN 的重傳次數,調整客戶端的三次握手時間上限。比如內網中通訊時,就可以適當調低重試次數,儘快把錯誤暴露給應用程式。

服務端最佳化

當服務端收到 SYN 包後,服務端會立馬回覆 SYN+ACK 包,表明確認收到了客戶端的序列號,同時也把自己的序列號發給對方。

此時,服務端出現了新連線,狀態是 SYN_RCV。在這個狀態下,Linux 核心就會建立一個「半連線佇列」來維護「未完成」的握手資訊,當半連線佇列溢位後,服務端就無法再建立新的連線。

image
image

SYN 攻擊,攻擊的是就是這個半連線佇列。

如何檢視由於 SYN 半連線佇列已滿,而被丟棄連線的情況?

我們可以透過該 netstat -s 命令給出的統計結果中, 可以得到由於半連線佇列已滿,引發的失敗次數:

image
image

上面輸出的數值是累計值,表示共有多少個 TCP 連線因為半連線佇列溢位而被丟棄。隔幾秒執行幾次,如果有上升的趨勢,說明當前存在半連線佇列溢位的現象。

如何調整 SYN 半連線佇列大小?

要想增大半連線佇列,不能只單純增大 tcp_max_syn_backlog 的值,還需一同增大 somaxconn 和 backlog,也就是增大 accept 佇列。否則,只單純增大 tcp_max_syn_backlog 是無效的。

增大 tcp_max_syn_backlog 和 somaxconn 的方法是修改 Linux 核心引數:

image
image

增大 backlog 的方式,每個 Web 服務都不同,比如 Nginx 增大 backlog 的方法如下:

image
image

最後,改變了如上這些引數後,要重啟 Nginx 服務,因為 SYN 半連線佇列和 accept 佇列都是在 listen() 初始化的。

如果 SYN 半連線佇列已滿,只能丟棄連線嗎?

並不是這樣,開啟 syncookies 功能就可以在不使用 SYN 半連線佇列的情況下成功建立連線。

syncookies 的工作原理:伺服器根據當前狀態計算出一個值,放在己方發出的 SYN+ACK 報文中發出,當客戶端返回 ACK 報文時,取出該值驗證,如果合法,就認為連線建立成功,如下圖所示。

image
image

syncookies 引數主要有以下三個值:

  • 0 值,表示關閉該功能;

  • 1 值,表示僅當 SYN 半連線佇列放不下時,再啟用它;

  • 2 值,表示無條件開啟功能;

那麼在應對 SYN 攻擊時,只需要設定為 1 即可:

image
image

SYN_RCV 狀態的最佳化

當客戶端接收到伺服器發來的 SYN+ACK 報文後,就會回覆 ACK 給伺服器,同時客戶端連線狀態從 SYN_SENT 轉換為 ESTABLISHED,表示連線建立成功。

伺服器端連線成功建立的時間還要再往後,等到服務端收到客戶端的 ACK 後,服務端的連線狀態才變為 ESTABLISHED。

如果伺服器沒有收到 ACK,就會重發 SYN+ACK 報文,同時一直處於 SYN_RCV 狀態。

當網路繁忙、不穩定時,報文丟失就會變嚴重,此時應該調大重發次數。反之則可以調小重發次數。修改重發次數的方法是,調整 tcp_synack_retries 引數:

image
image

tcp_synack_retries 的預設重試次數是 5 次,與客戶端重傳 SYN 類似,它的重傳會經歷 1、2、4、8、16 秒,最後一次重傳後會繼續等待 32 秒,如果服務端仍然沒有收到 ACK,才會關閉連線,故共需要等待 63 秒。

伺服器收到 ACK 後連線建立成功,此時,核心會把連線從半連線佇列移除,然後建立新的完全的連線,並將其新增到 accept 佇列,等待程式呼叫 accept 函式時把連線取出來。

如果程式不能及時地呼叫 accept 函式,就會造成 accept 佇列(也稱全連線佇列)溢位,最終導致建立好的 TCP 連線被丟棄。

image
image

accept 佇列已滿,只能丟棄連線嗎?

丟棄連線只是 Linux 的預設行為,我們還可以選擇向客戶端傳送 RST 復位報文,告訴客戶端連線已經建立失敗。開啟這一功能需要將 tcp_abort_on_overflow 引數設定為 1。

image
image

tcp_abort_on_overflow 共有兩個值分別是 0 和 1,其分別表示:

  • 0 :如果 accept 佇列滿了,那麼 server 扔掉 client 發過來的 ack ;

  • 1 :如果 accept 佇列滿了,server 傳送一個 RST 包給 client,表示廢掉這個握手過程和這個連線;

如果要想知道客戶端連線不上服務端,是不是服務端 TCP 全連線佇列滿的原因,那麼可以把 tcp_abort_on_overflow 設定為 1,這時如果在客戶端異常中可以看到很多 connection reset by peer 的錯誤,那麼就可以證明是由於服務端 TCP 全連線佇列溢位的問題。

通常情況下,應當把 tcp_abort_on_overflow 設定為 0,因為這樣更有利於應對突發流量。

舉個例子,當 accept 佇列滿導致伺服器丟掉了 ACK,與此同時,客戶端的連線狀態卻是 ESTABLISHED,客戶端程式就在建立好的連線上傳送請求。只要伺服器沒有為請求回覆 ACK,客戶端的請求就會被多次「重發」。如果伺服器上的程式只是短暫的繁忙造成 accept 佇列滿,那麼當 accept 佇列有空位時,再次接收到的請求報文由於含有 ACK,仍然會觸發伺服器端成功建立連線。

image
image

所以,tcp_abort_on_overflow 設為 0 可以提高連線建立的成功率,只有你非常肯定 TCP 全連線佇列會長期溢位時,才能設定為 1 以儘快通知客戶端。

如何調整 accept 佇列的長度呢?

accept 佇列的長度取決於 somaxconn 和 backlog 之間的最小值,也就是 min(somaxconn, backlog),其中:

  • somaxconn 是 Linux 核心的引數,預設值是 128,可以透過 net.core.somaxconn 來設定其值;

  • backlog 是 listen(int sockfd, int backlog) 函式中的 backlog 大小;

Tomcat、Nginx、Apache 常見的 Web 服務的 backlog 預設值都是 511。

如何檢視服務端程式 accept 佇列的長度?

可以透過 ss -ltn 命令檢視:

image
image
  • Recv-Q:當前 accept 佇列的大小,也就是當前已完成三次握手並等待服務端 accept() 的 TCP 連線;

  • Send-Q:accept 佇列最大長度,上面的輸出結果說明監聽 8088 埠的 TCP 服務,accept 佇列的最大長度為 128;

如何檢視由於 accept 連線佇列已滿,而被丟棄的連線?

當超過了 accept 連線佇列,服務端則會丟掉後續進來的 TCP 連線,丟掉的 TCP 連線的個數會被統計起來,我們可以使用 netstat -s 命令來檢視:

image
image

上面看到的 41150 times ,表示 accept 佇列溢位的次數,注意這個是累計值。可以隔幾秒鐘執行下,如果這個數字一直在增加的話,說明 accept 連線佇列偶爾滿了。

如果持續不斷地有連線因為 accept 佇列溢位被丟棄,就應該調大 backlog 以及 somaxconn 引數。

如何繞過三次握手?

以上我們只是在對三次握手的過程進行最佳化,接下來我們看看如何繞過三次握手傳送資料。

三次握手建立連線造成的後果就是,HTTP 請求必須在一個 RTT(從客戶端到伺服器一個往返的時間)後才能傳送。

image
image

在 Linux 3.7 核心版本之後,提供了 TCP Fast Open 功能,這個功能可以減少 TCP 連線建立的時延。

接下來說說,TCP Fast Open 功能的工作方式。

image
image

在客戶端首次建立連線時的過程:

  1. 客戶端傳送 SYN 報文,該報文包含 Fast Open 選項,且該選項的 Cookie 為空,這表明客戶端請求 Fast Open Cookie;

  2. 支援 TCP Fast Open 的伺服器生成 Cookie,並將其置於 SYN-ACK 資料包中的 Fast Open 選項以返回客戶端;

  3. 客戶端收到 SYN-ACK 後,本地快取 Fast Open 選項中的 Cookie。

所以,第一次發起 HTTP GET 請求的時候,還是需要正常的三次握手流程。

之後,如果客戶端再次向伺服器建立連線時的過程:

  1. 客戶端傳送 SYN 報文,該報文包含「資料」(對於非 TFO 的普通 TCP 握手過程,SYN 報文中不包含「資料」)以及此前記錄的 Cookie;

  2. 支援 TCP Fast Open 的伺服器會對收到 Cookie 進行校驗:如果 Cookie 有效,伺服器將在 SYN-ACK 報文中對 SYN 和「資料」進行確認,伺服器隨後將「資料」遞送至相應的應用程式;如果 Cookie 無效,伺服器將丟棄 SYN 報文中包含的「資料」,且其隨後發出的 SYN-ACK 報文將只確認 SYN 的對應序列號;

  3. 如果伺服器接受了 SYN 報文中的「資料」,伺服器可在握手完成之前傳送「資料」,這就減少了握手帶來的 1 個 RTT 的時間消耗;

  4. 客戶端將傳送 ACK 確認伺服器發回的 SYN 以及「資料」,但如果客戶端在初始的 SYN 報文中傳送的「資料」沒有被確認,則客戶端將重新傳送「資料」;

  5. 此後的 TCP 連線的資料傳輸過程和非 TFO 的正常情況一致。

所以,之後發起 HTTP GET 請求的時候,可以繞過三次握手,這就減少了握手帶來的 1 個 RTT 的時間消耗。

開啟了 TFO 功能,cookie 的值是存放到 TCP option 欄位裡的:

image
image

注:客戶端在請求並儲存了 Fast Open Cookie 之後,可以不斷重複 TCP Fast Open 直至伺服器認為 Cookie 無效(通常為過期)。

Linux 下怎麼開啟 TCP Fast Open 功能呢?

在 Linux 系統中,可以透過設定 tcp_fastopn 核心引數,來開啟 Fast Open 功能:

image
image

tcp_fastopn 各個值的意義:

  • 0 關閉

  • 1 作為客戶端使用 Fast Open 功能

  • 2 作為服務端使用 Fast Open 功能

  • 3 無論作為客戶端還是伺服器,都可以使用 Fast Open 功能

TCP Fast Open 功能需要客戶端和服務端同時支援,才有效果。

小結

本小結主要介紹了關於最佳化 TCP 三次握手的幾個 TCP 引數。

image
image

客戶端的最佳化

當客戶端發起 SYN 包時,可以透過 tcp_syn_retries 控制其重傳的次數。

服務端的最佳化

當服務端 SYN 半連線佇列溢位後,會導致後續連線被丟棄,可以透過 netstat -s 觀察半連線佇列溢位的情況,如果 SYN 半連線佇列溢位情況比較嚴重,可以透過 tcp_max_syn_backlog、somaxconn、backlog 引數來調整 SYN 半連線佇列的大小。

服務端回覆 SYN+ACK 的重傳次數由 tcp_synack_retries 引數控制。如果遭受 SYN 攻擊,應把 tcp_syncookies 引數設定為 1,表示僅在 SYN 佇列滿後開啟 syncookie 功能,可以保證正常的連線成功建立。

服務端收到客戶端返回的 ACK,會把連線移入 accpet 佇列,等待進行呼叫 accpet() 函式取出連線。

可以透過 ss -lnt 檢視服務端程式的 accept 佇列長度,如果 accept 佇列溢位,系統預設丟棄 ACK,如果可以把 tcp_abort_on_overflow 設定為 1 ,表示用 RST 通知客戶端連線建立失敗。

如果 accpet 佇列溢位嚴重,可以透過 listen 函式的 backlog 引數和 somaxconn 系統引數提高佇列大小,accept 佇列長度取決於 min(backlog, somaxconn)。

繞過三次握手

TCP Fast Open 功能可以繞過三次握手,使得 HTTP 請求減少了 1 個 RTT 的時間,Linux 下可以透過 tcp_fastopen 開啟該功能,同時必須保證服務端和客戶端同時支援。

02 TCP 四次揮手的效能提升

接下來,我們一起看看針對 TCP 四次揮手關閉連線時,如何最佳化效能。

在開始之前,我們得先了解四次揮手狀態變遷的過程。

客戶端和服務端雙方都可以主動斷開連線,通常先關閉連線的一方稱為主動方,後關閉連線的一方稱為被動方。

image
image

可以看到,四次揮手過程只涉及了兩種報文,分別是 FIN 和 ACK:

  • FIN 就是結束連線的意思,誰發出 FIN 報文,就表示它將不會再傳送任何資料,關閉這一方向上的傳輸通道;

  • ACK 就是確認的意思,用來通知對方:你方的傳送通道已經關閉;

四次揮手的過程:

  • 當主動方關閉連線時,會傳送 FIN 報文,此時傳送方的 TCP 連線將從 ESTABLISHED 變成 FIN_WAIT1。

  • 當被動方收到 FIN 報文後,核心會自動回覆 ACK 報文,連線狀態將從 ESTABLISHED 變成 CLOSE_WAIT,表示被動方在等待程式呼叫 close 函式關閉連線。

  • 當主動方收到這個 ACK 後,連線狀態由 FIN_WAIT1 變為 FIN_WAIT2,也就是表示主動方的傳送通道就關閉了。

  • 當被動方進入 CLOSE_WAIT 時,被動方還會繼續處理資料,等到程式的 read 函式返回 0 後,應用程式就會呼叫 close 函式,進而觸發核心傳送 FIN 報文,此時被動方的連線狀態變為 LAST_ACK。

  • 當主動方收到這個 FIN 報文後,核心會回覆 ACK 報文給被動方,同時主動方的連線狀態由 FIN_WAIT2 變為 TIME_WAIT,在 Linux 系統下大約等待 1 分鐘後,TIME_WAIT 狀態的連線才會徹底關閉。

  • 當被動方收到最後的 ACK 報文後,被動方的連線就會關閉。

你可以看到,每個方向都需要一個 FIN 和一個 ACK,因此通常被稱為四次揮手。

這裡一點需要注意是:主動關閉連線的,才有 TIME_WAIT 狀態。

主動關閉方和被動關閉方最佳化的思路也不同,接下來分別說說如何最佳化他們。

主動方的最佳化

關閉連線的方式通常有兩種,分別是 RST 報文關閉和 FIN 報文關閉。

如果程式異常退出了,核心就會傳送 RST 報文來關閉,它可以不走四次揮手流程,是一個暴力關閉連線的方式。

安全關閉連線的方式必須透過四次揮手,它由程式呼叫 close 和 shutdown 函式發起 FIN 報文(shutdown 引數須傳入 SHUT_WR 或者 SHUT_RDWR 才會傳送 FIN)。

呼叫 close 函式和 shutdown 函式有什麼區別?

呼叫了 close 函式意味著完全斷開連線,完全斷開不僅指無法傳輸資料,而且也不能傳送資料。 此時,呼叫了 close 函式的一方的連線叫做「孤兒連線」,如果你用 netstat -p 命令,會發現連線對應的程式名為空。

使用 close 函式關閉連線是不優雅的。於是,就出現了一種優雅關閉連線的 shutdown 函式,它可以控制只關閉一個方向的連線:

image
image

第二個引數決定斷開連線的方式,主要有以下三種方式:

  • SHUT_RD(0):關閉連線的「讀」這個方向,如果接收緩衝區有已接收的資料,則將會被丟棄,並且後續再收到新的資料,會對資料進行 ACK,然後悄悄地丟棄。也就是說,對端還是會接收到 ACK,在這種情況下根本不知道資料已經被丟棄了。

  • SHUT_WR(1):關閉連線的「寫」這個方向,這就是常被稱為「半關閉」的連線。如果傳送緩衝區還有未傳送的資料,將被立即傳送出去,併傳送一個 FIN 報文給對端。

  • SHUT_RDWR(2):相當於 SHUT_RD 和 SHUT_WR 操作各一次,關閉套接字的讀和寫兩個方向。

close 和 shutdown 函式都可以關閉連線,但這兩種方式關閉的連線,不只功能上有差異,控制它們的 Linux 引數也不相同。

FIN_WAIT1 狀態的最佳化

主動方傳送 FIN 報文後,連線就處於 FIN_WAIT1 狀態,正常情況下,如果能及時收到被動方的 ACK,則會很快變為 FIN_WAIT2 狀態。

但是當遲遲收不到對方返回的 ACK 時,連線就會一直處於 FIN_WAIT1 狀態。此時,核心會定時重發 FIN 報文,其中重複次數由 tcp_orphan_retries 引數控制(注意,orphan 雖然是孤兒的意思,該引數卻不只對孤兒連線有效,事實上,它對所有 FIN_WAIT1 狀態下的連線都有效),預設值是 0。

image
image

你可能會好奇,這 0 表示幾次?實際上當為 0 時,特指 8 次,從下面的核心原始碼可知:

image
image

如果 FIN_WAIT1 狀態連線很多,我們就需要考慮降低 tcp_orphan_retries 的值,當重傳次數超過 tcp_orphan_retries 時,連線就會直接關閉掉。

對於普遍正常情況時,調低 tcp_orphan_retries 就已經可以了。如果遇到惡意攻擊,FIN 報文根本無法傳送出去,這由 TCP 兩個特性導致的:

  • 首先,TCP 必須保證報文是有序傳送的,FIN 報文也不例外,當傳送緩衝區還有資料沒有傳送時,FIN 報文也不能提前傳送。

  • 其次,TCP 有流量控制功能,當接收方接收視窗為 0 時,傳送方就不能再傳送資料。所以,當攻擊者下載大檔案時,就可以透過接收視窗設為 0 ,這就會使得 FIN 報文都無法傳送出去,那麼連線會一直處於 FIN_WAIT1 狀態。

解決這種問題的方法,是調整 tcp_max_orphans 引數,它定義了「孤兒連線」的最大數量:

image
image

當程式呼叫了 close 函式關閉連線,此時連線就會是「孤兒連線」,因為它無法再傳送和接收資料。Linux 系統為了防止孤兒連線過多,導致系統資源長時間被佔用,就提供了 tcp_max_orphans 引數。如果孤兒連線數量大於它,新增的孤兒連線將不再走四次揮手,而是直接傳送 RST 復位報文強制關閉。

FIN_WAIT2 狀態的最佳化

當主動方收到 ACK 報文後,會處於 FIN_WAIT2 狀態,就表示主動方的傳送通道已經關閉,接下來將等待對方傳送 FIN 報文,關閉對方的傳送通道。

這時,如果連線是用 shutdown 函式關閉的,連線可以一直處於 FIN_WAIT2 狀態,因為它可能還可以傳送或接收資料。但對於 close 函式關閉的孤兒連線,由於無法再傳送和接收資料,所以這個狀態不可以持續太久,而 tcp_fin_timeout 控制了這個狀態下連線的持續時長,預設值是 60 秒:

image
image

它意味著對於孤兒連線(呼叫 close 關閉的連線),如果在 60 秒後還沒有收到 FIN 報文,連線就會直接關閉。

這個 60 秒不是隨便決定的,它與 TIME_WAIT 狀態持續的時間是相同的,後面我們再來說說為什麼是 60 秒。

TIME_WAIT 狀態的最佳化

TIME_WAIT 是主動方四次揮手的最後一個狀態,也是最常遇見的狀態。

當收到被動方發來的 FIN 報文後,主動方會立刻回覆 ACK,表示確認對方的傳送通道已經關閉,接著就處於 TIME_WAIT 狀態。在 Linux 系統,TIME_WAIT 狀態會持續 60 秒後才會進入關閉狀態。

TIME_WAIT 狀態的連線,在主動方看來確實快已經關閉了。然後,被動方沒有收到 ACK 報文前,還是處於 LAST_ACK 狀態。如果這個 ACK 報文沒有到達被動方,被動方就會重發 FIN 報文。重發次數仍然由前面介紹過的 tcp_orphan_retries 引數控制。

TIME-WAIT 的狀態尤其重要,主要是兩個原因:

  • 防止具有相同「四元組」的「舊」資料包被收到;

  • 保證「被動關閉連線」的一方能被正確的關閉,即保證最後的 ACK 能讓被動關閉方接收,從而幫助其正常關閉;

原因一:防止舊連線的資料包

TIME-WAIT 的一個作用是防止收到歷史資料,從而導致資料錯亂的問題。

假設 TIME-WAIT 沒有等待時間或時間過短,被延遲的資料包抵達後會發生什麼呢?

image
image
  • 如上圖黃色框框服務端在關閉連線之前傳送的 SEQ = 301 報文,被網路延遲了。

  • 這時有相同埠的 TCP 連線被複用後,被延遲的 SEQ = 301 抵達了客戶端,那麼客戶端是有可能正常接收這個過期的報文,這就會產生資料錯亂等嚴重的問題。

所以,TCP 就設計出了這麼一個機制,經過 2MSL 這個時間,足以讓兩個方向上的資料包都被丟棄,使得原來連線的資料包在網路中都自然消失,再出現的資料包一定都是新建立連線所產生的。

原因二:保證連線正確關閉

TIME-WAIT 的另外一個作用是等待足夠的時間以確保最後的 ACK 能讓被動關閉方接收,從而幫助其正常關閉。

假設 TIME-WAIT 沒有等待時間或時間過短,斷開連線會造成什麼問題呢?

image
image
  • 如上圖紅色框框客戶端四次揮手的最後一個 ACK 報文如果在網路中被丟失了,此時如果客戶端 TIME-WAIT 過短或沒有,則就直接進入了 CLOSE 狀態了,那麼服務端則會一直處在 LAST-ACK 狀態。

  • 當客戶端發起建立連線的 SYN 請求報文後,服務端會傳送 RST 報文給客戶端,連線建立的過程就會被終止。

我們再回過頭來看看,為什麼 TIME_WAIT 狀態要保持 60 秒呢?這與孤兒連線 FIN_WAIT2 狀態預設保留 60 秒的原理是一樣的,因為這兩個狀態都需要保持 2MSL 時長。MSL 全稱是 Maximum Segment Lifetime,它定義了一個報文在網路中的最長生存時間(報文每經過一次路由器的轉發,IP 頭部的 TTL 欄位就會減 1,減到 0 時報文就被丟棄,這就限制了報文的最長存活時間)。

為什麼是 2 MSL 的時長呢?這其實是相當於至少允許報文丟失一次。比如,若 ACK 在一個 MSL 內丟失,這樣被動方重發的 FIN 會在第 2 個 MSL 內到達,TIME_WAIT 狀態的連線可以應對。

為什麼不是 4 或者 8 MSL 的時長呢?你可以想象一個丟包率達到百分之一的糟糕網路,連續兩次丟包的機率只有萬分之一,這個機率實在是太小了,忽略它比解決它更具價效比。

因此,TIME_WAIT 和 FIN_WAIT2 狀態的最大時長都是 2 MSL,由於在 Linux 系統中,MSL 的值固定為 30 秒,所以它們都是 60 秒。

雖然 TIME_WAIT 狀態有存在的必要,但它畢竟會消耗系統資源。如果發起連線一方的 TIME_WAIT 狀態過多,佔滿了所有埠資源,則會導致無法建立新連線。

  • 客戶端受埠資源限制:如果客戶端 TIME_WAIT 過多,就會導致埠資源被佔用,因為埠就65536個,被佔滿就會導致無法建立新的連線;

  • 服務端受系統資源限制:由於一個四元組表示TCP連線,理論上服務端可以建立很多連線,服務端確實只監聽一個埠,但是會把連線扔給處理執行緒,所以理論上監聽的埠可以繼續監聽。但是執行緒池處理不了那麼多一直不斷的連線了。所以當服務端出現大量 TIME_WAIT 時,系統資源被佔滿時,會導致處理不過來新的連線;

另外,Linux 提供了 tcp_max_tw_buckets 引數,當 TIME_WAIT 的連線數量超過該引數時,新關閉的連線就不再經歷 TIME_WAIT 而直接關閉:

image
image

當伺服器的併發連線增多時,相應地,同時處於 TIME_WAIT 狀態的連線數量也會變多,此時就應當調大 tcp_max_tw_buckets 引數,減少不同連線間資料錯亂的機率。

tcp_max_tw_buckets 也不是越大越好,畢竟記憶體和埠都是有限的。

有一種方式可以在建立新連線時,複用處於 TIME_WAIT 狀態的連線,那就是開啟 tcp_tw_reuse 引數。但是需要注意,該引數是隻用於客戶端(建立連線的發起方),因為是在呼叫 connect() 時起作用的,而對於服務端(被動連線方)是沒有用的。

image
image

tcp_tw_reuse 從協議角度理解是安全可控的,可以複用處於 TIME_WAIT 的埠為新的連線所用。

什麼是協議角度理解的安全可控呢?主要有兩點:

  • 只適用於連線發起方,也就是 C/S 模型中的客戶端;

  • 對應的 TIME_WAIT 狀態的連線建立時間超過 1 秒才可以被複用。

使用這個選項,還有一個前提,需要開啟對 TCP 時間戳的支援(對方也要開啟 ):

image
image

由於引入了時間戳,它能帶來了些好處:

  • 我們在前面提到的 2MSL 問題就不復存在了,因為重複的資料包會因為時間戳過期被自然丟棄;

  • 同時,它還可以防止序列號繞回,也是因為重複的資料包會由於時間戳過期被自然丟棄;

時間戳是在 TCP 的選項欄位裡定義的,開啟了時間戳功能,在 TCP 報文傳輸的時候會帶上傳送報文的時間戳。

image
image

我們來看看開啟了 tcp_tw_reuse 功能,如果四次揮手中的最後一次 ACK 在網路中丟失了,會發生什麼?

image
image

上圖的流程:

  • 四次揮手中的最後一次 ACK 在網路中丟失了,服務端一直處於 LAST_ACK 狀態;

  • 客戶端由於開啟了 tcp_tw_reuse 功能,客戶端再次發起新連線的時候,會複用超過 1 秒後的 time_wait 狀態的連線。但客戶端新發的 SYN 包會被忽略(由於時間戳),因為服務端比較了客戶端的上一個報文與 SYN 報文的時間戳,過期的報文就會被服務端丟棄;

  • 服務端 FIN 報文遲遲沒有收到四次揮手的最後一次 ACK,於是超時重發了 FIN 報文給客戶端;

  • 處於 SYN_SENT 狀態的客戶端,由於收到了 FIN 報文,則會回 RST 給服務端,於是服務端就離開了 LAST_ACK 狀態;

  • 最初的客戶端 SYN 報文超時重發了( 1 秒鐘後),此時就與服務端能正確的三次握手了。

所以大家都會說開啟了 tcp_tw_reuse,可以在複用了 time_wait 狀態的 1 秒過後成功建立連線,這 1 秒主要是花費在 SYN 包重傳。

另外,老版本的 Linux 還提供了 tcp_tw_recycle 引數,但是當開啟了它,就有兩個坑:

  • Linux 會加快客戶端和服務端 TIME_WAIT 狀態的時間,也就是它會使得 TIME_WAIT 狀態會小於 60 秒,很容易導致資料錯亂;

  • 另外,Linux 會丟棄所有來自遠端時間戳小於上次記錄的時間戳(由同一個遠端傳送的)的任何資料包。就是說要使用該選項,則必須保證資料包的時間戳是單調遞增的。那麼,問題在於,此處的時間戳並不是我們通常意義上面的絕對時間,而是一個相對時間。很多情況下,我們是沒法保證時間戳單調遞增的,比如使用了 NAT、LVS 等情況;

所以,不建議設定為 1 ,在 Linux 4.12 版本後,Linux 核心直接取消了這一引數,建議關閉它:

image
image

另外,我們可以在程式中設定 socket 選項,來設定呼叫 close 關閉連線行為。

image
image

如果 l_onoff 為非 0, 且 l_linger 值為 0,那麼呼叫 close 後,會立該傳送一個 RST 標誌給對端,該 TCP 連線將跳過四次揮手,也就跳過了 TIME_WAIT 狀態,直接關閉。

但這為跨越 TIME_WAIT 狀態提供了一個可能,不過是一個非常危險的行為,不值得提倡。

被動方的最佳化

當被動方收到 FIN 報文時,核心會自動回覆 ACK,同時連線處於 CLOSE_WAIT 狀態,顧名思義,它表示等待應用程式呼叫 close 函式關閉連線。

核心沒有權利替代程式去關閉連線,因為如果主動方是透過 shutdown 關閉連線,那麼它就是想在半關閉連線上接收資料或傳送資料。因此,Linux 並沒有限制 CLOSE_WAIT 狀態的持續時間。

當然,大多數應用程式並不使用 shutdown 函式關閉連線。所以,當你用 netstat 命令發現大量 CLOSE_WAIT 狀態。就需要排查你的應用程式,因為可能因為應用程式出現了 Bug,read 函式返回 0 時,沒有呼叫 close 函式。

處於 CLOSE_WAIT 狀態時,呼叫了 close 函式,核心就會發出 FIN 報文關閉傳送通道,同時連線進入 LAST_ACK 狀態,等待主動方返回 ACK 來確認連線關閉。

如果遲遲收不到這個 ACK,核心就會重發 FIN 報文,重發次數仍然由 tcp_orphan_retries 引數控制,這與主動方重發 FIN 報文的最佳化策略一致。

還有一點我們需要注意的,如果被動方迅速呼叫 close 函式,那麼被動方的 ACK 和 FIN 有可能在一個報文中傳送,這樣看起來,四次揮手會變成三次揮手,這只是一種特殊情況,不用在意。

如果連線雙方同時關閉連線,會怎麼樣?

由於 TCP 是雙全工的協議,所以是會出現兩方同時關閉連線的現象,也就是同時傳送了 FIN 報文。

此時,上面介紹的最佳化策略仍然適用。兩方傳送 FIN 報文時,都認為自己是主動方,所以都進入了 FIN_WAIT1 狀態,FIN 報文的重發次數仍由 tcp_orphan_retries 引數控制。

image
image

接下來,雙方在等待 ACK 報文的過程中,都等來了 FIN 報文。這是一種新情況,所以連線會進入一種叫做 CLOSING 的新狀態,它替代了 FIN_WAIT2 狀態。接著,雙方核心回覆 ACK 確認對方傳送通道的關閉後,進入 TIME_WAIT 狀態,等待 2MSL 的時間後,連線自動關閉。

小結

針對 TCP 四次揮手的最佳化,我們需要根據主動方和被動方四次揮手狀態變化來調整系統 TCP 核心引數。

image
image

主動方的最佳化

主動發起 FIN 報文斷開連線的一方,如果遲遲沒收到對方的 ACK 回覆,則會重傳 FIN 報文,重傳的次數由 tcp_orphan_retries 引數決定。

當主動方收到 ACK 報文後,連線就進入 FIN_WAIT2 狀態,根據關閉的方式不同,最佳化的方式也不同:

  • 如果這是 close 函式關閉的連線,那麼它就是孤兒連線。如果 tcp_fin_timeout 秒內沒有收到對方的 FIN 報文,連線就直接關閉。同時,為了應對孤兒連線佔用太多的資源,tcp_max_orphans 定義了最大孤兒連線的數量,超過時連線就會直接釋放。

  • 反之是 shutdown 函式關閉的連線,則不受此引數限制;

當主動方接收到 FIN 報文,並返回 ACK 後,主動方的連線進入 TIME_WAIT 狀態。這一狀態會持續 1 分鐘,為了防止 TIME_WAIT 狀態佔用太多的資源,tcp_max_tw_buckets 定義了最大數量,超過時連線也會直接釋放。

當 TIME_WAIT 狀態過多時,還可以透過設定 tcp_tw_reuse 和 tcp_timestamps 為 1 ,將 TIME_WAIT 狀態的埠複用於作為客戶端的新連線,注意該引數只適用於客戶端。

被動方的最佳化

被動關閉的連線方應對非常簡單,它在回覆 ACK 後就進入了 CLOSE_WAIT 狀態,等待程式呼叫 close 函式關閉連線。因此,出現大量 CLOSE_WAIT 狀態的連線時,應當從應用程式中找問題。

當被動方傳送 FIN 報文後,連線就進入 LAST_ACK 狀態,在未等到 ACK 時,會在 tcp_orphan_retries 引數的控制下重複 FIN 報文。


03 TCP 傳輸資料的效能提升

在前面介紹的是三次握手和四次揮手的最佳化策略,接下來主要介紹的是 TCP 傳輸資料時的最佳化策略。

TCP 連線是由核心維護的,核心會為每個連線建立記憶體緩衝區:

  • 如果連線的記憶體配置過小,就無法充分使用網路頻寬,TCP 傳輸效率就會降低;

  • 如果連線的記憶體配置過大,很容易把伺服器資源耗盡,這樣就會導致新連線無法建立;

因此,我們必須理解 Linux 下 TCP 記憶體的用途,才能正確地配置記憶體大小。

滑動視窗是如何影響傳輸速度的?

TCP 會保證每一個報文都能夠抵達對方,它的機制是這樣:報文發出去後,必須接收到對方返回的確認報文 ACK,如果遲遲未收到,就會超時重發該報文,直到收到對方的 ACK 為止。

所以,TCP 報文發出去後,並不會立馬從記憶體中刪除,因為重傳時還需要用到它。

由於 TCP 是核心維護的,所以報文存放在核心緩衝區。如果連線非常多,我們可以透過 free 命令觀察到 buff/cache 記憶體是會增大。

如果 TCP 是每傳送一個資料,都要進行一次確認應答。當上一個資料包收到了應答了, 再傳送下一個。這個模式就有點像我和你面對面聊天,你一句我一句,但這種方式的缺點是效率比較低的。

image
image

所以,這樣的傳輸方式有一個缺點:資料包的往返時間越長,通訊的效率就越低。

要解決這一問題不難,並行批次傳送報文,再批次確認報文即可。

image
image

然而,這引出了另一個問題,傳送方可以隨心所欲的傳送報文嗎?當然這不現實,我們還得考慮接收方的處理能力。

當接收方硬體不如傳送方,或者系統繁忙、資源緊張時,是無法瞬間處理這麼多報文的。於是,這些報文只能被丟掉,使得網路效率非常低。

為了解決這種現象發生,TCP 提供一種機制可以讓「傳送方」根據「接收方」的實際接收能力控制傳送的資料量,這就是滑動視窗的由來。

接收方根據它的緩衝區,可以計算出後續能夠接收多少位元組的報文,這個數字叫做接收視窗。當核心接收到報文時,必須用緩衝區存放它們,這樣剩餘緩衝區空間變小,接收視窗也就變小了;當程式呼叫 read 函式後,資料被讀入了使用者空間,核心緩衝區就被清空,這意味著主機可以接收更多的報文,接收視窗就會變大。

因此,接收視窗並不是恆定不變的,接收方會把當前可接收的大小放在 TCP 報文頭部中的視窗欄位,這樣就可以起到視窗大小通知的作用。

傳送方的視窗等價於接收方的視窗嗎?如果不考慮擁塞控制,傳送方的視窗大小「約等於」接收方的視窗大小,因為視窗通知報文在網路傳輸是存在時延的,所以是約等於的關係。

image
image

從上圖中可以看到,視窗欄位只有 2 個位元組,因此它最多能表達 65535 位元組大小的視窗,也就是 64KB 大小。

這個視窗大小最大值,在當今高速網路下,很明顯是不夠用的。所以後續有了擴充視窗的方法:在 TCP 選項欄位定義了視窗擴大因子,用於擴大 TCP 通告視窗,其值大小是 2^14,這樣就使 TCP 的視窗大小從 16 位擴大為 30 位(2^16 * 2^ 14 = 2^30),所以此時視窗的最大值可以達到 1GB。

image
image

Linux 中開啟這一功能,需要把 tcp_window_scaling 配置設為 1(預設開啟):

image
image

要使用視窗擴大選項,通訊雙方必須在各自的 SYN 報文中傳送這個選項:

  • 主動建立連線的一方在 SYN 報文中傳送這個選項;

  • 而被動建立連線的一方只有在收到帶視窗擴大選項的 SYN 報文之後才能傳送這個選項。

這樣看來,只要程式能及時地呼叫 read 函式讀取資料,並且接收緩衝區配置得足夠大,那麼接收視窗就可以無限地放大,傳送方也就無限地提升傳送速度。

這是不可能的,因為網路的傳輸能力是有限的,當傳送方依據傳送視窗,傳送超過網路處理能力的報文時,路由器會直接丟棄這些報文。因此,緩衝區的記憶體並不是越大越好。

如何確定最大傳輸速度?

在前面我們知道了 TCP 的傳輸速度,受制於傳送視窗與接收視窗,以及網路裝置傳輸能力。其中,視窗大小由核心緩衝區大小決定。如果緩衝區與網路傳輸能力匹配,那麼緩衝區的利用率就達到了最大化。

問題來了,如何計算網路的傳輸能力呢?

相信大家都知道網路是有「頻寬」限制的,頻寬描述的是網路傳輸能力,它與核心緩衝區的計量單位不同:

  • 頻寬是單位時間內的流量,表達是「速度」,比如常見的頻寬 100 MB/s;

  • 緩衝區單位是位元組,當網路速度乘以時間才能得到位元組數;

這裡需要說一個概念,就是頻寬時延積,它決定網路中飛行報文的大小,它的計算方式:

image
image

比如最大頻寬是 100 MB/s,網路時延(RTT)是 10ms 時,意味著客戶端到服務端的網路一共可以存放 100MB/s * 0.01s = 1MB 的位元組。

這個 1MB 是頻寬和時延的乘積,所以它就叫「頻寬時延積」(縮寫為 BDP,Bandwidth Delay Product)。同時,這 1MB 也表示「飛行中」的 TCP 報文大小,它們就在網路線路、路由器等網路裝置上。如果飛行報文超過了 1 MB,就會導致網路過載,容易丟包。

由於傳送緩衝區大小決定了傳送視窗的上限,而傳送視窗又決定了「已傳送未確認」的飛行報文的上限。因此,傳送緩衝區不能超過「頻寬時延積」。

傳送緩衝區與頻寬時延積的關係:

  • 如果傳送緩衝區「超過」頻寬時延積,超出的部分就沒辦法有效的網路傳輸,同時導致網路過載,容易丟包;

  • 如果傳送緩衝區「小於」頻寬時延積,就不能很好的發揮出網路的傳輸效率。

所以,傳送緩衝區的大小最好是往頻寬時延積靠近。

怎樣調整緩衝區大小?

在 Linux 中傳送緩衝區和接收緩衝都是可以用引數調節的。設定完後,Linux 會根據你設定的緩衝區進行動態調節。

調節傳送緩衝區範圍

先來看看傳送緩衝區,它的範圍透過 tcp_wmem 引數配置;

image
image

上面三個數字單位都是位元組,它們分別表示:

  • 第一個數值是動態範圍的最小值,4096 byte = 4K;

  • 第二個數值是初始預設值,87380 byte ≈ 86K;

  • 第三個數值是動態範圍的最大值,4194304 byte = 4096K(4M);

傳送緩衝區是自行調節的,當傳送方傳送的資料被確認後,並且沒有新的資料要傳送,就會把傳送緩衝區的記憶體釋放掉。

調節接收緩衝區範圍

而接收緩衝區的調整就比較複雜一些,先來看看設定接收緩衝區範圍的 tcp_rmem 引數:

image
image

上面三個數字單位都是位元組,它們分別表示:

  • 第一個數值是動態範圍的最小值,表示即使在記憶體壓力下也可以保證的最小接收緩衝區大小,4096 byte = 4K;

  • 第二個數值是初始預設值,87380 byte ≈ 86K;

  • 第三個數值是動態範圍的最大值,6291456 byte = 6144K(6M);

接收緩衝區可以根據系統空閒記憶體的大小來調節接收視窗:

  • 如果系統的空閒記憶體很多,就可以自動把緩衝區增大一些,這樣傳給對方的接收視窗也會變大,因而提升傳送方傳送的傳輸資料數量;

  • 反之,如果系統的記憶體很緊張,就會減少緩衝區,這雖然會降低傳輸效率,可以保證更多的併發連線正常工作;

傳送緩衝區的調節功能是自動開啟的,而接收緩衝區則需要配置 tcp_moderate_rcvbuf 為 1 來開啟調節功能:

image
image

調節 TCP 記憶體範圍

接收緩衝區調節時,怎麼知道當前記憶體是否緊張或充分呢?這是透過 tcp_mem 配置完成的:

image
image

上面三個數字單位不是位元組,而是「頁面大小」,1 頁表示 4KB,它們分別表示:

  • 當 TCP 記憶體小於第 1 個值時,不需要進行自動調節;

  • 在第 1 和第 2 個值之間時,核心開始調節接收緩衝區的大小;

  • 大於第 3 個值時,核心不再為 TCP 分配新記憶體,此時新連線是無法建立的;

一般情況下這些值是在系統啟動時根據系統記憶體數量計算得到的。根據當前 tcp_mem 最大記憶體頁面數是 177120,當記憶體為 (177120 * 4) / 1024K ≈ 692M 時,系統將無法為新的 TCP 連線分配記憶體,即 TCP 連線將被拒絕。

根據實際場景調節的策略

在高併發伺服器中,為了兼顧網速與大量的併發連線,我們應當保證緩衝區的動態調整的最大值達到頻寬時延積,而最小值保持預設的 4K 不變即可。而對於記憶體緊張的服務而言,調低預設值是提高併發的有效手段。

同時,如果這是網路 IO 型伺服器,那麼,調大 tcp_mem 的上限可以讓 TCP 連線使用更多的系統記憶體,這有利於提升併發能力。需要注意的是,tcp_wmem 和 tcp_rmem 的單位是位元組,而 tcp_mem 的單位是頁面大小。而且,千萬不要在 socket 上直接設定 SO_SNDBUF 或者 SO_RCVBUF,這樣會關閉緩衝區的動態調整功能。

小結

本節針對 TCP 最佳化資料傳輸的方式,做了一些介紹。

image
image

TCP 可靠性是透過 ACK 確認報文實現的,又依賴滑動視窗提升了傳送速度也兼顧了接收方的處理能力。

可是,預設的滑動視窗最大值只有 64 KB,不滿足當今的高速網路的要求,要想提升傳送速度必須提升滑動視窗的上限,在 Linux 下是透過設定 tcp_window_scaling 為 1 做到的,此時最大值可高達 1GB。

滑動視窗定義了網路中飛行報文的最大位元組數,當它超過頻寬時延積時,網路過載,就會發生丟包。而當它小於頻寬時延積時,就無法充分利用網路頻寬。因此,滑動視窗的設定,必須參考頻寬時延積。

核心緩衝區決定了滑動視窗的上限,緩衝區可分為:傳送緩衝區 tcp_wmem 和接收緩衝區 tcp_rmem。

Linux 會對緩衝區動態調節,我們應該把緩衝區的上限設定為頻寬時延積。傳送緩衝區的調節功能是自動開啟的,而接收緩衝區需要把 tcp_moderate_rcvbuf 設定為 1 來開啟。其中,調節的依據是 TCP 記憶體範圍 tcp_mem。

但需要注意的是,如果程式中的 socket 設定 SO_SNDBUF 和 SO_RCVBUF,則會關閉緩衝區的動態整功能,所以不建議在程式設定它倆,而是交給核心自動調整比較好。

有效配置這些引數後,既能夠最大程度地保持併發性,也能讓資源充裕時連線傳輸速度達到最大值。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70000181/viewspace-2772856/,如需轉載,請註明出處,否則將追究法律責任。

相關文章