前言
TCP 性能的提升不僅考察 TCP 的理論知識,還考察了對于作業系統提供的核心參數的了解與應用。
TCP 協定是由作業系統實作,是以作業系統提供了不少調節 TCP 的參數。
Linux TCP 參數
如何正确有效的使用這些參數,來提高 TCP 性能是一個不那麼簡單事情。我們需要針對 TCP 每個階段的問題來對症下藥,而不是病急亂投醫。
接下來,将以三個角度來闡述提升 TCP 的政策,分别是:
- TCP 三次握手的性能提升;
- TCP 四次揮手的性能提升;
- TCP 資料傳輸的性能提升;
本節提綱
正文
01 TCP 三次握手的性能提升
TCP 是面向連接配接的、可靠的、雙向傳輸的傳輸層通信協定,是以在傳輸資料之前需要經過三次握手才能建立連接配接。
三次握手與資料傳輸
那麼,三次握手的過程在一個 HTTP 請求的平均時間占比 10% 以上,在網絡狀态不佳、高并發或者遭遇 SYN 攻擊等場景中,如果不能有效正确的調節三次握手中的參數,就會對性能産生很多的影響。
如何正确有效的使用這些參數,來提高 TCP 三次握手的性能,這就需要了解「三次握手的狀态變遷」,這樣當出現問題時,先用
netstat
指令檢視是哪個握手階段出現了問題,再來對症下藥,而不是病急亂投醫。
TCP 三次握手的狀态變遷
用戶端和服務端都可以針對三次握手優化性能。主動發起連接配接的用戶端優化相對簡單些,而服務端需要監聽端口,屬于被動連接配接方,其間保持許多的中間狀态,優化方法相對複雜一些。
是以,用戶端(主動發起連接配接方)和服務端(被動連接配接方)優化的方式是不同的,接下來分别針對用戶端和服務端優化。
用戶端優化
三次握手建立連接配接的首要目的是「同步序列号」。
隻有同步了序列号才有可靠傳輸,TCP 許多特性都依賴于序列号實作,比如流量控制、丢包重傳等,這也是三次握手中的封包稱為 SYN 的原因,SYN 的全稱就叫 Synchronize Sequence Numbers(同步序列号)。
TCP 頭部
SYN_SENT 狀态的優化
用戶端作為主動發起連接配接方,首先它将發送 SYN 包,于是用戶端的連接配接就會處于
SYN_SENT
狀态。
用戶端在等待服務端回複的 ACK 封包,正常情況下,伺服器會在幾毫秒内傳回 SYN+ACK ,但如果用戶端長時間沒有收到 SYN+ACK 封包,則會重發 SYN 包,重發的次數由 tcp_syn_retries 參數控制,預設是 5 次:
通常,第一次逾時重傳是在 1 秒後,第二次逾時重傳是在 2 秒,第三次逾時重傳是在 4 秒後,第四次逾時重傳是在 8 秒後,第五次是在逾時重傳 16 秒後。沒錯,每次逾時的時間是上一次的 2 倍。
當第五次逾時重傳後,會繼續等待 32 秒,如果服務端仍然沒有回應 ACK,用戶端就會終止三次握手。
是以,總耗時是 1+2+4+8+16+32=63 秒,大約 1 分鐘左右。
SYN 逾時重傳
你可以根據網絡的穩定性和目标伺服器的繁忙程度修改 SYN 的重傳次數,調整用戶端的三次握手時間上限。比如内網中通訊時,就可以适當調低重試次數,盡快把錯誤暴露給應用程式。
服務端優化
當服務端收到 SYN 包後,服務端會立馬回複 SYN+ACK 包,表明确認收到了用戶端的序列号,同時也把自己的序列号發給對方。
此時,服務端出現了新連接配接,狀态是
SYN_RCV
。在這個狀态下,Linux 核心就會建立一個「半連接配接隊列」來維護「未完成」的握手資訊,當半連接配接隊列溢出後,服務端就無法再建立新的連接配接。
半連接配接隊列與全連接配接隊列
SYN 攻擊,攻擊的是就是這個半連接配接隊列。
如何檢視由于 SYN 半連接配接隊列已滿,而被丢棄連接配接的情況?
我們可以通過該
netstat -s
指令給出的統計結果中, 可以得到由于半連接配接隊列已滿,引發的失敗次數:
上面輸出的數值是累計值,表示共有多少個 TCP 連接配接因為半連接配接隊列溢出而被丢棄。隔幾秒執行幾次,如果有上升的趨勢,說明目前存在半連接配接隊列溢出的現象。
如何調整 SYN 半連接配接隊列大小?
要想增大半連接配接隊列,不能隻單純增大 tcp_max_syn_backlog 的值,還需一同增大 somaxconn 和 backlog,也就是增大 accept 隊列。否則,隻單純增大 tcp_max_syn_backlog 是無效的。
增大 tcp_max_syn_backlog 和 somaxconn 的方法是修改 Linux 核心參數:
增大 backlog 的方式,每個 Web 服務都不同,比如 Nginx 增大 backlog 的方法如下:
最後,改變了如上這些參數後,要重新開機 Nginx 服務,因為 SYN 半連接配接隊列和 accept 隊列都是在
listen()
初始化的。
如果 SYN 半連接配接隊列已滿,隻能丢棄連接配接嗎?
并不是這樣,開啟 syncookies 功能就可以在不使用 SYN 半連接配接隊列的情況下成功建立連接配接。
syncookies 的工作原理:伺服器根據目前狀态計算出一個值,放在己方發出的 SYN+ACK 封包中發出,當用戶端傳回 ACK 封包時,取出該值驗證,如果合法,就認為連接配接建立成功,如下圖所示。
開啟 syncookies 功能
syncookies 參數主要有以下三個值:
- 0 值,表示關閉該功能;
- 1 值,表示僅當 SYN 半連接配接隊列放不下時,再啟用它;
- 2 值,表示無條件開啟功能;
那麼在應對 SYN 攻擊時,隻需要設定為 1 即可:
SYN_RCV 狀态的優化
當用戶端接收到伺服器發來的 SYN+ACK 封包後,就會回複 ACK 給伺服器,同時用戶端連接配接狀态從 SYN_SENT 轉換為 ESTABLISHED,表示連接配接建立成功。
伺服器端連接配接成功建立的時間還要再往後,等到服務端收到用戶端的 ACK 後,服務端的連接配接狀态才變為 ESTABLISHED。
如果伺服器沒有收到 ACK,就會重發 SYN+ACK 封包,同時一直處于 SYN_RCV 狀态。
當網絡繁忙、不穩定時,封包丢失就會變嚴重,此時應該調大重發次數。反之則可以調小重發次數。修改重發次數的方法是,調整 tcp_synack_retries 參數:
tcp_synack_retries 的預設重試次數是 5 次,與用戶端重傳 SYN 類似,它的重傳會經曆 1、2、4、8、16 秒,最後一次重傳後會繼續等待 32 秒,如果服務端仍然沒有收到 ACK,才會關閉連接配接,故共需要等待 63 秒。
伺服器收到 ACK 後連接配接建立成功,此時,核心會把連接配接從半連接配接隊列移除,然後建立新的完全的連接配接,并将其添加到 accept 隊列,等待程序調用 accept 函數時把連接配接取出來。
如果程序不能及時地調用 accept 函數,就會造成 accept 隊列(也稱全連接配接隊列)溢出,最終導緻建立好的 TCP 連接配接被丢棄。
accept 隊列溢出
accept 隊列已滿,隻能丢棄連接配接嗎?
丢棄連接配接隻是 Linux 的預設行為,我們還可以選擇向用戶端發送 RST 複位封包,告訴用戶端連接配接已經建立失敗。打開這一功能需要将 tcp_abort_on_overflow 參數設定為 1。
tcp_abort_on_overflow 共有兩個值分别是 0 和 1,其分别表示:
- 0 :如果 accept 隊列滿了,那麼 server 扔掉 client 發過來的 ack ;
- 1 :如果 accept 隊列滿了,server 發送一個
包給 client,表示廢掉這個握手過程和這個連接配接;RST
如果要想知道用戶端連接配接不上服務端,是不是服務端 TCP 全連接配接隊列滿的原因,那麼可以把 tcp_abort_on_overflow 設定為 1,這時如果在用戶端異常中可以看到很多
connection reset by peer
的錯誤,那麼就可以證明是由于服務端 TCP 全連接配接隊列溢出的問題。
通常情況下,應當把 tcp_abort_on_overflow 設定為 0,因為這樣更有利于應對突發流量。
舉個例子,當 accept 隊列滿導緻伺服器丢掉了 ACK,與此同時,用戶端的連接配接狀态卻是 ESTABLISHED,用戶端程序就在建立好的連接配接上發送請求。隻要伺服器沒有為請求回複 ACK,用戶端的請求就會被多次「重發」。如果伺服器上的程序隻是短暫的繁忙造成 accept 隊列滿,那麼當 accept 隊列有空位時,再次接收到的請求封包由于含有 ACK,仍然會觸發伺服器端成功建立連接配接。
tcp_abort_on_overflow 為 0 可以應對突發流量
是以,tcp_abort_on_overflow 設為 0 可以提高連接配接建立的成功率,隻有你非常肯定 TCP 全連接配接隊列會長期溢出時,才能設定為 1 以盡快通知用戶端。
如何調整 accept 隊列的長度呢?
accept 隊列的長度取決于 somaxconn 和 backlog 之間的最小值,也就是 min(somaxconn, backlog),其中:
- somaxconn 是 Linux 核心的參數,預設值是 128,可以通過
來設定其值;net.core.somaxconn
- backlog 是
函數中的 backlog 大小;listen(int sockfd, int backlog)
Tomcat、Nginx、Apache 常見的 Web 服務的 backlog 預設值都是 511。
如何檢視服務端程序 accept 隊列的長度?
可以通過
ss -ltn
指令檢視:
- Recv-Q:目前 accept 隊列的大小,也就是目前已完成三次握手并等待服務端
的 TCP 連接配接;accept()
- Send-Q:accept 隊列最大長度,上面的輸出結果說明監聽 8088 端口的 TCP 服務,accept 隊列的最大長度為 128;
如何檢視由于 accept 連接配接隊列已滿,而被丢棄的連接配接?
當超過了 accept 連接配接隊列,服務端則會丢掉後續進來的 TCP 連接配接,丢掉的 TCP 連接配接的個數會被統計起來,我們可以使用 netstat -s 指令來檢視:
上面看到的 41150 times ,表示 accept 隊列溢出的次數,注意這個是累計值。可以隔幾秒鐘執行下,如果這個數字一直在增加的話,說明 accept 連接配接隊列偶爾滿了。
如果持續不斷地有連接配接因為 accept 隊列溢出被丢棄,就應該調大 backlog 以及 somaxconn 參數。
如何繞過三次握手?
以上我們隻是在對三次握手的過程進行優化,接下來我們看看如何繞過三次握手發送資料。
三次握手建立連接配接造成的後果就是,HTTP 請求必須在一個 RTT(從用戶端到伺服器一個往返的時間)後才能發送。
正常 HTTP 請求
在 Linux 3.7 核心版本之後,提供了 TCP Fast Open 功能,這個功能可以減少 TCP 連接配接建立的時延。
接下來說說,TCP Fast Open 功能的工作方式。
開啟 TCP Fast Open 功能
在用戶端首次建立連接配接時的過程:
- 用戶端發送 SYN 封包,該封包包含 Fast Open 選項,且該選項的 Cookie 為空,這表明用戶端請求 Fast Open Cookie;
- 支援 TCP Fast Open 的伺服器生成 Cookie,并将其置于 SYN-ACK 資料包中的 Fast Open 選項以發回用戶端;
- 用戶端收到 SYN-ACK 後,本地緩存 Fast Open 選項中的 Cookie。
是以,第一次發起 HTTP GET 請求的時候,還是需要正常的三次握手流程。
之後,如果用戶端再次向伺服器建立連接配接時的過程:
- 用戶端發送 SYN 封包,該封包包含「資料」(對于非 TFO 的普通 TCP 握手過程,SYN 封包中不包含「資料」)以及此前記錄的 Cookie;
- 支援 TCP Fast Open 的伺服器會對收到 Cookie 進行校驗:如果 Cookie 有效,伺服器将在 SYN-ACK 封包中對 SYN 和「資料」進行确認,伺服器随後将「資料」遞送至相應的應用程式;如果 Cookie 無效,伺服器将丢棄 SYN 封包中包含的「資料」,且其随後發出的 SYN-ACK 封包将隻确認 SYN 的對應序列号;
- 如果伺服器接受了 SYN 封包中的「資料」,伺服器可在握手完成之前發送「資料」,這就減少了握手帶來的 1 個 RTT 的時間消耗;
- 用戶端将發送 ACK 确認伺服器發回的 SYN 以及「資料」,但如果用戶端在初始的 SYN 封包中發送的「資料」沒有被确認,則用戶端将重新發送「資料」;
- 此後的 TCP 連接配接的資料傳輸過程和非 TFO 的正常情況一緻。
是以,之後發起 HTTP GET 請求的時候,可以繞過三次握手,這就減少了握手帶來的 1 個 RTT 的時間消耗。
開啟了 TFO 功能,cookie 的值是存放到 TCP option 字段裡的:
TCP option 字段 - TFO
注:用戶端在請求并存儲了 Fast Open Cookie 之後,可以不斷重複 TCP Fast Open 直至伺服器認為 Cookie 無效(通常為過期)。
Linux 下怎麼打開 TCP Fast Open 功能呢?
在 Linux 系統中,可以通過設定 tcp_fastopn 核心參數,來打開 Fast Open 功能:
tcp_fastopn 各個值的意義:
- 0 關閉
- 1 作為用戶端使用 Fast Open 功能
- 2 作為服務端使用 Fast Open 功能
- 3 無論作為用戶端還是伺服器,都可以使用 Fast Open 功能
TCP Fast Open 功能需要用戶端和服務端同時支援,才有效果。
小結
本小結主要介紹了關于優化 TCP 三次握手的幾個 TCP 參數。
三次握手優化政策
用戶端的優化
當用戶端發起 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 四次揮手關閉連接配接時,如何優化性能。
在開始之前,我們得先了解四次揮手狀态變遷的過程。
用戶端和服務端雙方都可以主動斷開連接配接,通常先關閉連接配接的一方稱為主動方,後關閉連接配接的一方稱為被動方。
用戶端主動關閉
可以看到,四次揮手過程隻涉及了兩種封包,分别是 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
函數,它可以控制隻關閉一個方向的連接配接:
第二個參數決定斷開連接配接的方式,主要有以下三種方式:
- 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。
你可能會好奇,這 0 表示幾次?實際上當為 0 時,特指 8 次,從下面的核心源碼可知:
如果 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 參數,它定義了「孤兒連接配接」的最大數量:
當程序調用了
close
函數關閉連接配接,此時連接配接就會是「孤兒連接配接」,因為它無法再發送和接收資料。Linux 系統為了防止孤兒連接配接過多,導緻系統資源長時間被占用,就提供了
tcp_max_orphans
參數。如果孤兒連接配接數量大于它,新增的孤兒連接配接将不再走四次揮手,而是直接發送 RST 複位封包強制關閉。
FIN_WAIT2 狀态的優化
當主動方收到 ACK 封包後,會處于 FIN_WAIT2 狀态,就表示主動方的發送通道已經關閉,接下來将等待對方發送 FIN 封包,關閉對方的發送通道。
這時,如果連接配接是用 shutdown 函數關閉的,連接配接可以一直處于 FIN_WAIT2 狀态,因為它可能還可以發送或接收資料。但對于 close 函數關閉的孤兒連接配接,由于無法再發送和接收資料,是以這個狀态不可以持續太久,而 tcp_fin_timeout 控制了這個狀态下連接配接的持續時長,預設值是 60 秒:
它意味着對于孤兒連接配接(調用 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 沒有等待時間或時間過短,被延遲的資料包抵達後會發生什麼呢?
接收到曆史資料的異常
- 如上圖黃色框框服務端在關閉連接配接之前發送的
封包,被網絡延遲了。SEQ = 301
- 這時有相同端口的 TCP 連接配接被複用後,被延遲的
抵達了用戶端,那麼用戶端是有可能正常接收這個過期的封包,這就會産生資料錯亂等嚴重的問題。SEQ = 301
是以,TCP 就設計出了這麼一個機制,經過
2MSL
這個時間,足以讓兩個方向上的資料包都被丢棄,使得原來連接配接的資料包在網絡中都自然消失,再出現的資料包一定都是建立立連接配接所産生的。
原因二:保證連接配接正确關閉
TIME-WAIT 的另外一個作用是等待足夠的時間以確定最後的 ACK 能讓被動關閉方接收,進而幫助其正常關閉。
假設 TIME-WAIT 沒有等待時間或時間過短,斷開連接配接會造成什麼問題呢?
沒有確定正常斷開的異常
- 如上圖紅色框框用戶端四次揮手的最後一個
封包如果在網絡中被丢失了,此時如果用戶端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 而直接關閉:
當伺服器的并發連接配接增多時,相應地,同時處于 TIME_WAIT 狀态的連接配接數量也會變多,此時就應當調大
tcp_max_tw_buckets
參數,減少不同連接配接間資料錯亂的機率。
tcp_max_tw_buckets 也不是越大越好,畢竟記憶體和端口都是有限的。
有一種方式可以在建立新連接配接時,複用處于 TIME_WAIT 狀态的連接配接,那就是打開 tcp_tw_reuse 參數。但是需要注意,該參數是隻用于用戶端(建立連接配接的發起方),因為是在調用 connect() 時起作用的,而對于服務端(被動連接配接方)是沒有用的。
tcp_tw_reuse 從協定角度了解是安全可控的,可以複用處于 TIME_WAIT 的端口為新的連接配接所用。
什麼是協定角度了解的安全可控呢?主要有兩點:
- 隻适用于連接配接發起方,也就是 C/S 模型中的用戶端;
- 對應的 TIME_WAIT 狀态的連接配接建立時間超過 1 秒才可以被複用。
使用這個選項,還有一個前提,需要打開對 TCP 時間戳的支援(對方也要打開 ):
由于引入了時間戳,它能帶來了些好處:
- 我們在前面提到的 2MSL 問題就不複存在了,因為重複的資料包會因為時間戳過期被自然丢棄;
- 同時,它還可以防止序列号繞回,也是因為重複的資料包會由于時間戳過期被自然丢棄;
時間戳是在 TCP 的選項字段裡定義的,開啟了時間戳功能,在 TCP 封包傳輸的時候會帶上發送封包的時間戳。
TCP option 字段 - 時間戳
我們來看看開啟了 tcp_tw_reuse 功能,如果四次揮手中的最後一次 ACK 在網絡中丢失了,會發生什麼?
四次揮手中的最後一次 ACK 在網絡中丢失
上圖的流程:
- 四次揮手中的最後一次 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 核心直接取消了這一參數,建議關閉它:
另外,我們可以在程式中設定 socket 選項,來設定調用 close 關閉連接配接行為。
如果
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 參數控制。
同時關閉
接下來,雙方在等待 ACK 封包的過程中,都等來了 FIN 封包。這是一種新情況,是以連接配接會進入一種叫做 CLOSING 的新狀态,它替代了 FIN_WAIT2 狀态。接着,雙方核心回複 ACK 确認對方發送通道的關閉後,進入 TIME_WAIT 狀态,等待 2MSL 的時間後,連接配接自動關閉。
針對 TCP 四次揮手的優化,我們需要根據主動方和被動方四次揮手狀态變化來調整系統 TCP 核心參數。
四次揮手的優化政策
主動發起 FIN 封包斷開連接配接的一方,如果遲遲沒收到對方的 ACK 回複,則會重傳 FIN 封包,重傳的次數由
tcp_orphan_retries
參數決定。
當主動方收到 ACK 封包後,連接配接就進入 FIN_WAIT2 狀态,根據關閉的方式不同,優化的方式也不同:
- 如果這是 close 函數關閉的連接配接,那麼它就是孤兒連接配接。如果
秒内沒有收到對方的 FIN 封包,連接配接就直接關閉。同時,為了應對孤兒連接配接占用太多的資源,tcp_fin_timeout
定義了最大孤兒連接配接的數量,超過時連接配接就會直接釋放。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 是每發送一個資料,都要進行一次确認應答。當上一個資料包收到了應答了, 再發送下一個。這個模式就有點像我和你面對面聊天,你一句我一句,但這種方式的缺點是效率比較低的。
按資料包進行确認應答
是以,這樣的傳輸方式有一個缺點:資料包的往返時間越長,通信的效率就越低。
要解決這一問題不難,并行批量發送封包,再批量确認封包即可。
并行處理
然而,這引出了另一個問題,發送方可以随心所欲的發送封包嗎?當然這不現實,我們還得考慮接收方的處理能力。
當接收方硬體不如發送方,或者系統繁忙、資源緊張時,是無法瞬間處理這麼多封包的。于是,這些封包隻能被丢掉,使得網絡效率非常低。
為了解決這種現象發生,TCP 提供一種機制可以讓「發送方」根據「接收方」的實際接收能力控制發送的資料量,這就是滑動視窗的由來。
接收方根據它的緩沖區,可以計算出後續能夠接收多少位元組的封包,這個數字叫做接收視窗。當核心接收到封包時,必須用緩沖區存放它們,這樣剩餘緩沖區空間變小,接收視窗也就變小了;當程序調用 read 函數後,資料被讀入了使用者空間,核心緩沖區就被清空,這意味着主機可以接收更多的封包,接收視窗就會變大。
是以,接收視窗并不是恒定不變的,接收方會把目前可接收的大小放在 TCP 封包頭部中的視窗字段,這樣就可以起到視窗大小通知的作用。
發送方的視窗等價于接收方的視窗嗎?如果不考慮擁塞控制,發送方的視窗大小「約等于」接收方的視窗大小,因為視窗通知封包在網絡傳輸是存在時延的,是以是約等于的關系。
從上圖中可以看到,視窗字段隻有 2 個位元組,是以它最多能表達 65535 位元組大小的視窗,也就是 64KB 大小。
這個視窗大小最大值,在當今高速網絡下,很明顯是不夠用的。是以後續有了擴充視窗的方法:在 TCP 選項字段定義了視窗擴大因子,用于擴大 TCP 通告視窗,其值大小是 2^14,這樣就使 TCP 的視窗大小從 16 位擴大為 30 位(2^16 * 2^ 14 = 2^30),是以此時視窗的最大值可以達到 1GB。
TCP option 選項 - 視窗擴充
Linux 中打開這一功能,需要把 tcp_window_scaling 配置設為 1(預設打開):
要使用視窗擴大選項,通訊雙方必須在各自的 SYN 封包中發送這個選項:
- 主動建立連接配接的一方在 SYN 封包中發送這個選項;
- 而被動建立連接配接的一方隻有在收到帶視窗擴大選項的 SYN 封包之後才能發送這個選項。
這樣看來,隻要程序能及時地調用 read 函數讀取資料,并且接收緩沖區配置得足夠大,那麼接收視窗就可以無限地放大,發送方也就無限地提升發送速度。
這是不可能的,因為網絡的傳輸能力是有限的,當發送方依據發送視窗,發送超過網絡處理能力的封包時,路由器會直接丢棄這些封包。是以,緩沖區的記憶體并不是越大越好。
如何确定最大傳輸速度?
在前面我們知道了 TCP 的傳輸速度,受制于發送視窗與接收視窗,以及網絡裝置傳輸能力。其中,視窗大小由核心緩沖區大小決定。如果緩沖區與網絡傳輸能力比對,那麼緩沖區的使用率就達到了最大化。
問題來了,如何計算網絡的傳輸能力呢?
相信大家都知道網絡是有「帶寬」限制的,帶寬描述的是網絡傳輸能力,它與核心緩沖區的計量機關不同:
- 帶寬是機關時間内的流量,表達是「速度」,比如常見的帶寬 100 MB/s;
- 緩沖區機關是位元組,當網絡速度乘以時間才能得到位元組數;
這裡需要說一個概念,就是帶寬時延積,它決定網絡中飛行封包的大小,它的計算方式:
比如最大帶寬是 100 MB/s,網絡時延(RTT)是 10ms 時,意味着用戶端到服務端的網絡一共可以存放 100MB/s * 0.01s = 1MB 的位元組。
這個 1MB 是帶寬和時延的乘積,是以它就叫「帶寬時延積」(縮寫為 BDP,Bandwidth Delay Product)。同時,這 1MB 也表示「飛行中」的 TCP 封包大小,它們就在網絡線路、路由器等網絡裝置上。如果飛行封包超過了 1 MB,就會導緻網絡過載,容易丢包。
由于發送緩沖區大小決定了發送視窗的上限,而發送視窗又決定了「已發送未确認」的飛行封包的上限。是以,發送緩沖區不能超過「帶寬時延積」。
發送緩沖區與帶寬時延積的關系:
- 如果發送緩沖區「超過」帶寬時延積,超出的部分就沒辦法有效的網絡傳輸,同時導緻網絡過載,容易丢包;
- 如果發送緩沖區「小于」帶寬時延積,就不能很好的發揮出網絡的傳輸效率。
是以,發送緩沖區的大小最好是往帶寬時延積靠近。
怎樣調整緩沖區大小?
在 Linux 中發送緩沖區和接收緩沖都是可以用參數調節的。設定完後,Linux 會根據你設定的緩沖區進行動态調節。
調節發送緩沖區範圍
先來看看發送緩沖區,它的範圍通過 tcp_wmem 參數配置;
上面三個數字機關都是位元組,它們分别表示:
- 第一個數值是動态範圍的最小值,4096 byte = 4K;
- 第二個數值是初始預設值,87380 byte ≈ 86K;
- 第三個數值是動态範圍的最大值,4194304 byte = 4096K(4M);
發送緩沖區是自行調節的,當發送方發送的資料被确認後,并且沒有新的資料要發送,就會把發送緩沖區的記憶體釋放掉。
調節接收緩沖區範圍
而接收緩沖區的調整就比較複雜一些,先來看看設定接收緩沖區範圍的 tcp_rmem 參數:
- 第一個數值是動态範圍的最小值,表示即使在記憶體壓力下也可以保證的最小接收緩沖區大小,4096 byte = 4K;
- 第三個數值是動态範圍的最大值,6291456 byte = 6144K(6M);
接收緩沖區可以根據系統空閑記憶體的大小來調節接收視窗:
- 如果系統的空閑記憶體很多,就可以自動把緩沖區增大一些,這樣傳給對方的接收視窗也會變大,因而提升發送方發送的傳輸資料數量;
- 反之,如果系統的記憶體很緊張,就會減少緩沖區,這雖然會降低傳輸效率,可以保證更多的并發連接配接正常工作;
發送緩沖區的調節功能是自動開啟的,而接收緩沖區則需要配置 tcp_moderate_rcvbuf 為 1 來開啟調節功能:
調節 TCP 記憶體範圍
接收緩沖區調節時,怎麼知道目前記憶體是否緊張或充分呢?這是通過 tcp_mem 配置完成的:
上面三個數字機關不是位元組,而是「頁面大小」,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 優化資料傳輸的方式,做了一些介紹。
資料傳輸的優化政策
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,則會關閉緩沖區的動态整功能,是以不建議在程式設定它倆,而是交給核心自動調整比較好。
有效配置這些參數後,既能夠最大程度地保持并發性,也能讓資源充裕時連接配接傳輸速度達到最大值。
巨人的肩膀
[1] 系統性能調優必知必會.陶輝.極客時間.
[2] 網絡程式設計實戰專欄.盛延敏.極客時間.
[3] http://www.blogjava.net/yongboy/archive/2013/04/11/397677.html
[4] http://blog.itpub.net/31559359/viewspace-2284113/
[5] https://blog.51cto.com/professor/1909022
[6] https://vincent.bernat.ch/en/blog/2014-tcp-time-wait-state-linux
唠嗑唠嗑
跟大家說個沉痛的事情。
我想大部分小夥伴都發現了,最近公衆号改版,訂閱号裡的資訊流不再是以時間順序了,而是以推薦算法方式顯示順序。
這對小林這種「周更」的作者,真的一次重重打擊,非常的不友好。
因為長時間沒發文,公衆号可能會把推薦的權重降低,這就會導緻很多讀者,會收不到我的「最新」的推文,如此下去,那小林文章不就無人問津了?(抱頭痛哭 …)
另外,小林更文時間長的原因,不是因為偷懶。
而是為了把知識點「寫的更清楚,畫的更清晰」,是以這必然會花費更多更長的時間。
如果你認可和喜歡小林的文章,不想錯過文章的第一時間推送,可以動動你的小手手,給小林公衆号一個「星标」。
平時沒事,就讓「小林coding」靜靜地躺在你的訂閱号底部,但是你要知道它在這其間并非無所事事,而是在努力地準備着更好的内容,等準備好了,它自然會「蹦出」在你面前。
小林是專為大家圖解的工具人,Goodbye,我們下次見!
讀者問答
讀者問:“小林,請教個問題,somaxconn和backlog是不是都是指的是accept隊列?然後somaxconn是核心參數,backlog是通過系統調用間隔地修改somaxconn,比如Linux中listen()函數?”
兩者取最小值才是 accpet 隊列。
讀者問:“小林,還有個問題要請教下,“如果 accept 隊列滿了,那麼 server 扔掉 client 發過來的 ack”,也就是說該TCP連接配接還是位于半連接配接隊列中,沒有丢棄嗎?”
- 當 accept 隊列滿了,後續新進來的syn包都會被丢失
- 我文章的突發流量例子是,那個連接配接進來的時候 accept 隊列還沒滿,但是在第三次握手的時候,accept 隊列突然滿了,就會導緻 ack 被丢棄,就一直處于半連接配接隊列。
關注公衆号:「小林coding」 ,回複「我要學習」即可免費獲得「伺服器 Linux C/C++ 」成長路程(書籍資料 + 思維導圖)