一次高频上报接口压测后的 goroutine 异常增长排查

文章发布时间:

最后更新时间:

一次高频数据上报接口压测时,接口并没有马上报错,平均响应时间也还在可接受范围内,但服务运行一段时间后,goroutine 数量持续上涨。压测停止后,goroutine 数没有明显回落。

这个问题最后定位到异步写入链路里的 channel 阻塞:请求侧把数据写入任务通道,但消费速度跟不上,部分 goroutine 卡在发送操作上。问题不属于“程序马上崩溃”的类型,但继续放任会导致内存、调度和连接资源被逐步吃掉。

现象

压测场景是一个高频数据上报接口,接口内部会完成参数校验,然后把合法数据投递到后台 worker 处理。压测开始后,表面指标并不算异常:

  • HTTP 成功率保持正常
  • 平均响应时间没有立刻升高
  • MySQL 写入没有明显报错

但运行几分钟后,服务进程的 goroutine 数开始持续增长。压测停止后,数量没有明显回落。

复现方式

使用 JMeter 对上报接口做持续压测:

  • 线程数:100
  • Ramp-up:10 秒
  • 循环时间:10 分钟
  • 请求体:模拟固定结构的上报数据
  • 观察指标:吞吐、平均响应时间、P95、错误率、goroutine 数

压测过程中同步打开 pprof:

1
go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine

同时保留 HTTP 形式的 goroutine 快照:

1
http://127.0.0.1:6060/debug/pprof/goroutine?debug=2

第一轮判断

最开始怀疑过三个方向:

  • 数据库写入慢,导致请求阻塞
  • worker 数量过少,消费速度跟不上
  • channel 没有容量或容量太小,发送方被阻塞

数据库连接池和慢 SQL 先被排除。JMeter 里没有明显的大面积超时,数据库日志也没有持续慢查询。问题更像是在应用内部排队。

pprof 观察

goroutine 快照里出现了大量相同堆栈,位置集中在向任务 channel 发送数据的地方。典型形态类似:

1
2
3
4
goroutine 18342 [chan send]:
internal/service.(*TelemetryService).Submit(...)
internal/handler.(*TelemetryHandler).Post(...)
net/http.HandlerFunc.ServeHTTP(...)

这说明请求 goroutine 并没有卡在数据库或网络 IO 上,而是卡在 channel 发送操作上。

如果 channel 是无缓冲的,发送方必须等接收方同时准备好;如果 channel 是有缓冲的,当缓冲区被写满后,发送方一样会阻塞。

问题代码

问题代码的简化形式如下:

1
2
3
4
func (s *Service) Submit(ctx context.Context, item Telemetry) error {
s.queue <- item
return nil
}

这段代码的问题在于,发送操作没有任何超时和降级路径。一旦 worker 消费慢,或者下游写入抖动导致队列被填满,请求 goroutine 就会一直卡在 s.queue <- item 上。

压测停止后,已经卡住的 goroutine 仍然等待队列腾出空间,所以数量不会马上回落。

修复方式

修复方向不是简单把队列调大。队列加大只能推迟问题暴露,不能解决发送方无限等待。

处理方式是给投递动作增加边界:

1
2
3
4
5
6
7
8
9
10
func (s *Service) Submit(ctx context.Context, item Telemetry) error {
select {
case s.queue <- item:
return nil
case <-ctx.Done():
return ctx.Err()
default:
return ErrQueueFull
}
}

修复后,请求侧有三个明确结果:

  • 投递成功
  • 请求上下文超时或取消
  • 队列已满,快速失败

同时补了两类指标:

  • 当前队列长度
  • 队列满导致的拒绝次数

这样后续压测时,不需要等 goroutine 涨起来才知道队列已经顶住。

验证结果

同样的 JMeter 参数重新压测后,观察结果如下:

  • goroutine 数在高峰期会升高,但压测停止后能够回落
  • 队列满时接口返回可预期错误,而不是无限阻塞
  • pprof 里不再出现大量堆积在 channel send 的 goroutine
  • JMeter 错误率能明确反映系统容量边界

这次处理没有追求“所有请求都成功”。高频上报场景下,系统更需要可控失败,不能让请求 goroutine 无限堆积。

总结

【1】内存泄漏不一定会导致程序马上崩溃,但是任何持续增长且无法回收的 goroutine 都应该处理掉。

【2】Go 语言中的无缓冲通道要求发送 goroutine 和接收 goroutine 同时准备好,才能完成发送和接收。任意一方没有准备好,另一方都会阻塞等待。

【3】有缓冲通道不代表不会阻塞。缓冲区满时会阻塞发送方,缓冲区空时会阻塞接收方。高频写入场景必须给发送动作设置超时、取消或快速失败路径。

【4】JMeter 用来复现压力,pprof 用来确认 goroutine 堆积位置。两者配合,比只看接口响应时间更容易定位并发类问题。