线上服务内存暴涨,竟是Goroutine泄漏:3个排查命令救了我


凌晨2点17分,手机响了。

我迷迷糊糊摸过来一看,不是闹钟,是P0告警。服务内存占用从正常的300MB飙到了8GB,而且还在涨。

第一反应:kill -9 重启。

第二反应:不对,上周刚重启过,又来了。

第三次,我决定找出到底是谁在吃我的内存


先确认:真的是内存泄漏吗?

先看监控。这种垂直上升的曲线,很像是内存泄漏:

![配图:Grafana内存飙升截图,时间轴显示从2:00开始直线上升]

但这里有个误区。Go 的 GC 其实很勤快,真正的内存泄漏在 Go 里并不常见。更常见的,是Goroutine泄漏——你开了协程,但协程卡住了,退不出来,越积越多。

每个 Goroutine 初始栈 2KB,但可能增长到 MB 级。10万个卡住的 Goroutine,内存直接爆炸。

判断方法很简单

import (
    "expvar"
    "net/http"
    _ "net/http/pprof"
)

func init() {
    http.Handle("/debug/vars", expvar.Handler())
    go http.ListenAndServe(":6060", nil)
}

// 在你的业务代码里加个接口,或者直接curl pprof
func getGoroutineCount() int {
    return runtime.NumGoroutine()
}

然后执行:

$ curl http://localhost:6060/debug/pprof/goroutine?debug=1 | head -20

goroutine profile: total 104857
104856 @ 0x43e3c6 0x4068b0 0x4065a5 0x78f2a3 ...
#	0x78f2a2	main.processTask+0x42	/app/main.go:127

看到那个 104857 了吗?

正常服务也就几十个、几百个 Goroutine。超过1万就该警惕了,我这直接10万+。

确认嫌疑:不是内存泄漏,是 Goroutine 泄泄漏


排查第一步:runtime.NumGoroutine() 快速确认

如果你连 pprof 都懒得配,一行代码就能验尸:

go func() {
    for {
        log.Printf("当前Goroutine数量: %d", runtime.NumGoroutine())
        time.Sleep(10 * time.Second)
    }
}()

把这段代码塞进你的服务,看日志:

2025/04/07 02:18:12 当前Goroutine数量: 127
2025/04/07 02:18:22 当前Goroutine数量: 1024
2025/04/07 02:18:32 当前Goroutine数量: 8192
...

肉眼可见的飙升,基本实锤。


排查第二步:pprof 定位泄漏点

知道泄漏了,关键是找出哪里泄漏的

Go 自带了神器,不用白不用:

# 查看所有Goroutine的堆栈
curl http://localhost:6060/debug/pprof/goroutine?debug=1 > goroutine.txt

# 或者用2,信息更全
curl http://localhost:6060/debug/pprof/goroutine?debug=2 > goroutine_full.txt

打开 goroutine.txt,你会看到类似这样的输出:

goroutine profile: total 104857
104856 @ 0x43e3c6 0x46f5f2 0x78f2a3 0x4709a1
#	0x78f2a2	main.(*Worker).Process+0x242	/app/worker.go:89
#	0x78f3b1	main.(*Worker).Start.func1+0x51	/app/worker.go:56

1 @ 0x43e3c6 0x46f5f2 0x791102 0x4709a1
#	0x791101	main.main+0x301	/app/main.go:34

重点看那个巨大的数字104856 @ ...

说明有 104856 个 Goroutine 都卡在了 worker.go:89 这一行。

打开代码看:

// worker.go
func (w *Worker) Process(task Task) {
    result := w.client.Send(task)  // 第89行
    w.handleResult(result)
}

再跟进去,Send 方法里:

func (c *Client) Send(t Task) Result {
    // 这里有个坑:没有设置超时!
    resp, err := c.httpClient.Post(c.url, "application/json", body)
    if err != nil {
        return Result{Err: err}
    }
    // 更坑的是:没有读取 Body,也没有关闭!
    return Result{Data: resp}
}

两个致命问题

  1. HTTP 请求没有超时 → 下游服务挂了,请求永远挂在那
  2. Response.Body 没有关闭 → 连接池泄漏,Goroutine 阻塞在 I/O 等待

每个请求开一个 Goroutine,请求卡住,Goroutine 就堆积。


排查第三步:可视化火焰图(可选但直观)

如果你喜欢用看的,而不是看文本:

# 本地启动可视化分析
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/goroutine

# 或者先下载再分析
curl http://localhost:6060/debug/pprof/goroutine > goroutine.prof
go tool pprof -http=:8080 goroutine.prof

然后打开 http://localhost:8080

![配图:pprof火焰图,显示一大片阻塞在 net/http.(*persistConn).roundTrip 的 Goroutine]

火焰图上一眼就能看到:哪块最宽,哪里就是罪魁祸首


修复:给请求加上缰绳

找到问题了,修复其实就几行代码:

// 修复前(泄漏)
func (c *Client) Send(t Task) Result {
    resp, err := c.httpClient.Post(c.url, "application/json", body)
    return Result{Data: resp}  // 没关Body,没超时
}

// 修复后(安全)
func (c *Client) Send(ctx context.Context, t Task) (Result, error) {
    req, err := http.NewRequestWithContext(ctx, "POST", c.url, body)
    if err != nil {
        return Result{}, err
    }
    req.Header.Set("Content-Type", "application/json")
    
    resp, err := c.httpClient.Do(req)
    if err != nil {
        return Result{}, err  // 必须先处理错误!
    }
    // ⚠️ 关键点:defer 必须放在 err 判断之后!
    // 如果 err != nil,resp 可能是 nil,此时 defer nil.Close() 会直接 Panic
    defer resp.Body.Close()
    
    // 即使不需要Body,也要读完(让连接复用)
    // 进阶:如果 resp.Body 很大,可用 io.LimitReader 限制读取长度
    io.Copy(io.Discard, resp.Body)
    
    return Result{Status: resp.StatusCode}, nil
}

调用方也要配合:

// 修复前
go w.Process(task)  // 裸go,没有任何控制

// 修复后
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

if err := w.Process(ctx, task); err != nil {
    log.Printf("任务处理超时: %v", err)
}

核心原则

  1. 所有外部请求必须带超时(context.WithTimeout)
  2. HTTP Response 必须关闭(defer resp.Body.Close())
  3. 别裸用 go 关键字,要有控制(Worker Pool 或至少带 Recover)

预防清单:上线前过一遍

事故复盘完了,但最好的排查是不让它发生。收藏这个清单,上线前自查:

检查项 说明 严重程度
☐ 裸 go 关键字 业务代码里直接 go func()?必须加 recover 🔴 P0
☐ HTTP 无超时 所有 http.Client 必须配置 Timeout 🔴 P0
☐ Response 未关闭 defer resp.Body.Close() 了吗 🔴 P0
☐ Channel 无缓冲 无缓冲 chan 必须有接收方,否则阻塞 🟡 P1
☐ time.After 在循环 循环里用 time.After 会内存泄漏 🟡 P1
☐ 第三方库回调 库的回调是否会在内部起 Goroutine 🟢 P2

监控告警配置:

# Prometheus 告警规则
groups:
  - name: goroutine-alerts
    rules:
      - alert: HighGoroutineCount
        expr: go_goroutines{job="my-service"} > 5000
        for: 5m
        annotations:
          summary: "Goroutine数量异常: {{ $value }}"

关于 time.After 的坑

// 泄漏代码:每次循环都新建一个 Timer
for {
    select {
    case <-time.After(5 * time.Second):  // 每次都会分配新的 Timer
        // do something
    }
}

// 正确做法:复用 Timer
for {
    timer := time.NewTimer(5 * time.Second)
    select {
    case <-timer.C:
        // do something
    }
    timer.Stop()  // 或者用 Reset 复用
}

time.After 底层会创建一个 time.Timer,在定时器到期之前,这个对象和它关联的内存不会被 GC 回收。如果在高频循环里使用,会瞬间堆积成千上万个 Timer。


写在最后

那天晚上,我用这3个命令,20分钟定位到了问题,10分钟修复,5分钟验证。

3个命令再总结一遍

  1. runtime.NumGoroutine() — 快速确认嫌疑
  2. curl /debug/pprof/goroutine?debug=1 — 定位泄漏代码行
  3. go tool pprof -http=:8080 — 可视化确认

Goroutine 是 Go 的杀手锏,但也是最容易被忽视的资源。它不像内存那样会被 GC 自动回收,一旦泄漏,只有重启能救你。

现在,去你的项目里跑一遍

curl http://localhost:6060/debug/pprof/goroutine?debug=1 | head -5

看看你的服务现在有多少 Goroutine 在跑。如果有异常,趁现在还来得及。


你遇到过 Goroutine 泄漏吗?是怎么排查的?欢迎在评论区聊聊,我去给你点赞


配图建议:封面用"Goroutine: 我免费了"的Meme图,或者一张"10万+ Goroutine"的监控截图做视觉冲击力。 ne: 我免费了"的Meme图,或者一张"10万+ Goroutine"的监控截图做视觉冲击力。*

wx

关注公众号

©2017-2023 鲁ICP备17023316号-1 Powered by Hugo