最近在维护一个以 gnet 框架做长连接服务的后端网关服务系统,在 OnTraffic 读包时出现了内存泄漏。
为了定位和修复,给服务增加了 pprof 端口(6060),通过 heap profile 对比分析,快速锁定了泄漏源头。
再由 pprof 数据反查业务代码,定位到 gnet 的 Peek() / Next() / Discard() API 使用不当,最终彻底修复。
1. 背景
项目是一个基于 gnet 的事件驱动长连接网关,核心网络模型是 reactor + epoll,少量 event-loop 管理大量连接。
在用户量在不断新增过程中,观察到程序内存占用持续上涨。
排查内存问题,Go 生态绕不开 pprof。只要项目中引入,就能零侵入地拿到运行时的 heap / goroutine / CPU profile。
2. 启用 pprof
在服务中加几行代码,监听一个独立的 HTTP 端口(生产环境建议绑定内网 IP,避免暴露到公网):
1
2
3
4
5
6
7
8
9
10
import (
_ "net/http/pprof"
"net/http"
)
func init() {
go func() {
http.ListenAndServe("127.0.0.1:6060", nil)
}()
}
3. 采集 heap profile
用 curl 直接拉取当前 heap 快照。建议采集前先触发一次 GC,拿到的是 “存活对象”,排除可回收的临时垃圾,数据更干净:
1
2
# 在服务端手动触发 GC 后再采集
curl -s "http://127.0.0.1:6060/debug/pprof/heap?gc=1" > heap_gc_$(date +%Y%m%d_%H%M%S).out
如果要对比”增量”,采集两个时间点的快照:
1
2
3
4
5
6
7
# 第一份快照(基线)
curl -o heap1.pprof http://127.0.0.1:6060/debug/pprof/heap
# 等待一段时间让内存继续涨...
# 第二份快照(问题状态)
curl -o heap2.pprof http://127.0.0.1:6060/debug/pprof/heap
如果是远程服务器,把文件 scp 到本地再做分析:
1
scp *.pprof root@103.xx.xx.196:/tmp/
4. 分析 profile
4.1. 查看整体内存占用
用 go tool pprof 打开 profile,-unit=MB 让输出以 MB 为单位更直观:
1
go tool pprof -unit=MB -text ./XSever ./heap_gc_20260302_202045.out
4.2. 火焰图可视化
pprof 内置 Web 界面,可以生成火焰图和调用图:
1
go tool pprof -http=:8080 ./XSever ./heap_gc_20260302_202045.out
4.3. 对比两个快照
这是定位泄漏增量最有效的方式——只看”新分配但没回收”的部分:
1
go tool pprof -diff_base=heap1.pprof -http=:8080 heap2.pprof
浏览器打开 http://localhost:8080,选择 Flame Graph 视图,很容易能看到哪个调用路径在持续堆积内存。
4.4. 交互式分析
go tool pprof 也支持交互模式,常用命令:
1
2
3
4
5
6
# go tool pprof ./XSever ./heap_gc_20260302_202045.out
(pprof) top20 # 前 20 个占用最多的调用
(pprof) list FuncName # 查看某函数的具体代码行开销
(pprof) traces # 完整调用栈
(pprof) peek FuncName # 某个函数的 caller / callee
5. 问题定位
5.1. 泄漏现场
使用 pprof 工具采集到数据后,用 top20 命令可以看到异常 —— byteslice.(*Pool).Get 占了 204.28MB,占整个 heap 的 86.84%:
1
2
3
4
5
6
(pprof) top20
Showing nodes accounting for 235.25MB, 100% of 235.25MB total
flat flat% sum% cum cum%
204.28MB 86.84% 86.84% 204.28MB 86.84% github.com/panjf2000/gnet/v2/pkg/pool/byteslice.(*Pool).Get
22.90MB 9.73% 96.57% 22.90MB 9.73% github.com/confluentinc/confluent-kafka-go/v2/kafka.NewProducer
1.50MB 0.64% 97.21% 2MB 0.85% XSever/core.(*ServerHandler).OnOpen
用 peek 命令看清完整的调用链:
1
2
3
4
(pprof) peek byteslice.Get
204.28MB 100% | github.com/panjf2000/gnet/v2/pkg/buffer/ring.(*Buffer).grow (inline)
0 0% 0% 204.28MB 86.84% | github.com/panjf2000/gnet/v2/pkg/pool/byteslice.Get
204.28MB 100% | github.com/panjf2000/gnet/v2/pkg/pool/byteslice.(*Pool).Get
调用路径非常清晰:
1
eventloop.read → conn.processIO → ring.Buffer.Write → ring.Buffer.grow → byteslice.Pool.Get
gnet 每次读数据都往 ring buffer 写,buffer 不够就 grow,grow 向 byteslice 池申请内存——但这块内存一直没有归还,越积越多。
5.2. 从 pprof 反查业务代码 —— 根因
pprof 告诉你 “哪里在分配”,但 “为什么不释放” 需要回到代码里找。
锁定 byteslice.Pool.Get 后,沿调用链回溯到业务代码 ReadFullPacketDataFromConn —— 这是每次 OnTraffic 回调里读包的入口函数。
泄漏前的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (h *ServerHandler) OnTraffic(c gnet.Conn) (action gnet.Action) {
...
err, packetData := ReadFullPacketDataFromConn(c, addr)
...
}
func ReadFullPacketDataFromConn(c gnet.Conn, remoteIp string) (err error, data []byte) {
...
// 再看一眼包头数据
bufTmp, err := c.Peek(packets.HEADER_BASE_LEN) // ← 又分配一次
...
// 最后 Next 取走整个包
buf, err := c.Next(int(packetLen)) // ← Next 依然不释放 c.cache
data = buf[:packetLen]
return nil, data
}
问题就在这里:gnet 的 c.Peek() 会把数据拷贝到内部的 c.cache 缓冲区(这个 cache 的底层就是 byteslice.Pool.Get)分配的)。而 c.Next() 只移动读取游标,不会释放 cache。每次 OnTraffic 进来,Peek 分配新的 cache,但旧的 cache 因为引用还在,GC 没法回收 —— 204MB 就是这么堆积出来的。
可以这样理解:
Peek = 把水舀到自家水缸里(c.cache),水缸只增不减。
Next = 告诉别人”这桶水我用完了”,但水缸里的水还在。
Discard = 把水缸倒掉。
5.3. 修复方案
核心思路:OnTraffic 结束时,使用链接的 Discard 强制清空 gnet 的内部缓存。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
}
5.4. 修复后验证
修复代码后重新采集 heap:
1
2
3
4
5
6
(pprof) top20
Showing nodes accounting for 30.50MB, 100% of 30.50MB total
flat flat% sum% cum cum%
22.90MB 75.08% 75.08% 22.90MB 75.08% github.com/confluentinc/confluent-kafka-go/v2/kafka.NewProducer
1.03MB 3.37% 78.45% 1.03MB 3.37% golang.org/x/net/html.map.init.1
1MB 3.29% 81.74% 1MB 3.29% runtime.allocm
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 总 heap | 235.25 MB | 30.50 MB |
| byteslice.Pool.Get | 204.28 MB (86.8%) | 0(完全消失) |
| 最大单一分配 | kafka.NewProducer (22.90 MB) | kafka.NewProducer (22.90 MB) |
byteslice 池的 204MB 彻底消失,堆内存从 235MB 降到了 30MB。
6. 总结
6.1. 排查路线
1
2
3
4
5
6
7
8
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
6.2. 经验教训
-
pprof 是线上排障的手段 —— 生产服务一定要留。后续还可以在 OnTick 中还加了定时输出内存指标的日志,便于持续观测。
-
gnet 的 Peek / Next / Discard 语义要区分清楚:
API 作用 是否释放 c.cache Peek(N) 预读 N 字节(拷贝到 c.cache) ❌ Next(N) 消费 N 字节(移动读游标) ❌ Discard(N) 丢弃 N 字节 ✅(N=0 时清空全部) 如果你用 Peek 看了数据,就一定要在合适时机 Discard(0) 清掉 cache,否则就是慢性泄漏。
7. 后记
排查完内存泄漏问题后,自己会反思,当前项目这点用户量是否适合使用 gnet 这样的复杂框架?
其实在处理这个问题前,还遇到了其它框架阻塞问题,前同事直接在 OnTraffic 回调中编写复杂的阻塞业务逻辑。
这样来看,他压根没有理解透彻这个框架的使用。
有时候,领导常常会将产品的前景描绘得如何牛逼,但实际开发中往往需要考虑研发成本和复杂性。
如果项目规模不大,还是建议使用更简单易用的框架。