协程泄漏最直接表现是 /livez 或 /readyz 响应变慢或超时,而 runtime.NumGoroutine() 持续单向上涨(请求后不回落、压测后不恢复、长期单调上升)是铁证;需用 ?debug=2 查阻塞堆栈,结合 goleak 在测试中拦截。

runtime.NumGoroutine() 是第一道筛子,但别只看单次值
协程泄漏导致健康检查僵死,最直接的表现是 /livez 或 /readyz 接口响应变慢甚至超时,而 ps、top 看不到明显 CPU/内存飙升。此时先抓 runtime.NumGoroutine():它不耗资源,5 秒内就能确认是否真在涨。
注意这三点:
- 刚启动时数字跳到 120+ 很正常——
pprof、log、healthzhandler 自身都会启 goroutine,不是泄漏 - 重点盯:单次请求处理完后
NumGoroutine()没回落;压测结束 30 秒后仍比空闲态高一大截;稳定运行几小时后呈单调上升(50 → 180 → 420) - 在 handler 入口、出口、空闲态三处打点:
log.Printf("goroutines@start: %d", runtime.NumGoroutine()),别只采一次样
debug=2 的 pprof goroutine 必须用,否则看不到卡在哪
/debug/pprof/goroutine?debug=1 只给统计摘要,完全没法定位;必须用 ?debug=2 才输出完整调用栈。Kubernetes 环境下直接 curl http://localhost:6060/debug/pprof/goroutine?debug=2 就行。
重点关注这些阻塞状态的 goroutine:
-
chan receive:监听未关闭的 channel,比如后台 ticker 启了但没 stop,或 consumer 从已关闭的chan读 -
select:漏写default,或所有 case 都阻塞(常见于依赖未 ready 时还硬等) -
semacquire:sync.WaitGroup忘记Done(),或锁没释放 -
IO wait:比如http.Get没设超时、io.ReadFull卡住、SSH 连接没 timeout
如果看到几百个 goroutine 全卡在同一个函数里,阻塞时间显示 “250000+ minutes”,基本就是它了。
goleak 要进单元测试,别等上生产才后悔
很多协程泄漏是测试阶段埋下的:比如 time.Ticker 忘记 Stop(),或者 go func() { ... }() 闭包等不到信号就一直挂着。这类问题 goleak 能当场揪出。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
在 TestMain 或每个测试末尾加:
defer goleak.VerifyNone(t)
它会对比测试前后 goroutine 列表,报告“新增但未退出”的协程及其初始调用栈。注意:这个检查只对测试有效,不能替代线上监控。
健康端点本身必须防泄漏,否则越查越堵
用 time.Ticker 做后台健康巡检很常见,但线上最常踩的坑是:服务热重启时旧 ticker 没停,新 ticker 又起,多个 goroutine 并发写同一个状态变量。
必须做到:
- 用
context.WithCancel包裹 ticker 循环,收到 shutdown 信号时主动ticker.Stop() - 健康状态变量(如
redisHealthy)必须用sync/atomic或sync.RWMutex保护,禁止裸写 -
/livez里绝对不调db.Ping()、redis.Ping()—— 它只做快照,耗时超过 200ms 就该怀疑进程本身有问题 -
/readyz的依赖探测结果必须缓存,不能每次请求都真实拨号;高频探针下,db.Ping()可能瞬间建数百个空闲连接
真正容易被忽略的是:健康端点 handler 一旦被 goroutine 泄漏拖慢,livenessProbe 就会开始误判,进而触发重启雪崩——这时你查的已经不是原故障,而是连锁反应了。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










