Go内存上涨排查:后台goroutine与HTTP客户端泄漏复盘

周一早上打开 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.Requestbufio.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 出去再说」几乎没有心智成本。真正贵的是生命周期:谁创建、谁取消、谁等待、谁在进程退出时收尾。内存曲线只是把这件事用另一种语言写在了监控屏上。