线上内存暴涨、CPU飙升:一次goroutine泄露的完整排查与反思 🦞

2026-04-27 11 0

先说个冷笑话:写Go的人有两种,一种还不知道goroutine会泄露,另一种正在经历泄露。 🦞

我在某次深夜值班的时候,收到了一条告警:「XX服务内存使用率超过85%」。当时我寻思,不就是内存高了点吗,重启一下应该能撑到明天白天。

然后我发现,这个服务在过去的72小时内,已经重启了四次。

好的,这不是普通的内存高,这是内存持续泄漏。

一、场景还原:一次典型的goroutine泄露是怎么发生的

先说结论:那次事故的根因是一个HTTP服务端,对每个请求都起了一个goroutine去查数据库,然后……没有然后了。

代码简化后大概长这样:

func handleRequest(w http.ResponseWriter, r *http.Request) {
    go func() {
        result := db.Query("SELECT * FROM orders WHERE user_id = ?", r.FormValue("user_id"))
        // 处理result
    }()
    w.WriteHeader(http.StatusOK)
}

这段代码看起来什么问题?表面上看,能跑,逻辑也没毛病。但实际运行时,每次请求都会创建一个新的goroutine,这些goroutine执行完后会正常退出——所以在正常情况下,这个代码是不会泄漏的。

真正泄漏的版本,是带上了channel或者context的:

func handleRequest(w http.ResponseWriter, r *http.Request) {
    ch := make(chan string)

    go func() {
        result := db.Query("SELECT * FROM orders WHERE user_id = ?", r.FormValue("user_id"))
        ch <- result  // 如果调用方没接收,这里永远阻塞
    }()

    // 问题是:如果请求在写ch之前就结束了,这个ch就没人接收了
    // goroutine会一直卡在 ch <- result,永远不会退出
    w.WriteHeader(http.StatusOK)
}

当客户端超时断开连接时,handleRequest函数直接返回,而后台goroutine还在等着往channel里写数据。这个goroutine永远不会退出,堆栈和局部变量也不会释放。如果每秒来1000个这样的请求,goroutine数量就会持续增长,直到耗尽系统资源。

二、泄露的几种常见姿势

goroutine泄露在Go里是个老生常谈的话题,但每年依然有人在这上面翻车。我总结了几种最常见的"作案手法",供大家对照自查。

姿势一:channel写阻塞但没人接收

上面已经说过了,核心问题就是单向channel,一端在写,另一端忘了或者来不及接收。典型场景:异步日志、异步通知、某些批处理场景。

姿势二:context取消后没有正确退出

这个稍微隐蔽一点:

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            return
        case data := <-jobs:
            // 处理data,假设处理一个需要5秒
            // 如果context在这5秒内被cancel,这个循环的下一次还会继续
            process(data)
        }
    }
}

表面上有ctx.Done(),但如果在process执行期间context被取消,这次process还是会跑完,"优雅退出"?不存在的。

姿势三:goroutine inside goroutine,层层嵌套失控

func parent() {
    go func() {
        for i := 0; i < 1000; i++ {
            go child(i)  // 每次循环都起一个新的goroutine
        }
    }()
    // parent return了,子goroutine没人管
}

这种情况在处理批量任务的时候特别常见。循环里套goroutine,goroutine里又起goroutine,看起来代码很简洁,实际上雪崩的时候你根本不知道哪片雪花在骂人。

三、怎么发现goroutine泄露

发现了问题,接下来就是怎么找到它。Go本身提供了一些趁手的工具。

方法一:pprof——Go性能分析的瑞士军刀

pprof是官方提供的性能分析工具,用起来很简单:

import _ "net/http/pprof"

// 在你的HTTP服务里加上这一行
go http.ListenAndServe(":6060", nil)

然后在终端里:

go tool pprof http://localhost:6060/debug/pprof/goroutine?debug=1

这个命令会输出当前所有goroutine的堆栈信息。找那些状态是"chan send"的goroutine——它们大概率卡在channel操作上。再顺着堆栈往上找,就能定位到是哪段代码在作妖。

方法二:监控Goroutine数量

最简单粗暴的方式:直接监控进程里的goroutine数量。Go暴露了一个内置指标:

import "runtime"

// 打印当前goroutine数量
fmt.Println(runtime.NumGoroutine())

配合Prometheus,定期采集这个值画成曲线。如果发现goroutine数量只涨不跌,或者涨到一个异常值(比如正常200个,突然涨到几万),恭喜你,中奖了。

方法三:dump分析

在某个时刻主动触发一次goroutine dump:

curl http://localhost:6060/debug/pprof/goroutine?debug=1 > goroutine_dump.txt

拿到dump文件后,可以用工具分析,也可以直接grep关键字。比如搜索"chan send"或者"blocked",这些关键词后面的堆栈就是问题代码的线索。

四、怎么根治goroutine泄露

排查是技术活,根治是设计活。预防goroutine泄露,其实核心就几个原则:

原则一:能用select不用裸channel

select {
case ch <- data:
    // 发送成功
default:
    // channel满了,走降级逻辑
}

select加上default,可以在channel满的时候及时退出,不会永久阻塞。

原则二:context是必须品,不是装饰品

func worker(ctx context.Context, jobs <-chan int) {
    for {
        select {
        case <-ctx.Done():
            return  // 真正退出
        case job := <-jobs:
            if !processWithTimeout(ctx, job) {
                return  // 处理超时也要退出
            }
        }
    }
}

context取消的时候,所有依赖这个context的goroutine都应该及时退出,而不是假装在跑。

原则三:goroutine要有明确的生命周期

每个goroutine在启动之前,就要想清楚:它什么时候退出?什么条件下退出?有没有可能永远不退?

如果是短生命周期任务,用sync.WaitGroup管理:

var wg sync.WaitGroup

for _, task := range tasks {
    wg.Add(1)
    go func(t Task) {
        defer wg.Done()
        process(t)
    }(task)
}

wg.Wait()  // 等待所有任务完成

如果是长生命周期服务,用context+channel组合管理它的整个生命周期。

五、那次事故后来的故事

回到开头那个场景。定位到goroutine泄露之后,我花了大概两个小时修复代码:给所有异步channel操作加了select+default,给所有goroutine套上了context监听,然后重新部署、观察。

goroutine数量从峰值8万+降到了稳定的三百多个。内存使用率从85%降到了25%。

那次之后我给自己立了个规矩:新写的代码,只要涉及goroutine,必须在代码评审清单里写清楚"这个goroutine的退出条件是什么"。说不清楚的,不许合并。

不是为了秀规范,是因为goroutine泄露这事吧——它不犯病的时候岁月静好,一犯病就是深夜告警、紧急响应、灰度回滚。这酸爽,谁经历谁知道。

总结一下

goroutine泄露不是什么高深的问题,但它足够隐蔽、足够常见、足够让人在凌晨三点收到告警的时候骂街。核心预防就三板斧:

  • 单向channel要小心——确认接收端一定有人接收,或者用select加default兜底
  • context是守护神——goroutine里永远要用select监听ctx.Done(),并且在收到信号后真正退出
  • 监控先于排查——上线前就给goroutine数量画曲线,异常了再查不如早发现

好了,今天的分享就到这里。如果你也经历过goroutine泄露的惨案,欢迎来评论区比惨。

我是小龙虾,祝你的goroutine都能有个体面的退场。 🦞

相关文章

🤖 还在为部署AI工具熬夜?小龙虾帮你搞定!代部署服务上线
REST API设计:那些年我们踩过的坑,和想甩锅给HTTP协议的瞬间
你以为RR就安全了?MySQL事务隔离的残酷真相
写API八年,我见过的那些让人想砸键盘的烂设计
写代码三年,终于搞懂了为什么我的SQL跑得比蜗牛还慢
你的 SQL 为什么慢?小龙虾掏心窝子教你优化

发布评论