【动手实验】TCP半连接队列、全连接队列实战分析

本文是对 从一次线上问题说起,详解 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 的值,然后运行实验代码查看全连接队列的长度变化。

  • 服务端实验代码
golang 复制代码
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)
  }
}

首先我们修改 somaxconn 为 128:

bash 复制代码
sudo sysctl -w net.core.somaxconn=128

启动服务后查看全连接队列的长度:

bash 复制代码
$ 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。

bash 复制代码
$ 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() 处理数据,模拟全连接队列溢出的情况。

  • 服务端代码
golang 复制代码
// 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) 一行代码,让连接一个个建立,可以更精准的复现全连接队列已满的情况。

golang 复制代码
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:

bash 复制代码
$ sudo sysctl -w net.core.somaxconn=5

$ cat /proc/sys/net/core/somaxconn
5

运行服务端和客户端后,查看全连接队列情况:

  • 服务端 socket 情况
bash 复制代码
$ 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 情况
bash 复制代码
$ 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
  • 客户端日志输出
bash 复制代码
$ 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 内核代码如下:

c 复制代码
// 源码地址
// 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 信息:

c 复制代码
https://github.com/torvalds/linux/commit/64a146513f8f12ba204b7bf5cb7e9505594ead42

[NET]: Revert incorrect accept queue backlog changes.
This reverts two changes:

8488df8
248f067

A 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 内核处理这部分逻辑的代码如下::

c 复制代码
// 源码地址:https://elixir.bootlin.com/linux/v5.15.130/source/net/ipv4/tcp_input.c#L6848

int 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 表示是一个新的请求连接建立的 SYN
	if ((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 满了则 drop
	if (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_COOKIES
	if (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 命令查看丢弃包的数量。

bash 复制代码
$ date;netstat -s | grep -i "SYNs to LISTEN"
Wed Feb 19 12:05:51 PM CST 2025
    1289 SYNs to LISTEN sockets dropped


$ date;netstat -s | grep -i "SYNs to LISTEN"
Wed Feb 19 12:06:05 PM CST 2025
    1301 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 以后,客户端已经主动断开连接了。

bash 复制代码
$ 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 包。可以通过如下方式修改

bash 复制代码
$ 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 函数, 先挖个坑,等后续梳理整个网络传输流程时在做进一步分析)。

bash 复制代码
$ 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 确认的字节数。

内核代码如下:

c 复制代码
// 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 函数。

c 复制代码
// 源码地址:https://elixir.bootlin.com/linux/v5.15.130/source/net/ipv4/tcp_input.c#L6848

int 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 和半连接队列是否超过全连接队列长度、半连接队列是否已满来判断是否 drop
	syncookies = 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 状态连接,其余的包被丢弃。

bash 复制代码
# 拦截服务端 8888 端口的包
$ sudo iptables -A INPUT -p tcp --sport 8888 -j DROP

# 发送 SYN 包
$ sudo hping3 -S 172.19.0.12 -p 8888 --flood

查看服务端情况

bash 复制代码
$ 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 数

bash 复制代码
$ date;netstat -s | grep -i "SYNs to LISTEN"
Fri Feb 21 12:01:58 PM CST 2025
    3030591019 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 2025
    3030591127 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 规则,是的三次握手能够正常进程。

bash 复制代码
$ sudo iptables -F

设置系统参数

bash 复制代码
$ 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 包都会被丢弃。

bash 复制代码
$ 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,符合预期。

bash 复制代码
$ date;netstat -s | grep -i "SYNs to LISTEN"
Fri Feb 21 12:18:19 PM CST 2025
    3030591661 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 2025
    3030591766 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 状态连接

分别设置并发送请求后,服务端显示结果如下,基本符合预期。

bash 复制代码
# 客户端设置 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)

c 复制代码
// 源码地址: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 状态连接数,结果如下,符合预期。

bash 复制代码
$ 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 2025

512

$ 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 2025

1024

$ 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 2025

4096

简要总结

  • 半连接队列受限于全连接队列长度,而全连接队列会受应用的影响,尽量不要将 somaxconn 设置的过小,否则会影响服务器的性能。
  • 尽量开启 syncookies,可以有效防止 SYN Flood 攻击,同时可以避免半连接队列被大量占用。
  • ss、netstat 的熟练使用对探查网络状态非常重要,要熟练掌握。
  • 代码之下无秘密,一定要结合源码去理解 Linux 的网络工作机制,不要只是死记硬背协议。
  • 动手!动手!动手!实践出真知。
相关推荐
sunfove1 天前
光网络的立交桥:光开关 (Optical Switch) 原理与主流技术解析
网络
Kevin Wang7271 天前
欧拉系统服务部署注意事项
网络·windows
min1811234561 天前
深度伪造内容的检测与溯源技术
大数据·网络·人工智能
汤愈韬1 天前
NAT策略
网络协议·网络安全·security·huawei
汤愈韬1 天前
Full Cone Nat
网络·网络协议·网络安全·security·huawei
zbtlink1 天前
现在还需要带电池的路由器吗?是用来干嘛的?
网络·智能路由器
桌面运维家1 天前
vDisk配置漂移怎么办?VOI/IDV架构故障快速修复
网络·架构
dalerkd1 天前
忙里偷闲叙-谈谈最近两年
网络·安全·web安全
汤愈韬1 天前
NAT ALG (应用层网关)
网络·网络协议·网络安全·security·huawei
运维栈记1 天前
虚拟化网络的根基-网络命名空间
网络·docker·容器