goroutine 泄漏排查:线上暴涨那次的记录

那天晚上看到监控面板上的 goroutine 数量是一条斜向上的直线,从三位数一路爬。重启之后归零,第二天又开始爬。所以 goroutine 泄漏怎么排查,关键一步是在它还没涨上去之前先抓一份栈快照——涨上去之后就来不及看清了。

一、goroutine 泄漏怎么排查:先看现象

服务本身还能响应,没有明显的错误率上升。但内存占用一直在涨,而且要重启才降得下来。

goroutine 数量的曲线很规整,斜率基本不变,说明是有节奏地在漏——每次触发某个操作就漏一批,而不是一次性漏完。

这个规律本身就很有用:匀速漏,说明漏点和某个周期性或高频操作绑在一起。

二、排查走过的顺序

第一步是看总量。指数增长、线性增长、阶梯增长,这三种对应的原因不一样。我这次是线性,所以先去想「哪些操作是高频的」。

第二步是抓栈快照。Go 的服务里可以暴露一个调试接口,拿到当前所有 goroutine 的调用栈。关键是抓两份,间隔一段时间,然后看哪些栈在两份快照里都存在且数量在增加。

只看一份快照没用,因为大量 goroutine 是正常在跑的短命任务。两份 diff 出来还在涨的,才是泄漏。

第三步是按栈归类数数量。同一份栈出现几十上百次,基本就锁定位置了。

我当时就是这么找到的:有一批 goroutine 的栈全部停在同一个 channel 的接收操作上,数量在两份快照之间涨了一截。

三、我犯的错

错误一:第一次抓快照抓晚了。我是等曲线已经很明显了才去抓,那时候同类栈的数量庞大,肉眼很难分辨哪些是正常的。应该看到曲线开始变陡的初期就抓。

错误二:一开始只抓了一份。看着几千个 goroutine 的栈,完全不知道该从哪个看起。加了对比之后五分钟定位。

错误三:在本地复现上浪费了时间。本地环境跑不出那个量级,因为触发条件跟真实使用频率有关。后来我直接用线上的快照定位,一次就中。

四、根因是什么

那批 goroutine 在等一个永远不会到来的消息。

上游有一次操作没有正常走完就返回了,负责收尾的那段代码没执行到,接收方就一直阻塞在 channel 上。每一次异常返回,就留下一个不会退出的 goroutine。

这类泄漏的共性很明显:发送方和接收方之间的约定被某条提前返回的路径打破了。

五、改法和复查

改法分两层。

第一层是给每个 goroutine 一个确定的退出条件。用 context 控制,上游取消或者超时的时候,下游能收到信号并退出。这样即使走的是异常路径,goroutine 也会收尾。

第二层是让 channel 的收发配对。如果发送方可能提前返回,就用带缓冲的 channel,或者在发送前先判一下取消状态,别让发送方无意义地阻塞。

复查方式是:改完再抓两份快照,看同类栈有没有还在涨。这个动作我等了两天才做,因为泄漏是慢的,改完立刻看数字是看不出来的。

六、能不能复制

能,而且方法很固定:先看曲线形状判断漏的节奏,再抓两份间隔快照做 diff,再按栈归类数数量,最后回到代码里看那条栈上的阻塞点在等谁。

这套流程不需要什么高级工具,标准库自带的调试接口就够。真正费时间的是抓快照的时机——要趁数量还小、栈还看得清的时候抓。

另外补一句:改完之后最好加一个长期观察项,把 goroutine 总数纳入日常监控。goroutine 泄漏怎么排查这件事,难的不是修,是发现得太晚。

常见问题

Q:goroutine 泄漏怎么排查最快?

A:抓两份间隔一段时间的 goroutine 栈快照,做 diff,看哪些调用栈的数量在增加。只看一份快照很难分辨正常任务和泄漏。

Q:怎么判断 goroutine 数量增长是不是泄漏?

A:看曲线形状。如果服务重启后归零、之后又按接近固定的斜率涨回去,基本可以判定是泄漏,而不是正常波动。

Q:平时怎么预防?

A:每个 goroutine 都要能回答「它什么时候退出」。用 context 给它一个取消路径,并且把 goroutine 总数放进日常监控。

相关阅读


本文作者西米,37 岁程序员,一个人加五个 AI 员工在跑一家公司。踩过的坑和真实账本都写在公众号「西米分享」。

完整代码、踩坑清单与最新进展,都在公众号。

📱 关注公众号「西米分享」