一次 goroutine 泄漏:pprof 说有 10 万个 goroutine,但问题不在 channel

pprof 的 goroutine profile 页面上,数字是 103,247。

我刷新了一下------103,891。还在涨。

这是一个跑了三年没出过事的服务。周五下班前一切正常,周一上班就收到内存告警。打开 Grafana,go_goroutines 指标画出了一条完美的斜线------从正常运行时的几百,涨到了十万量级,没有回落的迹象。

内存也在涨,但比 goroutine 数量慢。RSS 从平时的 200MB 爬到了 1.2GB,还在缓慢上升。按这个速度,再过几个小时就会触发 OOM Killer。

我的第一反应和你大概一样:goroutine 泄漏

但接下来的排查,让我重新理解了 goroutine 泄漏在生产环境中的真实面貌。

一、第一个怀疑:channel 没关?

如果你搜"Go goroutine 泄漏",前十个结果有八个会告诉你同一件事:

最常见的原因是 channel 没关闭,goroutine 在 <-ch 上永远阻塞。

合理。我也是这么想的。

打开代码库,全局搜 make(chan<-。这个服务不大,总共也就十几处用了 channel。逐个排查------每个 channel 都有对应的 close(),每个 select 都带着 case <-ctx.Done() 的退出分支。写代码的同事是个靠谱的人,channel 的生命周期管理没毛病。

不是 channel。

那 WaitGroup?全局搜 sync.WaitGroup,检查每个 Add 是否有配对的 Done。三处 WaitGroup 使用,全部配对正确。

也不是 WaitGroup。

我又看了 select 里有没有没带 default 的空 select{},有没有往 nil channel 发送的代码------这两个是教程里常提到的另外两种泄漏模式。扫了一遍,没有。

这两三种"常见原因"在 10 分钟内被全部否定了。我开始意识到一个问题:教程里讲的 goroutine 泄漏,和生产环境里真正致命的 goroutine 泄漏,可能不是一回事。

教程教你的是这种经典场景:

bash 复制代码
// 教科书式泄漏:channel 没关
ch := make(chan int)
go func() {
    val := <-ch // 永远阻塞,因为没人往 ch 发数据
    fmt.Println(val)
}()
// 忘记 close(ch) 或忘记往 ch 发数据
// 这个 goroutine 永远不会退出

这种泄漏容易写出来,也容易排查------搜 channel 就能找到。但生产环境里最危险的泄漏,往往不长这样。它们藏在更隐蔽的地方。

二、调用栈里的真相

排查 channel 和 WaitGroup 都无果,我决定换一个策略:不再猜原因,直接看调用栈。

Go 的 pprof 提供了一个非常强大的能力------导出所有 goroutine 的完整调用栈。当你有 10 万个 goroutine 时,你不需要逐个看。你要找的是大量重复的调用栈------如果上万个 goroutine 的调用栈完全相同,那个调用栈就是泄漏点。

bash 复制代码
curl -s http://localhost:6060/debug/pprof/goroutine?debug=1 | head -50

debug=1 模式会把相同调用栈的 goroutine 聚合显示,最前面是数量。输出的前几行就锁定了问题:

bash 复制代码
goroutine profile: total 103891
98237 @ 0x43e30e 0x44b6d2 0x6e2f10 0x6e1a82 ...
#	0x6e2f0f	net/http.(*Transport).roundTrip+0x...
#	0x6e1a81	net/http.(*Client).do+0x...
#	0x6e1d42	net/http.(*Client).Get+0x...
#	0x7a2e81	main.callUpstream+0x...

103,891 个 goroutine 总数。其中 98,237 个------95%------全部阻塞在同一个位置:net/http.(*Client).Get

不是 channel。不是 WaitGroup。是 HTTP 请求。

这些 goroutine 全都在等一个 HTTP 响应。响应迟迟没来,它们就这样挂着,谁也不回收,谁也不退出。每一次新的请求进来,如果上游恰好在慢,就多一个 goroutine 永远留在那里。

我翻到了出问题的代码。它简单得让人心凉:

bash 复制代码
func callUpstream(url string) (*http.Response, error) {
    return http.Get(url)  // 就这一行
}

http.Get 是个便利函数,底层用的是 http.DefaultClient

说一下 debug=1debug=2 的区别。debug=1 是聚合视图------相同调用栈的 goroutine 合并显示,前面是数量,适合快速定位"哪类 goroutine 最多"。debug=2 是全量展开------每个 goroutine 的完整调用栈单独列出,附带 goroutine ID、运行时长、当前状态(running/waiting/syscall)。当你需要看某个具体 goroutine 的 waiting 时长("它已经等了多久?")时,用 debug=2

在这次排查中,debug=2 还告诉了我一个有意思的信息:那些阻塞的 goroutine,最老的已经等了 48 小时------说明泄漏从周六就开始了,只是当时流量低,泄漏速度慢,没触发告警。

三、DefaultClient 的沉默陷阱

Go 的 http.DefaultClient 有一个你可能知道但几乎可以确定没放在心上的事实:

bash 复制代码
// Go 标准库源码,net/http/client.go
var DefaultClient = &Client{}

Client{} 的零值------Timeout 字段为 0。在 Go 的 HTTP 实现中,0 不是"超时为零秒",而是"没有超时"。

这意味着什么?如果上游服务在任何阶段卡住了------DNS 解析慢、TCP 握手超时、迟迟不发响应头、或者 body 传输中途网络丢包重传------你这边的 HTTP 请求会永远等下去。不报错,不超时,就这么安静地挂着。

更微妙的是,这个问题在正常情况下完全不可见。上游响应快的时候(比如 50ms),goroutine 来了就走,你根本感知不到有超时风险。只有当上游偶发变慢时------从 50ms 变成 30 秒甚至更久------这些 goroutine 才会堆积。

而"偶发变慢"在生产环境中是确定会发生的事。上游服务做 GC、数据库慢查询、网络抖动、流量高峰------任何一个原因都可能让某些请求的响应时间从毫秒级飙到秒级。

http.DefaultClient 没有超时,只是问题的三分之一。

四、三个坑叠在一起

让我把这次事故的因果链拆干净。

单独来看,三个条件中的任何一个都不会导致 goroutine 泄漏:

条件 单独存在时 为什么不泄漏
HTTP client 没设超时 上游永远 50ms 内响应 goroutine 来了就走
上游偶发慢响应 client 设了 5 秒超时 5 秒后请求放弃,goroutine 正常退出
没有 context cancel client 自己有超时兜底 超时机制保证 goroutine 最终退出

但三个条件同时存在时,就是灾难------两层防御同时缺失,偏偏遇上了触发条件。

bash 复制代码
上游服务偶发慢响应(从 50ms 变成 30s+)
       ↓
HTTP client 没设 Timeout(默认 0 = 无超时)
       ↓
goroutine 发起请求后进入无限等待
       ↓
没有 context.WithTimeout 可以叫停这个等待
       ↓
goroutine 永远挂着,无人回收
       ↓
每次上游变慢,就多一批 goroutine 泄漏
       ↓
goroutine 数量线性增长
       ↓
fd 耗尽 → 新请求 "too many open files"
       ↓
或:内存耗尽 → OOM Killer 杀进程

用三句话概括这个叠加效应:

  • 没有超时 → 无限等待成为可能
  • 上游变慢 → 无限等待成为现实
  • 而如果连 context cancel 都没有?这个等待就永远不会被终止。

三者缺一,问题都不会发生。但凑齐三个,goroutine 就会像滴水一样持续泄漏------你不会立刻注意到,但水费单迟早会来。

你可能会问:上游慢了,不应该修上游吗?当然应该。但上游是另一个团队的服务,你能控制的只有自己这端。而且"偶发慢"的根因可能是 GC、网络抖动、流量尖峰------你没法消灭它,只能防御它。

goroutine 泄漏像慢性病------它不是爆管让你立刻注意到的那种故障。它像水龙头滴水,一滴一滴地累积。等你发现的时候,水已经漫到地板上了。

你可能会想:10 万个 goroutine 本身就够让程序崩溃了吧?

其实不一定。Go goroutine 初始栈只有 2KB(Go 1.4+ 起固定为 2KB,运行时按需扩展)。一个阻塞在 HTTP 调用链上的 goroutine 实际栈占用通常在 4-8KB,10 万个约 400-800MB------对一台 8GB 内存的机器来说,还不至于 OOM。真正致命的不是 goroutine 本身,而是它背后占用的资源。

每个阻塞的 HTTP 请求都占着一个 TCP 连接和一个 file descriptor。我在出事的机器上查了一下 fd 使用情况:

bash 复制代码
ls /proc/$(pidof myservice)/fd | wc -l

将近 10 万个 fd,绝大部分是 TCP socket。而这台机器的 ulimit -n 设的是 100,000。也就是说,fd 马上就要耗尽了。

fd 耗尽后会发生什么?net.Dial 会返回 socket: too many open files------不仅你的 HTTP 请求发不出去,连新进来的客户端连接都无法接受。负载均衡器发现你的健康检查失败,把你从服务列表里踢出去。如果所有实例都在同时泄漏(因为上游是同一个慢服务),整个服务就彻底不可用了。

除了 fd,内存也在缓慢增长。粗略拆解:栈空间约 400-800MB,加上每个请求在堆上的 Request/Response/TLS 状态等对象(约 1-5KB/个,共 100-500MB),总计约 500-1.3GB------这也解释了为什么 Grafana 上的 RSS 从 200MB 涨到了 1.2GB。

五、眼见为实:复现它

排查清楚原因后,我做了一件事来验证结论:写一个最小复现。

不是为了"证明给别人看",而是为了确认自己的分析没有遗漏。排查过程中最危险的事不是"找不到原因",而是"找到了一个看起来合理但其实不完整的原因"。

慢 server 这边很简单------每个请求阻塞 30 秒:

bash 复制代码
http.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) {
    time.Sleep(30 * time.Second)
    w.Write([]byte("ok"))
})

client 端模拟问题代码------无超时、无 context:

bash 复制代码
client := &http.Client{}  // Timeout: 0,无超时,无 context
for i := 0; i < 1000; i++ {
    go func() {
        resp, err := client.Get("http://localhost:18080/slow")
        if err != nil { return }
        defer resp.Body.Close()
    }()
}

运行后持续监控 goroutine 数量:

bash 复制代码
初始 goroutine 数: 4
[100 请求已发]  goroutine 数: 105
[500 请求已发]  goroutine 数: 505
[1000 请求已发] goroutine 数: 1005
[等待 5s]  goroutine 数: 1005
[等待 10s] goroutine 数: 1005  ← 没有任何回落
[等待 30s] goroutine 数: 1005  ← 仍然没有回落

1000 个 goroutine,全部阻塞在 http.Get,零回收。只有等 30 秒后慢 server 返回响应,goroutine 才会退出。但在生产环境中,如果上游是真的卡死(而不是"慢 30 秒"),这些 goroutine 会永远挂着。

这就是线上那 10 万个 goroutine 的微缩版。

我先试了最简单的------给 client 加个 3 秒超时:

bash 复制代码
client := &http.Client{Timeout: 3 * time.Second}

同样 1000 个请求发向慢 server。3 秒后所有请求超时返回 context deadline exceeded,goroutine 正常退出。3 秒后 goroutine 数回到 5------和初始时一样。

那如果不改 client,只加 context 呢?

bash 复制代码
client := &http.Client{} // 仍然无超时
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
client.Do(req)

效果同样有效------3 秒后 context 到期,请求被取消,goroutine 退出。

但问题是:这要求每个调用点都手动加 context.WithTimeout。只要有一个地方忘了------比如那个三年前写的 callUpstream 函数------泄漏就会重现。

生产环境该怎么配?两层都加:

bash 复制代码
client := &http.Client{Timeout: 5 * time.Second}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
client.Do(req)

两者不冲突,谁先到谁生效。

这里有一个容易踩的坑值得单独说:你以为你加了 context,但其实没有生效。

看这段代码,看起来有 context,对吧?

bash 复制代码
func callUpstream(ctx context.Context, url string) (*http.Response, error) {
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()
    resp, err := http.Get(url)  // ← 问题在这行
    return resp, err
}

context 创建了,cancel 也 defer 了,看起来很完美。但 http.Get 不接受 context------它用的是 DefaultClient,完全不知道你的 context 存在。你的 WithTimeout 白设了。

正确写法是用 http.NewRequestWithContext 把 context 绑定到 request 上:

bash 复制代码
func callUpstream(ctx context.Context, url string) (*http.Response, error) {
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, err
    }
    return httpClient.Do(req)  // 用自定义 client,不用 DefaultClient
}

还有一种更隐蔽的变体------context 绑定了,但上层传进来的是 context.Background(),永远不会被 cancel:

bash 复制代码
// 调用方
resp, err := callUpstream(context.Background(), url)
// context.Background() 永不过期、永不被 cancel
// 即使 callUpstream 里加了 WithTimeout,上层链路中断时也无法级联取消

在 HTTP handler 中,应该传 r.Context()------它会在客户端断开连接时自动取消:

bash 复制代码
resp, err := callUpstream(r.Context(), url)

这种写法在功能测试中不会报错,但它放弃了 context 的级联取消能力。如果调用方自身已经超时(比如 HTTP handler 的请求已经被客户端取消),下游的 callUpstream 不会感知到------它还在傻等上游响应。

六、三重防护:一个可复用的框架

这次事故修完之后,我把修复方案抽象成了一个通用的防护框架。不是只修一个 bug,而是建立一套机制,让同类问题不再发生。

最低成本的防御是给 HTTP client 设全局超时------改一处,全局生效。

bash 复制代码
var httpClient = &http.Client{
    // 全局兜底超时
    Timeout: 30 * time.Second,
    Transport: &http.Transport{
        DialContext: (&net.Dialer{
            // TCP 连接建立超时
            Timeout:   5 * time.Second,
            KeepAlive: 30 * time.Second,
        }).DialContext,
        // TLS 握手超时
        TLSHandshakeTimeout: 5 * time.Second,
        // 空闲连接回收
        IdleConnTimeout:     90 * time.Second,
        MaxIdleConnsPerHost: 100,
        // 对单 host 最大并发连接数
        MaxConnsPerHost:     200,
        ExpectContinueTimeout: 1 * time.Second,
    },
}

三层超时各管一段:DialContext.Timeout 管 TCP 连接建立,TLSHandshakeTimeout 管 TLS 握手,client.Timeout 管整个请求的完整生命周期。任何一个阶段超时,请求就会被放弃。MaxConnsPerHost 限制对同一个上游 host 的最大并发连接------即使超时配置有遗漏,fd 也不会无限增长。

光有兜底还不够。不同业务场景需要不同的超时精度,这就是 context 的用处。封装 HTTP 调用函数,强制要求传入 context:

bash 复制代码
func callUpstream(ctx context.Context, url string) (*http.Response, error) {
    // 在上层 context 的基础上加业务级超时
    ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, err
    }
    return httpClient.Do(req)
}

关键改动:函数签名里有 ctx context.Context。这不是风格偏好,这是强制调用方思考超时。如果一个函数不接受 context,它就永远不会被 cancel------而"永远不会被 cancel"正是泄漏的根源。

还有一个实践经验:超时值怎么选?太短会导致正常请求被误杀,太长又起不到保护作用。我的经验法则是:看你的上游 P99 延迟,乘以 2-3 倍。如果上游 P99 是 500ms,设 1-2 秒是合理的。如果你不知道上游的延迟分布------说明你需要先加监控,再定超时。

client.Timeoutcontext.WithTimeout 的区别在于粒度和级联能力。client.Timeout 是从请求发起到完整读取响应 body 的总时间,是"无论如何不允许超过 30 秒"的兜底。context.WithTimeout 可以被上层 context 级联取消------如果 HTTP handler 自身的 context 先到期,下游请求也会被取消。这种级联取消能力是 client.Timeout 做不到的。

最后一道防线不是代码层面的,是监控。在 Prometheus 上对 go_goroutines 指标设告警规则:

bash 复制代码
groups:
  - name: goroutine-leak
    rules:
      - alert: GoroutineLeakSuspected
        expr: go_goroutines > 5000
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "goroutine 数量异常偏高 ({{ $value }})"
      - alert: GoroutineLeakCritical
        expr: go_goroutines > 20000
        for: 2m
        labels:
          severity: critical

5000 和 20000 这两个阈值因服务而异。核心原则是:goroutine 数量应该在一个稳定范围内波动,不应该出现持续单调递增的趋势。如果你的服务正常运行时 goroutine 数稳定在 100-300,突然涨到 5000 并且还在涨,那几乎可以确定是泄漏了。

七、为什么你早就知道,却还是会踩

修完这个 bug 之后,复盘时最让我不舒服的不是 bug 本身,而是一个事实:

"不要用 http.DefaultClient"这句话,我见过不下二十次。但还是踩了。

这不是我个人的问题,这是工程上的系统性盲区。复盘下来,原因有几个。

第一,代码不是我写的。那个 callUpstream 函数是三年前另一个同事写的,当时它可能就是个临时调用。后来这个函数被十几个地方复用,每次有人用它的时候都不会想到去检查底层的 HTTP client 配置------因为"它一直能用"。

代码库里最危险的不是写错的代码,而是"写对了但缺少防御"的代码。功能上完全正确,只是没有超时------你用 grep 搜不到任何 bug,用单测也测不出来。

第二,测试环境永远不暴露这个问题。测试环境的上游服务响应快(通常 <50ms),goroutine 来了就走,根本看不出泄漏。只有当生产环境的上游偶发慢响应时------比如对方在做全量 GC、网络路径上有丢包、或者流量高峰导致排队------泄漏才会浮现。而这种"偶发"可能几周才出现一次,之前三年没出过事不代表代码是安全的。

第三,Code Review 不 review 超时配置。回头看当初的 Code Review 记录,reviewer 关注的是逻辑正确性------"这个函数的返回值处理对不对?错误处理全不全?"没有人会问:"http.Client{} 有没有设 Timeout?" 因为这不是逻辑错误,而是配置缺失。代码在功能上是对的,只是缺少一层防御。

goroutine 泄漏不是知识问题,是工程问题。 你"知道"应该设超时,但如果团队里没有 linter 规则、没有 code review 检查清单、没有运行时监控,这种"知道"就只是个人记忆,不是系统性保障。

这次事故之后,我们在团队里做了几件事:

  • 封禁 DefaultClient :CI 加 noctx linter(可集成到 golangci-lint),扫描不传 context 的 HTTP 调用,直接触发 warning。→ 解决旧代码无超时问题。
  • 函数签名强制 context :涉及 I/O 的函数第一个参数必须是 context.Context,否则 CR 不过。→ 解决无法级联取消问题。
  • goroutine 监控常态化 :每个服务暴露 go_goroutines,持续上涨 5 分钟就告警。→ 解决泄漏早期发现问题。
  • 定期巡检老代码 :每季度用 noctx linter 扫描不传 context 的 HTTP 调用,检查有没有遗漏。→ 三年前的代码不会自己变安全。

其中效果最立竿见影的是第一条。linter 规则上线的第一周,就扫出了另外三处直接使用 http.DefaultClient 的代码------全是早期写的工具脚本,没人注意过。如果不是这次事故触发了全面清查,这些"隐形炸弹"可能还要再沉睡几年。


回到开头,pprof 页面上的那个数字。

排查 goroutine 泄漏,说到底就是在问几个问题:

  • 谁在等? --- goroutine dump 告诉你(debug=1 看聚合,debug=2 看细节)
  • 等什么? --- 调用栈告诉你(是 channel、是 HTTP、还是 DB 连接)
  • 为什么没人叫停? --- 超时和 context 的配置告诉你

如果三个问题的答案分别是"HTTP goroutine"、"慢上游"、"因为没设超时也没 context cancel"------那你遇到的,和我这次一样------三个条件凑齐了。

下次看到 goroutine 泄漏,别急着搜 channel。先看调用栈。

你遇到过哪些"三年没出事但其实一直有隐患"的代码?欢迎留言。

原文发布于止语 Lab

相关推荐
过期动态17 小时前
【LeetCode 热题 100】找到字符串中所有字母异位词
java·数据结构·算法·leetcode·职场和发展·rabbitmq
YDS8293 天前
大营销平台 —— 活动SKU库存扣减业务及其一致性处理
java·spring boot·redis·rabbitmq·ddd
霸道流氓气质3 天前
Kiro 中配置 RabbitMQ MCP Server 指南
分布式·rabbitmq·ruby
quweiie3 天前
tp8使用rabbitMQ消息队列的示例
消息队列·rabbitmq·thinkphp消息队列
六点_dn3 天前
RabbitMQ学习笔记-定义与作用
笔记·学习·rabbitmq
Steadfast_GG6 天前
RabbitMQ升级打怪之路(1) - RabbitMQ概述
消息队列·rabbitmq·流量削峰·消息分发·异步解耦
时代的狂7 天前
RabbitMQ 面试问答
分布式·面试·rabbitmq
半夜修仙7 天前
Spring集成邮箱功能
java·开发语言·spring boot·spring·rabbitmq·github·maven
半夜修仙8 天前
RabbitMQ的推模式和拉模式
java·分布式·中间件·rabbitmq·github·java-rabbitmq
9523610 天前
RabbitMQ - 高级特性
java·spring boot·分布式·后端·rabbitmq