生产服务器 Too many open files:文件描述符与连接泄漏排查实录

API 网关周一早高峰刷屏 Too many open files,机器上堆了 5.2w 个 TIME_WAIT、3.8w 个 CLOSE_WAIT,fd 被占满。一周治理:ulimit 三层调优 + TIME_WAIT 内核参数 + 连接池长连接复用 + somaxconn 队列调优 + arthas 定位 close 泄漏。4 实例扛住 3 倍流量再没复发。

2024 年某个周一早高峰,我们的 API 网关突然大面积报错,日志里刷屏 — "Too many open files",同时下游数据库连接也开始超时。盯监控发现机器上 TIME_WAIT 状态的连接堆了 5 万多个,文件描述符被占满。紧急扩容压住后,投了一周做 Linux 连接与文件描述符治理,从 ulimit、TIME_WAIT、连接池到内核参数全面排查,之后扛住了 3 倍流量也再没复发。本文复盘文件描述符、TCP 连接状态、连接泄漏、内核网络参数调优的完整实战。

问题背景

服务:API 网关(Java),8C16G,4 实例
事故现象:
- 日志刷屏 java.net.SocketException: Too many open files
- accept 新连接失败,大量 502
- 下游 MySQL/Redis 连接超时

现场排查:
# 1. 看进程打开的 fd 数
$ ls /proc/$(pgrep -f gateway)/fd | wc -l
65530                        # 接近上限了

# 2. 看 ulimit 限制
$ cat /proc/$(pgrep -f gateway)/limits | grep "open files"
Max open files   65536   65536

# 3. fd 都是什么类型
$ ls -l /proc/PID/fd | awk '{print $NF}' | grep -oP 'socket|pipe|\.' | sort | uniq -c
  61234 socket               ← 6w 个 socket,绝大多数
    230 pipe

# 4. 看 TCP 连接状态分布
$ ss -ant | awk '{print $1}' | sort | uniq -c
  52341 TIME-WAIT            ← 5.2w 个 TIME_WAIT,罪魁
   8932 ESTAB
    412 SYN-SENT

# 5. TIME_WAIT 都连到哪
$ ss -ant state time-wait | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn
  31200 10.0.1.20            ← 下游服务 A
  18900 10.0.1.21            ← 下游服务 B

根因:
1. 网关调下游每次新建连接,用完就关 → 主动关闭方产生 TIME_WAIT
2. TIME_WAIT 默认存活 60s,高 QPS 下瞬间堆积几万
3. 每个 TIME_WAIT 占一个 fd + 一个端口
4. ulimit 65536 在高并发网关场景偏小
5. 没用连接池,也没开内核的 TIME_WAIT 复用

修复 1:文件描述符上限

# === 三个层面的 fd 限制,要一起调 ===

# 1. 系统级:所有进程能打开的 fd 总数
$ cat /proc/sys/fs/file-max
1000000
# 调整
$ sysctl -w fs.file-max=2000000
# 持久化:/etc/sysctl.conf
fs.file-max = 2000000
fs.nr_open = 2000000           # 单进程 fd 硬上限,ulimit 不能超过它

# 2. 用户级:limits.conf(对登录会话生效)
# /etc/security/limits.conf
*  soft  nofile  655360
*  hard  nofile  655360
root soft nofile 655360
root hard nofile 655360

# 3. systemd 服务:limits.conf 对 systemd 拉起的服务无效!必须单独配
# /etc/systemd/system/gateway.service
[Service]
LimitNOFILE=655360
# 改完 systemctl daemon-reload && systemctl restart gateway

# === 验证(一定要看进程实际生效值,别只看 ulimit -n)===
$ cat /proc/$(pgrep -f gateway)/limits | grep "open files"
Max open files   655360   655360      ← 生效了

# 容器环境:还要看容器运行时的 ulimit
# docker run --ulimit nofile=655360:655360 ...
# K8s 里通过节点 kubelet / 基础镜像配置

修复 2:TIME_WAIT 治理

# === 先理解 TIME_WAIT:主动关闭连接的一方,进入 TIME_WAIT ===
# 等待 2*MSL(Linux 写死 60s)后才释放,期间占用 fd + 端口
# 它的存在是为了:1.可靠关闭 2.防止旧连接的延迟包串到新连接
# 所以不能粗暴消灭,要"减少产生"+"加快回收"

# === 内核参数:加快 TIME_WAIT 回收 ===
# /etc/sysctl.conf

# 1. 开启 TIME_WAIT 重用(对 outbound 连接,客户端侧有效)
net.ipv4.tcp_tw_reuse = 1
# 允许将 TIME_WAIT 的端口重新用于新的 outbound 连接(基于时间戳,安全)

# 注意:net.ipv4.tcp_tw_recycle 在 Linux 4.12 已被移除!
# 它在 NAT 环境下会丢包,是著名大坑,别用、也别抄老博客的配置

# 2. 扩大本地端口范围(outbound 连接要消耗本地端口)
net.ipv4.ip_local_port_range = 1024 65535     # 默认 32768-60999,扩到 6w+

# 3. 缩短 FIN_WAIT2 超时
net.ipv4.tcp_fin_timeout = 15

# 4. TIME_WAIT 桶上限(超过就直接 RST,不进 TIME_WAIT)
net.ipv4.tcp_max_tw_buckets = 100000

# 应用
$ sysctl -p

# === 但治本是:别让网关频繁主动建连/关连 → 用长连接 + 连接池 ===
# tcp_tw_reuse 只是缓解,根因是"短连接"模式
# 谁主动关连接谁产生 TIME_WAIT —— 让连接复用,就没有 TIME_WAIT

修复 3:连接池与长连接

// 根治 TIME_WAIT:用连接池复用长连接,而不是每次请求新建/关闭

// === HTTP 客户端连接池(网关调下游)===
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
cm.setMaxTotal(2000);                       // 总连接数
cm.setDefaultMaxPerRoute(200);              // 每个目标地址最大连接数
cm.setValidateAfterInactivity(5000);        // 空闲 5s 后复用前先校验

CloseableHttpClient client = HttpClients.custom()
    .setConnectionManager(cm)
    .setKeepAliveStrategy((response, ctx) -> 60_000)   // keep-alive 60s
    .evictIdleConnections(TimeValue.ofSeconds(30))     // 定期清理空闲连接
    .evictExpiredConnections()
    .build();
// 复用连接 → 不频繁关闭 → TIME_WAIT 几乎消失

// === 数据库连接池(HikariCP)===
HikariConfig cfg = new HikariConfig();
cfg.setMaximumPoolSize(50);                 // 不是越大越好,= 核心数*2 + 磁盘数 经验值
cfg.setMinimumIdle(10);
cfg.setConnectionTimeout(3000);             // 拿连接最多等 3s
cfg.setIdleTimeout(600_000);                // 空闲 10min 回收
cfg.setMaxLifetime(1_800_000);              // 连接最长存活 30min(略小于 DB 的 wait_timeout)
cfg.setKeepaliveTime(120_000);              // 每 2min 探活,防连接被中间设备掐断
cfg.setLeakDetectionThreshold(60_000);      // 连接借出超 60s 未还 → 打印泄漏告警

// === Redis 连接池(Lettuce/Jedis)===
GenericObjectPoolConfig poolCfg = new GenericObjectPoolConfig();
poolCfg.setMaxTotal(100);
poolCfg.setMaxIdle(50);
poolCfg.setMinIdle(10);
poolCfg.setMaxWait(Duration.ofMillis(2000));
poolCfg.setTestWhileIdle(true);             // 空闲连接定期探活

// 连接池核心原则:
// 1. 池大小要压测定,不是越大越好(连接也是资源,DB 侧也有上限)
// 2. maxLifetime < 数据库/中间件的 idle 超时,避免用到已被对端关闭的死连接
// 3. 开启探活(keepalive/testWhileIdle),防 NAT/LB 静默断连
// 4. 开启泄漏检测(leakDetectionThreshold),及早发现忘记归还的连接

修复 4:内核网络参数调优

# /etc/sysctl.conf —— 高并发网关的网络栈调优

# === 连接队列 ===
# 全连接队列(accept queue):握手完成等 accept 的连接
net.core.somaxconn = 32768                  # 默认 128,高并发必调大
# 半连接队列(SYN queue)
net.ipv4.tcp_max_syn_backlog = 16384
# 注意:somaxconn 调大了,应用层 listen(backlog) 也要跟着调大才生效
# Java: new ServerSocket(port, backlog);  Nginx: listen ... backlog=32768;

# === 连接跟踪表(有 iptables/NAT 时)===
net.netfilter.nf_conntrack_max = 1000000    # conntrack 满了会丢包(报 table full)
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 30

# === TCP 缓冲区 ===
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216

# === TCP 行为 ===
net.ipv4.tcp_syncookies = 1                 # SYN flood 防护
net.ipv4.tcp_keepalive_time = 600           # keepalive 探测起始时间
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 3
net.ipv4.tcp_slow_start_after_idle = 0      # 空闲后不重置拥塞窗口(长连接友好)

# === 应用 ===
$ sysctl -p

# === 排查全连接队列溢出(连接被丢但没日志的元凶)===
$ ss -lnt
State   Recv-Q  Send-Q  Local Address:Port
LISTEN  129     128     0.0.0.0:8080         ← Recv-Q > Send-Q 说明队列溢出!
# Send-Q=128 是 backlog,Recv-Q=129 表示已溢出
$ netstat -s | grep -i "listen"
  4123 times the listen queue of a socket overflowed   ← 溢出计数,持续涨就是问题

修复 5:连接/fd 泄漏定位

# 调大 ulimit 只是争取时间,如果 fd 一直涨就是泄漏,必须找到源头

# === 1. 持续观察 fd 增长趋势 ===
$ while true; do
    echo "$(date +%T) fd=$(ls /proc/PID/fd | wc -l)"
    sleep 10
  done
# fd 数只涨不跌 → 确认泄漏

# === 2. 看 fd 具体是什么(找泄漏类型)===
$ ls -l /proc/PID/fd | awk '{print $NF}' | sort | uniq -c | sort -rn | head
  41203 socket:[...]              ← socket 泄漏
   1820 /app/logs/biz.log         ← 同一文件开了 1820 次?日志句柄泄漏!

# === 3. socket 泄漏:看连到哪、什么状态 ===
$ ss -anp | grep PID | awk '{print $1}' | sort | uniq -c
# 大量 CLOSE-WAIT → 对端关了连接,本端没 close() → 经典连接泄漏
$ ss -antp state close-wait | grep PID | wc -l
38201                            # 3.8w CLOSE_WAIT,本地代码漏 close
// CLOSE_WAIT 堆积 = 代码里资源没 close,这是 Java 最常见的 fd 泄漏

// 错误:异常时连接/流泄漏
// HttpResponse resp = client.execute(request);
// String body = EntityUtils.toString(resp.getEntity());  // 这行异常 → resp 没关
// resp.close();

// 正确:try-with-resources 保证一定关闭
try (CloseableHttpResponse resp = client.execute(request)) {
    return EntityUtils.toString(resp.getEntity());
}   // 无论正常还是异常,resp 一定被 close

// 数据库连接同理
try (Connection conn = dataSource.getConnection();
     PreparedStatement ps = conn.prepareStatement(sql);
     ResultSet rs = ps.executeQuery()) {
    // ...
}   // conn 归还连接池,ps/rs 释放

// === 用 jstack + arthas 定位泄漏代码 ===
// arthas 监控某个方法的连接对象创建
// $ watch com.xxx.HttpClient execute '{params,returnObj}' -x 2
// 或 stack 看哪个调用栈在创建连接
// $ stack org.apache.http.impl.client.CloseableHttpClient execute

// 排查 fd 泄漏的通用流程:
// 1. /proc/PID/fd 确认涨且不跌
// 2. 按类型分类,锁定 socket 还是文件
// 3. socket 看 TCP 状态:CLOSE_WAIT 多 = 本端漏 close
// 4. arthas/jstack 定位创建资源却没释放的代码
// 5. 统一改成 try-with-resources

修复 6:监控告警

# node_exporter + process_exporter,Prometheus 告警
groups:
- name: fd-and-conn
  rules:
  # 1. 进程 fd 使用率
  - alert: ProcessFdHigh
    expr: |
      process_open_fds / process_max_fds > 0.8
    for: 5m
    annotations:
      summary: "{{ $labels.instance }} fd 使用率 > 80%,检查连接泄漏"

  # 2. fd 持续增长(泄漏特征)
  - alert: ProcessFdLeaking
    expr: |
      deriv(process_open_fds[30m]) > 0
      and process_open_fds > 10000
    for: 30m
    annotations:
      summary: "{{ $labels.instance }} fd 持续 30min 单调增长,疑似泄漏"

  # 3. TIME_WAIT 过多
  - alert: TooManyTimeWait
    expr: node_sockstat_TCP_tw > 30000
    for: 5m
    annotations:
      summary: "{{ $labels.instance }} TIME_WAIT > 3w,检查短连接/连接池"

  # 4. CLOSE_WAIT 过多(代码漏 close)
  - alert: TooManyCloseWait
    expr: node_netstat_Tcp_CurrEstab and on() (node_sockstat_TCP_alloc > 50000)
    for: 5m
    annotations:
      summary: "{{ $labels.instance }} 连接数异常,排查 CLOSE_WAIT 泄漏"

  # 5. 全连接队列溢出
  - alert: ListenQueueOverflow
    expr: rate(node_netstat_TcpExt_ListenOverflows[5m]) > 0
    annotations:
      summary: "{{ $labels.instance }} accept 队列溢出,调大 somaxconn/backlog"

  # 6. conntrack 表使用率
  - alert: ConntrackHigh
    expr: node_nf_conntrack_entries / node_nf_conntrack_entries_limit > 0.8
    annotations:
      summary: "{{ $labels.instance }} conntrack 表 > 80%,满了会丢包"

优化效果

指标                    治理前          治理后
=============================================================
TIME_WAIT 数量          5.2w            < 800
CLOSE_WAIT 数量         3.8w(泄漏)     0
进程 fd 占用            65530/65536     ~9000/655360
ulimit nofile           65536           655360
下游调用方式            短连接新建/关闭  连接池长连接复用
accept 队列溢出         4123 次/天      0
Too many open files     周期性爆发      消失
单实例可承载 QPS        1.5w(就崩)     5w+ 稳定

成本与稳定性:
- 不再需要靠定时重启"清 fd"
- 同样 4 实例,扛住流量从 6w 涨到 18w(3 倍)
- 连接池复用后,下游 TCP 握手开销省掉,P99 -8ms
- fd 泄漏检测入监控,泄漏在萌芽期就告警

排查耗时:
- 现场定位 TIME_WAIT/CLOSE_WAIT:2 小时
- 连接池改造(HTTP/DB/Redis 三处):3 天
- 内核参数调优 + 压测验证:2 天
- arthas 定位并修复 2 处 close 泄漏:1 天

避坑清单

  1. fd 限制有系统级/用户级/systemd 三层,systemd 服务必须配 LimitNOFILE
  2. 验证 ulimit 看 /proc/PID/limits,别只信 ulimit -n
  3. tcp_tw_recycle 已被内核移除,NAT 下丢包,千万别抄老配置
  4. TIME_WAIT 是主动关连方产生的,治本靠连接池长连接复用
  5. CLOSE_WAIT 堆积 100% 是代码 bug,本端漏了 close
  6. 所有连接/流/资源用 try-with-resources,杜绝异常路径泄漏
  7. 连接池 maxLifetime 要小于下游的 idle 超时,避免用到死连接
  8. somaxconn 调大后,应用层 listen backlog 也要同步调大
  9. 有 NAT/iptables 时关注 conntrack 表,满了会静默丢包
  10. fd、TIME_WAIT、CLOSE_WAIT、队列溢出都要上监控,泄漏要趋势告警

总结

这次 Too many open files 事故表面是文件描述符不够,本质却是连接管理的全面失守。最大的认知改变是理解了 TIME_WAIT 和 CLOSE_WAIT 的本质区别:TIME_WAIT 出现在主动关闭连接的一方,它是协议设计的正常产物,堆积说明你在频繁地建连关连 —— 这不是 bug,是架构问题,解药是连接池;而 CLOSE_WAIT 出现在被动关闭的一方,它堆积一定是 bug,说明对端已经关了连接、你的代码却没有调用 close,解药是修代码。把这两者分清,排查方向就清晰了一半。最被低估的是连接池,很多人觉得"调下游嘛,new 个连接、用完关掉"天经地义,殊不知每一次主动关闭都在生产 TIME_WAIT,高 QPS 下几万个 TIME_WAIT 既占 fd 又占端口,而连接池让连接复用、不频繁关闭,从源头消灭了 TIME_WAIT,顺便还省掉了每次 TCP 握手的几毫秒。最容易踩的坑有两个:一是抄老博客打开 tcp_tw_recycle,这个参数在 NAT 环境会导致诡异丢包、并且早已被内核删除;二是 systemd 拉起的服务,/etc/security/limits.conf 对它根本不生效,必须在 service 文件里写 LimitNOFILE,无数人改了 limits.conf 重启服务发现 ulimit 没变就是栽在这里。最后,调大 ulimit 永远只是买时间,如果 fd 单调增长不回落,那就是泄漏,扩容和重启都治标不治本,老老实实用 arthas 找到那行没有 close 的代码,改成 try-with-resources,才算真正解决。

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

订单消息一会丢一会重:RocketMQ 消息可靠性与消费幂等实战

2026-5-20 12:11:49

技术教程

Redis 分布式锁踩了三个坑:超时、误删、主从切换丢锁实录

2026-5-20 12:18:20

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