← 返回新闻列表

深度探讨TCP连接中的TIME_WAIT状态及其应对策略

本文将深入剖析TCP协议中备受关注的TIME_WAIT状态。通过笔者在开源探活工具EaseProbe中遇到的实际问题,不仅将详细阐述这一状态的产生根源与潜在影响,还将介绍业界常见的解决方案,并分享在特定场景下如何有效规避TIME_WAIT带来的资源耗尽困扰,以确保程序和系统的稳定运行。

文 / 编辑部 · 2022/07/19 · 阅读约 11 分钟

分享:
深度探讨TCP连接中的TIME_WAIT状态及其应对策略

今天,我们将重点探讨传输控制协议(TCP)中广为人知的TIME_WAIT状态。虽然这是一个普遍话题,但最近笔者在处理一个不寻常的场景时,对其有了新的认识并成功解决了相关问题。因此,希望借此机会全面梳理关于TIME_WAIT的方方面面。这个特殊场景与我开发的开源服务探活工具EaseProbe紧密相关,我会先从这个工具遇到的具体问题入手,再逐步展开对TIME_WAIT机制的详细分析。

EaseProbe是一款轻量且独立的工具,专用于检测各种服务的健康状况。它支持包括HTTP、TCP、Shell、SSH、TLS、主机以及各类中间件的探活功能,并将结果直接发送至主流即时通讯平台,例如Slack、Telegram、Discord、Email、Team,以及国内的企业微信、钉钉和飞书等,其便捷性广受用户好评。

在每次执行探活任务时,EaseProbe都需要重新建立完整的网络连接流程。这意味着它必须从头开始进行DNS查询,建立TCP连接,进行数据通信,然后随即关闭连接。我们刻意不启用TCP的KeepAlive机制来重用连接,因为探活工具不仅要检查远端服务的可用性,更要全面监测整个网络的状况。因此,每次探活都必须是一个全新的过程,这样才能准确捕捉到链路上的所有潜在问题。

然而,这种频繁建立和关闭连接的行为,根据TCP的状态机定义,会在探测端产生大量的TIME_WAIT TCP连接。TCP协议规定,这些处于TIME_WAIT状态的连接需要等待两倍于最大报文段生命周期(2MSL)的时间才能被系统彻底回收。在此之前,每个连接都会占用系统资源,主要包括文件描述符(虽然可调整)和至关重要的端口号。作为发起请求的客户端,理论上每个IP地址可用端口号上限约为64K,但实际上系统默认通常只有大约3万个端口可用(如从32,768到60,999)。如果TIME_WAIT连接数量过多,可能导致后续TCP连接无法建立,甚至因资源耗尽引发程序或整个系统异常。

试想,如果我们以每10秒探测1万个节点的速度进行,而TIME_WAIT的超时时间是120秒,那么在短短60秒后,等待超时的TIME_WAIT连接就可能耗尽某个特定IP的所有可用端口。即使系统勉强维持,也可能出现性能瓶颈。值得注意的是,这不仅仅适用于纯TCP连接,HTTP协议也存在类似问题。由于探活通常涉及域名解析和API网关,导致大量连接指向相同的DNS服务器和网关地址。即使四元组理论上不冲突,本地程序也可能因端口耗尽而无法绑定出站连接。

那么,为什么TCP的TIME_WAIT状态需要等待2MSL这么长的时间呢?

笔者早前撰写过一篇关于TCP的宏观文章(上篇、下篇),其中曾提及此点,在此再次阐述。TCP连接断开时,会经历一系列状态转换。主动断开连接方在进入TIME_WAIT状态后,不再需要等待对方的ACK确认,而是直接进入超时等待。这是因为在不可靠的网络环境中,要确保发送的数据已被对方接收,就需要对方发送确认(ACK)。但反过来,对方如何确认其发送的ACK已被己方接收呢?如果再发送一个确认的ACK,这将陷入无限循环的“两将军问题”——一个在不稳定信道上无法完美解决的同步协商困境(此问题在《分布式事务》一文中也有提及,此处不再赘述)。

因此,TCP设计者选择等待一个预设的最大时间,以解决以下两个核心问题:

首先,为了防止来自旧连接的延迟报文段被后续建立的、使用了相同四元组(源地址、源端口、目标地址、目标端口)的新连接误接收。尽管可以通过序列号(sequence number)范围来限制接收,但这只能降低问题发生的几率。对于高吞吐量的应用,尤其是在拥有大接收窗口的快速连接中,问题依然可能出现。RFC 1337详细解释了TIME_WAIT状态不足时可能导致的问题。例如,缩短TIME_WAIT可能导致旧连接的TCP段被新的不相关连接错误接收。

其次,确保远端已经完全关闭连接。如果最后一个ACK丢失,对端将停留在LAST-ACK状态。在没有TIME_WAIT状态的情况下,新连接可能被重新建立,而远端仍认为之前的连接有效。当收到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`查看。

针对TIME_WAIT过多的问题,网上通常提供以下解决方案:

一是缩短超时时间,以加快端口回收速度。但设置过短可能在流量高峰时仍会导致TIME_WAIT耗尽。

二是启用`tcp_tw_reuse`参数。RFC 1323引入了一系列TCP扩展以提升高带宽路径性能,其中包含一个新的TCP选项,带有两个四字节的时间戳字段。第一个是当前TCP时间戳的值,第二个是接收到的最新远端时间戳。如果新的时间戳严格大于之前连接记录的最新时间戳,Linux会将处于TIME_WAIT状态的现有连接重新用于新的出站连接。需要强调的是,此参数仅对出站连接有效,对入站连接无效。

三是启用`tcp_tw_recycle`参数。此参数同样依赖时间戳选项,并同时影响入站和出站连接。然而,它在网络地址转换(NAT)环境下存在问题。例如,若一个公司的所有员工共享一个公网IP访问外部网络,时间戳条件可能导致该公网IP背后的设备在一段时间内无法正常连接,因为它们的时钟不同步。因此,禁用此选项通常是更好的选择,否则可能导致难以检测和诊断的问题(注:自Linux 4.10起,Linux为每个连接随机化时间戳偏移量,从而使其失效;自Linux 4.12起,该参数已被完全移除)。

对于服务器而言,上述三种方法均无法有效解决TIME_WAIT过多的问题。真正的解决方案在于“不作死就不会死”,即服务器不应主动断开连接,而是通过设置KeepAlive机制让客户端主动断开,这样服务器端只会产生CLOSE_WAIT状态。

然而,对于像EaseProbe这种需要建立出站连接的探测工具来说,启用`tcp_tw_reuse`可以复用TIME_WAIT连接,但这仍不能彻底解决TIME_WAIT数量过多的问题。

几天前,我突然回想起《UNIX网络编程》中曾提到一个名为`SO_LINGER`的Socket参数。我在之前的编程生涯中从未用过它。此参数主要用于延迟关闭,即当应用调用`close()`函数时,如果仍有数据未发送完毕,它会等待一段时间以确保数据发送完成。但若将延迟时间设置为0,Socket会直接丢弃未发送数据,并向对端发送一个RST(复位)报文来强制终止连接。由于通过RST报文终止连接,因此不会产生TIME_WAIT状态。

这个功能绝不能在服务器端使用,否则客户端将频繁收到“connection reset by peer”的TCP连接错误。但对于EaseProbe这类客户端探测工具而言,`SO_LINGER(0)`简直是完美方案。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)`来完成,毕竟封装好的接口更符合设计原则。经过深入阅读Go 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上搭建了一台服务器,通过脚本生成了前1万和前2万个网站列表,以5秒、10秒、30秒、60秒的间隔进行探活。尽管这导致Cloudflare的1.1.1.1 DNS服务器一度将我列入黑名单,但最终的测试结果非常令人满意:系统中未出现任何TIME_WAIT连接。相关的测试方法、数据和报告可以在基准测试报告中查看。

总结如下:

首先,TIME_WAIT是TCP协议为了确保连接完整性而设计的重要机制,尽管有副作用,但其作用至关重要,不应轻易妥协。

其次,永远不要使用`tcp_tw_recycle`参数,因为它具有极大的破坏性,尤其是在NAT环境中。

再者,服务器端绝不应使用`SO_LINGER(0)`。同时,`tcp_tw_reuse`对服务器端的意义不大,因为它仅对出站流量有效。

服务器端最佳实践是不要主动断开连接,而是设置KeepAlive机制来重用连接,让客户端主动关闭。

最后,客户端可以在特定场景下考虑使用`tcp_tw_reuse`和`SO_LINGER(0)`来优化资源。

强烈推荐阅读文章《Coping with the TCP TIME-WAIT state on busy Linux servers》以获取更多相关知识。

广告位 · 文末横幅