本文是對 從一次線上問題說起,詳解 TCP 半連接隊列、全連接隊列 這篇文章的實驗復現和總結,借此加深對 TCP 半連接隊列、全連接隊列的理解。
實驗環境
兩臺騰訊云服務器 node2(172.19.0.12) 和 node3(172.19.0.15)配置為 2C4G,Ubuntu 系統,內核版本 5.15.0-130-generic 。
全連接半連接隊列簡介
在 TCP 三次握手過程中,Linux 會維護兩個隊列分別是:
- SYN Queue 半連接隊列
- Accept Queue 全連接隊列
創建連接時,兩個隊列作用如下:
- 客戶端向服務端發送 SYN 包,客戶端進入 SYN_SENT 狀態
- 服務端收到 SYN 包后,進入 SYN_RECV 狀態,內核將連接信息放入 SYN Queue 隊列,然后向客戶端發送 SYN+ACK 包
- 客戶端收到 SYN+ACK 包后,發送 ACK 包,客戶端進入 ESTABLISHED 狀態
- 服務端收到 ACK 包后,將連接從 SYN Queue 隊列中取出移到 Accept Queue 隊列,Server 端進入 ESTABLISHED。
- 服務端應用程序調用 accept 函數處理數據,連接從 Accept Queue 隊列移除。
圖片來自:從一次線上問題說起,詳解 TCP 半連接隊列、全連接隊列
圖片來自Cloudflare Blog: SYN Packet Handling in the Wild
兩個隊列的長度都是有限的,當隊列滿了之后,新建連接時內核會將 SYN 包丟棄或者直接返回 RST 包。
全連接隊列實戰
全連接隊列長度控制
TCP 全連接隊列的長度計算公式為:
min(somaxconn, backlog)
- somaxconn Linux 內核參數
net.core.somaxconn
的值,默認為 4096。可以通過修改該參數來控制全連接隊列的長度。 - backlog 是系統調用 listen 函數
int listen(int sockfd, int backlog)
的 backlog 參數, Golang 中默認使用系統 somaxconn 的值。
下面是 Linux 5.15.130 內核源碼中計算全連接隊列長度的代碼:
源碼地址:https://elixir.bootlin.com/linux/v5.15.130/source/net/socket.c#L1716
我們修改 somaxconn 的值,然后運行實驗代碼查看全連接隊列的長度變化。
- 服務端實驗代碼
package mainimport ("log""net""time"
)func main() {l, err := net.Listen("tcp", ":8888")if err != nil {log.Printf("failed to listen due to %v", err)}defer l.Close()log.Println("listen :8888 success")for {time.Sleep(time.Second * 100)}
}
首先我們修改 somaxconn 為 128:
sudo sysctl -w net.core.somaxconn=128
啟動服務后查看全連接隊列的長度:
$ go run server.go
2025/02/13 09:53:01 listen :8888 success$ ss -lnt
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 128 *:8888 *:*
...
這里簡單解釋下 ss 命令輸出的含義:
-
對于 Listen 狀態的 socket,Recv-Q 表示當前全連接隊列的長度,也就是已經完成三次握手,等待應用層調用 accept 的 TCP 連接數;Send-Q 表示全連接隊列的最大長度。
-
對于非 Listen 狀態的 socket,Recv-Q 表示已經收到但尚未被應用讀取的字節數;Send-Q 表示已發送但尚未收到確認的字節數。
再次修改 somaxconn 為 1024 重啟服務后,查看全連接隊列的長度已經變成了 1024。
$ sudo sysctl -w net.core.somaxconn=1024
$ go run server.go
2025/02/13 09:53:01 listen :8888 success$ ss -lnt
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 1024 *:8888 *:*
...
全連接隊列溢出
下面我們讓服務端只 Listen 端口但不執行 accept() 處理數據,模擬全連接隊列溢出的情況。
- 服務端代碼
// server 端監聽 8888 tcp 端口
package main import ( "log" "net" "time"
) func main() { l, err := net.Listen("tcp", ":8888") if err != nil { log.Printf("failed to listen due to %v", err) } defer l.Close() log.Println("listen :8888 success") for { time.Sleep(time.Second * 100) }
}
- 客戶端代碼
和原實驗相比加了 time.Sleep(500 * time.Millisecond)
一行代碼,讓連接一個個建立,可以更精準的復現全連接隊列已滿的情況。
package main import ( "context" "log" "net" "os" "os/signal" "sync" "syscall" "time"
) var wg sync.WaitGroup func establishConn(ctx context.Context, i int) { defer wg.Done() conn, err := net.DialTimeout("tcp", ":8888", time.Second*5) if err != nil { log.Printf("%d, dial error: %v", i, err) return } log.Printf("%d, dial success", i) _, err = conn.Write([]byte("hello world")) if err != nil { log.Printf("%d, send error: %v", i, err) return } select { case <-ctx.Done(): log.Printf("%d, dail close", i) }
} func main() { ctx, cancel := context.WithCancel(context.Background()) // 并發請求 10 次服務端,連接建立成功后發送數據for i := 0; i < 10; i++ { wg.Add(1) time.Sleep(500 * time.Millisecond)go establishConn(ctx, i) } go func() { sc := make(chan os.Signal, 1) signal.Notify(sc, syscall.SIGINT) select { case <-sc: cancel() } }() wg.Wait() log.Printf("client exit")
}
我們先將全連接隊列的最大長度設置為 5:
$ sudo sysctl -w net.core.somaxconn=5$ cat /proc/sys/net/core/somaxconn
5
運行服務端和客戶端后,查看全連接隊列情況:
- 服務端 socket 情況
$ ss -ant | grep -E "Recv|8888"
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 6 5 *:8888 *:*
ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:40148
ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:40162
ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:40128
ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:40132
ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:40110
ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:40112
- 客戶端 socket 情況
$ ss -ant | grep -E "Recv|8888"
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
ESTAB 0 0 172.19.0.15:40132 172.19.0.12:8888
ESTAB 0 0 172.19.0.15:40162 172.19.0.12:8888
ESTAB 0 0 172.19.0.15:40148 172.19.0.12:8888
SYN-SENT 0 1 172.19.0.15:51906 172.19.0.12:8888
ESTAB 0 0 172.19.0.15:40112 172.19.0.12:8888
ESTAB 0 0 172.19.0.15:40128 172.19.0.12:8888
SYN-SENT 0 1 172.19.0.15:51912 172.19.0.12:8888
SYN-SENT 0 1 172.19.0.15:40176 172.19.0.12:8888
ESTAB 0 0 172.19.0.15:40110 172.19.0.12:8888
SYN-SENT 0 1 172.19.0.15:51926 172.19.0.12:8888
- 客戶端日志輸出
$ go run client.go
2025/02/19 11:14:22 0, dial success
2025/02/19 11:14:22 1, dial success
2025/02/19 11:14:23 2, dial success
2025/02/19 11:14:23 3, dial success
2025/02/19 11:14:24 4, dial success
2025/02/19 11:14:24 5, dial success
2025/02/19 11:14:30 6, dial error: dial tcp 172.19.0.12:8888: i/o timeout
2025/02/19 11:14:30 7, dial error: dial tcp 172.19.0.12:8888: i/o timeout
2025/02/19 11:14:31 8, dial error: dial tcp 172.19.0.12:8888: i/o timeout
2025/02/19 11:14:31 9, dial error: dial tcp 172.19.0.12:8888: i/o timeout
我們來分析下上述結果:
1. 全連接隊列是否已滿
服務端 Listen 狀態的 socket 顯示 Send-Q 為 5,表示該 socket 的全連接隊列最大值為 5;Recv-Q 為 6,表示當前 Accept queue 中數量為 6,我們看有 6 條 ESTAB 狀態的連接,符合觀察結果。Linux 內核的判斷依據是 > 而不是 >=,所以實際的連接數為比隊列的最大值多 1 個。5.15.0-130-generic 內核代碼如下:
// 源碼地址
// https://elixir.bootlin.com/linux/v5.15.130/source/include/net/sock.h#L980
/* Note: If you think the test should be:* return READ_ONCE(sk->sk_ack_backlog) >= READ_ONCE(sk->sk_max_ack_backlog);* Then please take a look at commit 64a146513f8f ("[NET]: Revert incorrect accept queue backlog changes.")*/
static inline bool sk_acceptq_is_full(const struct sock *sk)
{return READ_ONCE(sk->sk_ack_backlog) > READ_ONCE(sk->sk_max_ack_backlog);
}
之所以這樣做,是為了保證在 backlog 設置為 0 時,依然可以有一個連接進入全連接隊列,具體可以查看以下 commit 信息:
https://github.com/torvalds/linux/commit/64a146513f8f12ba204b7bf5cb7e9505594ead42[NET]: Revert incorrect accept queue backlog changes.
This reverts two changes:8488df8
248f067A backlog value of N really does mean allow "N + 1" connections
to queue to a listening socket. This allows one to specify
"0" as the backlog and still get 1 connection.Noticed by Gerrit Renker and Rick Jones.Signed-off-by: David S. Miller <davem@davemloft.net>
2. 內核 drop 包處理邏輯
客戶端有 6 個 ESTAB 狀態的 socket,另外還有 4 個 SYN-SENT 狀態的 socket,對應著 4 條 timeout 報錯信息。我們只改了全連接隊列大小為 5,半連接隊列大小依然為默認的 net.ipv4.tcp_max_syn_backlog=256
,所以第 6 個請連接建立后 Accept Queue 滿了但 SYN Queue 還沒有滿。按理說從第 7 個請求開始服務端可以接收 SYN 但不能在處理客戶端的 ACK 進入 Accept Queue,服務端會有 4 條 SYN-RECV 狀態的連接,而實際情況是服務端不存在 SYN_RECV 狀態的連接,這是因為當 Accept Queue 被占滿時,即使 SYN Queue 沒有滿,Linux 內核也會將新來的 SYN 請求丟棄掉。 5.15.0-130-generic 內核處理這部分邏輯的代碼如下::
// 源碼地址:https://elixir.bootlin.com/linux/v5.15.130/source/net/ipv4/tcp_input.c#L6848int tcp_conn_request(struct request_sock_ops *rsk_ops,const struct tcp_request_sock_ops *af_ops,struct sock *sk, struct sk_buff *skb)
{// ... 代碼省略syncookies = READ_ONCE(net->ipv4.sysctl_tcp_syncookies);/* TW buckets are converted to open requests without* limitations, they conserve resources and peer is* evidently real one.*/// 強制啟用 SYN cookie 或者半連接隊列已滿// !isn 表示是一個新的請求連接建立的 SYNif ((syncookies == 2 || inet_csk_reqsk_queue_is_full(sk)) && !isn) {// 這里表示是否啟用 SYN cookie 機制;如果不開啟,則直接 drop,如果開啟,則繼續執行。want_cookie = tcp_syn_flood_action(sk, rsk_ops->slab_name);if (!want_cookie)goto drop;}// 如果 accept queue 滿了則 dropif (sk_acceptq_is_full(sk)) {NET_INC_STATS(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);goto drop;}static bool tcp_syn_flood_action(const struct sock *sk, const char *proto)
{struct request_sock_queue *queue = &inet_csk(sk)->icsk_accept_queue;const char *msg = "Dropping request";struct net *net = sock_net(sk);bool want_cookie = false;u8 syncookies;syncookies = READ_ONCE(net->ipv4.sysctl_tcp_syncookies);// 開啟 SYN Cookie 機制
#ifdef CONFIG_SYN_COOKIESif (syncookies) {msg = "Sending cookies";want_cookie = true;__NET_INC_STATS(sock_net(sk), LINUX_MIB_TCPREQQFULLDOCOOKIES);} else
#endif// 沒有啟用 syncookies,統計丟棄包的數量__NET_INC_STATS(sock_net(sk), LINUX_MIB_TCPREQQFULLDROP);// 如果啟用了 SYN cookie 機制,發送警告if (!queue->synflood_warned && syncookies != 2 &&xchg(&queue->synflood_warned, 1) == 0)net_info_ratelimited("%s: Possible SYN flooding on port %d. %s. Check SNMP counters.\n",proto, sk->sk_num, msg);return want_cookie;
}// 判斷半連接隊列是否滿,用的是半連接隊列的長度是否大于等于全連接隊列的最大長度
static inline int inet_csk_reqsk_queue_is_full(const struct sock *sk)
{return inet_csk_reqsk_queue_len(sk) >= sk->sk_max_ack_backlog;
}
從代碼中可以推測出 net.ipv4.tcp_syncookies
參數值的含義和 Linux 的處理機制:
- 2:強制開啟 SYN Cookie 機制,發送警告
- 1:當半連接隊列滿時,開啟 SYN Cookie 機制,發送警告
- 0:不開啟 SYN Cookie 機制,并統計丟棄包的數量
這里判斷半連接隊列是否滿的依據是 inet_csk_reqsk_queue_len(sk) >= sk->sk_max_ack_backlog
,也就是說當半連接隊列長度不小于全連接隊列的最大長度時,如果不開啟 SYN Cookie 機制,就會將 SYN 包丟棄。
回到我們的實驗環境,net.ipv4.tcp_syncookies
設置為 1 并且半連接隊列沒滿,因此不會開啟 SYN Cookie 機制,繼續往后執行時會因為 Accept Queue 滿了將包丟棄。可以通過 netstat -s
命令查看丟棄包的數量。
$ date;netstat -s | grep -i "SYNs to LISTEN"
Wed Feb 19 12:05:51 PM CST 20251289 SYNs to LISTEN sockets dropped$ date;netstat -s | grep -i "SYNs to LISTEN"
Wed Feb 19 12:06:05 PM CST 20251301 SYNs to LISTEN sockets dropped
可以看到有 12 個 SYN 包被 DROP 了,查看抓包情況可以看到,我們有 4 個請求連接超時,每個請求傳了 3 次 SYN(一次發起 + 兩次重傳)。
查看客戶端 socket 狀態能夠看到重傳計時器在工作,這里重傳了兩次和默認的 net.ipv4.tcp_syn_retries = 6
有出入,是因為代碼 conn, err := net.DialTimeout("tcp", "172.19.0.12:8888", time.Second*5)
設置了 5s 超時,操作系統的默認重傳間隔大約為 1s、2s、4s、8s、16s、32s,第 3 次重傳會發生在 7s 以后,客戶端已經主動斷開連接了。
$ sudo netstat -anpo | grep -E "Recv-Q|8888"
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timer
tcp 0 0 172.19.0.15:57384 172.19.0.12:8888 ESTABLISHED 3123924/client keepalive (7.57/0/0)
tcp 0 0 172.19.0.15:57388 172.19.0.12:8888 ESTABLISHED 3123924/client keepalive (8.07/0/0)
tcp 0 0 172.19.0.15:60276 172.19.0.12:8888 ESTABLISHED 3123924/client keepalive (9.58/0/0)
tcp 0 1 172.19.0.15:60304 172.19.0.12:8888 SYN_SENT 3123924/client on (0.08/1/0)
tcp 0 1 172.19.0.15:60286 172.19.0.12:8888 SYN_SENT 3123924/client on (2.60/2/0)
tcp 0 0 172.19.0.15:60270 172.19.0.12:8888 ESTABLISHED 3123924/client keepalive (9.08/0/0)
tcp 0 0 172.19.0.15:60280 172.19.0.12:8888 ESTABLISHED 3123924/client keepalive (10.08/0/0)
tcp 0 1 172.19.0.15:60292 172.19.0.12:8888 SYN_SENT 3123924/client on (3.11/2/0)
tcp 0 0 172.19.0.15:57398 172.19.0.12:8888 ESTABLISHED 3123924/client keepalive (8.57/0/0)
tcp 0 1 172.19.0.15:60294 172.19.0.12:8888 SYN_SENT 3123924/client on (3.62/2/0)
3. overflow 參數控制
當全連接隊列滿時,Linux 默認會 drop 掉包,這個受 net.ipv4.tcp_abort_on_overflow
參數控制,默認為 0 表示直接 drop,為 1 則表示中斷連接,服務端會返回 RST 包。可以通過如下方式修改
$ sudo sysctl -w net.ipv4.tcp_abort_on_overflow=1或者echo 1 > /proc/sys/net/ipv4/tcp_abort_on_overflow
我們修改參數后再次執行客戶端請求,會出現 connection reset by peer
錯誤,抓包能看到 RST 包。(在實驗時,如果客戶端不加時間間隔,會出現返回 RST 包的情況,如果加了則不會出現這種情況,應該是和兩者的生效機制有關,SYN Cookie 和全連接隊列滿 drop 發生在 tcp_conn_request 函數,而 abort_on_overflow 發生在 tcp_check_req 函數, 先挖個坑,等后續梳理整個網絡傳輸流程時在做進一步分析)。
$ go run client.go
2025/03/01 13:36:55 2, dial success
2025/03/01 13:36:55 5, dial success
2025/03/01 13:36:55 4, dial success
2025/03/01 13:36:55 1, dial success
2025/03/01 13:36:55 3, dial success
2025/03/01 13:36:55 0, dial success
2025/03/01 13:36:55 7, dial error: dial tcp 172.19.0.12:8888: connect: connection reset by peer
2025/03/01 13:36:55 6, dial error: dial tcp 172.19.0.12:8888: connect: connection reset by peer
4. ss 命令展示含義
服務端有 6 條 ESTAB 狀態的 socket,RECV_Q 的值為 11,與客戶端發送的數據 []byte("hello world")
數據長度一致,因為我們的沒有執行 accept 接收數據,所以 RECV_Q 會展示這部分數據的大小;
客戶端 6 條 ESTAB 狀態的 socket,其 RECV_Q 和 SEND_Q 均為 0;而 4 條 SYN-SENT 狀態的 SEND-Q 為 1,這是因為 6 條已建立連接的 socket 包可以被正常 ACK,而 4 條建立連接失敗的 socket,其 SYN 包沒有收到 ACK 包,因為 SEND-Q 顯示為 1。由此我們可以再次總結下 ss 的展示含義:
對于 LISTEN 狀態的 socket
- Recv-Q:表示當前全連接隊列的大小,即已完成三次握手等待應用程序 accept() 的 TCP 連接數。
- Send-Q:全連接隊列的最大長度,即全連接隊列所能容納的 socket 數量。
對于非 LISTEN 狀態的 socket
- Recv-Q:表示已被接收但尚未執行 accept 被應用程序讀取的數據字節數,通常在服務端能觀察到。
- Send-Q:表示已經發送但尚未收到 ACK 確認的字節數。
內核代碼如下:
// https://elixir.bootlin.com/linux/v5.15.130/source/net/ipv4/tcp_diag.c#L18
static void tcp_diag_get_info(struct sock *sk, struct inet_diag_msg *r,void *_info)
{struct tcp_info *info = _info;if (inet_sk_state_load(sk) == TCP_LISTEN) { // LISTEN 狀態的連接// 當前已完成三次握手但未被 accept 的連接數r->idiag_rqueue = READ_ONCE(sk->sk_ack_backlog); // 最大隊列長度r->idiag_wqueue = READ_ONCE(sk->sk_max_ack_backlog);} else if (sk->sk_type == SOCK_STREAM) { // 非 LISTEN 狀態的普通連接const struct tcp_sock *tp = tcp_sk(sk);// TCP 讀隊列,即接收緩沖區中未被應用層讀取的數據量,單位是字節r->idiag_rqueue = max_t(int, READ_ONCE(tp->rcv_nxt) -READ_ONCE(tp->copied_seq), 0);// TCP 寫隊列,即已經發送但尚未被對方 ACK 確認的數據量,單位是字節r->idiag_wqueue = READ_ONCE(tp->write_seq) - tp->snd_una;}if (info)tcp_get_info(sk, info);
}
5. SYN+ACK 重傳
原實驗有三種情況:
- 三次握手成功,數據正常發送
- 客戶端認為連接建立成功,但服務端一直處于 SYN-RECV 狀態,不斷重傳 SYN + ACK
- 客戶端發送 SYN 未得到響應一直在重傳
我們復現了第 1 中和第 3 種,之所以沒有第二種情況是因為每次請求加了 500ms 的間隔,這樣下一個請求發起 SYN 時,上一個請求已經完成三次握手,服務端的 socket 已經進入全連接隊列了。如果我們去掉時間間隔,請求可能會一下子發出去全部進入半連接隊列,等到服務端在接收到客戶端的 ACK 包時,全連接隊列已經滿了,從而導致服務端的 socket 無法進入全連接隊列,從而 DROP 掉 ACK 包出現第二種情況。這里我們去掉時間間隔嘗試復現,此時可以看到服務端有 SYN-RECV 狀態的連接,
$ ss -ant | grep -E "Recv|8888"
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 6 5 *:8888 *:*
ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:33430
ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:33458
ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:33482
SYN-RECV 0 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:33512
ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:33442
ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:33428
ESTAB 11 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:33472
SYN-RECV 0 0 [::ffff:172.19.0.12]:8888 [::ffff:172.19.0.15]:33496
查看抓包結果可以看到 SYN-ACK 包重傳。
全連接隊列的實驗就到這里,下面我們來看半連接隊列的實驗。
半連接隊列實戰
半連接隊列的最大長度計算有些麻煩,網絡上資料也很繁雜,本著 talk is cheap, show me the code 的原則,這里還是直接看 Linux 的源碼來分析,還是 tcp_conn_request 函數。
// 源碼地址:https://elixir.bootlin.com/linux/v5.15.130/source/net/ipv4/tcp_input.c#L6848int tcp_conn_request(struct request_sock_ops *rsk_ops,const struct tcp_request_sock_ops *af_ops,struct sock *sk, struct sk_buff *skb)
{// ... 代碼省略u8 syncookies;// 第一部分,基于 syncookies 和半連接隊列是否超過全連接隊列長度、半連接隊列是否已滿來判斷是否 dropsyncookies = READ_ONCE(net->ipv4.sysctl_tcp_syncookies);if ((syncookies == 2 || inet_csk_reqsk_queue_is_full(sk)) && !isn) {want_cookie = tcp_syn_flood_action(sk, rsk_ops->slab_name);if (!want_cookie)goto drop;}// 第二部分,判斷全連接隊列是否已滿if (sk_acceptq_is_full(sk)) {NET_INC_STATS(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);goto drop;}req = inet_reqsk_alloc(rsk_ops, sk, !want_cookie);if (!req)goto drop;// ... 代碼省略if (!want_cookie && !isn) {// 獲取系統參數 ``net.ipv4.tcp_max_syn_backlog`` 的值int max_syn_backlog = READ_ONCE(net->ipv4.sysctl_max_syn_backlog);/* Kill the following clause, if you dislike this way. */// 第三部分:判斷半連接隊列是否超過長度限制if (!syncookies &&(max_syn_backlog - inet_csk_reqsk_queue_len(sk) <(max_syn_backlog >> 2)) &&!tcp_peer_is_proven(req, dst)) {/* Without syncookies last quarter of* backlog is filled with destinations,* proven to be alive.* It means that we continue to communicate* to destinations, already remembered* to the moment of synflood.*/pr_drop_req(req, ntohs(tcp_hdr(skb)->source),rsk_ops->family);goto drop_and_release;}isn = af_ops->init_seq(skb);}tcp_ecn_create_request(req, skb, sk, dst);if (want_cookie) {isn = cookie_init_sequence(af_ops, sk, skb, &req->mss);if (!tmp_opt.tstamp_ok)inet_rsk(req)->ecn_ok = 0;}return 0;}
核心計算邏輯是 (max_syn_backlog - inet_csk_reqsk_queue_len(sk) < (max_syn_backlog >> 2))
,即 max_syn_backlog 的值減去當前半連接隊列的長度的值小于 max_syn_backlog 的 1/4 時,就會將 SYN 包丟棄。簡單來說就是半連接隊列長度不能超過 max_syn_backlog 的 3/4。因為比較條件是 > 而不是 >=,所以在不開啟 syncookies 的情況下,實際的半連接隊列長度應該是 max_syn_backlog 的 3/4 + 1。大致計算如下:
- max_syn_backlog 為 128,則半連接隊列長度最大為 97
- max_syn_backlog 為 256,則半連接隊列長度最大為 193
- max_syn_backlog 為 512,則半連接隊列長度最大為 385
- max_syn_backlog 為 1024,則半連接隊列長度最大為 769
結合上面全連接實驗中的代碼分析,我們可以總結下 Linux 5.15.30 內核下 SYN 包的 Drop 機制:
我們修改參數驗證下上述三種情況。
實驗一:關閉 syncookies,半連接長度超過全連接最大長度
客戶端我們使用 iptables 將服務端的包攔截,模擬 SYN Flood 攻擊,這樣服務端不會收到 ACK 包,也就不會進入全連接隊列。系統參數 syn_cookies=0,max_syn_backlog=128,somaxconn=64,理論上會有 64 個 SYN-RECV 狀態連接,其余的包被丟棄。
# 攔截服務端 8888 端口的包
$ sudo iptables -A INPUT -p tcp --sport 8888 -j DROP# 發送 SYN 包
$ sudo hping3 -S 172.19.0.12 -p 8888 --flood
查看服務端情況
$ ss -ant | grep -E "Recv|8888"
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 64 *:8888 *:*# ubuntu @ node2 in ~ [11:58:11]
$ sudo netstat -nat | grep :8888 | grep SYN_RECV | wc -l
64
結果符合預期。這里可以用 go 客戶端做更精確的驗證,我們使用 Go 程序發送 100 個請求,然后查看服務端連接數和 DROP 數
$ date;netstat -s | grep -i "SYNs to LISTEN"
Fri Feb 21 12:01:58 PM CST 20253030591019 SYNs to LISTEN sockets dropped$ sudo netstat -nat | grep :8888 | grep SYN_RECV | wc -l
64$ date;netstat -s | grep -i "SYNs to LISTEN"
Fri Feb 21 12:02:14 PM CST 20253030591127 SYNs to LISTEN sockets dropped
可以看到服務端只有 64 個 SYN-RECV 狀態連接,程序執行有有 3030591127-3030591019=108 個 SYN 包被丟棄。上面我們分析過,因為客戶端設置了超時時間為 5s,所以 SYN 只會重傳 2 次,也就是每個被 DROP 的連接都會發送 3 次 SYN。100 - 64 = 36,36 * 3 = 108,符合我們預期。
實驗二:關閉 syncookies,全連接隊列已滿
修改服務端系統參數 syn_cookies=0,max_syn_backlog=128,somaxconn=64,這樣全連接隊列最大長度為 64,當有 65 個連接建立時,全連接隊列就會滿,此時再有 SYN 包建立連接時就會被丟棄。
首先我們清理掉客戶端機器的 iptables 規則,是的三次握手能夠正常進程。
$ sudo iptables -F
設置系統參數
$ sudo sysctl -w net.ipv4.tcp_syncookies=0
$ sudo sysctl -w net.ipv4.tcp_max_syn_backlog=128
$ sudo sysctl -w net.core.somaxconn=64
我們再次用 Go 客戶端發送 100 個請求,然后查看服務端狀態,可以看到有 65 個 ESTAB 狀態連接,沒有 SYN-RECV 狀態連接,因為全連接隊列已滿,所有 SYN 包都會被丟棄。
$ ss -ant | grep -E "Recv|8888"
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 65 64 *:8888$ sudo netstat -nat | grep :8888 | grep ESTAB | wc -l
65# ubuntu @ node2 in ~ [12:18:27] C:130
$ sudo netstat -nat | grep :8888 | grep SYN_RECV | wc -l
0
按照以上邏輯,會有 35 個連接被拒絕,一共有 35 * 3 = 105 個 SYN 包被丟棄。我們查看統計信息可以驗證,3030591766 - 3030591661 = 105,符合預期。
$ date;netstat -s | grep -i "SYNs to LISTEN"
Fri Feb 21 12:18:19 PM CST 20253030591661 SYNs to LISTEN sockets dropped# ubuntu @ node2 in ~ [12:18:19]
$ date;netstat -s | grep -i "SYNs to LISTEN"
Fri Feb 21 12:18:34 PM CST 20253030591766 SYNs to LISTEN sockets dropped
實驗三:關閉 syncookies,半連接隊列長度超過 max_syn_backlog 的 3/4
現在我們將全連接隊列長度調大 net.core.somaxconn
設置為 4096,使用 iptables 攔截服務端 8888 端口的包,這樣全連接隊列始終不會填滿,然后 max_syn_backlog 分別設置為:
- 128,預期有 97 個 SYN-RECV 狀態連接
- 256,預期有 193 個 SYN-RECV 狀態連接
- 512,預期有 385 個 SYN-RECV 狀態連接
- 1024,預期有 769 個 SYN-RECV 狀態連接
分別設置并發送請求后,服務端顯示結果如下,基本符合預期。
# 客戶端設置 iptables 攔截服務端
sudo iptables -A INPUT -p tcp --sport 8888 -j DROP# 服務端查看 SYN-RECV 狀態連接數
$ sudo sysctl -w net.ipv4.tcp_max_syn_backlog=128
$ ss -ant | grep -E "Recv|:8888" | grep SYN-RECV | wc -l
97$ sudo sysctl -w net.ipv4.tcp_max_syn_backlog=256
$ ss -ant | grep -E "Recv|:8888" | grep SYN-RECV | wc -l
193$ sudo sysctl -w net.ipv4.tcp_max_syn_backlog=512
$ ss -ant | grep -E "Recv|:8888" | grep SYN-RECV | wc -l
385$ sudo sysctl -w net.ipv4.tcp_max_syn_backlog=1024
$ ss -ant | grep -E "Recv|:8888" | grep SYN-RECV | wc -l
769
執行過程數值會有變化,但最大半連接隊列長度符合預期。
實驗四:開啟 syncookies,半連接隊列長度取決于 max(somaxconn, backlog)
當開啟 syncookies 時,半連接隊列不在保留 1/4 的限制,而是取決于 max(somaxconn, backlog)。這里源碼判斷是 >=,因此最大長度應該會等于 max(somaxconn, backlog)
// 源碼地址:https://elixir.bootlin.com/linux/v5.15.130/source/include/net/inet_connection_sock.h#L280
static inline int inet_csk_reqsk_queue_is_full(const struct sock *sk)
{return inet_csk_reqsk_queue_len(sk) >= sk->sk_max_ack_backlog;
}
我們分別設置 net.core.somaxconn
為 512,1024,4096并設置 net.ipv4.tcp_syncookies=1
開啟 syncookies,每次設置完重啟服務端,然后在發起請求,理論上會有 512,1024,4096 個 SYN-RECV 狀態連接。
修改服務端 somaxconn 并重啟后,使用 watch 命令查看 SYN-RECV 狀態連接數,結果如下,符合預期。
$ watch -n 1 "netstat -nat | grep :8888 | grep SYN_RECV | wc -l"
Every 1.0s: netstat -nat | grep :8888 | grep SYN_RECV | wc -l node2: Sat Mar 1 15:07:22 2025512$ watch -n 1 "netstat -nat | grep :8888 | grep SYN_RECV | wc -l"
Every 1.0s: netstat -nat | grep :8888 | grep SYN_RECV | wc -l node2: Sat Mar 1 15:08:15 20251024$ watch -n 1 "netstat -nat | grep :8888 | grep SYN_RECV | wc -l"
Every 1.0s: netstat -nat | grep :8888 | grep SYN_RECV | wc -l node2: Sat Mar 1 15:09:11 20254096
簡要總結
- 半連接隊列受限于全連接隊列長度,而全連接隊列會受應用的影響,盡量不要將 somaxconn 設置的過小,否則會影響服務器的性能。
- 盡量開啟 syncookies,可以有效防止 SYN Flood 攻擊,同時可以避免半連接隊列被大量占用。
- ss、netstat 的熟練使用對探查網絡狀態非常重要,要熟練掌握。
- 代碼之下無秘密,一定要結合源碼去理解 Linux 的網絡工作機制,不要只是死記硬背協議。
- 動手!動手!動手!實踐出真知。