Go 服务里的 goroutine 泄漏怎么查:从现象到定位的排查顺序

Go 服务里的 goroutine 泄漏怎么查:从现象到定位的排查顺序

Go 的并发能力很好用,但也正因为 goroutine 很轻,很多泄漏问题在早期并不明显。服务刚跑起来时一切正常,过一段时间后内存涨、CPU 抖、响应慢,再看监控才发现 goroutine 数量一直在往上走。

goroutine 泄漏最麻烦的地方在于,它通常不是立刻把服务打崩,而是慢慢侵蚀资源。等你真正注意到的时候,问题往往已经跑在线上很久了。

第一步先确认:数量上涨是不是持续性的

看到 goroutine 数量变多,不要立刻下结论。有些服务在流量高峰期 goroutine 数量会自然升高,关键要看它会不会回落。

更值得警惕的通常是这两种情况:

  1. 流量回落后 goroutine 数仍然不降
  2. 每次执行某个功能后数量都会多一点

这说明问题更像“没收干净”,而不是“业务正常并发”。

常见泄漏源头通常就那几类

在中小项目里,goroutine 泄漏高频来源通常有:

  1. channel 没有消费者或没人关闭
  2. `select` 缺少退出分支
  3. 网络请求超时没设好
  4. 后台 worker 没有停止机制
  5. 定时任务不断创建新 goroutine
  6. 外部订阅或监听在退出后仍然挂着

所以排查时不要一开始就到处乱看,先从这些位置下手,效率会高很多。

第二步:先拿 goroutine profile

如果服务已经暴露了 pprof,那排查会快很多。你最需要看的不是某个局部函数,而是 goroutine 大头都卡在哪些栈上。

很多时候 profile 一拉出来,你就能直接看到:

  1. 一堆 goroutine 卡在 channel receive
  2. 一堆卡在网络 IO 等待
  3. 一堆卡在 timer 或 ticker
  4. 一堆卡在某个 select 循环里

这一步的价值在于,它能先帮你锁定问题类型,而不是靠猜。

第三步:沿“创建点”和“退出条件”一起查

光看到 goroutine 卡在哪里还不够,你还得回头看它是在哪被创建的,以及它原本应该如何退出。

排查时我通常会问两个问题:

  1. 这类 goroutine 是谁启动的
  2. 它在什么条件下应该结束

很多泄漏本质上不是因为代码“不会跑”,而是因为代码“没有结束条件”。

比如一个消费循环写成:

go func() {
    for {
        select {
        case msg := <-ch:
            handle(msg)
        }
    }
}()

这段逻辑看上去能跑,但如果 ch 不再写入、或者服务准备关闭,它依然会一直挂着。

更稳的写法通常会引入:

  1. `context.Context`
  2. `done` channel
  3. 明确的关闭动作

定时器和 ticker 是很典型的坑

不少项目会在某个请求里临时开一个 ticker,或者在后台任务里无限循环开定时器,但结束时又没 Stop()

这类问题早期特别不明显,因为每次多出来的不多,但时间一长就会积成问题。

所以只要你看到:

  1. `time.NewTicker`
  2. `time.NewTimer`
  3. 带循环的定时调度

都值得重点检查有没有停止和回收。

外部调用超时如果没做好,也会拖住 goroutine

另一个很常见的问题是下游调用没有设置超时。例如:

  1. HTTP 客户端没有 timeout
  2. RPC 调用没带 context deadline
  3. 数据库或消息系统连接等待过长

结果就是上层业务结束了,但底下 goroutine 还在挂着等返回。

所以 goroutine 泄漏很多时候不是并发逻辑本身的问题,而是依赖调用没有边界。

生产环境里最有价值的信号

如果你在线上想早点发现这类问题,我更建议盯下面这些指标:

  1. goroutine 数量趋势
  2. 内存曲线是否缓慢爬升
  3. GC 压力是否升高
  4. 某类接口耗时是否随运行时间变长
  5. 发布重启后指标是否立刻恢复

尤其是“重启后恢复、运行久了又恶化”这种现象,往往很像泄漏而不是瞬时抖动。

一个更稳的编码习惯

如果你想减少 goroutine 泄漏,平时写代码时最好养成这几个习惯:

  1. 每个长期运行 goroutine 都有退出路径
  2. 每个下游调用都带超时
  3. ticker 和 timer 用完就停
  4. channel 的生产和消费边界清楚
  5. 后台 worker 有统一生命周期管理

这些看起来像小事,但比等线上出问题再排查轻松太多。

结语

goroutine 泄漏真正难的,不是“查不到”,而是很多团队太晚才开始看。只要你先抓数量趋势,再看 profile,再沿创建点和退出条件回查,问题通常都能逐步缩小。

对 Go 服务来说,稳定性往往不是靠并发开得多,而是靠并发开出去之后收得干净。