一次高频上报接口压测后的 goroutine 异常增长排查
最后更新时间:
一次高频数据上报接口压测时,接口并没有马上报错,平均响应时间也还在可接受范围内,但服务运行一段时间后,goroutine 数量持续上涨。压测停止后,goroutine 数没有明显回落。
这个问题最后定位到异步写入链路里的 channel 阻塞:请求侧把数据写入任务通道,但消费速度跟不上,部分 goroutine 卡在发送操作上。问题不属于“程序马上崩溃”的类型,但继续放任会导致内存、调度和连接资源被逐步吃掉。
现象
压测场景是一个高频数据上报接口,接口内部会完成参数校验,然后把合法数据投递到后台 worker 处理。压测开始后,表面指标并不算异常:
- HTTP 成功率保持正常
- 平均响应时间没有立刻升高
- MySQL 写入没有明显报错
但运行几分钟后,服务进程的 goroutine 数开始持续增长。压测停止后,数量没有明显回落。
复现方式
使用 JMeter 对上报接口做持续压测:
- 线程数:100
- Ramp-up:10 秒
- 循环时间:10 分钟
- 请求体:模拟固定结构的上报数据
- 观察指标:吞吐、平均响应时间、P95、错误率、goroutine 数
压测过程中同步打开 pprof:
1 | |
同时保留 HTTP 形式的 goroutine 快照:
1 | |
第一轮判断
最开始怀疑过三个方向:
- 数据库写入慢,导致请求阻塞
- worker 数量过少,消费速度跟不上
- channel 没有容量或容量太小,发送方被阻塞
数据库连接池和慢 SQL 先被排除。JMeter 里没有明显的大面积超时,数据库日志也没有持续慢查询。问题更像是在应用内部排队。
pprof 观察
goroutine 快照里出现了大量相同堆栈,位置集中在向任务 channel 发送数据的地方。典型形态类似:
1 | |
这说明请求 goroutine 并没有卡在数据库或网络 IO 上,而是卡在 channel 发送操作上。
如果 channel 是无缓冲的,发送方必须等接收方同时准备好;如果 channel 是有缓冲的,当缓冲区被写满后,发送方一样会阻塞。
问题代码
问题代码的简化形式如下:
1 | |
这段代码的问题在于,发送操作没有任何超时和降级路径。一旦 worker 消费慢,或者下游写入抖动导致队列被填满,请求 goroutine 就会一直卡在 s.queue <- item 上。
压测停止后,已经卡住的 goroutine 仍然等待队列腾出空间,所以数量不会马上回落。
修复方式
修复方向不是简单把队列调大。队列加大只能推迟问题暴露,不能解决发送方无限等待。
处理方式是给投递动作增加边界:
1 | |
修复后,请求侧有三个明确结果:
- 投递成功
- 请求上下文超时或取消
- 队列已满,快速失败
同时补了两类指标:
- 当前队列长度
- 队列满导致的拒绝次数
这样后续压测时,不需要等 goroutine 涨起来才知道队列已经顶住。
验证结果
同样的 JMeter 参数重新压测后,观察结果如下:
- goroutine 数在高峰期会升高,但压测停止后能够回落
- 队列满时接口返回可预期错误,而不是无限阻塞
- pprof 里不再出现大量堆积在 channel send 的 goroutine
- JMeter 错误率能明确反映系统容量边界
这次处理没有追求“所有请求都成功”。高频上报场景下,系统更需要可控失败,不能让请求 goroutine 无限堆积。
总结
【1】内存泄漏不一定会导致程序马上崩溃,但是任何持续增长且无法回收的 goroutine 都应该处理掉。
【2】Go 语言中的无缓冲通道要求发送 goroutine 和接收 goroutine 同时准备好,才能完成发送和接收。任意一方没有准备好,另一方都会阻塞等待。
【3】有缓冲通道不代表不会阻塞。缓冲区满时会阻塞发送方,缓冲区空时会阻塞接收方。高频写入场景必须给发送动作设置超时、取消或快速失败路径。
【4】JMeter 用来复现压力,pprof 用来确认 goroutine 堆积位置。两者配合,比只看接口响应时间更容易定位并发类问题。