<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          深入解析常見 TCP 三次握手異常

          共 9967字,需瀏覽 20分鐘

           ·

          2021-08-10 18:56


          今天跟大家說說 TCP 三次握手期間存在的異常現(xiàn)象,了解這個有助于我們在工作中排查疑難雜癥。

          在后端接口性能指標(biāo)中一類重要的指標(biāo)就是接口耗時。具體包括平均響應(yīng)時間 TP90、TP99 耗時值等。

          這些值越低越好,一般來說是幾毫秒,或者是幾十毫秒。如果響應(yīng)時間一旦過長,比如超過了 1 秒,在用戶側(cè)就能感覺到非常明顯的卡頓。如果長此以往,用戶可能就直接用腳投票,卸載我們的 App 了。

          在正常情況下一次 TCP 連接耗時也就大約是一次 RTT 多一點。但事情不一定總是這么美好,總會有意外發(fā)生。在某些情況下,可能會導(dǎo)致連接耗時上漲、CPU 處理開銷增加、甚至是超時失敗。

          今天飛哥就來說一下我在線上遇到過的那些 TCP 握手相關(guān)的各種異常情況。

          一、客戶端 connect 異常

          端口號和 CPU 消耗這二者聽起來感覺沒啥太大聯(lián)系。但我卻遭遇過因為端口號不足導(dǎo)致 CPU 消耗大幅上漲的情況。來聽飛哥分析分析為啥會出現(xiàn)這種問題!

          客戶端在發(fā)起 connect 系統(tǒng)調(diào)用的時候,主要工作就是端口選擇。

          在選擇的過程中,有個大循環(huán),從 ip_local_port_range 的一個隨機位置開始把這個范圍遍歷一遍,找到可用端口則退出循環(huán)。如果端口很充足,那么循環(huán)只需要執(zhí)行少數(shù)幾次就可以退出。但假設(shè)說端口消耗掉很多已經(jīng)不充足,或者干脆就沒有可用的了。那么這個循環(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 是一個隨機數(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í)行多次的搜索。注意這里的是自旋鎖,是一種非阻塞的鎖,如果資源被占用,進程并不會被掛起,而是會占用 CPU 去不斷嘗試獲取鎖。

          但假設(shè)端口范圍 ip_local_port_range 配置的是 10000 - 30000, 而且已經(jīng)用盡了。那么每次當(dāng)發(fā)起連接的時候都需要把循環(huán)執(zhí)行兩萬遍才退出。這時會涉及大量的 HASH 查找以及自旋鎖等待開銷,系統(tǒng)態(tài) CPU 將會出現(xiàn)大幅度的上漲。

          這是線上截取到的正常時的 connect 系統(tǒng)調(diào)用耗時,是 22 us(微秒)。

          這個是我們一臺服務(wù)器在端口不足情況下 connect 開銷,是 2581 us(微秒)。

          從上兩張圖中可以看出,異常情況下的 connect 耗時是正常情況下的 100 多倍。雖然換算成毫秒只有 2 ms 多一點,但是要知道這消耗的全是 CPU 時間。

          二、第一次握手丟包

          服務(wù)器在響應(yīng)來自客戶端的第一次握手請求的時候,會判斷一下半連接隊列和全連接隊列是否溢出。如果發(fā)生溢出,可能會直接將握手包丟棄,而不會反饋給客戶端。接下來我們分別來詳細(xì)看一下。

          2.1 半連接隊列滿

          我們來看下半連接隊列在何種情況下會導(dǎo)致丟包。

          //file: net/ipv4/tcp_ipv4.c
          int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb)
          {
           //看看半連接隊列是否滿了
           if (inet_csk_reqsk_queue_is_full(sk) && !isn) {
            want_cookie = tcp_syn_flood_action(sk, skb, "TCP");
            if (!want_cookie)
             goto drop;
           }

           //看看全連接隊列是否滿了
           ...
          drop:
           NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENDROPS);
           return 0
          }

          在上面代碼中,inet_csk_reqsk_queue_is_full 如果返回 true 就表示半連接隊列滿了,另外 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;
          }

          也就是說,如果半連接隊列滿了,而且 ipv4.tcp_syncookies 參數(shù)設(shè)置為 0,那么來自客戶端的握手包將 goto drop,意思就是直接丟棄!

          SYN Flood 攻擊就是通過消耗光服務(wù)器上的半連接隊列來使得正常的用戶連接請求無法被響應(yīng)。不過在現(xiàn)在的 Linux 內(nèi)核里只要打開 tcp_syncookies,半連接隊列滿了仍然也還可以保證正常握手的進行。

          2.2 全連接隊列滿

          我們注意到當(dāng)半連接隊列判斷通過以后,緊接著還有全連接隊列滿的相關(guān)判斷。如果這個條件成立,服務(wù)器對握手包的處理還是會 goto drop,丟棄了之。我們來看下源碼:

          //file: net/ipv4/tcp_ipv4.c
          int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb)
          {
           //看看半連接隊列是否滿了
           ...

           //看看全連接隊列是否滿了
           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 來判斷全連接隊列是否滿了,inet_csk_reqsk_queue_young 判斷的是有沒有 young_ack(未處理完的半連接請求)。

          這段代碼可以看到,假如全連接隊列滿的情況下,且同時有 young_ack ,那么內(nèi)核同樣直接丟掉該 SYN 握手包

          2.3 客戶端發(fā)起重試

          假設(shè)說服務(wù)器側(cè)發(fā)生了全/半連接隊列溢出而導(dǎo)致的丟包。那么從轉(zhuǎn)換到客戶端視角來看就是 SYN 包沒有任何響應(yīng)。

          好在客戶端在發(fā)出握手包的時候,開啟了一個重傳定時器。如果收不到預(yù)期的 synack 的話,超時重傳的邏輯就會開始執(zhí)行。不過重傳計時器的時間單位都是以秒來計算的,這意味著,如果有握手重傳發(fā)生,即使第一次重傳就能成功,那接口最快響應(yīng)也是 1 s 以后的事情了。這對接口耗時影響非常的大。

          我們來詳細(xì)看下重傳相關(guān)的邏輯。服務(wù)器在 connect 發(fā)出 syn 后就開啟了重傳定時器。

          //file:net/ipv4/tcp_output.c
          int tcp_connect(struct sock *sk)
          {
           ...
           //實際發(fā)出 syn
           err = tp->fastopen_req ? tcp_send_syn_data(sk, buff) :
                 tcp_transmit_skb(sk, buff, 1, sk->sk_allocation);

           //啟動重傳定時器
           inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS,
                inet_csk(sk)->icsk_rto, TCP_RTO_MAX);
          }

          在定時器設(shè)置中傳入的 inet_csk(sk)->icsk_rto 是超時時間,該值初始的時候被設(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 里,重傳定時器的初始值是 3 秒。

          //內(nèi)核版本:2.6.32
          //file: include/net/tcp.h
          #define TCP_TIMEOUT_INIT ((unsigned)(3*HZ))

          如果能正常接收到服務(wù)器響應(yīng)的 synack,那么客戶端的這個定時器會清除。這段邏輯在 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ā)生了丟包,那么定時器到時后會進行回調(diào)函數(shù) tcp_write_timer 中進行重傳。

          其實不只是握手,連接狀態(tài)的超時重傳也是在這里完成的。不過這里我們只討論握手重傳的情況。

          //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)
          {
           //取出定時器類型。
           event = icsk->icsk_pending;

           switch (event) {
           case ICSK_TIME_RETRANS:
            icsk->icsk_pending = 0;
            tcp_retransmit_timer(sk);
            break;
           ......
           }
          }

          tcp_retransmit_timer 是重傳的主要函數(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è)置下一次超時時間
          out_reset_timer:
           //計算超時時間
           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 的判斷邏輯其實也有點小復(fù)雜。對于 SYN 握手包主要是判斷依據(jù)是 net.ipv4.tcp_syn_retries,但其實并不是簡單對比次數(shù),而是轉(zhuǎn)化成了時間進行對比。所以如果你在線上看到實際重傳次數(shù)和對應(yīng)內(nèi)核參數(shù)不一致也不用太奇怪。

          接著在 tcp_retransmit_timer 重發(fā)了發(fā)送隊列里的頭元素。而且還設(shè)置了下一次超時的時間,為前一次的兩倍(左移操作相當(dāng)于乘2)。

          2.4 實際抓包結(jié)果

          我們來看一個因為服務(wù)器端響應(yīng)第一次握手丟包的握手過程抓包截圖。

          通過該圖可以看到,客戶端在 1 s 以后進行了第一次握手重試。重試仍然沒有響應(yīng),那么接下來依次又分別在 3 s、7 s 15 s,31 s,63 s 等時間共重試了 6 次(我的 tcp_syn_retries 當(dāng)時設(shè)置是 6)。

          假如我們服務(wù)器上在第一次握手的時候出現(xiàn)了半/全連接隊列溢出導(dǎo)致的丟包,那么我們的接口響應(yīng)時間將至少是 1 s 以上(在某些老版本的內(nèi)核上,SYN 第一次的重試就需要等 3 秒),如果連續(xù)兩三次握手都失敗,那 7,8 秒就出去了。你想想這對用戶是不是影響很大。

          三、第三次握手丟包

          客戶端在收到服務(wù)器的 synack 響應(yīng)的時候,就認(rèn)為連接建立成功了,然后會將自己的連接狀態(tài)設(shè)置為 ESTABLISHED,發(fā)出第三次握手請求。但服務(wù)器在第三次握手的時候,還有可能會有意外發(fā)生。

          //file: net/ipv4/tcp_ipv4.c
          struct sock *tcp_v4_syn_recv_sock(struct sock *sk, ...)
          {    
              //判斷接收隊列是不是滿了
              if (sk_acceptq_is_full(sk))
                  goto exit_overflow;
              ...
          exit_overflow:
           NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
           ...
          }

          從上述代碼可以看出,第三次握手時,如果服務(wù)器全連接隊列滿了,來自客戶端的 ack 握手包又被直接丟棄了

          想想也很好理解,三次握手完的請求是要放在全連接隊列里的。但是假如全連接隊列滿了,仍然三次握手也不會成功。

          不過有意思的是,第三次握手失敗并不是客戶端重試,而是由客戶端來重發(fā) synack。

          我們搞一個實際的 Case 來直接抓包看一下。我專門寫了個簡單的 Server 只 listen 不 accept,然后找個客戶端把它的連接隊列消耗光。這時候,再用另一個客戶端向它發(fā)起請求時的抓包結(jié)果。

          第一個紅框內(nèi)是第三次握手,其實這個握手請求在服務(wù)器端以及被丟棄了。但是這時候客戶端并不知情,它一直傻傻地以為三次握手已經(jīng)妥了呢。不過還好,這時在服務(wù)器的半連接隊列中仍然記錄著第一次握手時存的握手請求。

          服務(wù)器等到半連接定時器到時后,向客戶端重新發(fā)起 synack ,客戶端收到后再重新回復(fù)第三次握手 ack。如果這期間服務(wù)器端全連接隊列一直都是滿的,那么服務(wù)器重試 5 次(受內(nèi)核參數(shù) net.ipv4.tcp_synack_retries 控制)后就放棄了。

          在這種情況下大家還要注意另外一個問題。在實踐中,客戶端往往是以為連接建立成功就會開始發(fā)送數(shù)據(jù),其實這時候連接還沒有真的建立起來。他發(fā)出去的數(shù)據(jù),包括重試都將全部被服務(wù)器無視。直到連接真正建立成功后才行。


          四、總結(jié)

          衡量工程師是否優(yōu)秀的標(biāo)準(zhǔn)之一就是看他能否有能力定位和處理線上發(fā)生的各種問題。連看似簡單的一個 TCP 三次握手,工程實踐中可能會有各種意外發(fā)生。如果對握手理解不深,那么很有可能無法處理線上出現(xiàn)的各種故障。

          今天的文章主要是描述了端口不足、半連接隊列滿、全連接隊列滿時的情況,

          當(dāng)端口不充足的時候,會導(dǎo)致 connect 系統(tǒng)調(diào)用的時候過多地執(zhí)行自旋鎖等待與 Hash 查找,會引起 CPU 開銷上漲。嚴(yán)重情況下會耗光 CPU,影響用戶業(yè)務(wù)邏輯的執(zhí)行。出現(xiàn)這種問題處理起來方法有這么幾個。

          • 通過調(diào)整 ip_local_port_range 來盡量加大端口范圍
          • 盡量復(fù)用連接,使用長連接來削減頻繁的握手處理
          • 第三個有用,但是不太推薦的是開啟 tcp_tw_reuse 和 tcp_tw_recycle

          服務(wù)器端在第一次握手時可能會丟包, 在如下兩種情況下會發(fā)生。

          • 半連接隊列滿,且 tcp_syncookies 為 0
          • 全連接隊列滿,且有未完成的半連接請求

          在這兩種情況下,客戶端視角來看和網(wǎng)絡(luò)斷了沒有區(qū)別,就是發(fā)出去的 SYN 包沒有任何反饋,然后等待定時器到時后重傳握手請求。第一次重傳時間是 1 s ,接下來的等待間隔是翻倍地增長,2 s,4 s,8 s ...。總的重傳次數(shù)由 net.ipv4.tcp_syn_retries 內(nèi)核參數(shù)影響(注意我的用詞是影響,而不是決定)。

          服務(wù)器在第三次握手時也可能會出問題,如果全連接隊列滿,仍將會發(fā)生丟包。不過第三次握手失敗時,只有服務(wù)器端知道(客戶端誤以為連接已經(jīng)建立成功了)。服務(wù)器根據(jù)半連接隊列里的握手信息發(fā)起 synack 重試,重試次數(shù)由 net.ipv4.tcp_synack_retries 控制。

          一旦你的線上出現(xiàn)了上面這些連接隊列溢出導(dǎo)致的問題,你的服務(wù)將會受到比較嚴(yán)重的影響。即使第一次重試就能夠成功,那你的接口響應(yīng)耗時將直接上漲到 1 s(老版本上是 3 s)。如果重試上兩三次都沒有成功,Nginx 很有可能直接就報訪問超時失敗了。

          正因為握手重試對我們服務(wù)影響很大,所以能深刻理解三次握手中的這些異常情況很有必要。再說說如果出現(xiàn)了丟包的問題,我們該如何應(yīng)對。

          方法1,打開 syncookie

          在現(xiàn)代的 Linux 版本里,我們可以通過打開 tcp_syncookies 來防止過多的請求打滿半連接隊列包括 SYN Flood 攻擊,來解決服務(wù)器因為半連接隊列滿而發(fā)生的丟包。

          方法2,加大連接隊列長度

          全連接隊列的長度是 min(backlog, net.core.somaxconn)半連接隊列長度是。半連接隊列長度有點小復(fù)雜,是 min(backlog, somaxconn, tcp_max_syn_backlog) + 1 再上取整到 2 的冪次,但最小不能小于16。

          如果需要加大全/半連接隊列長度,請調(diào)節(jié)以上的一個或多個參數(shù)來達到目的。只要隊列長度合適,就能很大程序降低握手異常概率的發(fā)生。

          方法3,盡快地 accept

          另外這個雖然一般不會成為問題,但也要注意一下。你的應(yīng)用程序應(yīng)該盡快在握手成功之后通過 accept 把新連接取走。不要忙于處理其它業(yè)務(wù)邏輯而導(dǎo)致全連接隊列塞滿了。

          方法4,盡量減少 TCP 連接的次數(shù)

          如果上述方法都未能根治你的問題,那說明你的服務(wù)器上 TCP 連接請求太、太過于頻繁了。這個時候你應(yīng)該思考下是否可以用長連接代替短連接,減少過于頻繁的三次握手。這個方法不但能解決握手出問題的可能,而且還順帶砍掉了三次握手的各種內(nèi)存、CPU、時間上的開銷,對提升性能也有較大幫助。

          推薦閱讀:

          最后,還是求贊,求再看,求轉(zhuǎn)發(fā)!

          瀏覽 77
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  国产手机精品伦子伦 | 无码_免费在线视频网站入口_第1页 | 99热99这里只有精品6首页 | 熟女一区二区三区免费视频 | 亚洲插逼视频 |