goroutine泄漏是go服务内存持续上涨的主因,多因goroutine卡死或无限创建,如http请求中未管控的go func()导致。

goroutine泄漏:最常见也最容易被忽略的内存黑洞
Go服务内存持续上涨,90%以上的情况不是堆分配问题,而是 goroutine 卡死或无限创建。比如每次HTTP请求都 go func() { 启一个协程读 channel,但 <code>ch 永远不关闭,这个 goroutine 就永远阻塞、永远占着栈内存(默认2KB起步),且无法被GC回收。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 用
runtime.NumGoroutine()定期打点,压测前后对比——从20飙到2000?基本可断定泄漏源在业务启动的 goroutine 里 - 访问
/debug/pprof/goroutine?debug=2查看所有 goroutine 的调用栈,重点关注卡在chan receive、select或time.Sleep的堆栈 - 避免在循环中无条件启 goroutine,尤其别把
time.After()放进 for 循环——它背后是newTimer(),每个 timer 都会逃逸到堆并长期驻留
全局缓存与 map 持久引用:你以为在缓存,其实在囤积
自实现缓存时写 var cache = make(map[string]*User) 然后只增不删,是典型“温水煮青蛙”式泄漏。GC 能回收值,但只要 key 还在 map 里,整个键值对就一直活在堆上。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 所有缓存必须带淘汰机制:要么用
sync.Map+ 显式Delete,要么用bigcache/freecache这类带 LRU 和过期策略的库 - 慎用指针做 map value:如果
*User指向一个大结构体,即使只存一个字段,整个结构体也因引用关系无法释放 - 检查是否误将临时 slice 截取后赋给全局变量,如
globalSlice = bigSlice[:10]——底层底层数组仍被持有,导致百万字节内存无法回收
pprof堆快照对比:别只看 top,要会 diff
单次 go tool pprof http://localhost:6060/debug/pprof/heap 常常看不出问题,因为泄漏对象在 inuse_space 里占比太小。真正有效的是跨时间抓两个快照做差分。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 先用
wget http://localhost:6060/debug/pprof/heap -O heap1.out抓初始快照,等10分钟再抓heap2.out - 执行
go tool pprof -base heap1.out heap2.out,进入交互后输入top,此时看到的是“新增分配最多”的函数,而非总量最多 - 重点盯
bytes.makeSlice、runtime.malg、net/http.readRequest这类高频分配点——它们本身不泄漏,但暴露了你代码里反复 new buffer / request / response 的逻辑缺陷
连接池与资源未归还:泄漏不在代码里,在 defer 忘了的地方
Redis 客户端每次调用 GetRedisClient() 都新建一个 *redis.Client,而不是复用单例,会导致连接池对象爆炸式增长——就像你每点一次外卖都新开一家餐厅。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 数据库、Redis、HTTP client 等连接对象必须全局单例初始化,禁止在 handler 里反复 new;检查所有
NewClient是否被包在init()或var once sync.Once里 - 所有文件、
http.Response.Body、sql.Rows必须配defer xxx.Close(),且确保 defer 在 error 分支之后——别写成if err != nil { return } defer f.Close(),否则出错就漏关 - 用
go vet -shadow检查变量遮蔽,防止局部db, err := sql.Open(...)遮蔽了全局db,导致你以为在用连接池,其实一直在建新连接
最难排查的泄漏往往藏在“看起来很安全”的地方:一个没 Stop 的 time.Ticker、一个被闭包捕获的长生命周期 struct、一个被 context.WithCancel 创建却从未 cancel 的子 context。工具只能告诉你“哪胖了”,而判断“为什么胖”,还得回到代码里,一行行看谁悄悄 hold 住了不该 hold 的引用。










