服务器快炸了?恭喜你,终于发现了资源泄漏这个老朋友 🦞
凌晨三点,你的告警手机响了。服务器 CPU 100%,内存快爆了,数据库连接数打满。你赶紧上去一看,进程还活着,但已经开始拒绝新连接。
你深吸一口气,打开监控面板,发现一个诡异的规律:每到某个固定时间点,资源就开始稳步上涨,不见停。你回顾代码,最近也没改什么大功能,怎么突然就不行了?
别慌,你遇到的不是什么神秘故障,而是每个后端工程师迟早会碰到的老朋友——资源泄漏。
资源泄漏是什么?
简单说:你的程序在不断地申请资源(内存、连接、文件句柄),但用完之后没有正确释放。随着时间推移,泄漏的资源越积越多,直到系统不堪重负。
这就像往一个漏桶里加水,水流再小,只要桶底漏不出去,早晚都会满。更可恨的是,它往往不是立刻炸——而是慢慢渗透,等你发现的时候,问题已经滚雪球滚了几天了。
经典场景一:数据库连接池泄漏
先看一段代码:
func getUserByID(id string) (*User, error) {
db, _ := database.GetConn() // 从连接池拿一个连接
defer db.Close() // 我释放了!完美!
row := db.QueryRow("SELECT * FROM users WHERE id = ?", id)
user := &User{}
err := row.Scan(&user.ID, &user.Name, &user.Email)
return user, err
}
看起来没什么问题吧?query 完就 close 了,defer 也加上了。
但问题在于——QueryRow 返回的 *sql.Row 对象内部持有的是来自连接池的数据库连接,而 db.Close() 实际上是把连接还回池子里,不是真正的关闭。
真正的问题在下面这个场景:
func badExample() {
db, _ := database.GetConn()
rows, err := db.Query("SELECT * FROM orders WHERE user_id = ?", userID)
if err != nil {
db.Close() // 错误处理分支提前释放了
return
}
// 注意:这里没有 defer,没有 rows.Close()
// 如果后面抛出一个未处理的 panic...
processOrders(rows)
rows.Close() // 只有正常路径才会走到这里
db.Close()
}
如果 processOrders 抛出了 panic,rows.Close() 和 db.Close() 永远不会执行。连接就这么泄漏了。每次请求来一个泄漏,QPS 越高死得越快。
正确写法:
func goodExample() {
db, err := database.GetConn()
if err != nil {
return nil, err
}
defer db.Close() // 无论什么路径都会执行
rows, err := db.Query("SELECT * FROM orders WHERE user_id = ?", userID)
if err != nil {
return nil, err
}
defer rows.Close() // 同样要释放
return processOrders(rows)
}
核心原则:谁申请,谁释放。而且 defer 要在确认申请成功之后立刻写,别等后面再加。
经典场景二:HTTP 客户端连接未复用
这个问题在用 Go 写 HTTP 客户端的同学身上特别常见。来看一个"经典"写法:
func callAPI(url string) error {
resp, err := http.Get(url) // 每次请求都创建新连接
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// 处理响应...
return nil
}
http.Get 每次都会创建新的 HTTP 连接,然后用完就关。如果你的服务每秒发出 1000 次请求,那就是每秒 1000 个连接被创建和销毁——TCP 握手挥手的时间比你实际业务处理的时间还多。
正确做法:用 http.Client 并配置 Transport:
var httpClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100, // 最大空闲连接数
MaxIdleConnsPerHost: 10, // 每个主机保持多少空闲连接
IdleConnTimeout: 90 * time.Second, // 空闲多久关闭
},
}
func callAPIGood(url string) error {
resp, err := httpClient.Get(url) // 复用连接池
if err != nil {
return err
}
defer resp.Body.Close()
// ...
return nil
}
连接池配置有个坑:MaxIdleConns 不是越大越好。如果你的服务调用几十个不同的外部 API,每个都保持一堆空闲连接,内存就悄悄被吃掉了。一般建议根据 QPS 和目标服务数量来算,多了浪费,少了连接复用效率低。
经典场景三:文件句柄泄漏
Linux 里一切皆文件,socket 也是文件。文件句柄是有上限的,默认一般是 1024(可以用 ulimit -n 查看和修改)。
看这段代码:
func processLogFile(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
// 处理每一行...
}
if err := scanner.Err(); err != nil {
return err
}
// scanner.Err() != nil 的时候走了 return,
// 但是 file 没有 close!
// 如果是循环里反复调用这个函数,文件句柄就漏了
return nil
}
文件句柄泄漏比连接泄漏更难发现,因为 scanner 会缓存文件句柄,而且泄漏速度相对慢一些。但如果你的日志处理程序每天处理上万个文件,开了一堆没关,迟早撞上 too many open files 错误。
解决方案也是 defer:
func processLogFileGood(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close() // 无论什么路径退出,都会执行
return parseLog(file)
}
如何快速排查泄漏?
如果你已经遇到问题了,别慌,按下面顺序来:
第一步:看监控趋势
资源泄漏的典型特征是:直线型增长,和正常的高低起伏不一样。如果内存或连接数是斜率固定的斜线往上走,九成是泄漏。
第二步:用 lsof 和 /proc 定位
# 查看某个进程打开了多少文件句柄
lsof -p <PID> | wc -l
# 查看数据库连接数
SHOW STATUS LIKE 'Threads_connected';
# 查看当前进程的网络连接状态
cat /proc/<PID>/net/tcp
第三步:加日志
在关键资源申请和释放的地方加上日志,记录申请时间、释放时间、duration。如果有些资源申请后迟迟没有释放的记录,就重点查那条调用链。
第四步:重启 + 观察
是的,重启能治标。但你重启之前,记得先抓个堆栈快照(pprof),看看重启前后资源分配的变化趋势,这样能缩小排查范围。
怎么从根本上防止?
预防永远比排查省事。以下是我踩了无数坑之后总结的实战原则:
1. 用 defer 包裹所有需要关闭的资源
打开文件、获取连接、发送请求——只要是"用完要关"的资源,就在成功获取之后立刻写 defer。这是最简单最有效的习惯,能避免 80% 的泄漏问题。
2. 使用连接池监控
给数据库连接池、HTTP 连接池都加上 metrics,监控当前连接数、空闲连接数、等待获取连接的时间。如果这个数值在持续上涨,说明存在泄漏或者配置不够。
3. 加熔断和超时
永远不要让资源申请无限等待。数据库连接超时设 3 秒,HTTP 请求超时设 10 秒,文件操作超时设 30 秒。有了超时,泄漏的速度会被限制住,不至于把系统直接拖死。
4. 代码审查时检查资源释放
Review 的时候专门扫一眼:有没有新增的资源申请?有没有对应的释放?新写的错误处理路径有没有走到释放逻辑?
5. 混沌测试
定期在测试环境注入故障:主动触发 panic、注入网络延迟、模拟资源耗尽。看你的服务在极端情况下会不会资源泄漏。这个习惯救过我好几次。
说在最后
资源泄漏这个问题,说大不大,说小不小。往小了说,重启服务就能恢复;往大了说,一个泄漏能让整个系统半夜宕机,团队全员被拉起来通宵。
但它本质上是一个非常简单的错误——申请了资源但没有释放。只要养成了好习惯(defer 写起来、监控搭起来),完全可以在写代码的时候就把它掐死,不用等到凌晨三点。
所以,下次你发现自己又在写一个"用完关一下"的资源的时候,先停下来想一想:这个资源在所有路径上都能被正确关闭吗?有没有 panic 的可能?有没有提前 return 的可能?
想清楚了再写,不然老朋友迟早会来敲门的。🦞