你是不是也撞上过这种场景:线上服务无缘无故变慢,新请求大量超时,登上服务器敲一行netstat,结果刷出来几千个TIME_WAIT;或者更吓人的,服务进程明明还活着,端口也正常监听,但连接数全部卡在CLOSE_WAIT上不释放。遇到这种局面,很多人第一反应是怀疑下游机器出了问题,而实际上这两个状态各有各的脾气,处理方式也完全不同,甚至可以说是两个维度的技术问题。
这篇文章想把自己在实际运维和故障排查里关于这两个状态的积累一次性讲透:它们到底是怎么产生的,堆积起来会造成什么实质影响,以及从应用层到内核层分别怎么应对。适合后端开发、运维、SRE以及所有需要自己排查线上问题的同学参考,尤其是那些被连接状态搞到头大的朋友。
1. 两个状态到底是什么:从TCP挥手说起
1.1 一次正常断开背后隐藏的状态序列
TCP是面向连接的协议,正常断开需要经过四次挥手。虽然现在各种框架已经把连接管理封装得很到位,但真正维护连接状态的一直是内核里的TCP状态机。
假设 A 机器主动发起断开,B 机器是被动关闭方。A 发送FIN报文,B 收到后回复ACK,此时 B 进入CLOSE_WAIT,A 进入FIN_WAIT_2。随后 B 的应用层调用close(),B 再发送自己的FIN,A 收到后回复最后一个ACK,然后 A 进入TIME_WAIT,B 进入LAST_ACK并等待这个 ACK 到达,随后彻底关闭。
这套流程对很多后台开发者来说并不陌生,但实际排查时,很少有人会在脑子里把状态转换图完全过一遍。我的经验是,只需要抓住一个核心视角:TIME_WAIT永远归主动断开方,CLOSE_WAIT永远归被动断开方。搞清楚这一点,后面所有排查方向都不会跑偏。
1.2 TIME_WAIT为什么被称为“2MSL等待”
TIME_WAIT是主动断开方收尾时停留的状态。为什么都已经发出最后一个ACK了,还要继续等待 2MSL(报文最大生存时间的两倍)?这里有两点考虑。
第一,防止最后一个ACK丢失。如果这个 ACK 丢了,对端的LAST_ACK会重发FIN,主动方得有足够时间留在原地处理重传的FIN,否则对端永远关不掉连接。第二,防止旧连接上的延迟报文串到新连接里。等待足够长的时间,确保网络中属于旧连接的报文已经消失,不会带着旧序列号污染新连接。
Linux 把这个等待时间固定为 60 秒左右,以内核实现为准。这也就解释了为什么高并发短连接场景下TIME_WAIT数量会非常可观:每一条主动断开的连接,都要在这里待满大约一分钟才被回收。
1.3 CLOSE_WAIT:被动关闭一方的“僵尸房间”
CLOSE_WAIT是被动关闭方在收到FIN后、应用层执行close()之前的中间状态。正常情况下,这个状态只持续一瞬间:应用收到可读事件,read()返回 0 或 -1,然后调用close(),连接就进入LAST_ACK,等待对端确认。
如果应用一直没有调用close(),这个连接就永远停在CLOSE_WAIT。打个比方,对方已经说“我要挂了”,你这边也答应了一声“知道了”,但就是没把门彻底关上。每来一个这样的连接,就占着一个文件描述符,积累多了,进程句柄耗尽,任何新连接都收不进来,服务表现为“假死”。
这里有个关键区别:TIME_WAIT是内核主动维持的收尾状态,到了时间自己会消失;CLOSE_WAIT没有这样的超时机制,内核不会去回收它,只能等应用层动手。换句话说,CLOSE_WAIT堆积,基本等同于你的代码某个地方漏掉了close()。
2. 诊断先行:怎么确认服务器真的“大量”了
2.1 用netstat/ss快速统计连接状态
不啰嗦,直接上命令。查看所有连接状态的数量分布:
netstat -ant | awk '{print $6}' | sort | uniq -c | sort -n或者直接用 ss 看内核汇总信息:
ss -s后者会直接输出 TCP 各状态的数量,time-wait、close-wait 一目了然,推荐优先用ss。如果想单独看TIME_WAIT连接的具体四元组:
ss -ant state time-wait看CLOSE_WAIT同理:
ss -ant state close-wait这两个命令在看清楚“是什么状态多”之后,还要配合lsof定位到具体进程,后面章节会展开。注意一点:netstat在部分新发行版里已经不是默认安装了,它属于 net-tools 工具包,而ss属于 iproute2,基本是系统自带的,所以能用 ss 就尽量用 ss。
2.2 哪些业务特征会让TIME_WAIT自然偏高
先别一看到TIME_WAIT数量大就紧张。有些场景下它多,属于正常现象:
- 高并发短连接,比如 HTTP/1.0 请求或未开启 keep-alive 的 HTTP 访问
- Nginx、负载均衡代理大量转发短连接
- 服务自身作为客户端频繁请求下游接口,每次新建连接、用完即断
- 压测工具、爬虫大量建连后快速断开
但凡服务器角色是“主动发起连接的那一方”,并且业务特征又是短平快,TIME_WAIT多就是预期内的。重点不在于数字本身,而在于它有没有逼近资源上限,以及有没有持续增长的趋势。
2.3 判断问题严重程度的三条参考线
第一条,看总量是否逼近端口范围上限。单个 TCP 连接由本地 IP、本地端口、远端 IP、远端端口共同确定,服务器对外发起连接时,源端口不够用是最直接的故障。Linux 默认本地端口范围通常在 32768 到 60999,大约 28000 多个端口。TIME_WAIT占用着这些端口时,新连接就会分配不到本地端口,日志里出现Cannot assign requested address或者连接超时。
第二条,看CLOSE_WAIT是否持续增长而不回落。这属于高危信号,哪怕只有几十个,只要一直涨,也是故障前兆。第三条,看内存和连接跟踪表开销。TIME_WAIT连接同样占用内核 socket 结构体和内存,数量达到几万甚至更多时,对内核内存和连接跟踪表的压力都不小。
3. 大量TIME_WAIT:最常见的“高并发后遗症”
3.1 为什么高并发短连接会制造出成千上万个TIME_WAIT
前面提到TIME_WAIT的等待期约 60 秒。假设你的服务作为客户端每秒发出 800 个短连接,那么每秒就有 800 个连接进入TIME_WAIT。60 秒之后,处于TIME_WAIT的连接数就会累积到约 48000 个。而默认本地端口池只有约 28000 个,端口耗尽就发生在这一分钟之后。
这其实是一个很容易被忽略的细节:TIME_WAIT是由主动断开方承担的。在一个典型业务链路里,服务端接收连接通常不会堆积TIME_WAIT,反而是作为调用方的那一侧压力最大。比如 RPC 调用方、数据库客户端、Nginx 上游代理等。如果你的服务大量主动短连接打到下游,TIME_WAIT就都堆在你自己这一台机器上。
3.2 TIME_WAIT堆积有哪些实质性影响
- 本地端口耗尽,无法发起新的对外连接
- 内核内存、socket 结构体、连接跟踪条目的开销随连接数线性上涨
- 在 NAT 或 iptables 场景下,连接跟踪表可能先被打满,引起丢包
这里需要强调一个容易混淆的点:同样是TIME_WAIT,在服务端负责 accept 的连接上危害相对要小,因为服务端接收连接用的是固定监听端口,不受源端口范围限制。而作为客户端发起外联时,TIME_WAIT堆积的危害就大得多。所以排查时先确认自己这台机器在链路里扮演的角色是什么,再去定性严重程度。
3.3 解决措施:从应用层到内核参数逐层下手
先说应用层,这也是最根本的解法:连接复用,减少无谓的断开。
HTTP 请求尽量开启连接池,RPC 框架一般自带连接池,检查配置项即可。数据库连接池同样要注意合理设置最大连接数和空闲回收时间。连接一旦复用起来,TIME_WAIT数量会成数量级下降。
然后是内核参数。在真实业务场景里,以下三个参数组合是我最常用的:
net.ipv4.tcp_tw_reuse = 1 net.ipv4.ip_local_port_range = 1024 65535 net.ipv4.tcp_max_tw_buckets = 5000tcp_tw_reuse允许内核在需要分配新连接时,复用处于TIME_WAIT状态且满足条件的旧连接,前提是开启net.ipv4.tcp_timestamps。这个参数只对主动发起连接的一方有效,对接受连接的服务端监听 socket 没用。
ip_local_port_range扩大源端口范围,从默认约 28000 个扩大到 64511 个,短期能明显缓解端口耗尽。但要留意,端口范围不是越大越好,后面会单独说风险。
tcp_max_tw_buckets是TIME_WAIT连接数量上限,超过上限后内核会直接丢弃多出来的TIME_WAIT状态并打印告警日志。不建议设成 0,那等于完全关闭TIME_WAIT机制,会引入安全隐患。
3.4 调内核参数前必须搞清楚的两个风险
第一个风险,是网上流传的大量老教程里提到的tcp_tw_recycle参数。它在 NAT 环境下问题很大:同一个 NAT 出口后面可能有多台客户端,时间戳增量无法统一,回收逻辑会误判导致丢包。而且 Linux 4.12 之后这个参数已经在内核里被移除,新系统上想设也设不了。千万别再照抄老文章去开这个参数。
第二个风险,盲目调大ip_local_port_range可能导致端口池覆盖到保留端口,或者让连接跟踪表更快被打满。改大之前先看一下nf_conntrack_max,尤其是服务器上跑着 iptables NAT 规则的时候。端口范围改得再大,连接跟踪表不够用也是白搭。
4. 大量CLOSE_WAIT:十有八九是代码的事
4.1 主动关闭方“消失”后发生了什么
CLOSE_WAIT堆积通常等于“你的程序忘了关连接”。和TIME_WAIT是内核的自动回收机制不同,CLOSE_WAIT是应用层 Bug 的典型信号,也更值得警惕。
从连接生命周期来看,被动关闭方应在收到FIN后立即调用close()。如果这段时间内应用进程阻塞、线程卡死、逻辑分支遗漏或异常导致 socket 句柄没有被释放,连接就会挂在CLOSE_WAIT。TCP 协议本身没有超时机制去强制干掉这个状态,它只能等应用层主动close()。
所以一旦线上CLOSE_WAIT数量持续上涨,基本方向不是内核参数,而是代码路径。
4.2 用一次线上事故还原CLOSE_WAIT的生产过程
用一个模拟场景来还原:某个服务端收到外部请求后,在处理过程中调用下游 HTTP 接口,结果下游响应超时。代码里对这次调用做了异常捕获,但忘记在finally块里关闭 Response 流或连接。于是每次下游超时,就泄漏一个连接。
问题起初不严重,每个请求也就漏一个文件描述符。但下游一旦开始大量超时,泄漏速率就变成每秒几百个。一段时间过后,进程文件描述符达到上限,服务开始异常,连本机的健康检查请求都无法处理。表面看上去是下游故障引发的雪崩,实际是本机的连接泄漏在放大故障。
这类事故的典型特点是:CLOSE_WAIT数量持续增长,TIME_WAIT基本不变,进程 CPU、内存都正常,就是连接数一条条涨上去。
4.3 排查思路:谁打开了连接却没有关闭
排查CLOSE_WAIT,核心是确认是哪个进程、哪条调用链持有的连接没释放。
先看统计:
ss -ant state close-wait把所有处于CLOSE_WAIT的连接列出来,再配合lsof定位到具体进程和文件描述符:
lsof -nP -iTCP -sTCP:CLOSE_WAIT如果CLOSE_WAIT的连接对端都指向同一个下游地址,多半是调用该下游的代码没关连接。如果对端五花八门,问题大概率在主流程的某个公共逻辑里,比如全局的 HTTP 客户端封装、ORM 连接封装等。
还可以用strace观察相关进程,看是否缺少close()调用,不过在追查生产问题时,strace 有性能开销和生产安全隐患,建议先在测试环境复现。
4.4 代码层修复:连接管理的三件套
不管用什么语言,解决思路基本一致:
- 连接必须显式
close(),且放在finally或defer里 - 使用连接池的场景,由池负责生命周期管理,避免手写连接创建和销毁
- 给所有依赖下游的调用配置连接超时和读取超时,避免线程无限期阻塞导致无法释放
以 Java 为例,处理 HTTP 调用要确保InputStream和 Response 整体关闭,用 try-with-resources 是标准写法。Go 的defer也要注意,在循环里连续defer close()会导致 FD 不立即释放。Python 里同理,确保 socket 或响应对象在finally里关闭。
除了代码,还有两个系统参数值得检查:一是进程的文件描述符上限,也就是ulimit -n和fs.file-max;二是线程数或线程池配置。很多CLOSE_WAIT堆积前的第一个告警,往往就是 fd 使用率打满。
4.5 临时手段与长期方案的取舍
CLOSE_WAIT没有内核参数能直接清掉,系统层面能做的只是调大文件描述符上限、调大线程数、加快接受新连接,这些都属于“争取时间”,不是治本。
真正有效的只有应用层修复。但系统层面的监控和告警必须提前做:fd 使用率达到阈值就告警,能在问题扩大之前把信号暴露出来。我见过太多情况是问题发生了才上去看数据,此时CLOSE_WAIT已经堆到几千条了。
5. 实操过程与核心环节实现:把参数调成一个可落地的配置
5.1 一份可直接参考的内核参数配置清单
针对高并发短连接场景,一套比较稳妥的内核参数组合如下。通常我会把下面内容追加到/etc/sysctl.conf:
net.ipv4.tcp_tw_reuse = 1 net.ipv4.tcp_timestamps = 1 net.ipv4.ip_local_port_range = 1024 65535 net.ipv4.tcp_max_tw_buckets = 5000 net.ipv4.tcp_fin_timeout = 30 fs.file-max = 1000000其中tcp_timestamps默认就是开启的,写上是为了防止某些云镜像模板把它关掉。tcp_fin_timeout控制主动断开方停留在FIN_WAIT_2的时间,不是针对TIME_WAIT的,只有在确认不影响对端重传FIN的前提下才建议调小。fs.file-max是系统级文件描述符上限,和CLOSE_WAIT堆积场景也有关联,顺手调到一个保守偏大的值。
5.2 sysctl.conf配置解读与生效方式
写入配置后执行:
sysctl -p让配置立即生效。注意,/etc/sysctl.conf只是配置来源,真正的运行参数在/proc/sys/net/ipv4/对应的文件里,sysctl -p的本质就是把这些值写进 procfs 对应的节点。
验证实际生效值:
sysctl net.ipv4.tcp_tw_reuse net.ipv4.ip_local_port_range输出结果应与配置一致。如果某些参数在所在内核版本里不存在,sysctl -p会报错,但不会导致其他配置失效,可以单独确认错误行对应的内核版本兼容性。
5.3 后续验证:如何确认优化生效
改完参数不是结束,要持续观察验证效果。建议分三个时间点取样:
- 优化前:先记录当前各状态数量,作为基线
- 优化后即时:重新压测或观察业务高峰,看变化
- 一个连接生命周期之后,比如 2 分钟以上:确认
TIME_WAIT没有重新逼近阈值
统计可以用一条简单的 watch 命令:
watch -n 3 'ss -s | grep -E "time-wait|close-wait"'如果发现TIME_WAIT峰值回落到几千以内,源端口不再告警,说明参数组合有效。如果仍然持续增长,基本可以确定是应用层的连接复用做得不够,不能靠内核参数硬扛。
6. 常见问题与排查技巧实录
6.1 调大端口范围后问题反而更严重?
我遇到过这样一个案例:端口范围从默认的 32768 往上调大之后,TIME_WAIT还是有增长,最后发现是连接跟踪表nf_conntrack容量打满了。连接跟踪表满时,新连接会直接丢包,表面症状和端口耗尽类似,但方向完全不同。
所以在调大端口范围前,先确认系统连接跟踪上限:
sysctl net.netfilter.nf_conntrack_max如果服务器上用了 iptables NAT,conntrack 是必须考虑的前置瓶颈。可以先按nf_conntrack_max / 源端口数量粗估一下容量是否匹配。
6.2 服务端主动断开与keepalive策略的平衡
另一个常见误区是为了减少TIME_WAIT而把服务端 keep-alive 调得很短,导致服务端频繁主动断开,结果TIME_WAIT反而堆到了客户端。
这里的原则是:谁主动断开,谁承担TIME_WAIT。想让客户端机器轻一点,就让服务端主动断开,把TIME_WAIT集中在服务端;想让服务端轻一点,就要保证 keep-alive 足够长,让客户端主动断开时TIME_WAIT落在客户端。这个选择题没有统一答案,完全取决于两端资源哪边更紧张,需要按链路实际部署来设计。
6.3 抓包确认:用tcpdump验证连接断开过程
当理论分析和现象对不上时,抓包是最后的手段。在服务端监听端口抓包:
tcpdump -i eth0 -nn 'tcp port 8080' -w /tmp/drop.pcap然后用 Wireshark 打开,筛选FIN、RST报文,确认挥手过程是否正常、卡在哪个环节。大量RST说明对端往往直接暴力断开,大量重复FIN说明 ACK 丢失,这些都能解释状态堆积的深层原因。抓包耗时耗力,但胜在证据确凿,适合在参数和代码都排查过一轮、仍无法定位的时候使用。
6.4 快速排查自检清单
按下面这个顺序排查,基本能覆盖大多数连接状态异常场景:
- 统计当前各状态数量:
ss -s - 确认
TIME_WAIT/CLOSE_WAIT的主要对端 IP:ss -ant state time-wait/close-wait - 确认是哪个进程持有这些连接:
lsof -iTCP -sTCP:CLOSE_WAIT - 如果是
TIME_WAIT,检查连接复用策略、端口池范围、连接跟踪表状态 - 如果是
CLOSE_WAIT,检查代码里的连接关闭逻辑、超时配置、fd 使用率 - 配合压测或业务高峰复现,记录状态增长速率,确认参数调整后的效果
我个人在多次排查里最深的体会是,TIME_WAIT问题往内核参数上找答案通常能快速见效,但CLOSE_WAIT问题必须往代码上找答案,而且大概率要翻几次车才能定位到具体那一行。遇到这两种状态时,冷静区分“主动方”和“被动方”的身份,比瞎改参数重要得多。
最后再分享一个小技巧:遇到服务器连接状态异常,先别急着调参数,把ss -s的输出存下来,过两小时再看一眼。很多TIME_WAIT的“触目惊心”只是一时的高峰,而CLOSE_WAIT的持续增长才是真正要命的雷。