一、什么时候需要 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)
}
}()
// ...主逻辑...
}
```
关键点
-
**`_ "net/http/pprof"`**: 这个 side-effect import 会往 `http.DefaultServeMux` 自动注册所有 pprof handler
-
**`localhost:6060`**: 只监听回环网卡,外部连不上,**安全**。想访问必须通过 SSH 隧道
-
**端口 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?** 常见原因:
-
**channel 阻塞永不返回**: goroutine 卡在 `<-ch` 或 `ch <- x`,对端永远不 close 也不发
-
**context 没 cancel**: 起了 goroutine 但没传能取消的 context
-
**重连循环里旧连接没关**: 新连接起 goroutine,旧的 goroutine 还在 read 老 conn
-
**无限 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
```
**解读**:
-
`polyWS.connectLoop` 是入口 (cum 86%)
-
它调用 TLS.readRecord (cum 65%)
-
readRecord 里 bytes.growSlice 在囤字节 (flat 65%)
-
结论:**每次 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/天
排查过程
-
**加 pprof + 重新部署**(埋点)
-
**等 24 小时让 leak 累积够多**(信号明确)
-
**抓 heap + goroutine 各一份**
-
**看 goroutine 数量: 1857!** → 立刻锁定 leak
-
**看 heap top -cum: polyWS.connectLoop 86%** → 确认位置
-
**交叉验证**: goroutine 里 77% 也在 polyWS.connectLoop.func1
-
**结论**: `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)在生产环境无侵入抓样
参考资料
-
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/)