今天,我们将深入探讨人人皆知的TCP TIME_WAIT状态问题。虽然其广为人知,但笔者近期在一个特殊的技术场景中遇到了相关挑战,并成功解决了它。借此机会,我们将全面梳理TIME_WAIT的来龙去脉。
这个特定的场景与笔者开发的开源探活工具EaseProbe紧密相关。我们先从该工具遇到的具体状况入手,逐步展开对TIME_WAIT机制的细致分析。
EaseProbe是一款轻量且独立的健康状态探测工具,支持HTTP、TCP、Shell、SSH、TLS、主机以及各类中间件的活性检测。它还能直接向主流即时通讯平台发送通知,包括Slack、Telegram、Discord、Email,以及国内流行的企业微信、钉钉和飞书,深受用户好评。
该工具在每次执行探测任务时,必须从零开始建立完整的网络连接,这意味着它需要重新进行DNS查询、建立TCP连接、执行通信,然后关闭连接。我们刻意不启用TCP的KeepAlive机制来重用连接,因为探活工具的目的不仅是检测远端服务,更是为了全面评估整个网络的状况。因此,每次探测都必须是全新的连接过程,以便捕捉到整个链路的实时状态。
然而,这种频繁地新建和关闭连接的操作,根据TCP状态机的原则,必然会在探测端导致大量的TIME_WAIT TCP连接。根据TCP协议规范,TIME_WAIT状态的连接需要等待两倍于最大报文寿命(MSL)的时间才能被系统回收。在此之前,这些连接会占用系统资源,主要包括文件描述符(虽然可调整)和端口号(无法调整)。作为发起请求的客户端,理论上每个IP可用的端口号大约有64K(实际Linux系统默认范围约为32768到60999,约28232个)。如果TIME_WAIT连接数量过多,可能导致TCP连接建立失败,甚至因为资源耗尽而引发程序或系统异常。
试想,如果以10秒为周期探测1万个节点,而TIME_WAIT的超时设置为120秒,那么在短短60秒后,等待超时的TIME_WAIT连接就可能耗尽某个特定IP的可用端口。即便系统勉强维持,也可能会出现性能问题。(值得注意的是,我们不仅限于TCP探测,还包括HTTP协议。因此,不要简单地认为只要TCP四元组不冲突就不会耗尽端口。一方面,域名解析需要访问DNS服务,通常由一台服务器提供;另一方面,HTTPS API探测往往会通过网关代理,导致多个连接指向同一个网关。此外,即使出站连接仍能建立,本地程序也可能因端口耗尽而无法绑定套接字。所以,实际情况往往比理论复杂。)
那么,TCP为何要在TIME_WAIT状态下等待2MSL的时间呢?
笔者早前撰写的《TCP的那些事》(上篇)中曾提及,当TCP连接断开时,会经历一系列交互过程。主动断开连接一方进入TIME_WAIT后,便不再需要等待对方的ACK确认,而是进入超时等待状态。这主要是为了解决两个核心问题:
首先,是为了防止来自旧连接的延迟数据包被依赖于相同四元组(源地址、源端口、目标地址、目标端口)的新连接错误接收。尽管通过序列号(sequence number)校验可以在一定程度上降低此类事件的发生概率,但对于高吞吐量的应用,特别是在具有大接收窗口的快速网络环境中,问题依然可能出现。RFC 1337详细阐述了当TIME-WAIT状态不足时可能导致的问题,例如,由于TIME-WAIT状态被缩短,后续的TCP段可能会被不相关的连接误收。
其次,是为了确保远端连接已经完全关闭。如果最后一个ACK丢失,远端可能会停留在LAST-ACK状态。在没有TIME-WAIT状态的情况下,一个新的连接可能会被重新建立,而远端仍然错误地认为之前的连接有效。当它收到一个SYN数据段(且序列号匹配)时,由于不期望此时出现SYN,它会回复RST,导致新连接因错误中止。因此,如果远端因最后一个ACK丢失而滞留在LAST-ACK状态,试图使用相同四元组建立新连接将无法成功。
TIME_WAIT状态的超时时间配置如下:
在macOS系统上,默认为15秒,可通过`sysctl net.inet.tcp | grep net.inet.tcp.msl`命令查看。
在Linux系统上,默认为60秒,可通过`cat /proc/sys/net/ipv4/tcp_fin_timeout`命令查看。
对于这个普遍存在的问题,业界通常提出以下解决方案:
1. 缩短超时时间:适当减小TIME_WAIT的超时时间,以便更快地回收TCP端口。但这一举措需要权衡,如果流量较大,即使缩短了时间,TIME_WAIT仍可能被迅速耗尽。
2. 启用`tcp_tw_reuse`:RFC 1323引入了一系列TCP扩展以提升高带宽路径性能,其中包含一个新的TCP选项,携带两个四字节的时间戳字段。第一个代表发送方的当前TCP时间戳,第二个是接收到的最新时间戳。如果新时间戳严格大于前一个连接记录的最新时间戳,Linux系统将允许重用现有处于TIME_WAIT状态的连接进行出站链接。值得注意的是,此参数对入站连接无效。
3. 启用`tcp_tw_recycle`:该参数同样依赖时间戳选项,且对入站和出站链接均有影响。然而,它在NAT环境下可能引发问题,例如,当一个公司的所有员工共享同一个公网IP访问外部网络时,由于不同的设备可能不共享相同的时间戳时钟,时间戳条件会阻止所有这些设备在一分钟内建立新连接。因此,禁用此选项通常是更好的选择,因为它可能导致难以发现和诊断的问题。(注意:从Linux 4.10版本起,Linux系统对每个连接的时间戳偏移量进行了随机化处理,使得该选项无论在有无NAT环境下几乎都无法生效。在Linux 4.12版本中,此选项已被彻底移除。)
对于服务器端而言,上述三种方法都无法根本解决TIME_WAIT过多的问题。真正的解决方案在于“不主动关闭连接”,即服务器不应主动断开连接,而是通过设置KeepAlive机制,让客户端负责主动断开。在这种情况下,服务器只会进入CLOSE_WAIT状态。
但对于构建出站连接的EaseProbe探活工具来说,启用`tcp_tw_reuse`可以重用TIME_WAIT连接,但这依然不能完全解决TIME_WAIT数量过多的挑战。
几天后,我突然回想起《UNIX网络编程》中提及的一个Socket参数:”SO_LINGER”。在我的编程生涯中,我从未实际使用过这个设置。它的主要作用是延迟关闭,即当应用程序调用`close()`函数时,如果仍有数据未发送完毕,它会等待一个设定的延时时间以确保数据传输完成。然而,如果将延时设置为0,Socket会立即丢弃所有待发送数据,并向对方发送一个RST(复位)报文来终止连接。由于连接是通过RST报文终止的,因此不会产生TIME_WAIT状态。
这个参数在服务器端绝不能使用,否则客户端将频繁遭遇“connection reset by peer”的TCP连接错误。但对于EaseProbe这样的客户端探活工具来说,这简直是完美的解决方案。当EaseProbe完成探测后,直接重置连接,既不影响功能,也不干扰服务器,更不会产生恼人的TIME_WAIT问题。
在Go语言的实践中:
Go标准库中的`net.TCPConn`对象提供了`SetLinger()`方法来实现此功能,使用起来相对简单:
```go conn, _ := net.DialTimeout("tcp", t.Host, t.Timeout()) if tcpCon, ok := conn.(*net.TCPConn); ok { tcpCon.SetLinger(0) } ```
您需要将`net.Conn`类型转换为`net.TCPConn`类型,然后即可调用相应的方法。
然而,对于Go标准库中的HTTP对象,操作就稍微复杂一些了,因为Go的`http`库将底层的连接对象封装为私有变量,外部无法直接获取。一篇名为《How to Set Go net/http Socket Options - setsockopt() example》的文章提供了一种借助底层系统调用的方法:
```go dialer := &net.Dialer{ Control: func(network, address string, conn syscall.RawConn) error { var operr error if err := conn.Control(func(fd uintptr) { operr = syscall.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.TCP_QUICKACK, 1) }); err != nil { return err } return operr }, } client := &http.Client{ Transport: &http.Transport{ DialContext: dialer.DialContext, }, } ```
这种方法非常底层,直接涉及`setsockopt`这样的系统调用。我更倾向于使用`TCPConn.SetLinger(0)`来实现,既然已经被封装,最好不要破坏封装性而触及底层细节。
经过对Golang `http`包源码的研读和探索,我最终采用了以下方法:
```go client := &http.Client{ Timeout: h.Timeout(), Transport: &http.Transport{ TLSClientConfig: tls, DisableKeepAlives: true, DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { d := net.Dialer{Timeout: h.Timeout()} conn, err := d.DialContext(ctx, network, addr) if err != nil { return nil, err } tcpConn, ok := conn.(*net.TCPConn) if ok { tcpConn.SetLinger(0) return tcpConn, nil } return conn, nil }, }, } ```
随后,我选取了全球前100万的域名列表,并在AWS上部署了一台服务器。我编写脚本,以5秒、10秒、30秒、60秒的间隔对前1万和2万个网站进行探活。测试过程中,Cloudflare的1.1.1.1 DNS服务器甚至一度将我列入黑名单!最终的测试结果令人满意,系统几乎没有产生任何TIME_WAIT连接。相关的测试方法、数据和报告可以在基准测试报告中查看。
总结以下几点:
TIME_WAIT是TCP协议为了确保完整性而设计的重要机制,尽管可能带来一些副作用,但其作用至关重要,不应轻易妥协。
永远不要使用`tcp_tw_recycle`,这个参数具有巨大的破坏力,可能导致难以诊断的问题。
服务器端永远不要设置`SO_LINGER(0)`。此外,`tcp_tw_reuse`对服务器端的意义不大,因为它主要作用于出站流量。
在服务端,最佳实践是避免主动断开连接,应设置KeepAlive以重用连接,让客户端来主动断开连接。
在客户端,可以考虑使用`tcp_tw_reuse`或`SO_LINGER(0)`来管理TIME_WAIT状态。
最后,强烈推荐阅读这篇文章:《Coping with the TCP TIME-WAIT state on busy Linux servers》。
