一个拥有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连接的完整状态机。可以看到,只有主动关闭连接的一方才会进入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有两种工作模式:
-
设置超时为0:调用
close()时立即发送RST并丢弃所有未发送数据,不经过正常的四次挥手,因此不会进入TIME_WAIT状态。 -
设置超时大于0:
close()会阻塞等待未发送数据被确认或超时。如果数据成功发送,仍然会进入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协议可靠性的基石,而非需要消除的问题。在高并发场景下遇到端口耗尽时,正确的解决思路是:
- 优先从架构层面解决:让客户端主动关闭连接、使用连接池和长连接
- 对于出站连接,启用
tcp_tw_reuse配合TCP时间戳 - 扩展可用四元组的数量(端口范围、IP地址)
- 避免使用SO_LINGER发送RST来绕过TIME_WAIT
- 切勿使用
tcp_tw_recycle(Linux 4.12已移除)
Linux内核的TCP实现已经过多年优化,默认配置在大多数场景下都是合理的。盲目调优往往带来更多问题,理解TIME_WAIT的设计目的才是正确处理问题的关键。
参考文献
- Postel, J. “Transmission Control Protocol”, RFC 793, September 1981.
- Braden, R. “TIME-WAIT Assassination Hazards in TCP”, RFC 1337, May 1992.
- Jacobson, V., Braden, R., Borman, D. “TCP Extensions for High Performance”, RFC 1323, May 1992.
- Gont, F. “Reducing the TIME-WAIT State Using TCP Timestamps”, RFC 6191, April 2011.
- Eddy, W. “Transmission Control Protocol (TCP)”, RFC 9293, August 2022.
- Bernat, V. “Coping with the TCP TIME-WAIT state on busy Linux servers”, February 2014.
- Cloudflare Blog. “How to stop running out of ephemeral ports and start to love long-lived connections”, February 2022.
- Linux Kernel Documentation. “IP Sysctl”, https://docs.kernel.org/networking/ip-sysctl.html