
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 泄漏,可能不是一回事。
教程教你的是这种经典场景:
// 教科书式泄漏: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 的调用栈完全相同,那个调用栈就是泄漏点。
curl -s http://localhost:6060/debug/pprof/goroutine?debug=1 | head -50
debug=1 模式会把相同调用栈的 goroutine 聚合显示,最前面是数量。输出的前几行就锁定了问题:
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 永远留在那里。
我翻到了出问题的代码。它简单得让人心凉:
func callUpstream(url string) (*http.Response, error) {
return http.Get(url) // 就这一行
}
http.Get 是个便利函数,底层用的是 http.DefaultClient。
说一下 debug=1 和 debug=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 有一个你可能知道但几乎可以确定没放在心上的事实:
// 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 最终退出 |
但三个条件同时存在时,就是灾难——两层防御同时缺失,偏偏遇上了触发条件。
上游服务偶发慢响应(从 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 使用情况:
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 秒:
http.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(30 * time.Second)
w.Write([]byte("ok"))
})
client 端模拟问题代码——无超时、无 context:
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 数量:
初始 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 秒超时:
client := &http.Client{Timeout: 3 * time.Second}
同样 1000 个请求发向慢 server。3 秒后所有请求超时返回 context deadline exceeded,goroutine 正常退出。3 秒后 goroutine 数回到 5——和初始时一样。
那如果不改 client,只加 context 呢?
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 函数——泄漏就会重现。
生产环境该怎么配?两层都加:
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,对吧?
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 上:
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:
// 调用方
resp, err := callUpstream(context.Background(), url)
// context.Background() 永不过期、永不被 cancel
// 即使 callUpstream 里加了 WithTimeout,上层链路中断时也无法级联取消
在 HTTP handler 中,应该传 r.Context()——它会在客户端断开连接时自动取消:
resp, err := callUpstream(r.Context(), url)
这种写法在功能测试中不会报错,但它放弃了 context 的级联取消能力。如果调用方自身已经超时(比如 HTTP handler 的请求已经被客户端取消),下游的 callUpstream 不会感知到——它还在傻等上游响应。

六、三重防护:一个可复用的框架
这次事故修完之后,我把修复方案抽象成了一个通用的防护框架。不是只修一个 bug,而是建立一套机制,让同类问题不再发生。
最低成本的防御是给 HTTP client 设全局超时——改一处,全局生效。
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:
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.Timeout 和 context.WithTimeout 的区别在于粒度和级联能力。client.Timeout 是从请求发起到完整读取响应 body 的总时间,是"无论如何不允许超过 30 秒"的兜底。context.WithTimeout 可以被上层 context 级联取消——如果 HTTP handler 自身的 context 先到期,下游请求也会被取消。这种级联取消能力是 client.Timeout 做不到的。
最后一道防线不是代码层面的,是监控。在 Prometheus 上对 go_goroutines 指标设告警规则:
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 加
noctxlinter(可集成到 golangci-lint),扫描不传 context 的 HTTP 调用,直接触发 warning。→ 解决旧代码无超时问题。 - 函数签名强制 context:涉及 I/O 的函数第一个参数必须是
context.Context,否则 CR 不过。→ 解决无法级联取消问题。 - goroutine 监控常态化:每个服务暴露
go_goroutines,持续上涨 5 分钟就告警。→ 解决泄漏早期发现问题。 - 定期巡检老代码:每季度用
noctxlinter 扫描不传 context 的 HTTP 调用,检查有没有遗漏。→ 三年前的代码不会自己变安全。
其中效果最立竿见影的是第一条。linter 规则上线的第一周,就扫出了另外三处直接使用 http.DefaultClient 的代码——全是早期写的工具脚本,没人注意过。如果不是这次事故触发了全面清查,这些"隐形炸弹"可能还要再沉睡几年。

回到开头,pprof 页面上的那个数字。
排查 goroutine 泄漏,说到底就是在问几个问题:
- 谁在等? — goroutine dump 告诉你(
debug=1看聚合,debug=2看细节) - 等什么? — 调用栈告诉你(是 channel、是 HTTP、还是 DB 连接)
- 为什么没人叫停? — 超时和 context 的配置告诉你
如果三个问题的答案分别是"HTTP goroutine”、“慢上游”、“因为没设超时也没 context cancel”——那你遇到的,和我这次一样——三个条件凑齐了。
下次看到 goroutine 泄漏,别急着搜 channel。先看调用栈。
你遇到过哪些"三年没出事但其实一直有隐患"的代码?欢迎留言。
原文发布于 止语Lab