一次 gRPC 死锁把服务拖垮:我如何用 pprof 在 5 分钟内定位 goroutine 泄漏

发布时间:2026/7/6 4:14:52

一次 gRPC 死锁把服务拖垮:我如何用 pprof 在 5 分钟内定位 goroutine 泄漏 一次 gRPC 死锁把服务拖垮我如何用 pprof 在 5 分钟内定位 goroutine 泄漏上周生产环境出了个怪事。一个 Go 微服务的 goroutine 数从正常的几百个飙到 12 万内存直接吃满然后 OOM 被 K8s 杀掉。重启之后过半小时又这样。看监控像 goroutine 泄漏但代码里明明用了context.WithTimeout也没看到哪里有死循环。折腾了半天最后用 pprof 五分钟定位到根因一个 gRPC 流的关闭顺序问题导致的死锁。今天把这个排查过程记下来免得下次又踩。现象服务莫名其妙被 OOM先说症状。我们的服务是一个 gRPC 网关负责把 HTTP 请求转成 gRPC 调下游服务。平时 goroutine 数稳定在 300-500 个P99 延迟 50ms 左右。上周上线一个新功能后运维同学开始收到告警Goroutine 数缓慢上涨每小时涨几千个内存跟着线性增长6 小时后达到 8G 限制K8s 触发 OOMKillPod 重启重启后半小时曲线又开始往上爬一开始以为是内存泄漏但 heap profile 看下来内存占用大的地方全是runtime.malggoroutine 的栈内存。也就是说不是堆内存泄漏是 goroutine 泄漏。第一步用 pprof 看 goroutine 栈Go 内置的 pprof 简直是排查这类问题的神器。我们的服务已经集成了net/http/pprof直接 curl 就能拿到数据curl-shttp://localhost:6060/debug/pprof/goroutine?debug2|head-200导出来的 goroutine 栈密密麻麻几万行。但仔细看大部分都卡在同一个地方goroutine 114514 [semacquire, 3 minutes]: sync.runtime_SemacquireMutex(0xc0001a2030, 0x0, 0x1) /usr/local/go/src/runtime/sema.go:71 0x25 sync.(*Mutex).Lock(...) /usr/local/go/src/sync/mutex.go:138 0x8c main.(*StreamManager).Close(0xc0001a2000) /app/stream.go:45 0x3a google.golang.org/grpc.(*clientStream).RecvMsg(...) /app/vendor/google.golang.org/grpc/stream.go:884 0x1b2thousands of goroutines 都在等同一个 mutex。这个StreamManager.Close方法看起来就是罪魁祸首。根因gRPC 流关闭时的死锁来看问题代码。我们有个StreamManager管理 gRPC 双向流大概长这样typeStreamManagerstruct{mu sync.Mutex stream pb.MyService_ConnectClient closedbool}func(sm*StreamManager)Close(){sm.mu.Lock()defersm.mu.Unlock()ifsm.closed{return}sm.closedtrue// 关闭 gRPC 流 - 这里会阻塞sm.stream.CloseSend()}看起来没问题对吧加锁、标记关闭、关闭流。但问题就藏在CloseSend()里。gRPC 的CloseSend()内部会等待流的接收端也关闭。而如果接收端的处理 goroutine 正好也在调用StreamManager.Close()就会形成经典的死锁Goroutine A业务逻辑调用Close()→ 拿到锁 → 调用CloseSend()→ 等待接收端关闭Goroutine B接收循环收到 EOF → 调用Close()→ 尝试拿锁 → 被阻塞A 拿着锁等 BB 等着 A 释放锁。更坑的是CloseSend()在网络异常时可能会永远等下去不会超时。所以这些 goroutine 就永远卡在那越积越多。修复方案把 IO 操作移出临界区修复其实很简单不要把可能阻塞的 IO 操作放在锁里面。func(sm*StreamManager)Close(){sm.mu.Lock()ifsm.closed{sm.mu.Unlock()return}sm.closedtruestream:sm.stream// 复制引用sm.mu.Unlock()// 提前释放锁// IO 操作在锁外执行即使阻塞也不会死锁ifstream!nil{ctx,cancel:context.WithTimeout(context.Background(),5*time.Second)defercancel()done:make(chanstruct{})gofunc(){stream.CloseSend()close(done)}()select{case-done:// 正常关闭case-ctx.Done():log.Println(CloseSend timeout, forcing cleanup)}}}核心改动就两点锁里只做状态变更不做 IO标记closed true之后就释放锁给 CloseSend 加超时即使网络卡了5 秒后也会强制返回不会永远 hang 住改完上线goroutine 数立刻稳定回 400 个左右内存也恢复正常。踩坑记录坑 1defer 释放锁的陷阱原来的代码用了defer sm.mu.Unlock()虽然简洁但如果CloseSend()阻塞锁就永远释放不了。在可能阻塞的 IO 操作前建议显式Unlock()。坑 2pprof 默认只采样部分 goroutineGo 的 goroutine profile 默认只采样前 50 个等待的 goroutine。如果问题不明显可能看不到全貌。可以加上?debug2看全部或者用runtime.SetMutexProfileFraction调整采样率。坑 3gRPC 流没有内置超时CloseSend()不像普通 RPC 调用那样有context.WithTimeout控制。如果不自己做超时处理网络抖动时很容易 hang 住。gRPC 流的优雅关闭建议都加上 goroutine select 的超时保护。坑 4本地复现不出来这个问题在本地开发环境完全复现不了因为本地网络太快了CloseSend()瞬间就能完成。只有在生产环境网络有延迟或丢包时才会触发。所以生产环境一定要加监控告警goroutine 数、内存、goroutine 泄漏都得看。写在最后这次排查最大的收获不是修了这个 bug而是意识到锁 阻塞 IO 定时炸弹。Go 的 mutex 很简单但用不好就是生产事故的源头。尤其是 gRPC、数据库连接、网络请求这种可能阻塞的 IO千万别放在锁里面。pprof 真的是 Go 程序员的救命工具。如果你还没在工程里接入net/http/pprof强烈建议现在就加上。几行代码的事关键时刻能省你几个小时。对了改完代码后我还加了一条监控规则goroutine 数超过 5000 就告警。毕竟谁也不想半夜被 PagerDuty 叫醒。

相关新闻