线上一个 Java HTTP 网关运行 8 小时后接口开始大量超时,netstat 看到几万个 CLOSE_WAIT 状态的连接。重启就好,过几小时又来。这次把 CLOSE_WAIT 的根因和五种实战排查方法整理清楚,有同样问题的同学不用再瞎猜。
从 TCP 状态机说起
关键点:CLOSE_WAIT 是本端被动关闭后,等应用代码调用 close() 的状态。CLOSE_WAIT 堆积 = 应用代码没正确关连接。
诊断步骤 1:确认 CLOSE_WAIT 数量
# 总数
$ ss -tan state close-wait | wc -l
12453
# 按对端分组
$ ss -tan state close-wait | awk '{print $4}' | sort | uniq -c | sort -rn | head
8500 10.0.0.99:6379 ← Redis
2000 10.0.0.50:3306 ← MySQL
1900 10.0.0.30:8080 ← 内部服务
50 api.example.com:443
# 按进程
$ ss -tanp state close-wait | grep -oP 'pid=\K\d+' | sort | uniq -c | sort -rn
12000 12345 ← 主要是 PID 12345 这个进程
# 旧版本 netstat
$ netstat -tnp | grep CLOSE_WAIT | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn
诊断步骤 2:看 fd 实际指向
# 拿到泄漏进程的 PID
$ pid=12345
$ ls /proc/$pid/fd | wc -l
12678
# 列出所有 socket fd 对应的连接
$ lsof -p $pid -nP | grep CLOSE_WAIT | head
java 12345 app 324u IPv4 98765 0t0 TCP 10.0.0.5:54321->10.0.0.99:6379 (CLOSE_WAIT)
java 12345 app 325u IPv4 98766 0t0 TCP 10.0.0.5:54322->10.0.0.99:6379 (CLOSE_WAIT)
...
# 看哪个对端连接没关
$ lsof -p $pid -nP | grep CLOSE_WAIT | awk '{print $NF}' | sort | uniq -c
8500 (CLOSE_WAIT)
# 几乎全是 Redis 的
诊断步骤 3:Java 应用层定位
# arthas 看代码哪里在创建 Socket
$ ./arthas-boot
[arthas@12345]$ watch java.net.Socket <init> '{thread.getName(), ognl.MemoryUtil.toReferenceString(args)}' -n 50
# 看具体类的实例数
[arthas@12345]$ ognl '@java.lang.Thread@currentThread()'
# heapdump
$ jcmd 12345 GC.heap_dump /tmp/heap.bin
# MAT 打开,搜 java.net.SocksSocketImpl 实例,看 incoming references
# 一般会看到一个 List 或 Map 被业务代码持有,但 close 没调
# JFR 看 Socket 创建栈
$ jcmd 12345 JFR.start name=r duration=60s filename=/tmp/r.jfr settings=profile
# 等 60 秒
$ jfr print --events jdk.SocketRead,jdk.SocketWrite /tmp/r.jfr | head
真实案例 1:HTTP Client 没 close
// 错:Apache HttpClient 4.x 用完不 close,响应实体没消费
public String fetch(String url) {
CloseableHttpClient client = HttpClients.createDefault();
HttpGet get = new HttpGet(url);
CloseableHttpResponse resp = client.execute(get); // ← 这里申请了连接
String body = EntityUtils.toString(resp.getEntity());
return body;
// 没 resp.close(),连接不会回连接池,变成 CLOSE_WAIT
}
// 对:try-with-resources
public String fetch(String url) {
HttpGet get = new HttpGet(url);
try (CloseableHttpResponse resp = client.execute(get)) {
return EntityUtils.toString(resp.getEntity());
}
}
// 推荐:用连接池 + 强制清理
PoolingHttpClientConnectionManager pool = new PoolingHttpClientConnectionManager();
pool.setMaxTotal(200);
pool.setDefaultMaxPerRoute(50);
CloseableHttpClient client = HttpClients.custom()
.setConnectionManager(pool)
.evictExpiredConnections()
.evictIdleConnections(30, TimeUnit.SECONDS) // 30 秒空闲连接清理
.build();
真实案例 2:Jedis 没 returnResource
// 错(Jedis 旧版 API)
Jedis jedis = jedisPool.getResource();
String value = jedis.get("key");
return value;
// 忘了 jedis.close(),连接没回池,过段时间被 Redis 主动 FIN → CLOSE_WAIT
// 对(try-with-resources,Jedis 实现了 AutoCloseable)
try (Jedis jedis = jedisPool.getResource()) {
return jedis.get("key");
}
// 配置 Jedis 池
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(50);
config.setMaxIdle(20);
config.setMinIdle(5);
config.setMaxWaitMillis(2000);
config.setTestOnBorrow(false);
config.setTestOnReturn(false);
config.setTestWhileIdle(true);
config.setMinEvictableIdleTimeMillis(30000); // 空闲 30 秒可被驱逐
config.setTimeBetweenEvictionRunsMillis(10000); // 10 秒扫一次
config.setNumTestsPerEvictionRun(-1); // 每次扫所有
真实案例 3:Tomcat 长连接 keepalive 配置错
现象:CLOSE_WAIT 都是入站连接(从浏览器 / 网关进来的)
server.tomcat.connection-timeout=60000 # 连接空闲超时,默认 20 秒
server.tomcat.keep-alive-timeout=15000 # keepalive 复用超时,默认 = connection-timeout
server.tomcat.max-keep-alive-requests=100 # 一个连接最多复用 100 次
# 配置错的常见症状:
# Tomcat 主动断开了,但应用层没正确响应(比如 chunked encoding 流没关)
# 业务代码用了 InputStream 没 close,Tomcat 等不到 → 关连接 → CLOSE_WAIT
真实案例 4:Netty pipeline 异常没传播
// Netty channelInactive 必须确保资源清理
public class MyHandler extends ChannelInboundHandlerAdapter {
private FileChannel file;
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
// 处理逻辑,可能抛异常
process(msg);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
log.error("error", cause);
ctx.close(); // ← 必须有这个,异常时关 channel
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
if (file != null) file.close();
super.channelInactive(ctx);
}
}
诊断步骤 4:tcpdump 抓包
# 抓两端的 FIN/ACK 包,看谁先发 FIN
$ tcpdump -i any -nn -A 'tcp port 6379 and (tcp[tcpflags] & (tcp-fin|tcp-rst) != 0)' -w /tmp/fin.pcap
# 用 wireshark 打开,过滤 tcp.flags.fin == 1
# 时间线:
# 10:00:01.123 Redis(99) → App(5) FIN, ACK ← Redis 先发 FIN
# 10:00:01.124 App(5) → Redis(99) ACK ← 自动 ACK
# (等等等)
# 10:05:30.000 Redis(99) → App(5) RST ← Redis 等不及超时 RST 了
# 期间 App 这边 socket 一直在 CLOSE_WAIT
# 结论:Redis 主动关连接,应用没调 close
# 99% 是连接池配置或 close 没调
诊断步骤 5:bpftrace 跟踪 close 调用
# 看进程的 close 系统调用次数 vs 创建 socket 次数
$ bpftrace -e '
tracepoint:syscalls:sys_enter_socket /pid == 12345/ { @socket[comm] = count(); }
tracepoint:syscalls:sys_enter_close /pid == 12345/ { @close[comm] = count(); }
interval:s:30 { print(@socket); print(@close); clear(@socket); clear(@close); }
'
# 输出:
@socket[java]: 5023
@close[java]: 1234
# socket 创建 5023 次,但 close 只调了 1234 次,差 4000 个 → 泄漏
SO_REUSEADDR / SO_LINGER 的误解
很多人遇到 CLOSE_WAIT 第一反应是设 SO_REUSEADDR 或调 net.ipv4.tcp_*
❌ 这些都不解决 CLOSE_WAIT
✅ 它们解决 TIME_WAIT(本端主动关连接后的状态)
SO_REUSEADDR 允许复用 TIME_WAIT 的地址,不能消 CLOSE_WAIT
SO_LINGER=0 关连接时不走 TIME_WAIT,直接 RST(危险,可能丢数据)
tcp_tw_reuse 允许复用 TIME_WAIT 的 TCB,只对客户端有用
tcp_fin_timeout 影响 FIN_WAIT_2 超时,对 CLOSE_WAIT 没用
CLOSE_WAIT 的根治办法只有一个:让应用代码正确 close 连接
临时止血
# 1. 强制关闭进程的 CLOSE_WAIT 连接(救命用)
$ ss -K dst 10.0.0.99 dport = 6379 state close-wait
# 不是所有内核版本都支持 ss -K
# 也可以用 tcpkill,但生产环境慎用
# 2. 调小 keepalive 超时,让对端早点 RST
# /etc/sysctl.conf
net.ipv4.tcp_keepalive_time = 60 # 60 秒后开始探测(默认 7200)
net.ipv4.tcp_keepalive_intvl = 10
net.ipv4.tcp_keepalive_probes = 3
# 但前提是应用打开了 SO_KEEPALIVE
# 3. JVM 重启脚本
$ kill -9 $(cat /var/run/app.pid)
$ systemctl start app
# 重启能临时解决,但不解决根因
预防
// 1. 所有 IO 资源用 try-with-resources
try (Connection conn = ds.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
// ...
}
// 2. 连接池配置 leak detection
HikariConfig cfg = new HikariConfig();
cfg.setLeakDetectionThreshold(60_000); // 借出 60 秒没还日志告警
// 3. SpotBugs 静态检查
// pom.xml 加 spotbugs-maven-plugin
// 规则 OS_OPEN_STREAM 检测未关 InputStream
// 4. 健康检查里加 CLOSE_WAIT 计数
@GetMapping("/health")
public HealthStatus health() {
long closeWait = countCloseWait();
if (closeWait > 1000) {
return HealthStatus.unhealthy("close_wait too high: " + closeWait);
}
return HealthStatus.ok();
}
private long countCloseWait() throws IOException {
Process p = Runtime.getRuntime().exec("sh -c \"ss -tan state close-wait | wc -l\"");
return Long.parseLong(new BufferedReader(new InputStreamReader(p.getInputStream())).readLine().trim());
}
监控指标
# node_exporter 自带 netstat 指标
- alert: TcpCloseWaitHigh
expr: node_netstat_Tcp_CurrEstab - node_netstat_Tcp_PassiveOpens > 1000
for: 5m
annotations:
summary: '节点 {{ $labels.instance }} CLOSE_WAIT 数量异常'
# 自定义 exporter
- name: app_socket_close_wait_count
type: gauge
help: '当前进程持有的 CLOSE_WAIT 连接数'
# 告警条件
- alert: AppCloseWaitGrowing
expr: rate(app_socket_close_wait_count[10m]) > 5
annotations:
summary: 'CLOSE_WAIT 持续增长,每分钟新增 > 5'
checklist
- 所有 HTTP / DB / Redis 调用必须 try-with-resources
- 连接池必须设 leak detection 阈值
- Netty handler 必须实现 exceptionCaught 调 ctx.close()
- Tomcat keepalive 超时配合下游服务的超时
- 健康检查包含 CLOSE_WAIT 数量上限
- Prometheus 监控 CLOSE_WAIT 数量 + 增长率
- SpotBugs / FindBugs 加未关闭资源检测
- 压测必须包含"长时间运行 + 突发关连接"场景
CLOSE_WAIT 排查的核心是:这是应用层 bug,不是网络问题。所有的内核参数调优都是隔靴搔痒,真正的解法是让应用代码正确 close。这套排查方法在我们运维过的几百个服务上用过,基本能在 10 分钟内定位到泄漏代码。
—— 别看了 · 2026