RSS 一直涨到 235MB:pprof 定位 gnet 网关里 Peek/Next/Discard 的内存泄漏
服务运行后 RSS 一直涨。项目是一个基于 gnet 的事件驱动长连接网关,核心网络模型是 reactor + epoll,少量 event-loop 管理大量连接。为了定位和修复,给服务增加了 pprof 端口(6060),通过 heap profile 对比分析,锁定了泄漏源头。再由 pprof 数据反查业务代码,定位到 gnet 的 Peek() / Next() / Discard() API 使用不当,最终彻底修复。
采样本:抓两份 heap profile
用 curl 直接拉取当前 heap 快照。建议采集前先触发一次 GC,拿到的是“存活对象”,排除可回收的临时垃圾,数据更干净:
curl -s http://127.0.0.1:6060/debug/pprof/heap > heap_1.pb.gz
间隔一段时间再抓第二份 heap_2.pb.gz。
查看整体内存占用用 go tool pprof -unit=MB,输出以 MB 为单位更直观。对比两个 profile 的增量最有效,只看“新分配但没回收”的部分。
top 定位:byteslice.Pool.Get 占 86%
使用 pprof 工具采集到数据后,用 top20 命令可以看到异常 —— byteslice.(*Pool).Get 占了 204.28MB,占整个 heap 的 86.84%。用 peek 命令看清完整的调用链。
反查业务代码:根因在 Peek / Next 的语义
锁定 byteslice.Pool.Get 后,沿调用链回溯到业务代码 ReadFullPacketDataFromConn —— 这是每次 OnTraffic 回调里读包的入口函数。
headerBuf, err := c.Peek(packets.HEADER_MIN_LEN) // 又一次 Peek
...
fullPacketBuf, err := c.Next(packetLen) // Next 依然不释放 c.cache
问题就在这里:gnet 的 c.Peek() 会把数据拷贝到内部的 c.cache 缓冲区(这个 cache 的底层就是 byteslice.Pool.Get 分配的)。而 c.Next() 只移动读取游标,不会释放 cache。每次 OnTraffic 进来,Peek 分配新的 cache,但旧的 cache 因为引用还在,GC 没法回收 —— 204MB 就是这么堆积出来的。
修复:OnTraffic 结束时 Discard(0)
核心思路:OnTraffic 结束时,使用连接上的 Discard 强制清空 gnet 的内部缓存。
func ReadFullPacketDataFromConn(c gnet.Conn, addr string) (err error, data []byte) {
// 【核心修复】函数退出时,强制清空 Peek 产生的 c.cache
defer func() {
if _, errDiscard := c.Discard(0); errDiscard != nil {
log.Warn().Msgf("Discard cache failed, addr: %v, err: %v",
addr, errDiscard)
}
}()
// 正常读包:Peek → Next → 深拷贝 → 返回
headerBuf, err := c.Peek(packets.HEADER_MIN_LEN)
...
fullPacketBuf, err := c.Next(packetLen)
...
data = make([]byte, packetLen)
copy(data, fullPacketBuf)
return nil, data
}
验证:235MB → 30MB
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 总 heap | 235.25 MB | 30.50 MB |
| byteslice.Pool.Get | 204.28 MB (86.8%) | 占用很少(不在 top20 里了) |
| 最大单一分配 | kafka.NewProducer (22.90 MB) | kafka.NewProducer (22.90 MB) |
byteslice 池的 204MB 彻底消失,堆内存从 235MB 降到了 30MB。
可复用的排查清单
RSS 异常上涨
→ 接入 pprof,采集 heap profile
→ top20 发现 byteslice.Pool.Get 占 86%
→ peek 追溯完整调用链:eventloop.read → ring.Buffer.grow → byteslice.Get
→ 回到业务代码 ReadFullPacketDataFromConn 查 Peek/Next 用法
→ 定位:c.Peek() 分配 c.cache 但不释放,Next() 只移游标
→ 修复:defer c.Discard(0) 强制清 cache + OnClose 清理引用
→ 验证:heap 从 235MB 降到 30MB
经验
- pprof 是线上排障的手段 —— 生产服务一定要留。后续还可以在
OnTick中加定时输出内存指标的日志,便于持续观测。 - gnet 的
Peek/Next/Discard语义要区分清楚:
| API | 作用 | 是否释放 c.cache |
|---|---|---|
| Peek(N) | 预读 N 字节(拷贝到 c.cache) | 否 |
| Next(N) | 消费 N 字节(移动读游标) | 否 |
| Discard(N) | 丢弃 N 字节 | 是(N=0 时清空全部) |
如果你用 Peek 看了数据,就一定要在合适时机调用 Discard() 清理掉对应的 cache,否则就是慢性泄漏。