聊一个服务端开发基本都绕不开、但很多人一遇到就懵的问题:服务器上忽然冒出几万个 TIME_WAIT、CLOSE_WAIT,接着服务响应变慢、连接失败、日志疯狂报错。我第一次在压测环境看到ss -s里 TIME_WAIT 数量超过 4 万的时候,第一反应是怀疑内核出问题了,后来踩了几个月坑才搞清楚,这俩状态背后暴露的其实是两类完全不同的问题——一个是内核和连接策略的事,另一个八成是业务代码的事。今天把这部分的完整排查思路、原理和实操方案整理出来,希望对正在被连接状态折磨的人有帮助。
1. 先搞清楚这两个状态到底怎么回事
1.1 TCP 四次挥手里的“交接棒”
TCP 连接关闭过程中有个非常基础但关键的机制:四次挥手。A 和 B 各发一个 FIN、各回一个 ACK,顺序大致是 A 发 FIN -> B 回 ACK -> B 发 FIN -> A 回 ACK。四次挥手完成后,连接双方都应该从内核的连接表中消失。
现实情况远没有这么理想。在挥手的不同阶段,连接会停留在中间状态,其中两个最有名的就是 TIME_WAIT 和 CLOSE_WAIT。要理解“大量连接堆积”为什么危险,得先从这四个挥手动作为什么会卡住说起。
我自己常用的一个类比是:TIME_WAIT 像已经打包好准备寄出的快递单,包裹虽然收到了,但单号还在系统里挂着,防止买家反悔时能追溯;CLOSE_WAIT 更像你发出了“我要退货”的消息,对面不仅没回,反而自己把聊天窗口关了,你只能一直等对面确认退货结果。两者语义完全不同,排查方向也截然相反。
1.2 TIME_WAIT:主动关闭方的“冷静期”
TIME_WAIT 出现在主动发起关闭的一方。比如客户端请求完一个短连接后主动断开,或者服务端设置了超时主动断开空闲连接,这时候主动方就会进入 TIME_WAIT,并且要停留 2 个 MSL(Maximum Segment Lifetime,报文最大生存时间)之久。
为什么需要这个“冷静期”?因为最后那个 ACK 有可能在网络中丢失,如果被动方没收到 ACK,它会重发 FIN,主动方必须留有足够时间处理这种重传。另外,防止上一个连接的残留报文混入后续新建连接。所以 TIME_WAIT 本质是 TCP 的安全机制,正常情况下批量出现并不代表系统有故障,只有当量大到耗尽本地端口、影响新连接建立时,才需要介入处理。
Linux 默认的 MSL 是 60 秒左右,所以 TIME_WAIT 一般停留 60 到 120 秒。高并发短连接场景下,每秒建 1000 个连接、每个连接关闭后挂 60 秒,那你同时会看到 6 万个左右的 TIME_WAIT,数量就是这么堆出来的。
1.3 CLOSE_WAIT:被动关闭方的“未交接”
CLOSE_WAIT 的角色完全不同。它出现在被动关闭的一方:对端发来 FIN,服务端内核收到后立即回 ACK,这时连接进入 CLOSE_WAIT,等待应用进程调用 close() 关闭自己的 socket。
关键点在于:内核能替你把 ACK 回了,但它没办法替你的业务代码执行 close()。所以只要进程不关 socket,CLOSE_WAIT 就一直挂着,数量只增不减。这基本上不是网络问题,是应用层没有正确释放资源的问题。大量 CLOSE_WAIT 出现时,应该先怀疑代码,而不是调内核参数。
注意:TIME_WAIT 由内核自动管理,过一段时间自己会消失;CLOSE_WAIT 则完全依赖应用代码主动关闭,不会自动消失。看到 CLOSE_WAIT 长期不降,基本可以断定是文件描述符泄漏或线程卡死。
2. 大量 TIME_WAIT 的根源与影响
2.1 常见原因清单
结合我个人运维过的几个高并发服务,TIME_WAIT 大量堆积的原因通常跑不出这几类:
- 业务大量使用短连接。每次请求都新建 TCP 连接,请求结束立即断开,每次断开都产生一个 TIME_WAIT。短连接是 TIME_WAIT 的“生产机器”,并发越高、堆积越快。
- 连接池没有复用。明明是长连接可以解决的场景,代码里却每次都 dial 一个新的连接出来,用一次就丢。
- 服务主动断开空闲连接。不少服务配置了空闲超时,比如 60 秒没有请求就主动断连。如果业务本身是低频轮询类型,每个周期都会制造一批 TIME_WAIT。
- 高并发瞬时尖峰。比如秒杀、抢购、消息推送瞬间涌入大量请求,连接数在短时间内暴增,被动产生大量 TIME_WAIT。
- 健康检查频率过高。一些负载均衡器或注册中心每隔几秒就做一次 TCP 探测,每次探测即连即断,同样会产生 TIME_WAIT。
我在实际项目中还遇到过一个比较隐蔽的情况:某个内部 SDK 底层用 HTTP 协议,但 SDK 实现里没有开启连接复用(Keep-Alive),每次 RPC 调用都重新触发完整 TCP 握手,导致所有接入该 SDK 的节点都堆了几千个 TIME_WAIT。优化方案是把 HTTP 头里的 Connection 改为 keep-alive,并在客户端启用连接池,TIME_WAIT 数量直接降了一个数量级。
2.2 TIME_WAIT 过多带来的直接冲击
TIME_WAIT 的最大危害是本地端口耗尽。每个 TCP 连接在主动方这边都会占用一个本地端口(ip_local_port_range,默认一般是 32768-60999,大约 2.8 万个端口)。当大量 TIME_WAIT 停留在内核里占着端口不放时,新连接就找不到可用端口,表现就是connect: Cannot assign requested address。
我见过一个极端案例:某个网关节点短连接流量非常大,TIME_WAIT 峰值到 4 万左右,远超本地端口范围,随后大量外部调用报“Cannot assign requested address”,服务部分请求直接失败。这已经不只是性能下降,是可用性问题了。
TIME_WAIT 还会占用一定的内核内存。每个 socket 对象、定时器、缓冲区都占内存,虽然单个很小,但几万个连接累积起来,对内存不宽裕的容器环境还是有明显影响的。此外,在梳理连接统计信息时,大量 TIME_WAIT 会让监控图非常难看,干扰后续定位。
2.3 内核参数怎么调、什么不该调
很多人的第一反应是调整内核参数,赶紧把 TIME_WAIT 清掉。这种做法要分情况,盲目调整可能掩盖真正的问题,甚至引入新故障。
下面这几个参数是我做过对比测试的:
| 参数 | 作用 | 我的建议 |
|---|---|---|
net.ipv4.tcp_fin_timeout | 控制 TIME_WAIT 在重传机制下等待超时的时间,调小可以加速回收 | 建议从默认 60 调低到 15-30,注意需要同时启用 tcp_timestamps |
net.ipv4.tcp_tw_reuse | 允许将处于 TIME_WAIT 的 socket 用于新连接(仅出站方向) | 对客户端场景友好,建议开启,但需配合tcp_timestamps=1 |
net.ipv4.tcp_tw_recycle | 快速回收 TIME_WAIT,但 NAT 环境下会引发严重问题 | 不要开。新版本内核已移除或默认禁止,我从不建议开启 |
net.ipv4.ip_local_port_range | 扩大可用本地端口范围 | 可以适当扩大,比如 10240-65535,但仅是缓解 |
比较常见的误区是把tcp_tw_recycle打开。早年很多教程都推荐这么做,但这个参数在 NAT 场景下属于“坑人利器”,因为它依赖时间戳,当同一 NAT 出口背后有多台设备时,可能导致连接被误杀。Linux 4.12 之后该机制已经被移除,如果你还在旧内核上看到它,也别指望靠它解决生产问题。
真正需要明确的是:TIME_WAIT 多不等于系统有问题。如果 QPS 高、短连接多,合理数量的 TIME_WAIT 是必然现象。处理原则应该是:优先减少连接关闭频次(复用连接),其次才是调整内核回收策略。
关于 tcp_timestamps:修改 TIME_WAIT 相关参数前,务必确认开启时间戳选项。tcp_tw_reuse 依赖时间戳判断合法性,如果关闭了时间戳,这个参数就不会生效,而且会引发连接复用时的乱序报文问题。用
sysctl net.ipv4.tcp_timestamps确认。
3. 大量 CLOSE_WAIT 的根源与影响
3.1 CLOSE_WAIT 等于代码没关连接的“铁证”
如果说 TIME_WAIT 是网络层和内核策略问题,那 CLOSE_WAIT 几乎是 100% 的应用代码问题。原因前面已经说了,CLOSE_WAIT 是被动关闭方停在等待应用层 close() 的状态,如果大量出现且长时间不消失,说明进程没有正确关闭 socket。
有人会质疑:明明调用处写了 close 或者用了 defer close,为什么还会堆积?这就是我见过最多的现象——你以为关了,其实没关。比如:
- 提前 return 或异常抛出,导致 close 代码没执行;
- 连接对象从方法里返回给上层,上层处理完没有调用 close,连接池也没有回收逻辑;
- 读取响应体时没有读完整个流,导致底层 socket 认为数据未消费完,协议栈不会发送 FIN;
- 业务线程长时间阻塞在某个远程调用或者数据库查询上,根本没机会走到关闭代码。
CLOSE_WAIT 的可怕在于积累速度慢,初期完全无感,等数量过万、文件描述符耗尽时,服务可能已经假死几分钟了。它不像 TIME_WAIT 那样有“冷静期自动消失”的机制,没有人为干预,CLOSE_WAIT 会一直留下来。
3.2 常见业务场景和代码模式
列几个我实际遇到的高发场景,大家可以对照排查:
- HTTP 调用框架使用不当。用 Java 的 HttpClient 或 Python 的 requests 时,没有设置响应体读取超时,服务端响应慢或者响应体很大,客户端迟迟不读完,连接就卡在 CLOSE_WAIT。
- 数据库/缓存连接池无回收机制。某些中间件客户端连接池实现不完善,服务端(数据库)主动断开空闲连接后,客户端侧没有及时感知,池里的连接处于半开状态,慢慢变成 CLOSE_WAIT。
- 线程池队列堆积。任务没被线程消费,socket 资源放在队列里没人 close,CLOSE_WAIT 持续增长。这种情况不是“不会关”,是“没机会关”。
- 长连接服务端未处理异常断开。TCP Keepalive 没开,客户端机器掉电、断网,服务端不知道连接已死,也不至于马上变成 CLOSE_WAIT。但如果有中间设备,比如负载均衡器定期探活并发送 FIN,而服务端进程卡在业务逻辑里未响应,就容易堆积。
3.3 一个真实案例复盘
我接手过一个接入层微服务,现象极其典型:上线运行两天后,服务自动重启,日志里全是 “too many open files”。现场排查时用ss -tan | grep CLOSE_WAIT | wc -l,数量已经到 1.5 万。
用lsof -p <pid> | wc -l确认进程的文件描述符总量,确实超出了 ulimit 限制。再用lsof -p <pid> | grep TCP | tail找到一批连接到同一个外部依赖服务的 CLOSE_WAIT socket。
业务代码是 Java,用的是某 HTTP 客户端库。排查最终定位到问题点:读取响应流时,只在正常路径读取并关闭了流,异常路径直接把异常抛出,没走 close;同时这个 HTTP 客户端底层连接池对服务端返回的部分响应码不回收连接,连接就一直挂在 CLOSE_WAIT。
修复方案有三步:第一,把响应流的读取和关闭放到 finally 块里,确保无论成功失败都会回收;第二,给 HTTP 客户端设置最大连接数和闲置回收时间;第三,在连接池层增加健康检查,连接建立后先验证是否可用。修复后半个月,CLOSE_WAIT 一直维持在个位数。
这个案例给我的启发是:CLOSE_WAIT 排查一定不要在服务器和内核层面折腾,直接盯着应用代码里“谁创建的连接、谁负责关闭、异常时是否遗漏”这三个问题走,很快能定位。
4. 排查诊断流程与实用命令
4.1 第一件事:先用命令看清全貌
遇到连接状态异常,我一般不会直接上内核参数,而是先跑一套命令,确认到底多到什么程度、是哪些状态剧增、集中出现在哪个进程和哪个对端。
先看整体连接统计。以下命令适合 Linux 环境:
# 查看 TCP 连接状态总览 ss -s # 或者旧的 netstat 方式 netstat -s | grep -i "time wait"再看各状态详细数量:
# 统计各种 TCP 状态数量 ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn # 只看 TIME_WAIT 和 CLOSE_WAIT 的连接明细 ss -tan | grep -E 'TIME_WAIT|CLOSE_WAIT' | head -n 20这几条命令输出基本能告诉你现状。需要注意,ss比netstat更快,也更适合大连接数场景。高并发服务器上动不动几万条连接,netstat偶尔会卡住,用ss更稳。
4.2 第二件事:分清状态,选择不同的排查方向
通过会话观察到的状态要立刻分流:
- TIME_WAIT 占多数,而且集中在出站连接(客户端角色)上,优先排查连接复用策略、短连接比例、健康检查频率;
- CLOSE_WAIT 占多数,直接锁死到具体进程。先列出 CLOSE_WAIT 的源地址和目的地址,确认是连到哪个服务,再结合业务日志定位,是调用方没关还是被调方没关;
- 两者都很多,说明系统既存在短连接风暴,又有连接释放不彻底的代码路径,需要两头处理,先弄 CLOSE_WAIT,再优化连接复用。
针对 CLOSE_WAIT,用下面两条命令能快速缩小范围:
# 找出谁持有大量 CLOSE_WAIT 连接 ss -tanp | grep CLOSE_WAIT | awk '{print $6}' | sort | uniq -c | sort -rn | head # 按对端 IP 分组统计 CLOSE_WAIT ss -tan | grep CLOSE_WAIT | awk '{print $5}' | sed 's/:[0-9]*$//' | sort | uniq -c | sort -rn | head第一行输出里有进程的 PID 和进程名,能直接定位到是哪个进程;第二行能看到主要连向哪个外部依赖,然后沿着链路往下查。如果对端 IP 属于某个固定的服务集群,大概率是这个服务的连接池或代码有问题。
4.3 第三步:结合 FD 与代码定位根因
进入代码级排查前,先确认是不是文件描述符耗尽:
# 查看进程 FD 使用数 ls /proc/<pid>/fd | wc -l # 查看当前限制 cat /proc/<pid>/limits | grep "open files"如果 FD 使用率持续逼近上限,说明进程已经处于泄漏状态,赶紧先摘流量或扩容,再继续定位。如果 FD 使用率正常但 CLOSE_WAIT 仍然很多,则说明连接数还没突破上限,但数量已经在侵蚀资源了。
接下来就是在应用代码里搜创建连接的路径。重点排查几个点:是否用了连接池、连接创建后是否在超时路径关闭、异常抛出时是否有 finally 兜底、读取流是否读完、连接对象的生命周期是否长于实际业务需要。
我自己有个习惯:在测试环境用strace -p <pid> -e trace=network,close抓一段时间系统调用,观察是否有对应的 close 调用以及是谁发起的,配合业务日志基本能眼见为实。
5. 解决措施、配置落地与监控建议
5.1 TIME_WAIT 的实战应对清单
针对 TIME_WAIT 偏多的情况,我建议按下面这个优先级顺序去处理,而不是一上来就调内核:
第一步:应用层优先降低连接关闭频率。能用长连接就用长连接。HTTP 场景开启 Keep-Alive,数据库、缓存、MQ 客户端统一使用连接池,禁止每次请求新建连接。连接池大小要结合单连接吞吐评估,不是越大越好,否则空闲连接本身也占用资源。
第二步:客户端 TIME_WAIT 可以开启端口复用。如果服务角色是大量主动发起连接,也就是出站连接多,开启以下参数是合理的:
net.ipv4.tcp_timestamps = 1 net.ipv4.tcp_tw_reuse = 1 net.ipv4.tcp_fin_timeout = 30这说明 TIME_WAIT 的连接可以被快速复用于新的出站连接,端口耗尽问题能缓解。但注意,tcp_tw_reuse只作用于主动方出站连接,如果你是服务端,这个参数并不能减少接入侧的 TIME_WAIT。
第三步:服务端场景,调整连接生命周期。服务端收到的短连接很多,可以考虑在负载均衡器和应用层同时开启 keep-alive,减少四次挥手次数。另一方面,如果有些端口确实很难复用,可以适度扩大监听 backlog,避免新请求在 accept 队列堆积:
net.core.somaxconn = 1024 net.ipv4.tcp_max_syn_backlog = 1024第四步:如果连接量实在无法降低,再考虑缩短 fin_timeout。前提是已经开时间戳选项。把tcp_fin_timeout从 60 调到 30 甚至 15,能让 TIME_WAIT 更快被内核回收,代价是极小概率发生报文混淆。生产环境我一般只调到 30,再低就不太建议了。
重要提示:不要为了“消除 TIME_WAIT”而关闭 TCP 时间戳或搞什么“快速回收”方案。TIME_WAIT 是 TCP 可靠性的设计,强行抹掉可能引入数据错乱。正确目标是“TIME_WAIT 数量稳定且不耗尽资源”,而不是完全归零。
5.2 CLOSE_WAIT 的代码级清理措施
CLOSE_WAIT 的清理思路是“找到泄漏路径、修复释放逻辑、增加兜底监控”,核心操作都在应用侧:
- 代码评审重点检查连接关闭路径。所有网络操作必须保证最终关闭,Java 里用 try-with-resources,Go 里用 defer Close,Python 里用 context manager,C++ 里用 RAII 或 guard 对象。凡是手动管理连接的地方,都是高危点。
- 给所有网络操作设置超时和空闲过期。连接池内连接如果空闲超过指定时间,就主动关闭重建;HTTP 客户端设置 connect timeout、read timeout、write timeout,避免线程长期阻塞在慢连接上。
- 对连接池增加定期健康检查。用 ping 或 select 1 等轻量探活验证连接可用性,探活失败立即剔除,避免半开连接滞留。
- 加强与依赖服务的超时与异常策略。比如数据库 maxLifetime、连接验证间隔,这些参数不是越大越好,要和业务耗时匹配。
- 观察 CLOSE_WAIT 趋势。正常业务的 CLOSE_WAIT 应该只存在毫秒级或秒级瞬态,如果监控图上出现持续上升的爬坡曲线,基本就是代码有问题,立刻报警出来。
这里说一个很多人容易忽略的细节:服务端主动关闭连接也会产生 TIME_WAIT,但不会产生 CLOSE_WAIT;反之,对端先关闭而你不关闭,那就只会产生 CLOSE_WAIT。所以当服务对外提供接口时,如果对端调用方超时主动断开,而你这边处理线程还在等数据或者处理逻辑,同样会产生 CLOSE_WAIT。这种情况下大概率不是你代码忘了 close,而是你的处理链路里有某个环节被卡住了,比如线程池满了、数据库连接获取阻塞、下游调用迟迟不返回。排查时不要只盯着 close(),也要看线程堆栈。
5.3 监控、告警阈值与预防机制
连接状态问题最让人头疼的其实是“发现太晚”。TIME_WAIT 从 0 涨到几万只需要几十分钟,CLOSE_WAIT 更是可能在你睡觉的时候悄悄爬到上限。所以我强烈建议把以下监控指标纳入日常可观测体系:
| 指标 | 建议采集方式 | 告警阈值参考 |
|---|---|---|
| TIME_WAIT 总数 | node_exporter/ss 采集 | 绝对值 > 本地端口数 80% 或短期翻倍 |
| CLOSE_WAIT 总数 | 同上 | 持续增长超过 5 分钟且 > 1000 告警 |
| 单进程 FD 使用率 | process exporter / /proc 采集 | > 80% 告警,> 95% 紧急 |
| 新连接失败数 | connect 错误日志/内核计数器 | 出现 Cannot assign requested address 即告警 |
| 连接耗时和线程池队列 | 业务埋点 | P99 显著上升时联动连接状态数据 |
CLOSE_WAIT 这类问题还有个特点:重启能临时解决,但下次还会复现。所以绝不能靠人肉看,一定要有趋势性告警,否则业务挂了你还在查历史日志。
预防上,除了前面说的代码规范,还可以在 CI 里加静态扫描规则,比如网络工具类必须使用自动关闭模式,禁止手动创建连接而不归池。线上发布时也可以做连接状态巡检,发布后 10 分钟自动抓一次ss -s和 FD 数,跟基线对比,异常直接回滚。这套流程我在团队里跑了一段时间,效果是明显的——连接相关的线上事故基本归零。
6. 个人经验与最终建议
踩过 TIME_WAIT 和 CLOSE_WAIT 两个大坑之后,我最深的体会是:遇到这类问题,心态上一定要稳住,切忌看到连接数高就想去“优化内核”。大多数情况下,TIME_WAIT 只是表象,短连接策略不改、连接池不建,调什么参数都是饮鸩止渴;CLOSE_WAIT 则是代码质量问题的投影,内核帮不了你。
实际操作中,我一般按“看数量、分状态、查进程、断方向、改策略”这五步走。看数量用ss -s,分状态用状态统计命令,查进程用-p参数,方向判断则是对照本文章节里说的两类原因清单。整个过程通常半小时内就能锁定根因,剩下的就是改代码或调优参数并验证。
最后分享一个具体的小技巧:如果是偶发性的连接异常,又不想打扰线上服务,可以用tcpdump -i eth0 'tcp[tcpflags] & (tcp-fin|tcp-rst) != 0' -c 1000抓一小段时间的 FIN/RST 包,配合时间戳分析是先收到 FIN 还是后收到 FIN,能很快判断出是哪一端主动发起的断开。大多数连接关闭问题,靠这个抓包视角就能看得明明白白。希望这份沉淀下来的排查思路,能帮你少走一些弯路。