Tomcat 調優之從 Linux 核心原始碼層面看 Tcp backlog

2022-10-19 12:00:33

前兩天看到一群裡在討論 Tomcat 引數調優,看到不止一個人說通過 accept-count 來設定執行緒池大小,我笑了笑,看來其實很多人並不太瞭解我們用的最多的 WebServer Tomcat,這篇文章就來聊下 Tomcat 調優,重點介紹下執行緒池調優及 TCP 半連線、全連線佇列調優

Tomcat 執行緒池

先來說下執行緒池調優,就拿 SpringBoot 內建的 Tomcat 來說,確實是支援執行緒池引數設定的,但不是 accept-count 引數,可以通過 threads.max 和 threads.minSpare 來設定執行緒池最大執行緒數和核心執行緒數。

如果沒有設定,則會使用預設值

threads.max: 200
threads.minSpare: 10

Tomcat 底層用到的 ThreadPoolExecutor 也不是 JUC 原生的執行緒池,而是自定義的,做了一些調整來支援 IO 密集型場景使用,具體介紹可以看之前寫的兩篇文章。

動態執行緒池(DynamicTp),動態調整 Tomcat、Jetty、Undertow 執行緒池引數篇

以面試官視角萬字解讀執行緒池 10 大經典面試題!

通過這兩篇文章能瞭解到 Tomcat 自定義執行緒池的執行流程及原理,然後可以接入動態執行緒池框架 DynamicTp,將 Tomcat 執行緒池交由 DynamicTp 管理,使之能享受到動態調參、監控告警的功能。

在設定中心設定 tomcat 執行緒池核心引數

spring:
  dynamic:
    tp:
      tomcatTp:
        corePoolSize: 100
        maximumPoolSize: 400
        keepAliveTime: 60

Tomcat 執行緒池調優主要思想就是動態化執行緒池引數,上線前通過壓測初步確定一套較優的引數值,上線後通過監控、告警實時感知執行緒池負載情況,動態調整引數適應流量的變化。

執行緒池調優就說這些吧,下面主要介紹下 Tcp backlog 及半連線、全連線佇列相關內容。

劃重點

  1. threads.max 和 threads.minSpare 是用來設定 Tomcat 的工作執行緒池大小的,是執行緒池維度的引數

  2. accept-count 和 max-connections 是 TCP 維度的設定引數

TCP 狀態機

Client 端和 Server 端基於 TCP 協定進行通訊時,首先需要經過三次握手建連的,通訊結束時需要通過四次揮手斷連的。注意所謂的連線其實是個邏輯上的概念,並不存在真實連線的,那 TCP 是怎麼面向連線傳輸的呢?

TCP 定義了個複雜的有限狀態機模型,通訊雙方通過維護一個連線狀態,來達到看起來像有一條連線的效果。如下是 TCP 狀態機狀態流轉圖,這個圖非常重要,建議大家一定要掌握。圖片來自 TCP 狀態機

  1. 圖上半部分描述了三次握手建立連線過程中狀態的變化

  2. 圖下半部分描述了四次揮手斷開連線過程中狀態的變化

圖 2 是通過三次握手建立連線的過程,老八股文了,建議結合圖 1 狀態機變化圖看,圖片來源三次握手

圖 3 是通過四次揮手斷開連線的過程,建議結合圖 1 狀態機變化圖看,圖片來源四次揮手

伺服器端程式呼叫 listen() 函數後,TCP 狀態機從 CLOSED 轉變為 LISTEN,並且 linux 核心會建立維護兩個佇列。一個是半連線佇列(Syn queue),另一個是全連線佇列(Accept queue)。

建連主要流程如下:

  1. 使用者端向伺服器端傳送 SYN 包請求建立連線,傳送後用戶端進入 SYN_SENT 狀態

  2. 伺服器端收到使用者端的 SYN 請求,將該連線存放到半連線佇列(Syn queue)中,並向用戶端回覆 SYN + ACK,隨後伺服器端進入 SYN_RECV 狀態

  3. 使用者端收到伺服器端的 SYN + ACK 後,回覆伺服器端 ACK 並進入 ESTABLISHED 狀態

  4. 伺服器端收到使用者端的 ACK 後,從半連線佇列中取出連線放到全連線佇列(Accept queue)中,伺服器端進入 ESTABLISHED 狀態

  5. 伺服器端程式呼叫 accept() 方法,從全連線佇列中取出連線進行處理請求

連線佇列大小

上述提到了半連線佇列、全連線佇列,這兩佇列都有大小限制的,超過的連線會被丟掉或者返回 RST 包。

半連線佇列大小主要受:listen backlog、somaxconn、tcp_max_syn_backlog 這三引數影響

全連線佇列大小主要受:listen backlog 和 somaxconn 這兩引數影響

tcp_max_syn_backlog 和 somaxconn 都是 linux 核心引數,在 /proc/sys/net/ipv4/ 和 /proc/sys/net/core/ 下,可以通過 /etc/sysctl.conf 檔案來修改,預設值為 128。

listen backlog 引數其實就是我們呼叫 listen 函數時傳入的第二個引數。回到主題,Tomcat 的 accept-count 其實最後就會傳給 listen 函數做 backlog 用。

int listen(int sockfd, int backlog);

可以在組態檔中設定 tomcat accept-count 大小,預設為 100

以下程式碼註釋中也註明了 acceptCount 就是 backlog

以 Nio2Endpoint 為例看下程式碼,bind 方法首先會根據設定的核心執行緒數、最大執行緒數建立 worker 執行緒池。然後呼叫 jdk nio2 中的 AsynchronousServerSocketChannelImpl 的 bind 方法,該方法內會呼叫 Net.listen() 進行 socket 監聽。通過這幾段程式碼,我們可以清晰的看到 Tomcat accept-count = Tcp backlog,預設值為 100。

上面說到了半全兩個連線佇列,至於這兩個連線佇列大小怎麼確定,其實不同 linux 核心版本演演算法也都不太一樣,我們就以 v3.10 來看。

以下是 linux 核心 socket.c 中的原始碼,也就是我們呼叫 listen() 函數會執行的程式碼

/*
 * Perform a listen. Basically, we allow the protocol to do anything
 * necessary for a listen, and if that works, we mark the socket as
 * ready for listening.
 */
SYSCALL_DEFINE2(listen, int, fd, int, backlog)
{
    struct socket *sock;
    int err, fput_needed;
    int somaxconn;

    sock = sockfd_lookup_light(fd, &err, &fput_needed);
    if (sock) {
            somaxconn = sock_net(sock->sk)->core.sysctl_somaxconn;
            if ((unsigned int)backlog > somaxconn)
                    backlog = somaxconn;

            err = security_socket_listen(sock, backlog);
            if (!err)
                    err = sock->ops->listen(sock, backlog);

            fput_light(sock->file, fput_needed);
    }
    return err;
}

可以看到,此處會拿核心引數 somaxconn 和 傳入的 backlog 做比較,取二者中的較小者作為全連線佇列大小。

全連線佇列大小 = min(backlog, somaxconn)。

接下來 backlog 會依次傳遞給如下函數,格式約定(原始碼檔名#函數名)

af_inet.c#inet_listen() -> inet_connection_sock.c#inet_csk_listen_start() -> request_sock.c#reqsk_queue_alloc()

reqsk_queue_alloc() 函數程式碼如下,主要就是用來計算半連線佇列大小的。

計算邏輯可以簡化為下述公式,簡單描述 roundup_pow_of_two 演演算法就是向上取最接近的最大 2 的指數次冪,注意此處 backlog 已經是 min(backlog, somaxconn)

半連線佇列大小 = roundup_pow_of_two(max(8, min(backlog, tcp_max_syn_backlog))+1)

程式碼裡 max_qlen_log 在一個 for 迴圈裡計算,比如算出的半連線佇列大小 nr_table_entries = 16 = 2^4,那麼 max_qlen_log = 4,該值在判斷半連線佇列是否溢位時會用到。

舉個例子,如果 listen backlog = 10、somaxconn = 128、tcp_max_syn_backlog = 128,那麼半連線佇列大小 = 16,全連線佇列大小 = 10。

所以要知道,在做連線佇列大小調優的時候,一定要綜合上述三個引數,只修改某一個起不到想要的效果。

連線佇列大小檢視

全連線佇列大小

可以通過 linux 提供的 ss 命令來檢視全連線佇列的大小

引數說明,引數很多,其他引數可以自己 help 檢視說明

l:表示顯示 listening 狀態的 socket

n:不解析服務名稱

t:只顯示 tcp sockets

這個命令結果怎麼解讀呢?

主要看前三個欄位,Recv-Q 和 Send-Q 在 State 為 LISTEN 和非 LISTEN 狀態時代表不同的含義。

State: LISTEN

Recv-Q: 全連線佇列的當前長度,也就是已經完成三次握手等待伺服器端呼叫 accept() 方法獲取的連線數量

Send-Q: 全連線佇列的最大長度,也就是我們上述分析的 backlog 和somaxconn 的最小值

State: 非 LISTEN

Recv-Q: 已接受但未被應用程序讀取的位元組數

Send-Q: 已傳送但未收到確認的位元組數

以上區別從如下核心程式碼也可以看出,ss 命令就是從 tcp_diag 模組獲取的資料

半連線佇列大小

半連線佇列沒有像 ss 這種命令直接檢視,但伺服器端處於 SYN_RECV 狀態的連線都在半連線佇列裡,所以可以通過如下命令間接統計

netstat -natp | grep SYN_RECV | wc -l

半連線佇列最大長度可以使用我們上述分析得到的公式計算得到

半全連線佇列溢位

全連線佇列溢位

當請求量很大,全連線佇列比較小時,就有可能發生全連線佇列溢位的情況。

此程式碼是 linux 核心用來判斷全連線佇列是否已滿的函數,可以看到判斷用的是大於號,這也就是我們用 ss 命令可能會看到 Recv-Q > Send-Q 的原因

  1. sk_ack_backlog 是當前全連線佇列的大小

  2. sk_max_ack_backlog 是全連線佇列的最大長度,也就是 min(listen_backlog, somaxconn)

當全連線佇列滿了發生溢位時,會根據 /proc/sys/net/ipv4/tcp_abort_on_overflow 核心引數來決定怎麼處理後續的 ack 請求,tcp_abort_on_overflow 預設值為 0。

  1. 當 tcp_abort_on_overflow = 0 時,如果全連線佇列已滿,伺服器端會直接扔掉使用者端傳送的 ACK,此時伺服器端處於 SYN_RECV 狀態,使用者端處於 ESTABLISHED 狀態,伺服器端的超時重傳定時器會重傳 SYN + ACK 包給使用者端(重傳次數由/proc/sys/net/ipv4/tcp_synack_retries 指定,預設值為 5,重試間隔為 1s、2s、4s、8s、16s,共 31s,第 5 次發出後還要等 32s 才知道第 5 次也超時了,所以總共需要 63s)。超過 tcp_synack_retries 後,伺服器端不會在重傳,這時如果使用者端傳送資料過來,伺服器端會返回 RST 包,使用者端會報 connection reset by peer 異常

  2. 當 tcp_abort_on_overflow = 1 時,如果全連線佇列已滿,伺服器端收到使用者端的 ACK 後,會傳送一個 RST 包給使用者端,表示結束掉這個握手過程和這個連線,使用者端會報 connection reset by peer 異常

一般情況下 tcp_abort_on_overflow 保持預設值 0 就行,能提高建立連線的成功率

半連線佇列溢位

我們知道,伺服器端收到使用者端傳送的 SYN 包後會將該連線放入半連線佇列中,然後回覆 SYN+ACK,如果使用者端一直不回覆 ACK 做第三次握手,這樣就會使得伺服器端有大量處於 SYN_RECV 狀態的 TCP 連線存在半連線佇列裡,超過設定的佇列長度後就會發生溢位。

下述程式碼是 linux 核心判斷是否發生半連線佇列溢位的函數

// 程式碼在 include/net/inet_connection_sock.h 中
static inline int inet_csk_reqsk_queue_is_full(const struct sock *sk)
{
    return reqsk_queue_is_full(&inet_csk(sk)->icsk_accept_queue);
}

// 程式碼在 include/net/request_sock.h 中
static inline int reqsk_queue_is_full(const struct request_sock_queue *queue)
{
   /*
    * qlen 是當前半連線佇列大小
    * max_qlen_log 上述解釋過,如果半連線佇列大小 = 16 = 2^4,那麼該值就是4
    * 非常巧妙的用了移位執行來判斷半連線佇列是否溢位,底層滿滿的都是細節
    */
    return queue->listen_opt->qlen >> queue->listen_opt->max_qlen_log;
}

我們常說的 SYN Flood 洪水攻擊 是一種典型的 DDOS 攻擊,就是利用了這個點,給伺服器端傳送一個 SYN 包後用戶端就下線了,伺服器端會超時重傳 SYN+ACK 包,上述也說了總共需要 63s 才停止重傳,也就是說伺服器端需要經過 63s 後才斷開該連線,這樣就會導致半連線佇列快速被耗盡,不能處理正常的請求。

那是怎麼防止攻擊的呢?

linux 提供個一個核心引數 /proc/sys/net/ipv4/tcp_syncookies 來應對該攻擊,當半連線佇列滿了且開啟 tcp_syncookies = 1 設定時,伺服器端在收到 SYN 並返回 SYN+ACK 後,不將該連線放入半連線佇列,而是根據這個 SYN 包 TCP 頭資訊計算出一個 cookie 值。將這個 cookie 作為第二次握手 SYN+ACK 包的初始序列號 seq 發過去,如果是攻擊者,就不會有響應,如果是正常連線,使用者端回覆 ACK 包後,伺服器端根據頭資訊計算 cookie,與返回的確認序列號進行比對,如果相同,則是一個正常建立連線。

下述程式碼是計算 cookie 的函數,可以看到跟這些欄位有關(源 ip、源埠、目標 ip、目標埠、使用者端 syn 包序列號、時間戳、mssind)

下面看下第一次握手,收到 SYN 包後伺服器端的處理程式碼,程式碼太多,簡化提出跟半連線佇列溢位相關程式碼

int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb)
{
   /*
    * 如果半連線佇列已滿,且 tcp_syncookies 未開啟,則直接丟棄該連線
    */
    if (inet_csk_reqsk_queue_is_full(sk) && !isn) {
        want_cookie = tcp_syn_flood_action(sk, skb, "TCP");
        if (!want_cookie)
                goto drop;
    }

   /*
    * 如果全連線佇列已滿,並且沒有重傳 SYN+ACk 包的連線數量大於1,則直接丟棄該連線
    * inet_csk_reqsk_queue_young 獲取沒有重傳 SYN+ACk 包的連線數量
    */
    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;
    }

    // 分配 request sock 核心物件
    req = inet_reqsk_alloc(&tcp_request_sock_ops);
    if (!req)
        goto drop;

    if (want_cookie) {
        // 如果開啟了 tcp_syncookies 且半連線佇列已滿,則計算 cookie
        isn = cookie_v4_init_sequence(sk, skb, &req->mss);
        req->cookie_ts = tmp_opt.tstamp_ok;
    } else if (!isn) {
         /* 如果沒有開啟 tcp_syncookies 並且 max_syn_backlog - 半連線佇列當前大小 < max_syn_backlog >> 2,則丟棄該連線 */
        else if (!sysctl_tcp_syncookies &&
                 (sysctl_max_syn_backlog - inet_csk_reqsk_queue_len(sk) <
                  (sysctl_max_syn_backlog >> 2)) &&
                 !tcp_peer_is_proven(req, dst, false)) {
            LIMIT_NETDEBUG(KERN_DEBUG pr_fmt("drop open request from %pI4/%u\n"),
                           &saddr, ntohs(tcp_hdr(skb)->source));
            goto drop_and_release;
        }
        isn = tcp_v4_init_sequence(skb);
    }
    tcp_rsk(req)->snt_isn = isn;
    // 構造 syn+ack 響應包
    skb_synack = tcp_make_synack(sk, dst, req,
        fastopen_cookie_present(&valid_foc) ? &valid_foc : NULL);
    if (likely(!do_fastopen)) {
        int err;
        // 傳送 syn+ack 響應包
        err = ip_build_and_send_pkt(skb_synack, sk, ireq->loc_addr,
             ireq->rmt_addr, ireq->opt);
        err = net_xmit_eval(err);
        if (err || want_cookie)
                goto drop_and_free;

        tcp_rsk(req)->snt_synack = tcp_time_stamp;
        tcp_rsk(req)->listener = NULL;
        // 新增到半連線佇列,並且開啟超時重傳定時器
        inet_csk_reqsk_queue_hash_add(sk, req, TCP_TIMEOUT_INIT);
    } else if (tcp_v4_conn_req_fastopen(sk, skb, skb_synack, req))
        goto drop_and_free;
}

檢視溢位命令

當連線佇列溢位時,可以通過 netstart -s 命令查詢

  # 表示全連線佇列溢位的次數,累計值
  119005 times the listen queue of a socket overflowed

  # 表示半連線佇列溢位的次數,累計值
  119085 SYNs to LISTEN sockets dropped

如果發現這兩個值一直在增加,就說明發生了佇列溢位,需要看情況調大佇列大小

常用元件 backlog 大小

  1. Redis 預設 backlog = 511

  2. Nginx 預設 backlog = 511

  3. Mysql 預設 backlog = 50

  4. Undertow 預設 backlog = 1000

  5. Tomcat 預設 backlog = 100

總結

這篇文章以 Tomcat 效能調優為切入點,首先簡單講了下 Tomcat 執行緒池調優。然後借 Tomcat 設定引數 accept-count 引出了 Tcp backlog,從 linux 核心原始碼層面詳細講解了下 TCP backlog 引數以及半連線、全連線佇列的相關知識,包括連線佇列大小設定,以及佇列溢位怎麼排查,這些東西也是我們伺服器端開發需要掌握的,在效能調優,問題排查時會有一定的幫助。

個人開源專案

DynamicTp 是一個基於設定中心實現的輕量級動態執行緒池管理工具,主要功能可以總結為動態調參、通知報警、執行監控、三方包執行緒池管理等幾大類。

目前累計 2k star,歡迎大家試用,感謝你的 star,歡迎 pr,業務之餘一起給開源貢獻一份力量

官網https://dynamictp.cn

gitee 地址https://gitee.com/dromara/dynamic-tp

github 地址https://github.com/dromara/dynamic-tp