前兩天看到一群裡在讨論 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 及半連接配接、全連接配接隊列相關内容。
劃重點
- threads.max 和 threads.minSpare 是用來配置 Tomcat 的工作線程池大小的,是線程池次元的參數
- accept-count 和 max-connections 是 TCP 次元的配置參數
TCP 狀态機
Client 端和 Server 端基于 TCP 協定進行通信時,首先需要經過三次握手建連的,通信結束時需要通過四次揮手斷連的。注意所謂的連接配接其實是個邏輯上的概念,并不存在真實連接配接的,那 TCP 是怎麼面向連接配接傳輸的呢?
TCP 定義了個複雜的有限狀态機模型,通信雙方通過維護一個連接配接狀态,來達到看起來像有一條連接配接的效果。如下是 TCP 狀态機狀态流轉圖,這個圖非常重要,建議大家一定要掌握。圖檔來自 TCP 狀态機
圖上半部分描述了三次握手建立連接配接過程中狀态的變化
圖下半部分描述了四次揮手斷開連接配接過程中狀态的變化
圖 2 是通過三次握手建立連接配接的過程,老八股文了,建議結合圖 1 狀态機變化圖看,圖檔來源三次握手
圖 3 是通過四次揮手斷開連接配接的過程,建議結合圖 1 狀态機變化圖看,圖檔來源四次揮手
服務端程式調用 listen() 函數後,TCP 狀态機從 CLOSED 轉變為 LISTEN,并且 linux 核心會建立維護兩個隊列。一個是半連接配接隊列(Syn queue),另一個是全連接配接隊列(Accept queue)。
建連主要流程如下:
用戶端向服務端發送 SYN 包請求建立連接配接,發送後用戶端進入 SYN_SENT 狀态
服務端收到用戶端的 SYN 請求,将該連接配接存放到半連接配接隊列(Syn queue)中,并向用戶端回複 SYN + ACK,随後服務端進入 SYN_RECV 狀态
用戶端收到服務端的 SYN + ACK 後,回複服務端 ACK 并進入 ESTABLISHED 狀态
服務端收到用戶端的 ACK 後,從半連接配接隊列中取出連接配接放到全連接配接隊列(Accept queue)中,服務端進入 ESTABLISHED 狀态
服務端程式調用 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 的原因
sk_ack_backlog 是目前全連接配接隊列的大小
sk_max_ack_backlog 是全連接配接隊列的最大長度,也就是 min(listen_backlog, somaxconn)
當全連接配接隊列滿了發生溢出時,會根據 /proc/sys/net/ipv4/tcp_abort_on_overflow 核心參數來決定怎麼處理後續的 ack 請求,tcp_abort_on_overflow 預設值為 0。
- 當 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 異常
- 當 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 大小
- Redis 預設 backlog = 511
- Nginx 預設 backlog = 511
- Mysql 預設 backlog = 50
- Undertow 預設 backlog = 1000
- Tomcat 預設 backlog = 100
總結
這篇文章以 Tomcat 性能調優為切入點,首先簡單講了下 Tomcat 線程池調優。然後借 Tomcat 配置參數 accept-count 引出了 Tcp backlog,從 linux 核心源碼層面詳細講解了下 TCP backlog 參數以及半連接配接、全連接配接隊列的相關知識,包括連接配接隊列大小設定,以及隊列溢出怎麼排查,這些東西也是我們服務端開發需要掌握的,在性能調優,問題排查時會有一定的幫助。
作者:CodeFox
連結:https://juejin.cn/post/7155997400484544543
來源:稀土掘金