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 天
避坑清单
- fd 限制有系统级/用户级/systemd 三层,systemd 服务必须配 LimitNOFILE
- 验证 ulimit 看 /proc/PID/limits,别只信 ulimit -n
- tcp_tw_recycle 已被内核移除,NAT 下丢包,千万别抄老配置
- TIME_WAIT 是主动关连方产生的,治本靠连接池长连接复用
- CLOSE_WAIT 堆积 100% 是代码 bug,本端漏了 close
- 所有连接/流/资源用 try-with-resources,杜绝异常路径泄漏
- 连接池 maxLifetime 要小于下游的 idle 超时,避免用到死连接
- somaxconn 调大后,应用层 listen backlog 也要同步调大
- 有 NAT/iptables 时关注 conntrack 表,满了会静默丢包
- 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