Go 服务 RSS 一直涨:pprof 抓两份 heap 用 -base 对比,定位到 gnet 的 Peek 缓存
参考链接:
- https://www.mfun.ink/2026/02/14/go-memory-leak-pprof-flamegraph/
- gnet Peek 泄漏案例
- https://jishuzhan.net/article/2078673413882388481
线上现象:Go 服务 RSS 一路涨,重启后短暂恢复,过几小时继续涨。QPS 稳定甚至下降,RSS 和 Heap Objects 仍持续爬升,Full GC 后也不明显回落。
先看三组指标
不要只看某一刻的值,至少同时看:
process_resident_memory_bytes:进程 RSSgo_memstats_heap_inuse_bytesgo_memstats_heap_objects
判断标准:稳定负载下 HeapInuse 持续单向增长,例如每分钟涨 2MB、持续 5 分钟以上,且重启后复现。否则大概率是 GC 滞后、长生命周期缓存,或对象本来就该活久一点。
开 pprof 埋点与采样
pprof 只监听内网或 localhost,不要裸奔在公网。
import _ "net/http/pprof"
go func() {
_ = http.ListenAndServe("127.0.0.1:6060", nil)
}()
采样至少保留两份间隔样本,单样本很难判断增长责任方:
curl -s http://127.0.0.1:6060/debug/pprof/heap > heap_1.pb.gz
sleep 300
curl -s http://127.0.0.1:6060/debug/pprof/heap > heap_2.pb.gz
curl -s http://127.0.0.1:6060/debug/pprof/allocs > allocs.pb.gz
# 想排除 GC 滞后可加 ?gc=1
分析时重点看几类命令:
go tool pprof -top heap_2.pb.gz
go tool pprof -base heap_1.pb.gz heap_2.pb.gz # 只显示新增的分配
go tool pprof -http=:8081 heap_2.pb.gz # FlameGraph
关注 inuse_space 增长最大的类型,以及同一调用路径是否持续扩大,常见对象是 map、slice、string、buffer。
goroutine 也要一起看
goroutine 数量可以参考:100 正常,100-500 需关注,大于 1000 几乎 100% 是泄漏。查完整堆栈:
curl "http://localhost:6060/debug/pprof/goroutine?debug=2"
debug=1 只返回总数。重点盯 chan receive、select、time.Sleep 状态的 goroutine。
真实案例:gnet 的 Peek 缓存
top20显示byteslice.(*Pool).Get占 204.28MB,整个 heap 的 86.84%。- 沿调用链回溯:
eventloop.read→ring.Buffer.grow→byteslice.Get→ 业务函数ReadFullPacketDataFromConn,也就是每次OnTraffic回调的读包入口。 - 根因:gnet 的
c.Peek(N)会把数据拷贝进内部c.cache,底层由byteslice.Pool分配;c.Next(N)只移动读游标,不释放 cache。每次OnTraffic都Peek分配新 cache,旧 cache 引用还在,GC 收不掉。 - API 语义:
Peek预读,不释放 cache;Next消费并移动游标,不释放 cache;Discard(N)丢弃并释放,N=0清空全部。
修复方式是在读包函数退出时 defer c.Discard(0) 强制清 cache:
defer func() {
if _, err := c.Discard(0); err != nil {
log.Warn().Err(err).Msg("Discard cache failed")
}
}()
结果:总 heap 从 235.25MB 降到 30.50MB,byteslice.Pool.Get 掉出 top20。
常见泄漏模式清单
- 全局 map 只增不删,key 持续增长。用时间戳或 UUID 当 key,却从不
delete。 - goroutine 泄漏:channel 阻塞永不返回、context 没 cancel、重连循环旧连接没关、
for+select没 default/timeout。每个泄漏的 goroutine 会钉住一堆大对象。 - 缓存没有 TTL/LRU,上限失控。
- ticker/timer 忘
Stop。 - channel 积压导致对象滞留。
pprof 看不见的三类
- cgo 分配的 C 堆,例如
C.malloc、C.CString,可用 valgrind 验证。 unsafe.Pointer或reflect绕过类型系统持有的内存。- netpoll、timer、worker goroutine 内部结构。
这类问题常见表现是 NumGoroutine 持续涨、RSS 涨得比 HeapInuse 快。
回归验证
同负载压测 30-60 分钟;对比修复前后 heap_objects 斜率;复抓 heap 做 -base 对比。理想状态是对象总量进入平台期,GC 后 heap_inuse 明显回落,RSS 不再单边上升。
Go 的「内存泄漏」大多不是 GC 失灵,而是对象引用路径设计失控。