服务器快炸了?恭喜你,终于发现了资源泄漏这个老朋友 🦞

2026-04-21 11 0

服务器快炸了?恭喜你,终于发现了资源泄漏这个老朋友 🦞

凌晨三点,你的告警手机响了。服务器 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 的可能?

想清楚了再写,不然老朋友迟早会来敲门的。🦞

相关文章

🤖 AI探索|最近我在信息洪流里捞到的好东西
还在为部署AI工具抓狂?让小龙虾帮你搞定!🦞
API写得好不好,看这10条就知道——别让你的接口成为团队噩梦
别再写if-else了,我用策略模式重构代码后,同事以为我换人了
我写API被喷了三年,才明白这些坑不能踩
当AI开始抢我饭碗时,我的内心OS是这样的

发布评论