真正内存泄漏需满足:HeapInuse持续单向增长,且pprof中某类对象的inuse_space随时间线性上升;goroutine泄漏则表现为select{}、<-ch等阻塞态长期存在。

Go 程序内存没降 ≠ 泄漏,真正要盯的是 heap_inuse 持续单向增长,且某类对象(如 *http.Request、[]uint8)的 -inuse_space 随时间线性上升。
怎么确认是真泄漏,不是 GC 没来得及回收
别一看到 top -cum 里 bytes.makeSlice 占得多就下结论——那是短期分配热点,不是泄漏。关键看两点:
- 用
curl http://localhost:6060/debug/pprof/heap?debug=1查原始统计,盯heap_inuse和heap_objects:如果多次采样(间隔 ≥30 秒)后,heap_objects增量 ≈ 新分配数、且不回落,才值得深挖 - 采样时加
?gc=1参数,比如go tool pprof http://localhost:6060/debug/pprof/heap?gc=1,强制触发 GC 后再抓快照,避免把本该回收的对象误判为泄漏 - 手动调一次
runtime.GC()后立刻采样,如果inuse_space仍不掉,基本可以锁定是长期存活对象没被释放
pprof 抓到 *model.User 占大头,但调用栈只显示 http.HandlerFunc.ServeHTTP
这说明对象被 HTTP handler 的闭包意外捕获了,不是直接 new 出来的。泄漏点往往藏在中间层:
- 进 Web UI(
go tool pprof -http=:8080 heap.pb.gz),点「Focus」输入*model.User,再点「View → Call graph」,看谁在 new 它、又谁在持有它 - 重点查
context.WithCancel、未关闭的channel、time.Ticker或time.Timer——它们一旦启动,就会强引用整个闭包环境 - 如果调用栈里有
runtime.gopark或runtime.selectgo,优先检查for range ch循环前是否有人忘了close(ch),或select里没处理ctx.Done()
goroutine 泄漏才是内存涨得慢的真凶
90% 的“缓慢内存上涨”背后不是对象没回收,而是 goroutine 卡死,死钉着一堆大对象。比如一个闭包捕获了含 []byte 的结构体,它活着,整个结构体就永远进不了 GC。
- 查 goroutine 不要看
/debug/pprof/goroutine默认页(只给总数),必须用?debug=2:比如curl "http://localhost:6060/debug/pprof/goroutine?debug=2",才能看到完整堆栈 - 重点盯状态为
chan receive、select、time.Sleep的 goroutine;数量随请求稳定增加,就是典型泄漏 - HTTP handler 中启 goroutine,必须绑定
req.Context(),并在select中监听ctx.Done();time.Ticker必须显式Stop(),否则 runtime 会一直持有其函数引用 - 普通
map和sync.Map是最常被忽略的泄漏源:用时间戳、UUID 当 key 却从不delete,等于自己造内存黑洞
本地复现时 profile 文件打不开或分析不准
常见原因是路径或后缀不对,或者没走对采集路径:
- 用
pprof.WriteHeapProfile(f)手动写文件时,文件后缀必须是.heap,否则go tool pprof无法识别 - HTTP 接口采集时,别只依赖
/debug/pprof/heap,要带上参数:比如/debug/pprof/heap?gc=1&seconds=30(30 秒内强制 GC 并采样) - 如果服务启在非本地,确保目标端口(如 6060)能从你的机器访问;若被防火墙拦住,改用本地文件方式:
go tool pprof your_binary heap.pb.gz - Web UI 中点「Source」查看具体代码行时,如果显示
???,说明二进制没带调试信息,编译时加-gcflags="all=-N -l"
真正难的不是跑通 pprof,而是看懂调用图里那个看似无害的 context.WithCancel 为什么会让整个 handler 闭包里的 [][]byte 一直活在堆上——这种间接持有关系,最容易被忽略。


















