结论先行:Go 服务里有一类事故长得很像「资源泄漏」,其实一个 goroutine 都没漏——它们都好好地阻塞在某个「永远不会醒」的等待里,因为发起等待的那个 context 在上游被吞掉了。
pprof看 goroutine 数量只增不减,kill 不掉、重启才活。这篇文章复盘一次真实的 P1,把「context 被吞」的三种典型姿势、定位命令和一份「取消穿透」清单讲清楚。
一、现象:服务「活着」,但 goroutine 9 万、新连接全部 502
先给数字。去年 9 月,我们一个 Go 1.21 写的数据同步服务(8 实例,4C8G,常驻 goroutine 基线约 1,200),某次上游数据源(一个第三方 HTTP API)抖动 40 分钟后,事故链是:
- 03:12 上游开始限流,返回 429;我们客户端的重试逻辑开始循环;
- 03:20
runtime.NumGoroutine()从 1,200 涨到 8,400,内存 RSS 从 1.1G 涨到 3.8G; - 03:35 三个实例被 K8s OOMKilled,重启后 90 秒内 goroutine 又涨回去——说明不是「一次性泄漏」,是持续产生;
- 03:41 存活实例的
http.ListenAndServe还能响应健康检查(返回 200),但业务接口 P99 从 80ms 飙到 25s,新连接开始 502; - 03:58 人工重启全部实例 + 上游恢复,止血。全程 46 分钟,期间我们一度怀疑是连接池打满,但
http.Transport的Conns()只有 312/10000。
关键反差:健康检查活着、CPU 只有 35%、内存曲线单调上涨——这个组合在 Go 服务里几乎只指向一件事:goroutine 泄漏(准确说是「悬挂」)。
二、根因:context 在三个地方被「静默吞掉」
用 curl -s localhost:6060/debug/pprof/goroutine?debug=1 | grep -A 30 "created by" 抓栈,9 万个 goroutine 几乎全落在三类栈上:
姿势 1:重试循环里只判断了「成功」,没判断「取消」
最经典的一处,代码长这样(已脱敏):
func fetchWithRetry(ctx context.Context, url string) ([]byte, error) {
var lastErr error
for i := 0; i < 5; i++ {
resp, err := client.Get(url) // 姿势2 的雷也埋在这
if err == nil && resp.StatusCode == 200 {
// ... 正常返回
}
lastErr = err
time.Sleep(2 * time.Second) // 姿势3:Sleep 不看 ctx
}
return nil, lastErr
}
上游返回 429 时 err == nil(429 不是网络错误),循环继续;time.Sleep 不响应取消,5 次 × 2s 固定间隔硬扛。这一处单看问题不大,但它是「放大器的扳机」——下面两处才是把 goroutine 钉死的地方。
姿势 2:client.Get(url) 根本没带 context
http.Client.Get 内部用的是 context.Background()。我们客户端层设了 client.Timeout = 30 * time.Second,所以每个请求最多悬挂 30 秒——这解释了为什么内存是「缓慢上涨」而不是「瞬间爆」。但 30 秒的悬挂窗口 × 每 2 秒一个重试 × 8 个 worker 并发拉取,稳态悬挂 goroutine 就有 8 * 30/2 * 5 ≈ 600+ 个在途,叠加上游恢复后雪崩重放,就是 9 万的量级。
姿势 3:外层用 WaitGroup.Wait() 等这些「永远不会结束」的 goroutine
var wg sync.WaitGroup
for _, task := range tasks {
wg.Add(1)
go func(t Task) {
defer wg.Done()
fetchWithRetry(ctx, t.URL) // ctx 在这里被「吞」:没人等它 Done
}(task)
}
wg.Wait() // 阻塞到所有 goroutine 退出——包括那些卡在 30s 超时的
WaitGroup.Wait() 没有 context 参数,它只能「干等」。于是上层调度协程也被钉住,任务队列停止消费,新任务堆积——一个 goroutine 的悬挂,沿 wg 链向上传染了一整层。
一句话根因:Go 的 context 取消是「协作式」的,任何一处 time.Sleep / client.Get / wg.Wait() 不主动看 ctx,取消信号就在那一跳断链。这和 gRPC 那条链上 deadline 跨进程丢失是同一个病根的两个表现——「取消/超时不会自动穿透,谁在链路里谁就得接住」。
三、修复:三处改造 + 一个兜底监控
1. 重试循环改成「可取消」的
func fetchWithRetry(ctx context.Context, url string) ([]byte, error) {
var lastErr error
for i := 0; i < 5; i++ {
select {
case <-ctx.Done():
return nil, ctx.Err() // 第一优先:先问「还该不该干」
default:
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req) // 替换 client.Get:ctx 穿透到连接层
if err != nil {
lastErr = err
if ctx.Err() != nil {
return nil, ctx.Err() // 区分「网络错」和「被取消」
}
} else {
defer resp.Body.Close()
if resp.StatusCode == 200 {
return io.ReadAll(io.LimitReader(resp.Body, 8<<20))
}
lastErr = fmt.Errorf("http %d", resp.StatusCode)
}
// 退避也改成可取消,且带抖动
backoff := time.Duration(2<<uint(i)) * time.Second
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(jitter(backoff)):
}
}
return nil, lastErr
}
2. 批量任务用 errgroup 替代裸 WaitGroup
import "golang.org/x/sync/errgroup"
func runBatch(ctx context.Context, tasks []Task) error {
g, gctx := errgroup.WithContext(ctx)
g.SetLimit(8) // 顺手限并发,替代手写的 worker 池
for _, t := range tasks {
t := t
g.Go(func() error {
_, err := fetchWithRetry(gctx, t.URL)
return err
})
}
return g.Wait() // 任一 goroutine 出错/ctx 取消,gctx 立即 Done,其余在 select 里退出
}
关键差别:errgroup.WithContext 派生的 gctx 会在任一任务失败或父 ctx 取消时自动 Done,其余 goroutine 在 select { case <-ctx.Done() } 里立刻醒来——悬挂链被截断。
3. 兜底:给「悬挂」本身加监控
这是我们这次最大的教训——goroutine 数从 1.2k 涨到 9 万用了 20 分钟,期间没有任何一条告警。落地两条:
// 暴露 runtime 指标(用 prometheus/client_golang 的 expvar 或自定义 collector)
prometheus.NewGoCollector() // 自带 go_goroutines
// 告警规则:goroutine 数相对 15 分钟前基线增长 > 300%,持续 5 分钟
go_goroutines > (go_goroutines offset 15m) * 4
另外在 K8s 侧给该服务加了 resource.requests 与 OOMKilled 事件的联动告警——这次 3 个实例 OOMKilled 后我们居然是靠「内存曲线」才发现的,这个本身就不该发生。
量化收益
- 改造后重放上游 429 限流场景:goroutine 峰值 1,200 → 2,300(可取消重试在途),不再单调上涨;
- 上游恢复后任务队列 40 分钟积压 → 改造后 3 分钟消化完(因为悬挂 goroutine 不再占着 worker 槽位);
- 「发现 → 止血」从 46 分钟 → 演练里 4 分钟(goroutine 增速告警先于 OOMKilled 触发)。
四、踩过的坑 & 反直觉之处
client.Timeout是「悬挂的天花板」,不是「超时的保障」。它只保证 30 秒后释放,不保证你 30 秒内知道「该停了」。这次 9 万 goroutine 里每一个都「合规地」活过了 30 秒,问题不是它们超时了,是没有人在等它们超时。- 429/5xx 不是 error,
err == nil的重试判断会漏掉它们。我们最初以为是网络错误重试,实际是业务错误重试——resp.StatusCode != 200才是该进重试分支的信号,而不是err != nil。 - 健康检查活着 ≠ 服务可用。这次健康检查走的是内存里的 flag,完全不经过被悬挂的 worker 池。如果健康检查和业务路径共享同一个「被吞 ctx 的层」,K8s 早就把它重启了——某种意义上「没被自动摘除」反而让我们多花了 20 分钟排查。
- pprof 的
goroutine?debug=1在 9 万 goroutine 时输出 400MB+,别直接curl到终端,先落盘再grep -c "created by"统计 top 栈,不然 SSH 会话先卡死。
五、可复用清单
- [ ] 全仓 grep
client.Get(/client.Post(:任何不带Context的调用都是候选雷点,统一换http.NewRequestWithContext; - [ ] 全仓 grep
time.Sleep(:确认每一处前面都有select { case <-ctx.Done() },或至少该 goroutine 的存活时间有上限; - [ ] 全仓 grep
wg.Wait():评估能否换errgroup.WithContext;换不了的,确认所有wg.Add的 goroutine 都有「必然退出路径」; - [ ] 给每个长驻 Go 服务加
go_goroutines增速告警(相对基线倍数,而非绝对值——绝对值阈值在扩缩容时全是误报); - [ ] 健康检查路径与业务路径解耦审查:确认「活着」的定义包含「能消费任务」,而不只是「能回 200」。
一个开放问题
我注意到「context 被吞」和「deadline 跨跳丢失」本质是同一类问题——取消信号在每一跳都依赖「这一跳的开发者主动接住」。Go 生态有没有比 errgroup 更彻底的做法,比如给 http.Client 加一个「全局取消预算」,让超时不用每个调用点手写?还是说协作式取消本身就是语言哲学的一部分,「吞 ctx」应该靠 review 和 lint 兜住,而不是靠运行时机制兜住?我现在的做法是写了一个 tiny linter 扫「goroutine 函数体内出现 time.Sleep 且作用域内无 ctx.Done()」的模式,误报率不高,但总觉得这是打地鼠。你们团队是怎么防这一类悬挂的?