真泄漏是对象被钉住不放,需同时满足:HeapInuse在稳定负载下线性增长、重启归零复现、GC后不回落;抓heap profile须禁用?gc=1、文件名带.heap/.pb.gz后缀、间隔≥30秒采样,再用pprof差分inuse_space定位。

真泄漏不是“分配多”,而是对象被钉住不放——Gin 本身不泄漏,但你用它写的 handler、中间件、全局缓存,很容易无意中把 *http.Request、[]byte、*UserConfig 钉在内存里不放。
怎么确认是 Gin 场景下的真泄漏,不是 GC 滞后或缓存预热
别只看容器 RSS 或 top 的 RES 值。盯三个指标,缺一不可:
-
runtime.ReadMemStats().HeapInuse在稳定流量下(比如每秒 100 QPS 持续 5 分钟)线性上涨,且每次重启归零复现 - 两次
/debug/pprof/heap?inuse_space请求间隔 ≥ 30 秒,返回值持续增大(如从 120MB → 180MB → 240MB) - 执行
runtime.GC()后,HeapInuse不回落,甚至更高——说明 GC 收不走那些对象
刚启动时内存涨得快?大概率是 Gin 的 sync.Pool 初始化、HTTP 连接池预热、或模板缓存加载,不是泄漏。
Gin 中最常钉住内存的 4 类写法
这些代码在 Gin 路由或中间件里一出现,就极易导致泄漏:
-
HTTP handler 闭包捕获大结构体:
func(c *gin.Context) { data := loadBigData(); c.JSON(200, data) }——data被闭包持有,随每次请求累积,GC 不敢动 -
中间件里往
c.Request.Context()存大对象:c.Request.Context().Value("payload", hugeBytes),又没清理,context.Value 是 map,永不释放 -
全局
sync.Map或map[string]*T不清过期项:比如用json.Unmarshal解析配置进全局 map,key 永远不删,*T就一直驻留 -
未设超时的
http.Client+goroutine泄漏:中间件里起go func() { resp, _ := client.Do(req) },没加context.WithTimeout,请求超时后 goroutine 卡在chan receive,钉住整个响应体和 request body
抓 heap profile 必须绕开的三个坑
线上 profile 抓错,90% 的分析都是白忙:
-
绝对不要加
?gc=1:它强制 GC 后采样,会把本该长期存活的泄漏对象“刷掉”,profile 里只剩临时垃圾,完全失真 -
文件名必须带
.heap或.pb.gz后缀:比如before.heap,否则go tool pprof报错"unrecognized profile format" -
两次采样不能在 GC 高峰期:用
runtime.ReadMemStats()查NextGC和HeapAlloc,若两者差值
推荐操作:wget http://127.0.0.1:6060/debug/pprof/heap -O before.heap → 等 45 秒 → wget ... -O after.heap。
用 pprof 差分定位 Gin 泄漏源的关键动作
命令行只是入口,真正定位靠 Web UI 的三步操作:
- 运行
go tool pprof -http=:8080 -base before.heap after.heap ./myapp(./myapp是编译产物路径,不能省) - 浏览器打开后,右上角
SAMPLE切到inuse_space(不是alloc_objects) - 点
View → Difference,再在搜索框Focus输入你的可疑类型,比如*model.Order或[]byte
重点盯调用链里夹着这些关键词的路径:(*ConnPool).reaper、timerproc、select、chan receive——它们往往出现在 Gin 中间件或 handler 的 goroutine 启动点。如果看到 gin.(*Context).JSON 下一层是 json.(*Encoder).Encode,再往下是 make([]byte),而该 []byte 的 inuse_space 持续增长,说明你可能在 handler 里反复构造大 JSON,又没复用 buffer。
复杂点在于:泄漏常常是间接的。比如 *UserConfig 看似没被 new,但它被塞进全局 sync.Map 后,key 永远存在;或者一个 time.Ticker 在中间件里启动却没 Stop(),它背后持有一个闭包,闭包又捕获了整个 *gin.Context。这些都得靠 Difference + Focus 才能揪出来,单看 topN 几乎无效。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











