go 垃圾回收器不管理未关闭的 goroutine、http.response.body、time.ticker 或子切片持有的底层数组;内存泄漏源于隐式引用未释放,如 cancel() 仅发通知而非终止阻塞 goroutine。

Go 的垃圾回收器不会帮你关掉没用的 goroutine、没读完的 http.Response.Body、没停止的 time.Ticker,也不会替你释放被子切片长期持有的底层数组。内存泄漏不是 GC 失效,而是你还在“引用”它——只是你忘了它还在那儿。
goroutine 泄漏:cancel() 调了但没用
调用 context.CancelFunc 只是发通知,不是杀进程。如果 goroutine 正卡在 http.Do、(无缓冲通道)、<code>time.Sleep 或死循环里,它根本收不到信号。
- 必须在所有阻塞点插入
select { case 检查 - 避免在 goroutine 内部直接用
time.After;高并发下应改用time.NewTicker+Stop() - 用
sync.WaitGroup等待启动的 goroutine 显式退出,别只靠defer cancel()就以为万事大吉 - 检查
pprof的/debug/pprof/goroutine?debug=2,看堆栈是否大量停留在runtime.gopark或net/http相关调用上
资源未 Close:io.Closer 被当摆设
任何实现了 io.Closer 接口的对象(*os.File、*http.Response、*sql.Rows、*sql.Conn)都必须显式 Close(),否则文件句柄、连接池、内存缓冲都会持续累积。
-
http.Get后必须defer resp.Body.Close();哪怕你只读前几个字节,不关就 leak - 数据库查询用
rows.Close(),别依赖defer在函数末尾才关——如果函数提前 return,defer不会触发 - 打开文件后立即
defer f.Close(),且确保f是成功返回的非 nil 值,否则 panic - 注意
sql.DB本身不用 Close,但db.Query返回的*sql.Rows必须 Close
slice/string 截取:底层数组被意外锁死
s[:n] 或 str[:n] 不会复制数据,只是共享原底层数组。若原 slice/string 很大,而截取部分很小且长期存活,整个底层数组都无法被 GC 回收。
- 从大字符串中提取小片段时,用
string([]byte(s[:n]))强制复制(注意:有两次分配开销) - 或用
strings.Builder构造新字符串,Grow()避免多次扩容 - 切片操作后需保留独立数据时,用
append([]T(nil), s[:n]...)替代直接截取 - 尤其警惕全局缓存中存了子切片——一个
cache["key"] = bigData[:10]可能锁住上百 MB
pprof 定位:别只看 top,要对比和追踪
单次 /debug/pprof/heap?debug=1 很难看出泄漏,因为增长量可能只占总量千分之一。真正有效的是时间维度上的差异分析。
- 用
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap抓两个间隔数分钟的快照 - 在 pprof UI 中选
top -cum或执行diff -base before.pprof after.pprof,聚焦inuse_space增长项 - 重点关注
runtime.malg(goroutine 栈)、bytes.makeSlice、strings.Builder.String、net/http.(*Transport).roundTrip这类高频分配点 - 若发现某函数调用栈反复出现且 size 持续上涨,大概率就是泄漏源头——它可能没做清理,也可能在不断 new 对象却没释放引用
最常被忽略的点是:泄漏往往不在你写的主逻辑里,而在你依赖的第三方库的回调、中间件的上下文传递、或者日志埋点中悄悄持有了 request/response 的引用。上线前跑一次 go tool pprof 对比基线,比出问题再救火成本低得多。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











