netstat一堆CLOSE_WAIT和TIME_WAIT,哪个该紧张?怎么处理?
本文目录
先说结论
CLOSE_WAIT 危险,是'本端应用没调 close()'的症状,100% 是 bug。堆积会耗尽 fd,最终进程挂掉。看到 CLOSE_WAIT 长期不降必须查应用代码。
TIME_WAIT 基本无害,是主动关闭方的必经状态,2MSL(一般 60 秒)后自动消失。几万个 TIME_WAIT 在高并发短连接服务上是正常的,不用特别处理。需要优化时开 `tcp_tw_reuse`,但绝对不要开 `tcp_tw_recycle`(会和 NAT 冲突,4.12 内核已删除)。
区分方向很重要:CLOSE_WAIT 在'收到对方 FIN、自己还没发 FIN'的一端出现;TIME_WAIT 在主动调 close() 的一端出现。记住这两句话就能快速判断问题出在哪一方。
先把这段讲顺;面试官追问时,再展开下面的原理和细节。
详细讲解
这是一道考'TCP 状态机'的经典题,也是线上踩坑最多的一类。很多人背过 TCP 三次握手,但一问 '四次挥手里 CLOSE_WAIT 和 TIME_WAIT 分别出现在哪一方' 就答不上来。更进一步:一个 Spark driver 上出现 5000 多个 CLOSE_WAIT 该慌还是不该慌?TIME_WAIT 几万个是不是正常?tcp_tw_reuse 和 tcp_tw_recycle 为什么一个能开一个千万别开?能把这些讲清楚的,面试官基本就认定你是真懂 TCP 的。
业务场景:Spark driver 上 CLOSE_WAIT 堆积到 5000+
某出海硬件品牌有一次 Spark 批作业运行几小时后突然挂掉,报 'Too many open files'。第一反应是 ulimit 不够,把 `nofile` 从 65535 调到 262144,重跑还是挂,只是撑得久一点。再次挂掉时登上去看:
# 看 driver 进程的 fd 数
ls /proc/$(pgrep -f 'spark.*driver')/fd | wc -l
# 201245 <-- 20 万个 fd,快爆了
# fd 都是什么类型
ls -l /proc/$(pgrep -f 'spark.*driver')/fd | awk '{print $NF}' | awk -F: '{print $1}' | sort | uniq -c | sort -rn
# 198234 socket
# 2800 /data/logs/...
# 123 /tmp/...
# socket 占了 99%,继续看状态
ss -tnp state close-wait | grep $(pgrep -f 'spark.*driver') | wc -l
# 5012 <-- 5000 个 CLOSE_WAIT 但还在堆
# 都连谁
ss -tnp state close-wait | grep $(pgrep -f 'spark.*driver') | awk '{print $4, $5}' | sort | uniq -c | sort -rn | head
# 4890 0.0.0.0:45xx api.internal.xxx:443
# 几乎全是连一个内部 HTTP API 的
定位链路很清晰:driver 大量连接内部一个 HTTP 服务,这些连接被对方主动关闭了(对方发 FIN),但 driver 这边 app 层没调 close(),所以卡在 CLOSE_WAIT 状态,fd 不释放,直到爆掉。
查业务代码:driver 里有一段代码用 OkHttp 调内部 API,但同事没复用 OkHttpClient 实例,每次请求都 new 一个,响应处理完也没 close body。OkHttp 的 response body 如果不 close,底层 socket 永远不释放。fix 就两行代码:①OkHttpClient 用单例;② 每次请求 try-with-resources 或 `response.close()`。上线之后 CLOSE_WAIT 再没超过 10 个。
手段一:TCP 状态机复习(挥手那一半)
面试必问:'CLOSE_WAIT 和 TIME_WAIT 分别出现在四次挥手的哪一步?' 用图讲不清楚,直接用 ss 命令验证。
# TCP 状态关键节点(关闭过程):
#
# 主动方 (A) 被动方 (B)
# ESTABLISHED ESTABLISHED
# |
# | close() → 发 FIN
# v
# FIN_WAIT_1
# ← ACK CLOSE_WAIT <-- B 收到 FIN 后状态
# FIN_WAIT_2
# |
# | close() → 发 FIN
# v
# ← FIN LAST_ACK
# | → ACK
# v
# TIME_WAIT <-- A 等 2MSL(60s)
# |
# v
# CLOSED CLOSED
# 关键结论:
# 1. CLOSE_WAIT 在被动关闭方(没调 close 的那端)
# 2. TIME_WAIT 在主动关闭方
# 3. CLOSE_WAIT 堆积 = 被动方应用忘了调 close
# 4. TIME_WAIT 堆积 = 大量短连接(正常现象)
# 验证:起一个 TCP server 和 client,中间人为让 server 不 close
# server 侧会看到 CLOSE_WAIT
手段二:ss / netstat 看状态分布
第一步永远是看全局分布,别上来就盯单个连接。
# TCP 状态分布
ss -tan | awk '{print $1}' | sort | uniq -c
# 8 LISTEN
# 120 ESTAB
# 45 TIME-WAIT
# 12 CLOSE-WAIT
# 和 netstat 的对比
netstat -tan | awk '{print $6}' | sort | uniq -c
# ESTABLISHED, TIME_WAIT, CLOSE_WAIT, LISTEN, ...
# 只看 CLOSE_WAIT
ss -tn state close-wait
# 按进程聚合 CLOSE_WAIT 数量
ss -tnp state close-wait | awk -F'"' '{print $2}' | sort | uniq -c | sort -rn
# 按远端地址聚合(找是连哪个服务出的问题)
ss -tn state close-wait | awk '{print $5}' | awk -F: '{print $1}' | sort | uniq -c | sort -rn
# TIME_WAIT 类似
ss -tn state time-wait | wc -l
# 实时监控
watch -n 2 "ss -tan | awk '{print \$1}' | sort | uniq -c"
手段三:CLOSE_WAIT 的排查和修复
CLOSE_WAIT 是'bug 型故障',必须从应用层修,调内核参数没用。
# 1. 确认是哪个进程
ss -tnp state close-wait | head
# 2. 看这个进程的 fd 数
ls /proc/<pid>/fd | wc -l
# 3. 看 fd 的 socket 都连谁(可能是数据库连接池泄漏、HTTP 没 close)
ls -l /proc/<pid>/fd | grep socket | wc -l
# 4. 用 lsof 把 socket fd 和远端对应起来
lsof -p <pid> -i | grep CLOSE_WAIT | head
# 5. 结合应用框架特征判断
# - 大量 CLOSE_WAIT 连同一个数据库端口 = 连接池泄漏
# - 大量 CLOSE_WAIT 连 HTTP API = 响应没 close
# - 大量 CLOSE_WAIT 连 Kafka broker = 消费者实例没 close
# 常见应用层 bug 模式:
# Java:
# - InputStream/OutputStream 没 close(try-with-resources)
# - HttpURLConnection 用完没 disconnect
# - OkHttp response.body 没 close
# - JDBC Connection 没 close(用连接池也可能)
# - 每次请求 new 一个 HttpClient 而不是共享
# 应急处理:重启应用(治标不治本)
# 根治:修代码
重点:CLOSE_WAIT 堆积内核一侧没什么可以调的。你调 `tcp_keepalive_time` 或者 `tcp_fin_timeout` 都影响不了 CLOSE_WAIT,因为那些参数管的是 FIN_WAIT 和 keepalive,CLOSE_WAIT 只能应用层调 close() 才能转到 LAST_ACK。
手段四:TIME_WAIT 的优化
TIME_WAIT 不是 bug,是 TCP 设计机制:主动关闭方要等 2MSL 确保最后一个 ACK 到达对方,同时防止旧连接的乱序包污染新连接。但在高并发短连接场景,TIME_WAIT 能堆到几万个,占用 fd 和端口。
# 看系统参数
sysctl net.ipv4.tcp_fin_timeout
sysctl net.ipv4.tcp_tw_reuse
sysctl net.ipv4.tcp_tw_recycle # 内核 4.12+ 已删除
sysctl net.ipv4.tcp_max_tw_buckets
# 默认值:
# tcp_fin_timeout = 60 (FIN_WAIT_2 超时时间,不是 TIME_WAIT)
# tcp_tw_reuse = 0 (作为客户端复用 TIME_WAIT 连接)
# tcp_tw_recycle = 0 (已删除,不要开)
# tcp_max_tw_buckets = 180000 (TIME_WAIT 上限)
# 优化方案(按推荐度):
# 1. 应用层:用连接池,不要短连接
# 这是最正道的方式。HTTP 用 keep-alive,JDBC/Redis/MySQL 用连接池。
# 2. 开 tcp_tw_reuse(客户端 out)
sysctl -w net.ipv4.tcp_tw_reuse=1
# 允许将 TIME_WAIT 的 socket 重新用于新的对外连接,基于时间戳
# 需要同时开 tcp_timestamps(默认已开)
# 3. 加大本地端口范围(客户端)
sysctl -w net.ipv4.ip_local_port_range='1024 65535'
# 默认是 32768-60999,客户端高并发会把端口用光
# 4. 加大 tcp_max_tw_buckets
sysctl -w net.ipv4.tcp_max_tw_buckets=360000
# 超过上限的 TIME_WAIT 会被强制关闭,可能导致连接不稳
# 不要做的事:
# 千万不要开 tcp_tw_recycle(旧内核才有)
# 它在 NAT 环境下会把正常连接当成回包丢掉,导致'连不上'的诡异现象
重点:`tcp_tw_recycle` 是 Linux 的黑历史。它为了'提前回收 TIME_WAIT'启用了 PAWS 时间戳校验,在 NAT 环境下(客户端集群共享一个出口 IP)会因为时间戳不匹配把合法包丢掉,导致 '有的请求能过有的过不了'。4.12 内核已经彻底删除。面试时能点出这个坑就是大加分项。
CLOSE_WAIT vs TIME_WAIT 对比
| 维度 | CLOSE_WAIT | TIME_WAIT |
|---|---|---|
| 出现在哪一方 | 被动关闭方 | 主动关闭方 |
| 触发条件 | 收到对方 FIN | 自己 close() 后 |
| 持续时间 | 直到 app 调 close() | 2 × MSL (60s) |
| 是不是 bug | 100% 是 bug | 正常现象 |
| 应急处理 | 重启应用 | 基本不用处理 |
| 根治方案 | 修代码(close) | 用连接池/长连接 |
| 内核参数调整 | 没用 | tcp_tw_reuse |
面试高频追问
追问一:CLOSE_WAIT 堆积在服务端还是客户端都可能出现,区别是什么?
CLOSE_WAIT 只看谁被'被动关闭'——也就是谁收到了对方的 FIN。客户端主动断开(比如用户关掉浏览器)会让服务端收到 FIN,这时如果服务端 app 没调 close(),服务端就有 CLOSE_WAIT;反过来服务端主动断开(比如连接池踢旧连接)会让客户端收到 FIN,客户端没 close 就有 CLOSE_WAIT。典型场景:① 服务端处理完请求没调 response.close()(Java Servlet、Netty 等);② 客户端连接池实现有 bug,拿到已被服务端关闭的连接还没 return pool(连接池健康检查能避免);③ 消费方(如 Kafka consumer)对长连接没做 heartbeat 检查。CLOSE_WAIT 在哪一方出现,就去查哪一方的应用代码,和 TCP 主动/被动身份无关。
追问二:TIME_WAIT 状态为什么要 2MSL?2MSL 具体是多少?
2MSL 要做两件事:① 确保最后一个 ACK 能到达对方;② 防止旧连接的 delay 包(比如网络重传的包)污染后续新建的同四元组连接。MSL(Maximum Segment Lifetime)是一个 TCP 段在网络中的最大存活时间,RFC 793 规定 2 分钟,实际 Linux 内核硬编码是 60 秒(`TCP_TIMEWAIT_LEN`),所以 2MSL 就是 60 秒。这个值在 Linux 里不能通过 sysctl 改(除非改内核编译),`tcp_fin_timeout` 是 FIN_WAIT_2 的超时,不是 TIME_WAIT 的。想要改 TIME_WAIT 时长只能改内核源码重编。
追问三:Nginx 反向代理上游有很多 TIME_WAIT,怎么优化?
典型场景:Nginx 作为反向代理短连接转发到上游,默认每次请求都是短连接,连完上游立刻关,所以 Nginx 侧作为主动关闭方堆 TIME_WAIT。三步优化:① `proxy_http_version 1.1; proxy_set_header Connection "";`,把上游改成 HTTP/1.1 长连接;② `upstream backend { server ...; keepalive 64; }`,配置 keepalive 连接池,复用上游连接;③ `ip_local_port_range` 加大。这一套下来 Nginx 到上游的 TIME_WAIT 从几万降到几百,且没有副作用。这题面试官喜欢问,因为它把 TCP 状态、Nginx 配置、连接池概念串起来了。
实战踩坑:tcp_tw_recycle 引发的 NAT 惨案
这个坑不是我踩的,是早年某个老项目的同事踩的,但印象太深刻,每次聊 TIME_WAIT 都要提一下。
# 场景:
# 某公司内部一个 Web 服务,有段时间 TIME_WAIT 堆到几十万
# 运维同学看了一篇博客,学到开 tcp_tw_recycle 可以加速回收
sysctl -w net.ipv4.tcp_tw_recycle=1
# 同时开了 tcp_timestamps(默认就是开的)
# 开完当天一切正常,TIME_WAIT 降到几千
# 三天后:
# 客服群开始报 '有时候登录不上',但工程师自己测都没问题
# QA 在办公网测也没问题
# 但大量用户(特别是移动端)访问会偶发 Connection reset
# 排查:
tcpdump -i any -nn 'host <用户IP>'
# 看到三次握手的 SYN 包直接被服务端 drop 掉
# 内核日志:
dmesg | grep -i 'passive connections reject'
# 找到罪魁祸首
# 根因:
# 用户通过运营商 NAT 出口,很多用户共享一个 IP
# tcp_tw_recycle 开启后,内核会校验同一个远端 IP 的时间戳单调递增
# 但 NAT 后面每个用户的时间戳各自算的,不是全局一致
# 用户 A 刚连完,用户 B 立刻连(从服务端看是同一个 IP),但时间戳可能倒退
# 内核认为是老包,直接丢
修复与教训:`sysctl -w net.ipv4.tcp_tw_recycle=0` 立刻恢复。事后总结:① 别看博客抄命令上生产;② 涉及 TCP 内核参数必须先懂原理再动手;③ Linux 4.12 之后官方删掉了这个参数是有原因的。正确的 TIME_WAIT 优化永远是 '应用层改连接池 + tcp_tw_reuse',永远不要用 tcp_tw_recycle。这个故事我在面试里讲了好几次,每次面试官都兴致勃勃地让我展开,因为它同时覆盖了 TCP 状态机、时间戳机制、NAT、内核参数多个点。
总结
CLOSE_WAIT 和 TIME_WAIT 的区别是 TCP 面试的试金石。记住三句话:① CLOSE_WAIT 在被动关闭方、是 bug、必须改代码;② TIME_WAIT 在主动关闭方、是正常机制、用连接池和 tcp_tw_reuse 优化;③ 绝对不要开 tcp_tw_recycle(NAT 杀手,4.12 已删除)。能顺着四次挥手讲出这三点,再举一个踩坑案例,这道题稳过。
下一篇聊磁盘 IO 的经典题:iostat 里 %util 100% 是不是磁盘饱和了?怎么定位 IO 慢在哪一步?
你们线上 CLOSE_WAIT 最高纪录是多少?评论区聊聊~
关注「Wood聊大数据」| 9年大数据老兵,聊技术、聊职场、聊成长