深入解析常見三次握手異常
本文轉(zhuǎn)載自微信公眾號(hào)「開發(fā)內(nèi)功修煉」,作者張彥飛allen。轉(zhuǎn)載本文請(qǐng)聯(lián)系開發(fā)內(nèi)功修煉公眾號(hào)。
大家好,我是飛哥!
在后端接口性能指標(biāo)中一類重要的指標(biāo)就是接口耗時(shí)。具體包括平均響應(yīng)時(shí)間 TP90、TP99 耗時(shí)值等。這些值越低越好,一般來說是幾毫秒,或者是幾十毫秒。如果響應(yīng)時(shí)間一旦過長,比如超過了 1 秒,在用戶側(cè)就能感覺到非常明顯的卡頓。如果長此以往,用戶可能就直接用腳投票,卸載我們的 App 了。
在正常情況下一次 TCP 連接耗時(shí)也就大約是一次 RTT 多一點(diǎn)。但事情不一定總是這么美好,總會(huì)有意外發(fā)生。在某些情況下,可能會(huì)導(dǎo)致連接耗時(shí)上漲、CPU 處理開銷增加、甚至是超時(shí)失敗。
今天飛哥就來說一下我在線上遇到過的那些 TCP 握手相關(guān)的各種異常情況。
一、客戶端 connect 異常
端口號(hào)和 CPU 消耗這二者聽起來感覺沒啥太大聯(lián)系。但我卻遭遇過因?yàn)槎丝谔?hào)不足導(dǎo)致 CPU 消耗大幅上漲的情況。來聽飛哥分析分析為啥會(huì)出現(xiàn)這種問題!
客戶端在發(fā)起 connect 系統(tǒng)調(diào)用的時(shí)候,主要工作就是端口選擇(參見TCP連接中客戶端的端口號(hào)是如何確定的?)。
在選擇的過程中,有個(gè)大循環(huán),從 ip_local_port_range 的一個(gè)隨機(jī)位置開始把這個(gè)范圍遍歷一遍,找到可用端口則退出循環(huán)。如果端口很充足,那么循環(huán)只需要執(zhí)行少數(shù)幾次就可以退出。但假設(shè)說端口消耗掉很多已經(jīng)不充足,或者干脆就沒有可用的了。那么這個(gè)循環(huán)就得執(zhí)行很多遍。我們來看下詳細(xì)的代碼。
- //file:net/ipv4/inet_hashtables.c
- int __inet_hash_connect(...)
- {
- inet_get_local_port_range(&low, &high);
- remaining = (high - low) + 1;
- for (i = 1; i <= remaining; i++) {
- // 其中 offset 是一個(gè)隨機(jī)數(shù)
- port = low + (i + offset) % remaining;
- head = &hinfo->bhash[inet_bhashfn(net, port,
- hinfo->bhash_size)];
- //加鎖
- spin_lock(&head->lock);
- //一大段的選擇端口邏輯
- //......
- //選擇成功就 goto ok
- //不成功就 goto next_port
- next_port:
- //解鎖
- spin_unlock(&head->lock);
- }
- }
在每次的循環(huán)內(nèi)部需要等待鎖,以及在哈希表中執(zhí)行多次的搜索。注意這里的是自旋鎖,是一種非阻塞的鎖,如果資源被占用,進(jìn)程并不會(huì)被掛起,而是會(huì)占用 CPU 去不斷嘗試獲取鎖。
但假設(shè)端口范圍 ip_local_port_range 配置的是 10000 - 30000, 而且已經(jīng)用盡了。那么每次當(dāng)發(fā)起連接的時(shí)候都需要把循環(huán)執(zhí)行兩萬遍才退出。這時(shí)會(huì)涉及大量的 HASH 查找以及自旋鎖等待開銷,系統(tǒng)態(tài) CPU 將會(huì)出現(xiàn)大幅度的上漲。
這是線上截取到的正常時(shí)的 connect 系統(tǒng)調(diào)用耗時(shí),是 22 us(微秒)。
這個(gè)是我們一臺(tái)服務(wù)器在端口不足情況下 connect 開銷,是 2581 us(微秒)。
從上兩張圖中可以看出,異常情況下的 connect 耗時(shí)是正常情況下的 1000 多倍。雖然換算成毫秒只有 2 ms 多一點(diǎn),但是要知道這消耗的全是 CPU 時(shí)間。
二、第一次握手丟包
服務(wù)器在響應(yīng)來自客戶端的第一次握手請(qǐng)求的時(shí)候,會(huì)判斷一下半連接隊(duì)列和全連接隊(duì)列是否溢出。如果發(fā)生溢出,可能會(huì)直接將握手包丟棄,而不會(huì)反饋給客戶端。接下來我們分別來詳細(xì)看一下。
2.1 半連接隊(duì)列滿
我們來看下半連接隊(duì)列在何種情況下會(huì)導(dǎo)致丟包。
- //file: net/ipv4/tcp_ipv4.c
- int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb)
- {
- //看看半連接隊(duì)列是否滿了
- if (inet_csk_reqsk_queue_is_full(sk) && !isn) {
- want_cookie = tcp_syn_flood_action(sk, skb, "TCP");
- if (!want_cookie)
- goto drop;
- }
- //看看全連接隊(duì)列是否滿了
- ...
- drop:
- NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENDROPS);
- return 0;
- }
在上面代碼中,inet_csk_reqsk_queue_is_full 如果返回 true 就表示半連接隊(duì)列滿了,另外 tcp_syn_flood_action 判斷是否打開了內(nèi)核參數(shù) tcp_syncookies,如果未打開則返回 false。
- //file: net/ipv4/tcp_ipv4.c
- bool tcp_syn_flood_action(...)
- {
- bool want_cookie = false;
- if (sysctl_tcp_syncookies) {
- want_cookie = true;
- }
- return want_cookie;
- }
也就是說,如果半連接隊(duì)列滿了,而且 ipv4.tcp_syncookies 參數(shù)設(shè)置為 0,那么來自客戶端的握手包將 goto drop,意思就是直接丟棄!
SYN Flood 攻擊就是通過消耗光服務(wù)器上的半連接隊(duì)列來使得正常的用戶連接請(qǐng)求無法被響應(yīng)。不過在現(xiàn)在的 Linux 內(nèi)核里只要打開 tcp_syncookies,半連接隊(duì)列滿了仍然也還可以保證正常握手的進(jìn)行。
2.2 全連接隊(duì)列滿
我們注意到當(dāng)半連接隊(duì)列判斷通過以后,緊接著還有全連接隊(duì)列滿的相關(guān)判斷。如果這個(gè)條件成立,服務(wù)器對(duì)握手包的處理還是會(huì) goto drop,丟棄了之。我們來看下源碼:
- //file: net/ipv4/tcp_ipv4.c
- int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb)
- {
- //看看半連接隊(duì)列是否滿了
- ...
- //看看全連接隊(duì)列是否滿了
- if (sk_acceptq_is_full(sk) && inet_csk_reqsk_queue_young(sk) > 1) {
- NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
- goto drop;
- }
- ...
- drop:
- NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENDROPS);
- return 0;
- }
sk_acceptq_is_full 來判斷全連接隊(duì)列是否滿了,inet_csk_reqsk_queue_young 判斷的是有沒有 young_ack(未處理完的半連接請(qǐng)求)。
這段代碼可以看到,假如全連接隊(duì)列滿的情況下,且同時(shí)有 young_ack ,那么內(nèi)核同樣直接丟掉該 SYN 握手包。
2.3 客戶端發(fā)起重試
假設(shè)說服務(wù)器側(cè)發(fā)生了全/半連接隊(duì)列溢出而導(dǎo)致的丟包。那么從轉(zhuǎn)換到客戶端視角來看就是 SYN 包沒有任何響應(yīng)。
好在客戶端在發(fā)出握手包的時(shí)候,開啟了一個(gè)重傳定時(shí)器。如果收不到預(yù)期的 synack 的話,超時(shí)重傳的邏輯就會(huì)開始執(zhí)行。不過重傳計(jì)時(shí)器的時(shí)間單位都是以秒來計(jì)算的,這意味著,如果有握手重傳發(fā)生,即使第一次重傳就能成功,那接口最快響應(yīng)也是 1 s 以后的事情了。這對(duì)接口耗時(shí)影響非常的大。
我們來詳細(xì)看下重傳相關(guān)的邏輯。服務(wù)器在 connect 發(fā)出 syn 后就開啟了重傳定時(shí)器。
- //file:net/ipv4/tcp_output.c
- int tcp_connect(struct sock *sk)
- {
- ...
- //實(shí)際發(fā)出 syn
- err = tp->fastopen_req ? tcp_send_syn_data(sk, buff) :
- tcp_transmit_skb(sk, buff, 1, sk->sk_allocation);
- //啟動(dòng)重傳定時(shí)器
- inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS,
- inet_csk(sk)->icsk_rto, TCP_RTO_MAX);
- }
在定時(shí)器設(shè)置中傳入的 inet_csk(sk)->icsk_rto 是超時(shí)時(shí)間,該值初始的時(shí)候被設(shè)置為了 1 秒。
- //file:ipv4/tcp_output.c
- void tcp_connect_init(struct sock *sk)
- {
- //初始化為 TCP_TIMEOUT_INIT
- inet_csk(sk)->icsk_rto = TCP_TIMEOUT_INIT;
- ...
- }
- //file: include/net/tcp.h
- #define TCP_TIMEOUT_INIT ((unsigned)(1*HZ))
在一些老版本的內(nèi)核,比如 2.6 里,重傳定時(shí)器的初始值是 3 秒。
- //內(nèi)核版本:2.6.32
- //file: include/net/tcp.h
- #define TCP_TIMEOUT_INIT ((unsigned)(3*HZ))
如果能正常接收到服務(wù)器響應(yīng)的 synack,那么客戶端的這個(gè)定時(shí)器會(huì)清除。這段邏輯在 tcp_rearm_rto 里。(tcp_rcv_state_process -> tcp_rcv_synsent_state_process -> tcp_ack -> tcp_clean_rtx_queue -> tcp_rearm_rto)
- //file:net/ipv4/tcp_input.c
- void tcp_rearm_rto(struct sock *sk)
- {
- inet_csk_clear_xmit_timer(sk, ICSK_TIME_RETRANS);
- }
如果服務(wù)器端發(fā)生了丟包,那么定時(shí)器到時(shí)后會(huì)進(jìn)行回調(diào)函數(shù) tcp_write_timer 中進(jìn)行重傳。
其實(shí)不只是握手,連接狀態(tài)的超時(shí)重傳也是在這里完成的。不過這里我們只討論握手重傳的情況。
- //file: net/ipv4/tcp_timer.c
- static void tcp_write_timer(unsigned long data)
- {
- tcp_write_timer_handler(sk);
- ...
- }
- void tcp_write_timer_handler(struct sock *sk)
- {
- //取出定時(shí)器類型。
- event = icsk->icsk_pending;
- switch (event) {
- case ICSK_TIME_RETRANS:
- icsk->icsk_pending = 0;
- tcp_retransmit_timer(sk);
- break;
- ......
- }
- }
tcp_retransmit_timer 是重傳的主要函數(shù)。在這里完成重傳,以及下一次定時(shí)器到期時(shí)間的設(shè)置。
- //file: net/ipv4/tcp_timer.c
- void tcp_retransmit_timer(struct sock *sk)
- {
- ...
- //超過了重傳次數(shù)則退出
- if (tcp_write_timeout(sk))
- goto out;
- //重傳
- if (tcp_retransmit_skb(sk, tcp_write_queue_head(sk)) > 0) {
- //重傳失敗
- ......
- }
- //退出前重新設(shè)置下一次超時(shí)時(shí)間
- out_reset_timer:
- //計(jì)算超時(shí)時(shí)間
- if (sk->sk_state == TCP_ESTABLISHED ){
- ......
- } else {
- icsk->icsk_rto = min(icsk->icsk_rto << 1, TCP_RTO_MAX);
- }
- //設(shè)置
- inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS, icsk->icsk_rto, TCP_RTO_MAX);
- }
tcp_write_timeout 是判斷是否重試過多,如果是則退出重試邏輯。
tcp_write_timeout 的判斷邏輯其實(shí)也有點(diǎn)小復(fù)雜。對(duì)于 SYN 握手包主要是判斷依據(jù)是 net.ipv4.tcp_syn_retries,但其實(shí)并不是簡單對(duì)比次數(shù),而是轉(zhuǎn)化成了時(shí)間進(jìn)行對(duì)比。所以如果你在線上看到實(shí)際重傳次數(shù)和對(duì)應(yīng)內(nèi)核參數(shù)不一致也不用太奇怪。
接著在 tcp_retransmit_timer 重發(fā)了發(fā)送隊(duì)列里的頭元素。而且還設(shè)置了下一次超時(shí)的時(shí)間,為前一次的兩倍(右移操作相當(dāng)于乘2)。
2.4 實(shí)際抓包結(jié)果
我們來看一個(gè)因?yàn)榉?wù)器端響應(yīng)第一次握手丟包的握手過程抓包截圖。
通過該圖可以看到,客戶端在 1 s 以后進(jìn)行了第一次握手重試。重試仍然沒有響應(yīng),那么接下來依次又分別在 3 s、7 s 15 s,31 s,63 s 等時(shí)間共重試了 6 次(我的 tcp_syn_retries 當(dāng)時(shí)設(shè)置是 6)。
假如我們服務(wù)器上在第一次握手的時(shí)候出現(xiàn)了半/全連接隊(duì)列溢出導(dǎo)致的丟包,那么我們的接口響應(yīng)時(shí)間將至少是 1 s 以上(在某些老版本的內(nèi)核上,SYN 第一次的重試就需要等 3 秒),如果連續(xù)兩三次握手都失敗,那 7,8 秒就出去了。你想想這對(duì)用戶是不是影響很大。
三、第三次握手丟包
客戶端在收到服務(wù)器的 synack 響應(yīng)的時(shí)候,就認(rèn)為連接建立成功了,然后會(huì)將自己的連接狀態(tài)設(shè)置為 ESTABLISHED,發(fā)出第三次握手請(qǐng)求。但服務(wù)器在第三次握手的時(shí)候,還有可能會(huì)有意外發(fā)生。
- //file: net/ipv4/tcp_ipv4.c
- struct sock *tcp_v4_syn_recv_sock(struct sock *sk, ...)
- {
- //判斷接收隊(duì)列是不是滿了
- if (sk_acceptq_is_full(sk))
- goto exit_overflow;
- ...
- exit_overflow:
- NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
- ...
- }
從上述代碼可以看出,第三次握手時(shí),如果服務(wù)器全連接隊(duì)列滿了,來自客戶端的 ack 握手包又被直接丟棄了。
想想也很好理解,三次握手完的請(qǐng)求是要放在全連接隊(duì)列里的。但是假如全連接隊(duì)列滿了,仍然三次握手也不會(huì)成功。
不過有意思的是,第三次握手失敗并不是客戶端重試,而是由客戶端來重發(fā) synack。
我們搞一個(gè)實(shí)際的 Case 來直接抓包看一下。我專門寫了個(gè)簡單的 Server 只 listen 不 accept,然后找個(gè)客戶端把它的連接隊(duì)列消耗光。這時(shí)候,再用另一個(gè)客戶端向它發(fā)起請(qǐng)求時(shí)的抓包結(jié)果。
第一個(gè)紅框內(nèi)是第三次握手,其實(shí)這個(gè)握手請(qǐng)求在服務(wù)器端以及被丟棄了。但是這時(shí)候客戶端并不知情,它一直傻傻地以為三次握手已經(jīng)妥了呢。不過還好,這時(shí)在服務(wù)器的半連接隊(duì)列中仍然記錄著第一次握手時(shí)存的握手請(qǐng)求。
服務(wù)器等到半連接定時(shí)器到時(shí)后,向客戶端重新發(fā)起 synack ,客戶端收到后再重新回復(fù)第三次握手 ack。如果這期間服務(wù)器端全連接隊(duì)列一直都是滿的,那么服務(wù)器重試 5 次(受內(nèi)核參數(shù) net.ipv4.tcp_synack_retries 控制)后就放棄了。
在這種情況下大家還要注意另外一個(gè)問題。在實(shí)踐中,客戶端往往是以為連接建立成功就會(huì)開始發(fā)送數(shù)據(jù),其實(shí)這時(shí)候連接還沒有真的建立起來。他發(fā)出去的數(shù)據(jù),包括重試都將全部被服務(wù)器無視。直到連接真正建立成功后才行。
四、總結(jié)
衡量工程師是否優(yōu)秀的標(biāo)準(zhǔn)之一就是看他能否有能力定位和處理線上發(fā)生的各種問題。連看似簡單的一個(gè) TCP 三次握手,工程實(shí)踐中可能會(huì)有各種意外發(fā)生。如果對(duì)握手理解不深,那么很有可能無法處理線上出現(xiàn)的各種故障。
今天的文章主要是描述了端口不足、半連接隊(duì)列滿、全連接隊(duì)列滿時(shí)的情況,
當(dāng)端口不充足的時(shí)候,會(huì)導(dǎo)致 connect 系統(tǒng)調(diào)用的時(shí)候過多地執(zhí)行自旋鎖等待與 Hash 查找,會(huì)引起 CPU 開銷上漲。嚴(yán)重情況下會(huì)耗光 CPU,影響用戶業(yè)務(wù)邏輯的執(zhí)行。出現(xiàn)這種問題處理起來方法有這么幾個(gè)。
通過調(diào)整 ip_local_port_range 來盡量加大端口范圍
盡量復(fù)用連接,使用長連接來削減頻繁的握手處理
第三個(gè)有用,但是不太推薦的是開啟 tcp_tw_reuse 和 tcp_tw_recycle
服務(wù)器端在第一次握手時(shí)可能會(huì)丟包, 在如下兩種情況下會(huì)發(fā)生。
半連接隊(duì)列滿,且 tcp_syncookies 為 0
全連接隊(duì)列滿,且有未完成的半連接請(qǐng)求
在這兩種情況下,客戶端視角來看和網(wǎng)絡(luò)斷了沒有區(qū)別,就是發(fā)出去的 SYN 包沒有任何反饋,然后等待定時(shí)器到時(shí)后重傳握手請(qǐng)求。第一次重傳時(shí)間是 1 s ,接下來的等待間隔是翻倍地增長,2 s,4 s,8 s ...??偟闹貍鞔螖?shù)由 net.ipv4.tcp_syn_retries 內(nèi)核參數(shù)影響(注意我的用詞是影響,而不是決定)。
服務(wù)器在第三次握手時(shí)也可能會(huì)出問題,如果全連接隊(duì)列滿,仍將會(huì)發(fā)生丟包。不過第三次握手失敗時(shí),只有服務(wù)器端知道(客戶端誤以為連接已經(jīng)建立成功了)。服務(wù)器根據(jù)半連接隊(duì)列里的握手信息發(fā)起 synack 重試,重試次數(shù)由 net.ipv4.tcp_synack_retries 控制。
一旦你的線上出現(xiàn)了上面這些連接隊(duì)列溢出導(dǎo)致的問題,你的服務(wù)將會(huì)受到比較嚴(yán)重的影響。即使第一次重試就能夠成功,那你的接口響應(yīng)耗時(shí)將直接上漲到 1 s(老版本上是 3 s)。如果重試上兩三次都沒有成功,Nginx 很有可能直接就報(bào)訪問超時(shí)失敗了。
正因?yàn)槲帐种卦噷?duì)我們服務(wù)影響很大,所以能深刻理解三次握手中的這些異常情況很有必要。再說說如果出現(xiàn)了丟包的問題,我們?cè)撊绾螒?yīng)對(duì)。
方法1,打開 syncookie
在現(xiàn)代的 Linux 版本里,我們可以通過打開 tcp_syncookies 來防止過多的請(qǐng)求打滿半連接隊(duì)列包括 SYN Flood 攻擊,來解決服務(wù)器因?yàn)榘脒B接隊(duì)列滿而發(fā)生的丟包。
方法2,加大連接隊(duì)列長度
在《為什么服務(wù)端程序都需要先 listen 一下?》中,我們討論過全連接隊(duì)列的長度是 min(backlog, net.core.somaxconn)半連接隊(duì)列長度是。半連接隊(duì)列長度有點(diǎn)小復(fù)雜,是 min(backlog, somaxconn, tcp_max_syn_backlog) + 1 再上取整到 2 的冪次,但最小不能小于16。
如果需要加大全/半連接隊(duì)列長度,請(qǐng)調(diào)節(jié)以上的一個(gè)或多個(gè)參數(shù)來達(dá)到目的。只要隊(duì)列長度合適,就能很大程序降低握手異常概率的發(fā)生。
方法3,盡快地 accept
另外這個(gè)雖然一般不會(huì)成為問題,但也要注意一下。你的應(yīng)用程序應(yīng)該盡快在握手成功之后通過 accept 把新連接取走。不要忙于處理其它業(yè)務(wù)邏輯而導(dǎo)致全連接隊(duì)列塞滿了。
方法4,盡量減少 TCP 連接的次數(shù)
如果上述方法都未能根治你的問題,那說明你的服務(wù)器上 TCP 連接請(qǐng)求太、太過于頻繁了。這個(gè)時(shí)候你應(yīng)該思考下是否可以用長連接代替短連接,減少過于頻繁的三次握手。這個(gè)方法不但能解決握手出問題的可能,而且還順帶砍掉了三次握手的各種內(nèi)存、CPU、時(shí)間上的開銷,對(duì)提升性能也有較大幫助。