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

一、什么时候需要 pprof

出现以下任一情况就该用:

  • 进程 RSS 持续上涨,没有回落

  • OOM killer 反复杀你的进程

  • 静态代码审计找不到明显 leak,但 heap 确实在涨

  • CPU 或延迟异常

**核心哲学**: 不要靠猜。让程序自己吐出 profile,数据说话。


二、第一步:埋 pprof endpoint

只需要在 `main.go` 加两行:

```go

import (

_ "net/http/pprof" // side-effect import,自动注册 /debug/pprof/* handler

"net/http"

"log"

)

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 handler

  2. **`localhost:6060`**: 只监听回环网卡,外部连不上,**安全**。想访问必须通过 SSH 隧道

  3. **端口 6060**: 是 Go 官方约定,不是硬性要求,可以随意改

为什么不用 0.0.0.0

pprof 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。

**自检隧道是否活着**:

```bash

lsof -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 应用 | 换端口 |


四、第三步:抓 profile

Go 有多种 profile,内存 leak 主要看两种:

| Profile | endpoint | 用来查什么 |

|---|---|---|

| **heap** | `/debug/pprof/heap` | 当前 in-use 的对象在哪分配 |

| **goroutine** | `/debug/pprof/goroutine` | 每个 goroutine 现在卡在哪 |

| allocs | `/debug/pprof/allocs` | 累计分配(含已释放的) |

| profile | `/debug/pprof/profile?seconds=30` | CPU profile(采样 30s) |

| trace | `/debug/pprof/trace?seconds=5` | 详细执行追踪 |

| 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.gz'

ssh 1.2.3.4 'curl -s http://localhost:6060/debug/pprof/goroutine > /tmp/goroutine.pb.gz'

scp 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 最直接的信号。

```bash

go tool pprof -top /tmp/goroutine.pb.gz | head -10

```

输出示例:

```

File: polymarket-dual-jeff-live8

Type: goroutine

Showing nodes accounting for 1857, 99.84% of 1860 total ← ★ 看这里

flat flat% sum% cum cum%

1857 99.84% 99.84% 1857 99.84% runtime.gopark

0 0% 99.84% 1436 77.20% ...polyWS.connectLoop.func1

0 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 但没传能取消的 context

  3. **重连循环里旧连接没关**: 新连接起 goroutine,旧的 goroutine 还在 read 老 conn

  4. **无限 for 里 select 少 case**: `select { case ch: ... }` 没 `default` 也没 timeout


六、第五步:分析 heap profile(找谁在囤内存)

```bash

go 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 大,自己在扩 slice

0 0% 65.09% 57.99MB 65.09% crypto/tls.Conn.readRecord

0 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.connectLoop

goroutine 里: 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。

```bash

go 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 = mapstring*Item{}

func onEvent(id string, item *Item) {

cacheid = 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.Conn

```go

// ❌ 泄漏版

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 忘 Stop

```go

// ❌ 泄漏版

func doStuff() {

ticker := time.NewTicker(time.Second)

// 忘 ticker.Stop(),goroutine 永远受调度

}

```

**pprof 表现**: goroutine 大量 `runtime.timerproc`

**修法**: `defer ticker.Stop()`


十一、本次真实案例复盘(v16e_live)

现象

  • 服务器 RSS 每天涨 100+MB

  • 5 天累积 500MB+,触发 OOM 被系统 killed

  • 静态代码审计只找到 5MB/周量级的小 leak,解释不了 100MB/天

排查过程

  1. **加 pprof + 重新部署**(埋点)

  2. **等 24 小时让 leak 累积够多**(信号明确)

  3. **抓 heap + goroutine 各一份**

  4. **看 goroutine 数量: 1857!** → 立刻锁定 leak

  5. **看 heap top -cum: polyWS.connectLoop 86%** → 确认位置

  6. **交叉验证**: goroutine 里 77% 也在 polyWS.connectLoop.func1

  7. **结论**: `polymarket-base-go/poly_ws.go` 每次 WS 断线重连时,老 goroutine 没退出,持有的 TLS conn + read buffer 累积

关键数据点

```

heap total: 89 MB

polyWS.connectLoop: 77 MB (86%)

├── crypto/tls.readRecord: 58 MB (65%)

└── websocket.ReadMessage: 52 MB (58%)

goroutine total: 1857

polyWS.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/pprof"

go 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.gz'

scp server:/tmp/heap.pb.gz /tmp/

命令行 top

go tool pprof -top -cum /tmp/heap.pb.gz | head -30

go tool pprof -top /tmp/goroutine.pb.gz | head -20

Web UI (最好用)

go tool pprof -http=:18080 /tmp/heap.pb.gz

go 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?seconds=30 > /tmp/cpu.pb.gz

go tool pprof -http=:18080 /tmp/cpu.pb.gz

触发 GC 后再抓(排除临时对象干扰)

curl http://localhost:6060/debug/pprof/heap?gc=1 > /tmp/heap-after-gc.pb.gz

查看 raw goroutine stack (debug=2)

curl http://localhost:6060/debug/pprof/goroutine?debug=2 > /tmp/goroutine-stack.txt

```


十四、进阶

  • **`runtime.ReadMemStats`** 主动打印 heap 各段大小,不用抓 profile

  • **`runtime.NumGoroutine()`** 定期日志输出 goroutine 数量,持续监控

  • **Continuous profiling** 工具(pyroscope / parca)自动化定期抓 profile

  • **eBPF 工具**(bpftrace / bcc)在生产环境无侵入抓样


参考资料

相关推荐
言乐66 小时前
Python实现基本搜索引擎
java·linux·开发语言·python·搜索引擎
Wang's Blog7 小时前
Go-Zero项目开发5: 数据库与缓存一致性问题及 go-zero 缓存机制解析
数据库·缓存·golang
小灰灰搞电子7 小时前
C++ boost::range 详解:基于最新版本的现代范围处理指南
开发语言·c++
ch.ju7 小时前
Java Programming Chapter 4——lambda expression
java·开发语言
布朗克1687 小时前
Go 入门到精通-18-文件操作
java·数据库·golang
罗超驿7 小时前
10.Java对象比较完全指南:从equals到Comparable与Comparator
java·开发语言
脱胎换骨-军哥7 小时前
C++ 与 Go 生成质量比拼,谁是分布式主力语言
c++·分布式·golang
快乐星空Maker7 小时前
C++【生存游戏】开发:荒岛往事 第三期
开发语言·c++·游戏·编程语言
郝学胜-神的一滴7 小时前
Qt 高级编程 035:无边框窗口阴影以及圆角双特效实现
开发语言·c++·qt·程序人生·用户界面