
周一早上打开 Grafana,那条 Go 服务的 RSS 曲线像台阶一样往上爬:夜里高峰过去了,内存却没跌回来。CPU 正常,错误率也不高,就是进程越来越胖。这类问题最烦——看起来「还能跑」,拖到周末再炸一次就晚了。
这篇文章不打算从「什么是 goroutine」讲起。只把那天怎么查、踩到哪块代码、后来怎么改,按时间顺序记下来。如果你也在写带外部 HTTP 调用的 Go 服务,也许能少熬一晚。
10:20 — 先排除「看起来像泄漏」的东西
第一反应是缓存。服务里有一层本地 LRU,峰值流量时变胖并不奇怪,但按理流量下去会慢慢挤掉旧条目。盯了半小时,缓存占用几乎没动,RSS 却还在爬。于是怀疑是全局 map 只增不减——搜了一圈,业务 map 都有上限或 TTL,不像主因。
然后上了 pprof。容器里已经挂了 net/http/pprof,直接:
go tool pprof http://127.0.0.1:6060/debug/pprof/heap
# top / list 看分配热点
堆上最显眼的不是业务结构体,而是一堆 *http.Request、bufio.Reader,以及和 net/http 传输相关的缓冲。goroutine 数量也从平时的几百慢慢顶到几千。方向清楚了:不是「存了太多业务数据」,而是有人一直在发请求,或者请求卡着不回来。
10:45 — 那段「为了不阻塞主流程」的 go
顺着调用链翻到一个很常见的写法。主 Handler 要尽快返回,于是把「通知下游」「补写日志到第三方」丢进后台:
func (h *Handler) Notify(w http.ResponseWriter, r *http.Request) {
// ... 解析、落库、写响应 ...
go func() {
// 错在这里:沿用了请求的 Context,但 goroutine 比请求活得久
ctx := r.Context()
_ = h.downstream.PostEvent(ctx, payload)
}()
w.WriteHeader(http.StatusAccepted)
}
初衷没错:主路径别被下游拖死。问题叠了三层:
- 1. Context 绑错了生命周期
- 客户端一断开或本服务超时取消,
r.Context()就 done 了。后台 goroutine 还在跑,要么立刻收到 cancel 打出一串「context canceled」,要么更糟——下游接口本身不认取消,连接一直挂着。 - 2. HTTP Client 没有硬超时
- 共享的
http.Client只设了Timeout: 0(默认),依赖 Context。Context 一旦用错,连接池里的 inflight 请求就可能无限挂起。 - 3. go 出去就不管了
- 没有限流、没有等待、没有失败退避。流量一高,goroutine 和连接一起堆积,内存曲线就成了早上看到的台阶。
复现其实很简单:用压测把 Notify 打到下游故意 sleep 的桩服务上,同时让一部分客户端提前断开。不用十分钟,goroutine 计数就会难看起来。
11:10 — 改法:给后台任务单独的「寿命」
后台任务不该继承「这个 HTTP 请求还在不在」的 Context,而该有自己的截止时间,并和进程退出挂钩。改完后大致是这样:
var httpClient = &http.Client{
Timeout: 8 * time.Second, // 硬上限,Context 之外再保一道
Transport: &http.Transport{
MaxIdleConns: 100,
MaxConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
},
}
func (h *Handler) Notify(w http.ResponseWriter, r *http.Request) {
// ... 解析、落库、写响应 ...
payload := clonePayload(payload) // 别让后台 goroutine 读到还在变的数据
h.bg.Go(func(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := h.downstream.PostEvent(ctx, payload); err != nil {
log.Printf("downstream notify failed: %v", err)
}
})
w.WriteHeader(http.StatusAccepted)
}
h.bg 可以是自研的 worker pool,也可以是 golang.org/x/sync/errgroup 包一层「带并发上限的后台」。要点只有三个:
- 独立超时:
WithTimeout从进程级 Context(或context.Background()+ 服务 shutdown 信号)派生,不要直接用r.Context()。 - Client 硬超时:即便 Context 忘了传,连接也不会无限挂。
- 并发有顶:高峰时宁愿丢弃或入队延迟处理,也不要无脑
go。
如果下游调用必须「请求结束前做完」,那就别 go——用同一个 r.Context() 同步调用,超时直接返回错误给上游。半同步半异步最容易写出这种泄漏。
旁注:PostEvent 里也要把 Context 传到底
Client 设了 Timeout 还不够。若内部又包了一层重试,每一次重试都要检查 ctx.Done(),并用 http.NewRequestWithContext:
func (c *Downstream) PostEvent(ctx context.Context, body []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body) // 读完 body,连接才能复用
if resp.StatusCode >= 300 {
return fmt.Errorf("downstream status %d", resp.StatusCode)
}
return nil
}
最后那行 io.Copy(io.Discard, resp.Body) 值得单独记一笔:不读完响应体就 Close,连接经常无法回池,表现也是连接数和内存慢慢涨,和 goroutine 泄漏很像。
当天晚上曲线平了之后
改完发灰度,RSS 在流量回落时跟着掉了,goroutine 数回到基线附近。事后在代码规范里加了三条很短的约束,比写长文档管用:
① 业务 Handler 里禁止裸
go func,一律走带上限的后台池。
② 任何出站 HTTP 必须同时具备 Context 截止时间与 Client.Timeout。
③ 响应体不读完不关,关之前至少 Discard。
Go 的并发模型很轻,轻到「先 go 出去再说」几乎没有心智成本。真正贵的是生命周期:谁创建、谁取消、谁等待、谁在进程退出时收尾。内存曲线只是把这件事用另一种语言写在了监控屏上。