Go 报错 too many open files 怎么解决?文件句柄泄漏排查定位与 ulimit、systemd LimitNOFILE 配置


报错现场

服务上线跑了几天,监控突然出现大量错误,日志里反复出现:

http: Accept error: accept tcp [::]:8080: accept4: too many open files; retrying in 5ms

与此同时,新连接建立失败、健康检查超时——这是 Linux 上最高频的 Go 线上故障之一:进程打开的文件描述符(fd)数量达到了系统上限。net/http 的 accept 循环遇到这种临时错误不会退出,而是按 5ms 起步指数退避重试(上限 1s),所以服务看起来「还在」,实际已经拒新连接了。本文按「确认上限 → 定位泄漏 → 修复代码 → 调大上限」四步给出完整排查路径。

第一步:确认当前 fd 上限和占用量

先拿三个数字:进程实际用了多少 fd、软限制是多少、硬限制是多少。

# 1. 进程当前打开的 fd 数量
ls /proc/<PID>/fd | wc -l
# 或
lsof -p <PID> | wc -l

# 2. 该进程的软/硬限制(Max open files 一行)
cat /proc/<PID>/limits | grep "open files"

# 3. 当前 shell 的默认限制(新进程从这里继承)
ulimit -n

如果第一项(已用 fd)长期贴着第二项(软限制)走,就是典型的句柄耗尽。系统级总开关 cat /proc/sys/fs/file-max 是整个机器所有进程的上限,一般远大于单进程限制,线上 99% 的情况是单进程 soft limit(常见默认 1024 或 4096)被打满,而不是系统级耗尽。

第二步:看 fd 都是什么,定位泄漏点

# 按 fd 类型统计分布
lsof -p <PID> | awk '{print $5}' | sort | uniq -c | sort -rn

典型分布对照表:

lsof 里 TYPE 列的分布 说明 最常见的泄漏源
大量 TCP / IPv4 / IPv6 socket 网络连接句柄堆积 HTTP 客户端 resp.Body 没关闭、下游连接无限建
大量 REG(普通文件) 文件句柄堆积 os.Open 后没有 Close,循环里反复打开
大量 FIFO / pipe 管道堆积 os/exec 创建了子进程但没回收
can't identify protocol 且数量巨大 半关闭的旧 socket TIME_WAIT 堆积(一般不占 fd,先排除前三种)

也可以用一段最小代码亲手复现耗尽过程(Linux 下几秒就会报错):

package main

import (
	"log"
	"os"
)

func main() {
	for i := 0; ; i++ {
		f, err := os.Open("/etc/hostname")
		if err != nil {
			log.Fatalf("opened %d files, err: %v", i, err) // too many open files
		}
		_ = f // 故意不 Close:fd 只增不减
	}
}

第三步:修复代码——三大高频泄漏源

3.1 HTTP 客户端:resp.Body 必须关闭

这是最常见的坑。http.Get / client.Do 返回的 resp.Body 不关闭,底层 TCP 连接就不会归还复用池,fd 只涨不回落:

// ❌ 错误:Body 不关闭,每个请求泄漏一个连接
resp, _ := http.Get("https://api.example.com/data")
data, _ := io.ReadAll(resp.Body)
_ = data

// ✅ 正确:defer 关闭,并读完剩余内容让连接复用
resp, err := http.Get("https://api.example.com/data")
if err != nil {
	log.Fatal(err)
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body) // 把 Body 读完,连接才会被复用池回收

只 Close 不读完 Body,连接同样进不了复用池(源码层面:未读完的 Body 关闭时会直接丢弃连接)。高频出站请求还应该显式调大每主机连接池,否则默认 MaxIdleConnsPerHost = 2,其余空闲连接照样被关掉重连,既耗 fd 又耗端口:

client := &http.Client{
	Transport: &http.Transport{
		MaxIdleConns:        100, // 全局空闲连接上限
		MaxIdleConnsPerHost: 20,  // 单主机空闲连接上限(默认仅 2)
		// MaxConnsPerHost:  50, // 可选:单主机总连接数上限,超了会阻塞等待
	},
}

3.2 文件句柄:defer 关闭,注意循环与早退

func readAll(path string) ([]byte, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close() // 无论后续哪条 return,都保证关闭
	return io.ReadAll(f)
}

循环内打开文件、error 分支提前 return 但没写 defer——是这类泄漏的经典组合。生产代码里可以用 gosec 或静态检查发现。

3.3 数据库与下游连接:设置池上限

用 database/sql 或 GORM 时,一定要给连接池设上限,否则突发流量会把 fd 顶满:

db, err := sql.Open("mysql", dsn)
if err != nil {
	log.Fatal(err)
}
db.SetMaxOpenConns(50)                  // 最大打开连接数
db.SetMaxIdleConns(10)                  // 最大空闲连接数
db.SetConnMaxLifetime(time.Hour)        // 连接最长存活时间

同时检查 Query 返回的 rows 是否全部 rows.Close() 了——和 HTTP Body 是同一类问题,排查方法也一样:看 lsof 里 Unix socket / TCP 到数据库端口的连接数是不是贴着 SetMaxOpenConns 走。

第四步:调大 ulimit——临时与永久两种姿势

临时生效(只对当前 shell 及其子进程有效,重启失效):

ulimit -n 65535

永久生效分两种场景:

  • 用 systemd 托管的服务(线上标准做法):在 unit 文件的 [Service] 段加一行,然后 systemctl daemon-reload && systemctl restart yourapp:
[Service]
LimitNOFILE=65535
  • 普通 shell / 非 systemd 启动的进程:编辑 /etc/security/limits.conf,追加:
*    soft    nofile    65535
*    hard    nofile    65535

注意:limits.conf 对 systemd 服务不生效(systemd 不经过 PAM 登录流程),所以线上服务务必用 LimitNOFILE,改完用 cat /proc/<PID>/limits 验证。

排查速查清单

  1. 报错先跑 ls /proc/<PID>/fd | wc -l 和 cat /proc/<PID>/limits | grep "open files",确认是贴着软限制。
  2. lsof -p <PID> | awk '{print $5}' | sort | uniq -c 看 fd 类型分布:socket 多查 HTTP Body / 下游连接,REG 多查文件关闭。
  3. 代码侧三件套:resp.Body.Close() + 读完、os.Open 配 defer、SetMaxOpenConns。
  4. 调大限制:systemd 用 LimitNOFILE=65535;普通进程用 limits.conf 或 ulimit -n。
  5. 改完重启服务,再跑一次 cat /proc/<PID>/limits 验证生效,并观察 fd 数量是否随流量回落。

相关阅读

版本说明

本文无第三方依赖,示例代码仅使用 Go 标准库(os、net/http、io、database/sql)。文中 Transport.MaxIdleConnsPerHost 默认值 2 及 MaxConnsPerHost 零值语义、net/http accept 循环的退避日志格式,均逐字对照 go1.26.0 src/net/http/transport.go 与 server.go 核实(2026-11-09 核对,官方源码:go.dev/src/net/http)。ulimit、limits.conf、LimitNOFILE 为 Linux 通用机制,与 Go 版本无关。

🛒 站长推荐(京东联盟)

以上链接含京东联盟推广代码;通过链接购买本站可能获得少量佣金,不影响你的购买价格。

📮 关注公众号「GO语言入门之路」
GO语言入门之路 公众号二维码

每周更新 Go + AI 应用开发实战:AI 面试官完整项目、MCP、Agent 工程化,完整源码随文章首发。

微信扫码,或搜索「GO语言入门之路」关注,第一时间获取系列更新与源码,不再错过任何一篇。

wx

关注公众号

©2017-2026 鲁ICP备17023316号-1 Powered by Hugo · 隐私政策