系统性能优化之TCP篇

系统性能优化之TCP篇

TCP 是一个可以双向传输的全双工协议,常见的 HTTP 等协议都是基于 TCP 实现的。

TCP 数据交互的主要过程有三次握手建立连接,拥塞控制,四次挥手断开连接,接下来分别介绍几个主要过程涉及到的可能优化方向。

文中的配置参数均可通过 sysctl 参数 查看,syn -w 参数=newValue 修改。

三次握手与TFO

三次握手建立连接的首要目的是同步序列号。只有同步了序列号才有可靠的传输,TCP 协议的许多特性都是依赖序列号实现的,比如流量控制、消息丢失后的重发等等,这也是三次握手中的报文被称为 SYN 的原因,因为 SYN 的全称就叫做 Synchronize Sequence Numbers。

三次握手的流程由下图所示:

image-20260507101236510

  1. 客户端发送 SYN 开启了三次握手,此时在客户端上用 netstat 命令可以看到连接的状态是 SYN_SENT(但持续时间一般非常短,几毫秒)。
1
2
[root@node-17 ~]# netstat | grep SYN_SENT
tcp 0 1 node-17:45506 192.168.**.**:***** SYN_SENT
  1. 当服务器收到 SYN 报文后,会立刻回复 SYN+ACK 报文,既确认了客户端的序列号,也把自己的序列号发给了对方。此时,服务器端出现了新连接,状态是 SYN_RCV。
  2. 当客户端接收到服务器发来的 SYN+ACK 报文后,就会回复 ACK 去通知服务器,同时己方连接状态从 SYN_SENT 转换为 ESTABLISHED,表示连接建立成功。
  3. 服务器端连接成功建立的时间还要再往后,到它收到 ACK 后状态才变为 ESTABLISHED。

那么握手流程中有什么可以优化的地方呢?

客户端优化

客户端在发送 SYN 后会等待服务端的回复,如果不回复会进行重试,重试的次数由 net.ipv4.tcp_syn_retries 参数控制,默认是 6 次。其中第一次重试发生在 1s 后,接着等待双倍时间后重试,所有重试结束需要 2 分钟。内网中通讯时,就可以适当调低重试次数,尽快把错误暴露给应用程序。

服务端优化

服务器收到 SYN 报文后,必须建立一个 SYN 半连接队列来维护未完成的握手信息(SYN_RCV),当这个队列溢出后,服务器将无法再建立新连接。

1
2
[root@iZm5e8izu4t87flkkvb649Z ~]# netstat -s | grep "SYNs to LISTEN"
1173823 SYNs to LISTEN sockets dropped

上述指令可以查看新连接由于队列已满而引发的失败次数,这是一个累计值,如果数值在持续增加,则应该调大 SYN 半连接队列。对应的参数为:net.ipv4.tcp_max_syn_backlog。

有一种攻击方式叫:SYN 泛洪攻击(攻击者恶意构造大量的 SYN 报文发送给服务器,造成 SYN 半连接队列溢出,导致正常客户端的连接无法建立)。Linux 开启 syncookies 功能可以在不使用 SYN 队列的情况下成功建立连接,对应的参数是:net.ipv4.tcp_syncookies(0不开启,1只在半连接队列满时开启,2无条件开启)。

syncookie 是这么做的:服务器根据当前状态计算出一个值,放在己方发出的 SYN+ACK 报文中发出,当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功。但是注意,这种方式建立的 TCP 连接,很多特性会无法使用

image-20260507103753944

服务器在发送完 SYN+ACK 后如果得不到客户端的 ACK 回复,会进行重试,重试的次数由 net.ipv4.tcp_synack_retries 参数控制,默认是 5 次。

服务器收到 ACK 后连接建立成功,此时,内核会把连接从 SYN 半连接队列中移出,再移入 accept 队列,等待进程调用 accept 函数时把连接取出来。如果进程不能及时地调用 accept 函数,就会造成 accept 队列溢出,最终导致建立好的 TCP 连接被丢弃(丢弃是 Linux 的默认行为,如果设置 net.ipv4.tcp_abort_on_overflow 为1则会告诉客户端连接建立失败。推荐设置为0,原因是如果服务器上的进程只是短暂的繁忙造成 accept 队列满,那么当 accept 队列有空位时,再次接收到的请求报文由于含有 ACK,仍然会触发服务器端成功建立连接)。

在 Java 网络编程中,指定的 backlog 参数就是 accept 队列的长度:

1
serverSocket.bind(new InetSocketAddress(port), backlog);

不过这个参数还受到 linux 系统级的队列长度上限控制,对应参数:net.core.somaxconn。

Linux 可以通过 ss -ltn 查看每个监听端口对应的 accept 队列长度:

1
2
3
4
5
6
7
[root@iZm5e8izu4t87flkkvb649Z ~]# ss -ltn
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 128 *:80 *:*
LISTEN 0 128 *:3344 *:*
LISTEN 0 128 *:22 *:*
LISTEN 0 128 *:9998 *:*
LISTEN 0 128 *:9999 *:*

至于是否需要调整可以通过 netstat -s | grep "listen queue" 命令给出的统计结果,看究竟有多少个连接因为队列溢出而被丢弃。

如果持续不断地有连接因为 accept 队列溢出被丢弃,就应该调大这两个参数。

TFO

三次握手建立连接造成的后果就是,HTTP 请求必须在一次 RTT(Round Trip Time,从客户端到服务器一个往返的时间)后才能发送,对于小请求来说,握手的时间占整个请求的比例是很可观的。

因此,Google 提出了 Tcp fast open 的概念,简称 TFO,客户端可以在后续请求的首个 SYN 报文中就携带请求,这节省了 1 个 RTT 的时间。

TFO 节省的其实是后续请求的时间,它的流程如下:

image-20260507110127575

首次建立连接,这时走正常的三次握手,但在客户端的 SYN 报文会明确地告诉服务器它想使用 TFO 功能,这样服务器会把客户端 IP 地址用只有自己知道的密钥加密(比如 AES 加密算法),作为 Cookie 携带在返回的 SYN+ACK 报文中,客户端收到后会将 Cookie 缓存在本地。之后,如果客户端再次向服务器建立连接,就可以在第一个 SYN 报文中携带请求数据,同时还要附带缓存的 Cookie。

TFO 功能通过 net.ipv4.tcp_fastopen 参数查看是否支持,由于只有客户端和服务器同时支持时,TFO 功能才能使用,所以该参数是按比特位控制的。其中,第 1 个比特位为 1 时,表示作为客户端时支持 TFO;第 2 个比特位为 1 时,表示作为服务器时支持 TFO,所以当 tcp_fastopen 的值为 3 时(比特为 0x11)就表示完全支持 TFO 功能。

拥塞控制与缓冲区

TCP 报文结构如下图:

image-20250627153818521

可以看到只有 2 字节即 16 位的接收窗口大小,最多标识 65KB,后来 RFC 文档定义了窗口扩大协议,linux 通过 net.ipv4.tcp_window_scaling 参数控制开启。

缓冲区

每个 TCP 连接都有发送缓冲区和接收缓冲区,发送缓冲区存已发送未确认数据和待发送数据,接收缓冲区存接收但是没有被上层服务读取的数据。由于 TCP 存在 ACK 机制,因此发送出去的数据不能立刻删除,需要先缓存在内核缓冲区。

在 linux 系统上执行 free 指令,其中的 buff/cache 会随着进程新建 TCP 连接和传输数据而变化。

1
2
3
4
[root@localhost ~]# free
total used free shared buff/cache available
Mem: 131829100 19243504 1255584 20380876 111330012 91410724
Swap: 4194300 14848 4179452

这是因为 TCP 连接是由内核维护的,内核为每个连接建立的内存缓冲区,既要为网络传输服务,也要充当进程与网络间的缓冲桥梁。如果连接的内存配置过小,就无法充分使用网络带宽,TCP 传输速度就会很慢;如果连接的内存配置过大,那么服务器内存会很快用尽,新连接就无法建立成功。

1
2
3
# 单个TCP连接的写缓冲区 分别代表 最小值,默认值,最大值,单位 Byte
# sysctl net.ipv4.tcp_wmem
net.ipv4.tcp_wmem = 4096 16384 4194304
1
2
3
# 单个TCP连接的读缓冲区 分别代表 最小值,默认值,最大值,单位 Byte
# sysctl net.ipv4.tcp_rmem
net.ipv4.tcp_rmem = 4096 87380 6291456

发送缓冲区的调节功能是自动开启的,而接收缓冲区则需要配置 tcp_moderate_rcvbuf 为 1 来开启调节功能

1
2
3
# 接收缓冲区自动调解功能
# sysctl net.ipv4.tcp_moderate_rcvbuf
net.ipv4.tcp_moderate_rcvbuf = 1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 其中 mem 代表当前 TCP 连接占用的页面数
[root@iZm5e8izu4t87flkkvb649Z ~]# cat /proc/net/sockstat
sockets: used 203
TCP: inuse 7 orphan 0 tw 5 alloc 9 mem 1
UDP: inuse 2 mem 1
UDPLITE: inuse 0
RAW: inuse 0
FRAG: inuse 0 memory 0

# Linux 对 TCP接收缓冲区的调解控制 分别代表 最小值,默认值,最大值 单位是页(page)
# sysctl net.ipv4.tcp_mem
net.ipv4.tcp_mem = 3087300 4116401 6174600

# 获取页面大小 单位 Byte
# getconf PAGESIZE
4096

tcp_mem 是 Linux 判断系统内存是否紧张的依据,当 TCP 内存小于第 1 个值时,不需要进行自动调节;在第 1 和第 2 个值之间时,内核开始调节接收缓冲区的大小;大于第 3 个值时,内核不再为 TCP 分配新内存,此时新连接是无法建立的。

在高并发服务器中,为了兼顾网速与大量的并发连接,我们应当保证缓冲区的动态调整上限达到带宽时延积,而下限保持默认的 4K 不变即可。而对于内存紧张的服务而言,调低默认值是提高并发的有效手段。

同时,如果这是网络 IO 型服务器,那么,调大 tcp_mem 的上限可以让 TCP 连接使用更多的系统内存,这有利于提升并发能力。需要注意的是,tcp_wmem 和 tcp_rmem 的单位是字节,而 tcp_mem 的单位是页面大小。而且,网络编程千万不要在 socket 上直接设置 SO_SNDBUF (写缓冲区上限)或者 SO_RCVBUF(读缓冲区上限),这样会关闭缓冲区的动态调整功能。

拥塞控制

在拥塞控制中,首先会进行慢开始(每收到1个 ACK 窗口就+1,相当于1个 RTT 翻一倍),慢开始指初始窗口比较小,而不是增长的慢,增长是指数级的,其实很快。这就涉及到初始拥塞窗口的大小:

1
2
# 查看当前连接的初始拥塞窗口
ss -nli | fgrep cwnd
1
2
3
4
# 修改初始拥塞窗口 10 MSS,如果网络特别好可以继续加大,有些高速 CDN 站点,甚至把初始拥塞窗口提升到 70 个 MSS
ip route | while read r; do
ip route change $r initcwnd 10;
done

初始拥塞窗口越大,小请求就可以更快发送完毕,也会更快的结束慢开始,结束慢开始的情况一般有以下三类:

  • 定时器超时,触发重传
  • 拥塞窗口的增长到达了慢启动阈值 ssthresh(全称为 slow start threshold)
  • 接收到重复的 ACK 报文,可能存在丢包

在第一种情况下,说明网络拥塞已经比较严重了,不同的算法会有不同的调整,目前主流的调整算法 CUBIC 算法会把拥塞窗口降为原先的 0.8 倍。

1
2
3
# 查看当前系统支持的拥塞调整算法
# sysctl net.ipv4.tcp_available_congestion_control
net.ipv4.tcp_available_congestion_control = reno cubic bbr
1
2
# 配置具体的拥塞调整算法
net.ipv4.tcp_congestion_control = cubic

这种算法在内网中效率高于 BBR,因为内网本来就时延低,但在高 RT 场景下表现不如 BBR,BBR 基于测量实现拥塞窗口的动态调整,在丢包率较高的网络中应用效果尤其好,Linux 4.9 版本之后都支持 BBR 算法,同样使用 tcp_congestion_control 进行配置。

在第二种情况下,已经到了慢启动阈值,可能接下来就会出现拥塞,因此拥塞窗口不再指数级增长,而是线性增长,也叫做拥塞避免阶段。

在第三种情况下,对方连续发送重复 ACK(例如3个) 说明网络情况尚可,可能由于中间设备等原因造成某包丢失或失序,不应认为网络出现严重拥塞,因此触发快速重传,略微降低发送速度,这里有个问题其实是,比如报文 6 7 8 一起发送,对方发了 3 个连续的 6 的 ACK,触发报文 6 的重传时,是否要发 7 和 8,如果发可能对方已经收到了只是 6 一直没到,就浪费带宽了,如果不发也可能网络不好丢失了,待会还要重发,SACK (Selective Acknowledgment)选择性确认机制解决了这个问题,接收方通过 TCP 头部选项字段精准反馈已接收的非连续数据段信息,使发送方仅重传真正丢失的报文段。

四次挥手

关闭连接的主动方一般是服务器,而服务器要服务成千上万的客户端,因此其行为必须慎重。

孤儿连接

关闭连接一般有 close 和 shutdown 两种方法,简单来说:close 会销毁 socket,释放文件描述符;shutdown 可以选择性关闭读、写,保留部分内容。

主动调用 close 一方的进程,调用之后,相关句柄已经释放,此连接已经和进程无关,由内核和另一方进行四次挥手交互,此时这个连接叫做孤儿(orphan)连接,或者叫做孤儿 socket。

可以使用如下指令查看孤儿进程的数量:

1
2
3
4
5
6
7
[root@iZm5e8izu4t87flkkvb649Z ~]# cat /proc/net/sockstat
sockets: used 205
TCP: inuse 8 orphan 0 tw 3 alloc 10 mem 1
UDP: inuse 2 mem 1
UDPLITE: inuse 0
RAW: inuse 0
FRAG: inuse 0 memory 0

如果使用 shutdown 函数关闭连接,即使主动方进入到 FIN_WAIT1 或 FIN_WAIT2 状态,这个时候进程仍然可以收到被动方发送过来的数据,这个时候不能称为孤儿进程。

先来看四次挥手的流程:

image-20260507111541997

  1. 主动方关闭连接时,会发送 FIN 报文,此时主动方的连接状态由 ESTABLISHED 变为 FIN_WAIT1。
  2. 被动方收到 FIN 报文后,内核自动回复 ACK 报文,连接状态由 ESTABLISHED 变为 CLOSE_WAIT,意为等待上层的进程调用 close 关闭连接。
  3. 主动方收到关于发送的 FIN 报文的 ACK,进入 FIN_WAIT2,发送通道就关闭了。
  4. 被动方进入 CLOSE_WAIT 状态时,进程的 read 函数会返回 0。开发人员在程序中就可以针对性地调用 close 函数,进而触发内核发送 FIN 报文,此时被动方连接的状态变为 LAST_ACK。
  5. 当主动方收到被动方 FIN 报文时,内核会自动回复 ACK,同时连接的状态由 FIN_WAIT2 变为 TIME_WAIT,Linux 系统下大约 1 分钟后 TIME_WAIT 状态的连接才会彻底关闭。
  6. 而被动方收到 ACK 报文后,连接就会关闭。

主动方优化

主动方发送 FIN 报文后,连接就处于 FIN_WAIT1 状态下,该状态通常应在数十毫秒内转为 FIN_WAIT2。只有迟迟收不到对方返回的 ACK 时,才能用 netstat 命令观察到 FIN_WAIT1 状态。此时,内核会定时重发 FIN 报文,其中重发次数由 net.ipv4.tcp_orphan_retries 参数控制(注意,orphan 虽然是孤儿的意思,该参数却不只对孤儿连接有效,事实上,它对所有 FIN_WAIT1 状态下的连接都有效),默认值是 0,特指 8 次。如果 FIN_WAIT1 状态连接有很多,可以考虑降低 tcp_orphan_retries 的值。当重试次数达到 tcp_orphan_retries 时,连接就会直接关闭掉。

TCP 有流控功能,当接收方将接收窗口设为 0 时,发送方就不能再发送数据。所以,当攻击者下载大文件时,就可以通过将接收窗口设为 0,导致 FIN 报文无法发送,进而导致连接一直处于 FIN_WAIT1 状态。解决这种问题的方案是调整 net.ipv4.tcp_max_orphans 参数,该参数定义了孤儿连接的最大数量,如果孤儿连接数量大于它,新增的孤儿连接将不再走四次挥手,而是直接发送 RST 复位报文强制关闭。

当主动方收到 ACK 进入 FIN_WAIT2 状态后,就表示主动方的发送通道已经关闭,接下来将等待对方发送 FIN 报文,关闭对方的发送通道。这时,如果连接是用 shutdown 函数关闭的,连接可以一直处于 FIN_WAIT2 状态。但对于 close 函数关闭的孤儿连接,这个状态不可以持续太久,而 net.ipv4.tcp_fin_timeout 控制了这个状态下连接的持续时长,默认值是 60 秒,如果 60 秒还未收到对方的 FIN 报文,连接会直接关闭。

TIME_WAIT 状态的持续时间也是 60 秒,这个数字是怎么定义的呢?在 Linux 系统中,MSL (Maximum Segment Lifetime,一个报文在网络中的最长生存时间)的值固定为 30 秒,所以 60 秒就是 2MSL。2MSL 的时长相当于允许报文有一次丢失,试想如果没有 TIME_WAIT 状态,主动方直接关闭连接并回复 ACK,但这个 ACK 在网络中丢失,被动方重传 FIN,此时主动方的一个新连接占用了这个端口,FIN 报文会导致这个新连接出现异常状态。

Linux 提供了 net.ipv4.tcp_max_tw_buckets 限制 TIME_WAIT 连接的最大个数,当 TIME_WAIT 连接数达到该值时,会不再经历 TIME_WAIT 状态直接关闭,不过这个值也不是越大越好,毕竟内存和端口号都是有限的。如果主动方也会充当客户端(例如 Nginx 服务器会作为客户端连接上游服务),那么可以设置 net.ipv4.tcp_tw_reuse 参数允许作为客户端的新连接,在安全条件下使用 TIME_WAIT 状态下的端口,这个值需要配合 net.ipv4.tcp_timestamps 使用(tcp_timestamps 双方都要开启)。

如果连接双方同时关闭连接,都认为自己是主动方,所以都进入了 FIN_WAIT1 状态,FIN 报文的重发次数仍由 tcp_orphan_retries 参数控制。

image-20260507133507512

双方在等待 ACK 报文的过程中,都等来了 FIN 报文。这是一种新情况,所以连接会进入一种叫做 CLOSING 的新状态,它替代了 FIN_WAIT2 状态。这是一种特殊情况,不用在意。

被动方优化

当被动方收到 FIN 报文时,就开启了被动方的四次挥手流程。内核自动回复 ACK 报文后,连接就进入 CLOSE_WAIT 状态,顾名思义,它表示等待进程调用 close 函数关闭连接。

进程调用 close 函数后,内核就会发出 FIN 报文关闭发送通道,同时连接进入 LAST_ACK 状态,等待主动方返回 ACK 来确认连接关闭。如果迟迟等不到 ACK,内核就会重发 FIN 报文,重发次数仍然由 net.ipv4.tcp_orphan_retries 参数控制。

总结

文中提到了诸多的 TCP 参数,整理如下:

参数 描述 默认值 / 推荐值
net.ipv4.tcp_syn_retries 客户端 SYN 报文重试次数 6(约 2 分钟)
net.ipv4.tcp_max_syn_backlog SYN 半连接队列最大长度 根据系统内存调整
net.ipv4.tcp_syncookies SYN 泛洪防护(0: 关闭, 1: 半连接队列满时开启, 2: 无条件开启) 1(推荐)
net.ipv4.tcp_synack_retries 服务端 SYN+ACK 报文重试次数 5
net.ipv4.tcp_abort_on_overflow accept 队列溢出时是否发送 RST(0: 丢弃连接, 1: 通知客户端失败) 0(推荐)
net.core.somaxconn 系统级 accept 队列长度上限 128(可调大)
net.ipv4.tcp_fastopen TFO 支持(比特位:1=客户端,2=服务端) 根据系统情况
net.ipv4.tcp_window_scaling 窗口扩大协议(0: 关闭, 1: 开启) 1(开启)
net.ipv4.tcp_wmem 写缓冲区(最小值, 默认值, 最大值,单位 Byte) 根据系统情况
net.ipv4.tcp_rmem 读缓冲区(最小值, 默认值, 最大值,单位 Byte) 根据系统情况
net.ipv4.tcp_moderate_rcvbuf 接收缓冲区自动调节(0: 关闭, 1: 开启) 1(开启)
net.ipv4.tcp_mem TCP 内存页面数(最小值, 压力值, 最大值,单位 page) 根据系统内存动态设置
net.ipv4.tcp_available_congestion_control 可用的拥塞控制算法 cubic
net.ipv4.tcp_congestion_control 当前使用的拥塞控制算法 cubic
net.ipv4.tcp_orphan_retries FIN / LAST_ACK 重试次数(0 特指 8 次) 0(8 次)
net.ipv4.tcp_max_orphans 孤儿连接最大数量 根据系统内存设置
net.ipv4.tcp_fin_timeout FIN_WAIT2 状态持续时间(秒) 60
net.ipv4.tcp_max_tw_buckets TIME_WAIT 连接最大数量 可调,过大占用内存/端口
net.ipv4.tcp_tw_reuse 作为客户端时允许重用 TIME_WAIT 端口 需配合 tcp_timestamps 开启
net.ipv4.tcp_timestamps TCP 时间戳选项(0: 关闭, 1: 开启) 1(开启,双方均需开启)

除此之外,TCP 还有很多的参数,诸如与 keepalive 相关的内容等,TCP 协议的优化方案还是比较复杂的,这也是享受 TCP 优势时我们必须要付出的代价。

参考

  1. 系统性能调优必知必会
  2. TCP参数参数调优全面解读
  3. 慢启动与拥塞避免概述
  4. BBR、CUBIC 与 TCP Reno 的性能对比与配置
  5. Sockets:Close 与 Shutdown 的区别
  6. Wireshark 网络包分析实战二:SACK

系统性能优化之TCP篇
https://zhuwenjie0716.github.io/2026/05/07/系统性能优化之TCP篇/
作者
Wenjie Zhu
发布于
2026年5月7日
许可协议