线上 TCP CLOSE_WAIT 堆积排查实录:5 个方法定位到应用层 bug

网关 8 小时后 CLOSE_WAIT 几万个、接口大量超时。本文讲透 TCP 状态机 + 5 种诊断方法(ss/lsof/arthas/tcpdump/bpftrace)+ Apache HttpClient / Jedis / Tomcat / Netty 4 个真实泄漏案例 + 内核参数误解辟谣 + 监控告警 + 预防 checklist。

线上一个 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

  1. 所有 HTTP / DB / Redis 调用必须 try-with-resources
  2. 连接池必须设 leak detection 阈值
  3. Netty handler 必须实现 exceptionCaught 调 ctx.close()
  4. Tomcat keepalive 超时配合下游服务的超时
  5. 健康检查包含 CLOSE_WAIT 数量上限
  6. Prometheus 监控 CLOSE_WAIT 数量 + 增长率
  7. SpotBugs / FindBugs 加未关闭资源检测
  8. 压测必须包含"长时间运行 + 突发关连接"场景

CLOSE_WAIT 排查的核心是:这是应用层 bug,不是网络问题。所有的内核参数调优都是隔靴搔痒,真正的解法是让应用代码正确 close。这套排查方法在我们运维过的几百个服务上用过,基本能在 10 分钟内定位到泄漏代码。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

线上服务周期性 Too many open files:fd 泄漏完整排查实录

2026-5-19 11:37:15

技术教程

大促网关 50w QPS 雪崩复盘:多维度限流 + Sentinel 系统保护实战

2026-5-19 11:41:48

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索