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 总数放进日常监控。
相关阅读
- Go 里 channel 和 mutex 该用哪个
- Go context 超时取消怎么一层层传下去
- Go 并发读写 map 报错,怎么改才安全
- Go 并发测试老是挂,我最后这么改稳的
- Go 并发编程的 5 个常见坑,新手先看
本文作者西米,37 岁程序员,一个人加五个 AI 员工在跑一家公司。踩过的坑和真实账本都写在公众号「西米分享」。
完整代码、踩坑清单与最新进展,都在公众号。
📱 关注公众号「西米分享」