Go 内存泄漏排查完整教程 (pprof)

发布时间:2026/7/19 9:22:14
Go 内存泄漏排查完整教程 (pprof) ## 一、什么时候需要 pprof出现以下任一情况就该用:- 进程 RSS 持续上涨,没有回落- OOM killer 反复杀你的进程- 静态代码审计找不到明显 leak,但 heap 确实在涨- CPU 或延迟异常**核心哲学**: 不要靠猜。让程序自己吐出 profile,数据说话。---## 二、第一步:埋 pprof endpoint只需要在 main.go 加两行:goimport (_ net/http/pprof // side-effect import,自动注册 /debug/pprof/* handlernet/httplog)func main() {// ...其他初始化...// pprof endpoint (仅本地回环,不对外)go func() {if err : http.ListenAndServe(localhost:6060, nil); err ! nil {log.Printf(pprof server exited: %v, err)}}()// ...主逻辑...}### 关键点1. **_ net/http/pprof**: 这个 side-effect import 会往 http.DefaultServeMux 自动注册所有 pprof handler2. **localhost:6060**: 只监听回环网卡,外部连不上,**安全**。想访问必须通过 SSH 隧道3. **端口 6060**: 是 Go 官方约定,不是硬性要求,可以随意改### 为什么不用 0.0.0.0pprof endpoint 会暴露:- heap 内容(可能含敏感数据结构)- goroutine stack(暴露代码路径)- CPU profile(暴露程序热点)**永远不要对公网开放**。localhost SSH 隧道是标准做法。---## 三、第二步:通过 SSH 隧道访问远程 pprof假设程序跑在服务器 1.2.3.4,你要在 mac 上访问:bash# 建隧道 (前台运行,别关这个终端)ssh -L 6060:localhost:6060 1.2.3.4-L 本地端口:远端主机:远端端口 语法。含义:mac 的 6060 转发到服务器上的 localhost:6060。**自检隧道是否活着**:bashlsof -iTCP:6060 -sTCP:LISTEN# 有输出 隧道已建立### 常见坑| 现象 | 原因 | 处理 ||---|---|---|| bind: Address already in use | 本地 6060 被别的程序占了 | 改本地端口: ssh -L 16060:localhost:6060 ... || connect: Connection refused | 服务器上 pprof 没起 / 服务挂了 | ssh 到服务器 ps 检查 || 浏览器打开显示 Whitelabel Error | 隧道没起,本地 8080/6060 是别的 java 应用 | 换端口 |---## 四、第三步:抓 profileGo 有多种 profile,内存 leak 主要看两种:| Profile | endpoint | 用来查什么 ||---|---|---|| **heap** | /debug/pprof/heap | 当前 in-use 的对象在哪分配 || **goroutine** | /debug/pprof/goroutine | 每个 goroutine 现在卡在哪 || allocs | /debug/pprof/allocs | 累计分配(含已释放的) || profile | /debug/pprof/profile?seconds30 | CPU profile(采样 30s) || trace | /debug/pprof/trace?seconds5 | 详细执行追踪 || block | /debug/pprof/block | 阻塞事件(需 runtime.SetBlockProfileRate) || mutex | /debug/pprof/mutex | 锁竞争(需 runtime.SetMutexProfileFraction) |**下载方式(直接从服务器抓)**:bash# 在 mac 上:ssh 1.2.3.4 curl -s http://localhost:6060/debug/pprof/heap /tmp/heap.pb.gzssh 1.2.3.4 curl -s http://localhost:6060/debug/pprof/goroutine /tmp/goroutine.pb.gzscp 1.2.3.4:/tmp/heap.pb.gz 1.2.3.4:/tmp/goroutine.pb.gz /tmp/或走隧道直接抓:bash# 隧道已建好后:curl -s http://localhost:6060/debug/pprof/heap /tmp/heap.pb.gz---## 五、第四步:分析 goroutine profile(最快找到 leak)**第一件事永远先看 goroutine 数量**。这是 leak 最直接的信号。bashgo tool pprof -top /tmp/goroutine.pb.gz | head -10输出示例:File: polymarket-dual-jeff-live8Type: goroutineShowing nodes accounting for 1857, 99.84% of 1860 total ← ★ 看这里flat flat% sum% cum cum%1857 99.84% 99.84% 1857 99.84% runtime.gopark0 0% 99.84% 1436 77.20% ...polyWS.connectLoop.func10 0% 99.84% 225 12.10% ...DVolManager.runOnce.func1### 判读| goroutine 总数 | 判断 ||---|---|| 100 | 正常 || 100-500 | 需关注,不一定是 bug || 1000 | **几乎 100% 是 leak** || 10000 | 严重 leak,程序随时会挂 |### 精准定位看 top 里 % 最高的那行(通常一个函数占 70%),就是罪魁。上面例子里 polyWS.connectLoop.func1 占 77%,直接锁定这个函数。**为什么 goroutine 会 leak?** 常见原因:1. **channel 阻塞永不返回**: goroutine 卡在 -ch 或 ch - x,对端永远不 close 也不发2. **context 没 cancel**: 起了 goroutine 但没传能取消的 context3. **重连循环里旧连接没关**: 新连接起 goroutine,旧的 goroutine 还在 read 老 conn4. **无限 for 里 select 少 case**: select { case ch: ... } 没 default 也没 timeout---## 六、第五步:分析 heap profile(找谁在囤内存)bashgo tool pprof -top -cum /tmp/heap.pb.gz | head -30### 关键理解:flat vs cum| 列 | 含义 ||---|---|| **flat** | 这个函数**自己**分配的字节数 || **cum** | 这个函数**加上它调用的所有子函数**累计分配 |### 用法- 按 **cum** 排序 → 找祖先(哪个高层业务函数在囤内存)- 按 **flat** 排序 → 找元凶(哪个底层函数真在 make/append)**先按 cum 找路径,再按 flat 找底部**。### 真实示例(来自本次 v16e_live 调查)flat flat% sum% cum cum%0 0% 0% 77.03MB 86.47% polyWS.connectLoop ← cum 大 flat 0,说明在调用别人57.99MB 65.09% 65.09% 57.99MB 65.09% bytes.growSlice ← flat 大,自己在扩 slice0 0% 65.09% 57.99MB 65.09% crypto/tls.Conn.readRecord0 0% 65.09% 57.99MB 65.09% crypto/tls.Conn.readRecordOrCCS**解读**:1. polyWS.connectLoop 是入口 (cum 86%)2. 它调用 TLS.readRecord (cum 65%)3. readRecord 里 bytes.growSlice 在囤字节 (flat 65%)4. 结论:**每次 WS 重连,readRecord 分配的 TLS 缓冲永远没释放**---## 七、第六步:交叉验证(最强证据)单独看 heap 或 goroutine 都可能被误导。**两者对上才是铁证**:heap 里: 86% 分配来自 polyWS.connectLoopgoroutine 里: 77% goroutine 卡在 polyWS.connectLoop.func1**同一个包在两份 profile 里都 dominant → 100% 确定 leak 在这** 。因为死掉的 goroutine 持有它当年分配的对象,GC 不能回收。### 反例| heap 大 但 goroutine 正常 | heap 大但不涨 | 短暂大对象,不是 leak || heap 正常但 goroutine 多 | goroutine 泄漏,但对象很小 | 精灵 goroutine 也是 bug,处理它 || 两者都大且都涨 | **典型 leak,先修** |---## 八、Web UI 深入分析命令行 top 只能看 top 20,想深入必须用 web UI。bashgo tool pprof -http:18080 /tmp/heap.pb.gz浏览器自动打开 http://localhost:18080。菜单栏 VIEW 里几个必掌握:| 视图 | 用途 ||---|---|| **Top** | 命令行版的 top,方便滚动查看 || **Graph** | 调用图,方块大小 cum 大小,箭头 调用关系。**最直观** || **Flame Graph** | 火焰图,从上往下追最宽的柱子就是 leak 路径 || **Peek** | 看某个函数被谁调用 调用了谁 || **Source** | 显示源码,行边标注哪一行分配了多少字节。**修 bug 时最有用** || **Disassemble** | 汇编级(平时用不到) |### 火焰图使用要点- **纵轴** 调用栈(上层调用下层)- **横轴** cum(宽度 该函数累计分配)- **颜色无意义**(只是为了区分邻居)- 顺着**最宽的柱子往下追**,追到 flat 最大的那一层就是元凶---## 九、Diff 分析(找出增量 leak)想知道过去 24 小时具体是什么在涨:bash# T0 抓一次curl http://localhost:6060/debug/pprof/heap /tmp/heap-t0.pb.gz# 24 小时后再抓curl http://localhost:6060/debug/pprof/heap /tmp/heap-t1.pb.gz# 求差集go tool pprof -http:18080 -base /tmp/heap-t0.pb.gz /tmp/heap-t1.pb.gz-base 参数会把 t0 的分配从 t1 里减掉,只显示**新增的**。这个视图能精准回答过去这段时间涨的 100MB 到底是啥。---## 十、常见 leak 模式速查### 模式 1: goroutine channel 泄漏go// ❌ 泄漏版func spawn() {ch : make(chan int)go func() {for v : range ch { // ch 永远没人 close,goroutine 永远卡这_ v}}()// 忘了 close(ch)}**pprof 表现**: goroutine 数量线性增长,大量 goroutine 在 runtime.chanrecv1**修法**: 用 context.WithCancel 或 明确 defer close(ch)### 模式 2: map 只写不删go// ❌ 泄漏版var cache map[string]*Item{}func onEvent(id string, item *Item) {cache[id] item // 只加不删,map 无限涨}**pprof 表现**: heap 里 map bucket / hmap 分配持续增长**修法**: 加 TTL / LRU / 明确 delete### 模式 3: sync.Pool 用错go// ❌ 泄漏版(sync.Pool 里放大对象,GC 又回收不了)var pool sync.Pool{New: func() any { return make([]byte, 10*1024*1024) }}**pprof 表现**: heap large object allocations 很大,但 goroutine 正常**修法**: 放合理大小的 buffer;超大对象另想办法### 模式 4: 未关闭的 io.Reader / net.Conngo// ❌ 泄漏版resp, _ : http.Get(url)data, _ : io.ReadAll(resp.Body)// 忘了 resp.Body.Close()**pprof 表现**: FD 数量 heap 都涨,goroutine 里能看到 net/http.readLoop**修法**: defer resp.Body.Close()### 模式 5: ticker / timer 忘 Stopgo// ❌ 泄漏版func doStuff() {ticker : time.NewTicker(time.Second)// 忘 ticker.Stop(),goroutine 永远受调度}**pprof 表现**: goroutine 大量 runtime.timerproc**修法**: defer ticker.Stop()---## 十一、本次真实案例复盘(v16e_live)### 现象- 服务器 RSS 每天涨 100MB- 5 天累积 500MB,触发 OOM 被系统 killed- 静态代码审计只找到 5MB/周量级的小 leak,解释不了 100MB/天### 排查过程1. **加 pprof 重新部署**(埋点)2. **等 24 小时让 leak 累积够多**(信号明确)3. **抓 heap goroutine 各一份**4. **看 goroutine 数量: 1857!** → 立刻锁定 leak5. **看 heap top -cum: polyWS.connectLoop 86%** → 确认位置6. **交叉验证**: goroutine 里 77% 也在 polyWS.connectLoop.func17. **结论**: polymarket-base-go/poly_ws.go 每次 WS 断线重连时,老 goroutine 没退出,持有的 TLS conn read buffer 累积### 关键数据点heap total: 89 MBpolyWS.connectLoop: 77 MB (86%)├── crypto/tls.readRecord: 58 MB (65%)└── websocket.ReadMessage: 52 MB (58%)goroutine total: 1857polyWS.connectLoop.func1: 1436 (77%)DVolManager.runOnce.func1: 225 (12%)UserWsHandle.connectLoop.func2: 166 (9%)### 教训- 100 MB/天 的 leak,**静态审计找不到,pprof 一抓就明**- 如果没有 pprof,可能会花好几天猜错方向- **提前埋 pprof 是零成本的保险**---## 十二、故障排除速查| 报错 | 原因 | 处理 ||---|---|---|| dial tcp: connect: connection refused | pprof 没启 / SSH 隧道断了 | 服务器 ps 看进程,ssh 重连 || server response: 404 Not Found | URL 拼错(常见 pprog → pprof) | 检查 URL || bind: Address already in use | 本地端口占了 | 换端口 -http:18080 || Whitelabel Error Page | Spring Boot 之类占了本地端口 | 换端口 || failed to fetch any source profiles | 网络问题 或 端点不存在 | curl 验证 endpoint || 火焰图为空 | 程序刚启动,没有分配 | 等 5 分钟再抓 |---## 十三、命令速查表bash# 埋点(main.go)import _ net/http/pprofgo http.ListenAndServe(localhost:6060, nil)# 隧道ssh -L 6060:localhost:6060 server-host# 抓 profile(远程)ssh server curl -s http://localhost:6060/debug/pprof/heap /tmp/heap.pb.gzscp server:/tmp/heap.pb.gz /tmp/# 命令行 topgo tool pprof -top -cum /tmp/heap.pb.gz | head -30go tool pprof -top /tmp/goroutine.pb.gz | head -20# Web UI (最好用)go tool pprof -http:18080 /tmp/heap.pb.gzgo tool pprof -http:18081 /tmp/goroutine.pb.gz# Diff (find delta)go tool pprof -http:18080 -base /tmp/heap-t0.pb.gz /tmp/heap-t1.pb.gz# CPU profile (30s 采样)curl http://localhost:6060/debug/pprof/profile?seconds30 /tmp/cpu.pb.gzgo tool pprof -http:18080 /tmp/cpu.pb.gz# 触发 GC 后再抓(排除临时对象干扰)curl http://localhost:6060/debug/pprof/heap?gc1 /tmp/heap-after-gc.pb.gz# 查看 raw goroutine stack (debug2)curl http://localhost:6060/debug/pprof/goroutine?debug2 /tmp/goroutine-stack.txt---## 十四、进阶- **runtime.ReadMemStats** 主动打印 heap 各段大小,不用抓 profile- **runtime.NumGoroutine()** 定期日志输出 goroutine 数量,持续监控- **Continuous profiling** 工具(pyroscope / parca)自动化定期抓 profile- **eBPF 工具**(bpftrace / bcc)在生产环境无侵入抓样---## 参考资料- [Go pprof 官方文档](https://pkg.go.dev/net/http/pprof)- [Go 官方博客: Profiling Go Programs](https://go.dev/blog/pprof)- [Uber Go 团队: Debugging with pprof](https://eng.uber.com/pprof-go-profiler/)- [Julia Evans: Debugging Go with pprof](https://jvns.ca/blog/2017/09/24/profiling-go-with-pprof/)