一个拥有16GB内存的服务器,运行着每秒处理5000次HTTP请求的API网关。运维人员发现,即使系统负载很低,新的连接请求却开始失败。ss -tan命令显示数万个连接停留在TIME_WAIT状态。有人建议调低tcp_fin_timeout,有人说要开启tcp_tw_recycle,还有人干脆在代码里加了SO_LINGER。这些建议哪个是对的?答案是:都不对,而且有些已经过时,有些则相当危险。

为什么TIME_WAIT必须存在

1981年9月,Jon Postel发布的RFC 793定义了TCP协议的核心规范。在这份文档中,TIME_WAIT状态被设计为连接关闭后的"冷却期",持续时间为2倍的最大段生存时间(2MSL)。RFC 793将MSL定义为2分钟,因此理论上的TIME_WAIT时长为4分钟。不过,Linux内核实现将这个值固定为60秒,相当于MSL=30秒。

TIME_WAIT的设计目的有两个,每一个都关乎TCP连接的正确性。

第一个目的是防止旧连接的延迟数据段被新连接错误接收。考虑这样一个场景:一个TCP连接刚刚关闭,但网络中可能还存在该连接的延迟数据段。如果立即用相同的四元组(源IP、源端口、目标IP、目标端口)建立新连接,这些延迟的数据段可能被新连接误认为是自己的数据,导致数据错乱。

TCP状态转换图

图片来源: d2pzklc15kok91.cloudfront.net

上图展示了TCP连接的完整状态机。可以看到,只有主动关闭连接的一方才会进入TIME_WAIT状态。被动关闭的一方在发送最后的ACK后会直接进入CLOSED状态。

第二个目的是确保远程端能够正确关闭连接。当主动关闭方发送最后的ACK时,这个ACK可能丢失。此时远程端还处于LAST_ACK状态,会重传FIN段。如果主动关闭方没有TIME_WAIT状态,而是立即进入CLOSED状态,那么当重传的FIN到达时,它会回复RST,导致远程端记录错误。更严重的是,如果此时用相同的四元组建立了新连接,这个RST可能意外终止新连接。

1992年5月,Bob Braden在RFC 1337中详细分析了TIME_WAIT被提前终止(称为"TIME-WAIT Assassination"或TWA)可能导致的危害:

  • H1:旧连接的重复数据可能被新连接错误接受
  • H2:新连接两端状态永久不同步,进入无限ACK循环
  • H3:新连接可能被意外终止

RFC 1337建议的简单修复方案是:在TIME_WAIT状态下忽略RST段。Linux内核提供了net.ipv4.rfc1337参数来启用这个行为,但默认是关闭的,因为这只是一个部分解决方案。

TIME_WAIT的真正代价

很多运维人员看到ss -tan | grep TIME_WAIT | wc -l返回数万条记录时会感到恐慌。但这个数字本身不是问题。要理解TIME_WAIT的真正代价,需要从三个维度分析:连接表槽位、内存占用和CPU开销。

连接表槽位

这是TIME_WAIT最实际的限制。一个处于TIME_WAIT状态的连接会在连接表中保留一分钟,这意味着相同的四元组在这段时间内不能被复用。

对于服务器而言,如果目标IP和端口固定(比如反向代理到后端服务器),源IP也固定(比如通过L7负载均衡器),那么唯一的变量就是源端口。Linux默认的临时端口范围是32768-60999,约28000个端口。这限制了每分钟只能建立约28000个连接,折合每秒约467个连接。

Cloudflare在2014年支持WebSocket时首次遇到这个问题。由于WebSocket是长连接,并发连接数激增,临时端口范围成为了瓶颈。他们最初通过设置SO_REUSEADDR并重试来解决,后来Linux 3.2内核引入了更优雅的IP_BIND_ADDRESS_NO_PORT选项。

内存占用

这是最常被误解的部分。一个TIME_WAIT连接占用的内存比想象中小得多。

Linux内核使用struct tcp_timewait_sock结构来表示TIME_WAIT状态的socket,这个结构只有168字节。对于出站连接,还需要一个struct inet_bind_bucket结构,占用48字节。所以一个出站的TIME_WAIT连接总共占用约216字节,入站连接则只需要168字节。

即使有60000个TIME_WAIT连接,内存占用也不到13MB。对于任何需要处理这种连接数的服务器来说,这点内存开销可以忽略不计。

$ sudo slabtop -o | grep -E '(tw_sock_TCP|tcp_bind_bucket)'
OBJS ACTIVE  USE OBJ SIZE  SLABS OBJ/SLAB CACHE SIZE NAME
50955  49725  97%    0.25K   3397       15    13588K tw_sock_TCP
44840  36556  81%    0.06K    760       59     3040K tcp_bind_bucket

CPU开销

查找空闲本地端口需要遍历已绑定的端口列表。不过,现代Linux内核(3.2以后)已经优化了这个过程,允许不同的目标复用相同的本地二元组(源IP+源端口),CPU开销通常不是瓶颈。

tcp_tw_recycle为何被移除

在很多旧的优化指南中,会看到建议开启net.ipv4.tcp_tw_recycle=1。这个参数在Linux 4.12版本中被彻底移除,原因与NAT环境下的严重问题有关。

tcp_tw_recycle的工作原理是利用TCP时间戳选项。当启用时,内核会记录每个远程主机的最新时间戳。如果一个数据段的时间戳小于记录值,就会被丢弃。这允许TIME_WAIT状态在更短的时间内(RTO间隔,通常远小于60秒)被回收。

问题在于NAT环境。当多个客户端通过同一个NAT设备访问服务器时,它们共享同一个IP地址,但各自维护独立的时间戳时钟。服务器只记录一个per-IP的时间戳值,因此只有时间戳恰好是最新值的那个客户端能够正常连接,其他客户端的SYN段会被服务器静默丢弃。

更糟糕的是,从Linux 4.10开始,内核为每个连接随机化时间戳偏移量,这使得tcp_tw_recycle完全失效——即使在非NAT环境下也不再工作。2017年3月,Google的Soheil Hassas Yeganeh提交了移除这个参数的补丁,理由很简单:“tcp_tw_recycle在NAT环境下已经无法工作,现在更是完全损坏”。

tcp_tw_reuse的正确使用

tcp_tw_recycle不同,tcp_tw_reuse仍然是一个相对安全的选项,但需要注意它的适用范围。

tcp_tw_reuse只对出站连接有效。当客户端需要建立新的出站连接,而对应的四元组处于TIME_WAIT状态时,如果新连接的时间戳大于之前记录的时间戳,内核会允许复用这个连接。

这依赖于TCP时间戳选项。RFC 1323定义了时间戳选项的两个用途:往返时间测量(RTTM)和防止序列号回绕(PAWS)。RFC 6191进一步扩展了这个机制,允许在TIME_WAIT状态下处理新的SYN段。

从协议角度看,时间戳机制确保了:

  • 来自旧连接的重复段会因为时间戳过旧而被PAWS机制丢弃
  • 即使最后的ACK丢失,远程端重传的FIN也能得到正确处理

启用tcp_tw_reuse需要同时启用TCP时间戳(Linux默认启用)。可以通过sysctl net.ipv4.tcp_timestamps确认。

架构层面的解决方案

比起调整内核参数,从架构设计入手往往更有效。

让客户端先关闭连接

这是最简单也最有效的策略。根据TCP状态机,只有主动关闭连接的一方才会进入TIME_WAIT状态。如果让客户端(或负载均衡器)主动关闭连接,服务器就不会积累大量TIME_WAIT状态的连接。

W. Richard Stevens在《Unix Network Programming》中写道:“TIME_WAIT状态是我们的朋友,它的存在是为了帮助我们(让旧的重复段在网络中过期)。与其试图避免这个状态,我们应该理解它。”

使用连接池和长连接

频繁创建和关闭短连接是产生大量TIME_WAIT的主要原因。使用连接池复用现有连接,或者启用HTTP长连接(keepalive),可以从根本上减少连接关闭的次数。

Nginx配置upstream keepalive的示例:

upstream backend {
    server 10.0.0.1:8080;
    keepalive 100;  # 每个worker保持100个空闲长连接
}

server {
    location / {
        proxy_pass http://backend;
        proxy_http_version 1.1;  # 必须使用HTTP/1.1
        proxy_set_header Connection "";  # 清除客户端传来的Connection头
    }
}

这个配置让Nginx与后端服务器之间保持长连接,大幅减少了TCP连接的创建和关闭次数。

扩展四元组

如果必须让服务器主动关闭连接,可以通过增加四元组的数量来提高上限:

  • 增加临时端口范围net.ipv4.ip_local_port_range
  • 增加服务器端口:让服务监听多个端口
  • 增加IP地址:为服务器配置多个IP,轮询使用

每种方法都能线性提升并发连接上限。

SO_LINGER的危险诱惑

有些开发者试图通过SO_LINGER socket选项来绕过TIME_WAIT,这通常是一个错误的决定。

SO_LINGER有两种工作模式:

  1. 设置超时为0:调用close()时立即发送RST并丢弃所有未发送数据,不经过正常的四次挥手,因此不会进入TIME_WAIT状态。

  2. 设置超时大于0close()会阻塞等待未发送数据被确认或超时。如果数据成功发送,仍然会进入TIME_WAIT状态。

第一种模式看起来很诱人,但代价是:

  • 对端收到RST会认为连接异常终止,可能记录错误或留下不完整的状态
  • 违反TCP协议规范
  • 可能触发RFC 1337描述的危害

只有在你完全理解后果、并且有明确理由需要立即释放资源时(比如某些HAProxy和Nginx在特定场景下的行为),才应该考虑使用SO_LINGER。

内核参数的正确配置

对于现代Linux系统(4.12+),推荐的TIME_WAIT相关配置:

# 允许复用TIME_WAIT状态的socket用于新的出站连接
net.ipv4.tcp_tw_reuse = 1

# 确保TCP时间戳已启用(通常默认启用)
net.ipv4.tcp_timestamps = 1

# 扩大临时端口范围
net.ipv4.ip_local_port_range = 10000 65535

# TIME_WAIT连接的最大数量(达到后会直接关闭而不是等待)
net.ipv4.tcp_max_tw_buckets = 262144

# 可选:忽略TIME_WAIT状态下的RST(参考RFC 1337)
net.ipv4.rfc1337 = 1

注意:tcp_fin_timeout参数经常被误解。它影响的是FIN_WAIT_2状态的持续时间,而不是TIME_WAIT。TIME_WAIT的时长在Linux中是硬编码的60秒,不可调整。

总结

TIME_WAIT状态是TCP协议可靠性的基石,而非需要消除的问题。在高并发场景下遇到端口耗尽时,正确的解决思路是:

  1. 优先从架构层面解决:让客户端主动关闭连接、使用连接池和长连接
  2. 对于出站连接,启用tcp_tw_reuse配合TCP时间戳
  3. 扩展可用四元组的数量(端口范围、IP地址)
  4. 避免使用SO_LINGER发送RST来绕过TIME_WAIT
  5. 切勿使用tcp_tw_recycle(Linux 4.12已移除)

Linux内核的TCP实现已经过多年优化,默认配置在大多数场景下都是合理的。盲目调优往往带来更多问题,理解TIME_WAIT的设计目的才是正确处理问题的关键。


参考文献

  1. Postel, J. “Transmission Control Protocol”, RFC 793, September 1981.
  2. Braden, R. “TIME-WAIT Assassination Hazards in TCP”, RFC 1337, May 1992.
  3. Jacobson, V., Braden, R., Borman, D. “TCP Extensions for High Performance”, RFC 1323, May 1992.
  4. Gont, F. “Reducing the TIME-WAIT State Using TCP Timestamps”, RFC 6191, April 2011.
  5. Eddy, W. “Transmission Control Protocol (TCP)”, RFC 9293, August 2022.
  6. Bernat, V. “Coping with the TCP TIME-WAIT state on busy Linux servers”, February 2014.
  7. Cloudflare Blog. “How to stop running out of ephemeral ports and start to love long-lived connections”, February 2022.
  8. Linux Kernel Documentation. “IP Sysctl”, https://docs.kernel.org/networking/ip-sysctl.html