news 2026/4/29 19:16:23

生产环境 gRPC 连接池泄漏导致 goroutine 暴涨:我用 pprof 和拦截器链 20 分钟止血

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
生产环境 gRPC 连接池泄漏导致 goroutine 暴涨:我用 pprof 和拦截器链 20 分钟止血

生产环境 gRPC 连接池泄漏导致 goroutine 暴涨:我用 pprof 和拦截器链 20 分钟止血

周二下午三点,监控群里突然炸了。P99 延迟飙到 8 秒,goroutine 数量从 2k 涨到 12k,内存使用率曲线像坐了火箭。

我当时正在喝咖啡,手一抖洒了半杯。这次不是 OOM,不是死锁,是 goroutine 泄漏。定位到最后,问题出在 gRPC 连接池上——一个我以为"早就搞定"的地方。

现象:不是普通的内存泄漏

一开始我以为是内存泄漏,但 heap profile 显示内存增长并不夸张。真正异常的是 goroutine 数量:

$curl-shttp://localhost:9090/metrics|grepgo_goroutines# HELP go_goroutines Number of goroutines that currently exist.# TYPE go_goroutines gaugego_goroutines12483

平时也就 2000 左右。翻了 6 倍。

pprof 抓 goroutine 堆栈,top 命令一眼看出问题:

go tool pprof goroutine.out(pprof)top10Showing nodes accountingfor11850,95% of12483total ---------------------------------------------------------- flat flat% sum% cum cum%820065.68%65.68%820065.68% google.golang.org/grpc.(*ClientConn).resetAddrConn215017.23%82.91%215017.23% google.golang.org/grpc.(*addrConn).createTransport...

65% 的 goroutine 卡在resetAddrConn里。这不对。gRPC 的连接应该被复用,哪来这么多ClientConn在创建连接?

根因:连接池配了,但没真的复用

我们的微服务架构里,A 服务调 B 服务,用的是 gRPC 短连接模式——每次 RPC 都grpc.Dial(),调完就defer conn.Close()

听起来很干净,对吧?实际上这就是坑。

gRPC 的ClientConn不是单条 TCP 连接,它内部维护了一个连接池(addrConn)。grpc.Dial()的代价很高:解析服务地址、建 TCP 连接、HTTP/2 握手、发送 SETTINGS 帧。如果你每次请求都 Dial/Close,连接根本来不及复用,反而在频繁创建和销毁 addrConn,goroutine 越积越多。

更坑的是,我们为了加链路追踪,写了个拦截器:

funcTracingInterceptor(ctx context.Context,methodstring,req,replyinterface{},cc*grpc.ClientConn,invoker grpc.UnaryInvoker,opts...grpc.CallOption)error{// 每次请求都新建一个带 timeout 的 contextctx,cancel:=context.WithTimeout(ctx,5*time.Second)defercancel()// 加追踪信息md:=metadata.New(map[string]string{"trace-id":generateTraceID(),})ctx=metadata.NewOutgoingContext(ctx,md)returninvoker(ctx,method,req,reply,cc,opts...)}

这个拦截器本身没问题,但结合"每次请求都 Dial"的使用方式,问题被放大了。context.WithTimeout创建的 timer goroutine,加上ClientConn内部的 addrConn goroutine,双重泄漏。

止血:三步把 goroutine 压回 2k

第一步:把短连接改成长连接

最简单也最有效的改动。服务启动时 Dial 一次,全局复用*grpc.ClientConn

typeServiceClientstruct{conn*grpc.ClientConn}funcNewServiceClient(targetstring)(*ServiceClient,error){conn,err:=grpc.Dial(target,grpc.WithInsecure(),// 生产环境请用 TLSgrpc.WithKeepaliveParams(keepalive.ClientParameters{Time:10*time.Second,Timeout:3*time.Second,PermitWithoutStream:true,}),)iferr!=nil{returnnil,err}return&ServiceClient{conn:conn},nil}func(c*ServiceClient)Close()error{returnc.conn.Close()}

关键点:grpc.Dial()只在初始化时调一次。后续所有 RPC 都复用同一个conn

第二步:拦截器加连接状态检查

长连接模式下,要防止连接断开导致请求失败。我在拦截器里加了状态检查:

funcHealthyInterceptor(ctx context.Context,methodstring,req,replyinterface{},cc*grpc.ClientConn,invoker grpc.UnaryInvoker,opts...grpc.CallOption)error{// 检查连接状态state:=cc.GetState()ifstate==connectivity.TransientFailure||state==connectivity.Shutdown{returnfmt.Errorf("grpc connection unhealthy: %v",state)}ctx,cancel:=context.WithTimeout(ctx,5*time.Second)defercancel()returninvoker(ctx,method,req,reply,cc,opts...)}

如果连接挂了,立刻报错,而不是 hung 住等超时。这对熔断策略很重要。

第三步:连接池参数调优

默认参数在生产环境不够激进。我调了这几个:

conn,err:=grpc.Dial(target,grpc.WithInsecure(),grpc.WithKeepaliveParams(keepalive.ClientParameters{Time:10*time.Second,// 每 10s 发一个 keepaliveTimeout:3*time.Second,// keepalive 等 3s 回 ACKPermitWithoutStream:true,// 空闲连接也保活}),grpc.WithDefaultServiceConfig(`{ "loadBalancingPolicy": "round_robin", "healthCheckConfig": {"serviceName": ""} }`),)

PermitWithoutStream: true是重点。默认情况下,如果连接上没有活跃 stream,gRPC 不会发 keepalive。这意味着空闲连接可能在网络设备(NAT、LB)的超时后被静默断开,客户端还以为是好的,下次请求就 hung 住。

验证:pprof 对比

改完上线,goroutine 数量立刻回落:

# 修复前go_goroutines12483# 修复后go_goroutines2150

pprof 抓修复后的 goroutine 堆栈,top 10 里已经看不到resetAddrConn了。

P99 延迟从 8 秒回到 120ms。内存增长曲线平了。

补充:一个更隐蔽的坑

改完后我复盘代码,发现还有一个更隐蔽的问题。我们用了 gRPC 的encoding.Codec做自定义序列化,里面有一行:

func(c*CustomCodec)Marshal(vinterface{})([]byte,error){buf:=bufferPool.Get().(*bytes.Buffer)deferbufferPool.Put(buf)// 这里!err:=json.NewEncoder(buf).Encode(v)returnbuf.Bytes(),err// 返回的 []byte 引用了 buf 的底层数组}

buf.Bytes()返回的切片引用的是bytes.Buffer的内部数组。defer bufferPool.Put(buf)把 buf 放回 pool 后,下一个拿到这个 buf 的 goroutine 可能覆盖数据。严格来说这跟 goroutine 泄漏无关,但属于"用了 pool 但数据不安全"的典型坑。

正确的做法:

func(c*CustomCodec)Marshal(vinterface{})([]byte,error){buf:=bufferPool.Get().(*bytes.Buffer)deferbufferPool.Put(buf)buf.Reset()err:=json.NewEncoder(buf).Encode(v)iferr!=nil{returnnil,err}// 复制一份,避免底层数组被覆盖result:=make([]byte,buf.Len())copy(result,buf.Bytes())returnresult,nil}

写在最后

这次事故让我重新理解了一件事:gRPC 的"连接池"不是自动生效的,它取决于你怎么用ClientConn。短连接模式下,gRPC 的性能甚至不如 HTTP/1.1 keep-alive。

还有,监控里一定要看 goroutine 数量。内存泄漏容易被发现,goroutine 泄漏往往是先涨延迟、再涨内存,等你反应过来可能已经晚了。

如果你也在用 gRPC,建议检查一下代码里有没有"每次 RPC 都 Dial"的写法。有的话,改掉它。就这一处改动,可能省下无数个凌晨被告警炸醒的夜晚。


参考代码完整版:[gist链接占位,实际可放在 GitHub 或内部仓库]

相关阅读:

  • gRPC Keepalive 官方文档
  • Go pprof 实战指南
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/29 19:12:36

RPG Maker MV/MZ加密资源逆向解析工具:技术实现与应用实践

RPG Maker MV/MZ加密资源逆向解析工具:技术实现与应用实践 【免费下载链接】Java-RPG-Maker-MV-Decrypter You can decrypt whole RPG-Maker MV Directories with this Program, it also has a GUI. 项目地址: https://gitcode.com/gh_mirrors/ja/Java-RPG-Maker-…

作者头像 李华
网站建设 2026/4/29 19:10:04

005双向链表 - 可向前也可向后遍历的动态结构

双向链表 - 可向前也可向后遍历的动态结构 双向链表——数字世界的后退键📰 5W1H 发明者故事 Who(何人)- 发明者是谁? 发明者:艾伦纽厄尔(Allen Newell)和赫伯特西蒙(Herbert Simo…

作者头像 李华
网站建设 2026/4/29 19:07:35

PDFMathTranslate:AI驱动的学术PDF翻译神器,保留格式精度达99%

PDFMathTranslate:AI驱动的学术PDF翻译神器,保留格式精度达99% 【免费下载链接】PDFMathTranslate PDF scientific paper translation with preserved formats - 基于 AI 完整保留排版的 PDF 文档全文双语翻译,支持 Google/DeepL/Ollama/Open…

作者头像 李华
网站建设 2026/4/29 18:59:24

Windows热键冲突完全手册:精准定位与彻底解决指南

Windows热键冲突完全手册:精准定位与彻底解决指南 【免费下载链接】hotkey-detective A small program for investigating stolen key combinations under Windows 7 and later. 项目地址: https://gitcode.com/gh_mirrors/ho/hotkey-detective 在Windows操作…

作者头像 李华