编程 RSS 一直涨到 235MB:pprof 定位 gnet 网关里 Peek/Next/Discard 的内存泄漏

2026-09-14 21:31:21

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

指标修复前修复后
总 heap235.25 MB30.50 MB
byteslice.Pool.Get204.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

经验

  1. pprof 是线上排障的手段 —— 生产服务一定要留。后续还可以在 OnTick 中加定时输出内存指标的日志,便于持续观测。
  2. gnet 的 Peek / Next / Discard 语义要区分清楚:
API作用是否释放 c.cache
Peek(N)预读 N 字节(拷贝到 c.cache)
Next(N)消费 N 字节(移动读游标)
Discard(N)丢弃 N 字节是(N=0 时清空全部)

如果你用 Peek 看了数据,就一定要在合适时机调用 Discard() 清理掉对应的 cache,否则就是慢性泄漏。

原文来源:https://wenfh2020.com/2026/03/06/go-memory-leak/

复制全文 生成海报 Go pprof 内存泄漏 gnet 性能排查

推荐文章

程序员茄子在线接单