Go · pprof 性能分析

先采样再优化, 拒绝猜 — 五类画像各答一题: CPU 谁热 / 内存谁涨 / goroutine 谁泄漏 / mutex 谁堵 / block 谁在等

暴露 喂给 benchstat 无显著收益就回滚 — 每一步都拿数据说话 被测服务 — 画像从哪来 net/http/pprof (在线) import _ "net/http/pprof" go http.Listen(":6060", nil) curl :6060/debug/pprof/ profile?seconds=30 生产只绑内网 / localhost runtime/pprof (离线) f, _ := os.Create("cpu.out") pprof.StartCPUProfile(f) defer pprof.StopCPUProfile() CLI / 批处理 / go test 场景 一个端口五种画像, 随取随用 采样器 — 五类画像, 各答一个问题 cpu 100Hz 抓调用栈 (默认频率) 回答: CPU 时间花在哪些函数上 heap 分配剖面: alloc 累计 / inuse 存量 回答: 内存被谁占着, 谁在涨 goroutine 当前全部 G 的栈快照 回答: 谁泄漏了, 卡在哪一行等 mutex 锁竞争: 持有者 + 等待栈 回答: 谁持锁堵住了大家 block chan 收发 / IO 阻塞等待耗时 回答: 谁在干等, 等了多久 go tool pprof 三板斧 1. top10 / top -cum flat% 排序直接看最热函数 cum% 看调用链总耗时 2. list 函数名 行级耗时 / 分配数 热点精确到 for 的哪一行 3. web / 火焰图 调用图与火焰图 (横宽=占比) 看清是谁把流量喂给热点 4. 定位根因 逃逸/分配 → 减分配+Pool 锁 → 缩临界区/分片 调度 → trace 而不是 pprof 符号要二进制与 profile 同版本 优化闭环 — 先测量, 再动手, 改完必须验证 采样拿基线 profile 存档 + top10 记录 没有基线的优化是猜 修改代码 减分配 / sync.Pool / 缩锁区 一次只改一个变量 benchmark 验证 b.ReportAllocs + benchstat 微观收益先在 bench 确认 再采样对比 -base 差分同一份负载 P99 / RSS / allocs 三件套 Legend 采集入口 cpu heap goroutine / 分析工具 mutex / block

五类画像各答一题

  • • cpu: CPU 时间花在哪
  • • heap: 内存被谁占, 谁在涨
  • • goroutine: 谁泄漏, 卡在哪
  • • mutex/block: 谁持锁堵人, 谁在等

三板斧走天下

  • • top10 看 flat%, top -cum 看调用链
  • • list 函数名: 热点精确到行
  • • web / 火焰图: 横宽才是占比
  • • -base 差分: 前后两份对比看增长

闭环纪律

  • • 没有基线不动手, 先采样留档
  • • 改动一次只改一个变量
  • • bench + benchstat 验证微观收益
  • • 上线后同负载再采样, 劣化就回滚

💡 一句话理解

pprof 的哲学是先采样再优化, 拒绝猜: 服务把五类"体检报告"挂在 /debug/pprof/ 下随时可取 —— CPU 画像 100Hz 抓调用栈告诉你"时间花在哪", heap 画像告诉你"内存被谁占着、谁在涨", goroutine 画像把每个 G 卡在哪一行拍给你看, mutex/block 画像回答"谁堵住了谁"。拿到报告后 go tool pprof 三板斧 (top → list → web) 逐层下钻, 火焰图上横宽才是占比。真正的纪律在闭环: 改动前存基线, 改完 benchmark 验证, 上线后同负载再采样差分 —— 数据说没收益, 就回滚。

🧠 必知必会 必考 & 必会

cpu profile
默认 100Hz 采样调用栈 (SIGPROF 驱动), 回答"CPU 时间分布在哪些函数"; 周期性热点要采够时长 (≥30s) 才不漏。
# 关键: 默认 100Hz, 周期性热点至少采 30s 才不漏
curl -o cpu.prof "http://svc:6060/debug/pprof/profile?seconds=30"
go tool pprof svc cpu.prof   # → top10 看 flat% 最热函数
heap profile
四种视角: alloc_space/alloc_objects 是累计分配量, inuse_space/inuse_objects 是当前存量 —— 查泄漏看 inuse, 查分配压力看 alloc。
go tool pprof -sample_index=inuse_space h.prof
# 关键: 查泄漏看 inuse(存量), 查 GC 压力看 alloc(累计)
# 四视角: alloc_space/alloc_objects/inuse_space/inuse_objects
goroutine profile
当前所有 G 的栈快照: ?debug=1 是"数量+栈摘要"适合数泄漏源, ?debug=2 是完整 runtime 头适合看阻塞原因。
curl "http://svc:6060/debug/pprof/goroutine?debug=1" | head
# → 85000 @ ... main.(*Hub).subscribe  (数量+栈摘要)
# debug=2 → 完整 runtime 头, 看阻塞原因
mutex profile
记录锁竞争的持有者与等待者; 默认关闭, 要 runtime.SetMutexProfileFraction(100) 开 1/100 采样后压测才有效果。
runtime.SetMutexProfileFraction(100) // 关键: 默认关
// 压测后取: go tool pprof svc http://svc:6060/debug/pprof/mutex
// → top -cum 点名持锁者与等待栈
block profile
chan 收发/IO 的阻塞等待耗时; 同样默认关, runtime.SetBlockProfileRate 开启, 是"服务明明不忙但延迟高"的排查入口。
runtime.SetBlockProfileRate(100) // 关键: 默认关, ns 阈值
// 答"谁在干等": chan 收发 / IO 阻塞耗时画像
// "服务不忙但延迟高"的排查入口
net/http/pprof
一个 import 把全部 handler 挂到 /debug/pprof/; 生产只绑内网或 localhost, 要么套 basic auth, 要么告警时临时拉起。
import _ "net/http/pprof"
go func() {
    http.Listen("127.0.0.1:6060", nil) // 关键: 只绑回环
}()
go tool pprof
top 找热点 → list 函数名 看行级 → web 出调用图; 符号解析依赖同一份二进制, list 要在模块目录里跑。
go tool pprof svc cpu.prof
(pprof) top10              # flat% 找最热函数
(pprof) list hashPassword  # 行级耗时, 须在模块目录跑
(pprof) web                # 调用图
火焰图读法
横宽 = CPU/内存占比, 纵向 = 调用深度; 看宽块别看"塔尖", 深层窄块再高也不热。
go tool pprof -http=:8080 cpu.prof
# 关键: 火焰图横宽=占比, 纵深=调用深度
# 塔尖高≠热, 只优化足够宽的块
-base 差分
go tool pprof -base old.prof new.prof 只显示差异 —— 内存只涨不降、优化前后对比的标准姿势。
go tool pprof -base h1.prof svc h2.prof
(pprof) top -inuse_space
# → 只显示差分: +1.9GB main.newSessionManager
benchmark 三件套
b.ReportAllocs() 输出每 op 分配, b.ResetTimer() 排除准备期, 循环体要消费结果防被编译器优化掉。
b.ReportAllocs()           // B/op, allocs/op
b.ResetTimer()             // 排除准备期
for i := 0; i < b.N; i++ {
    sink = render(tpl, fx) // 关键: 消费结果防优化掉
}
trace 与 pprof 分工
pprof 答"哪里热" (统计), trace 答"何时卡" (时间轴): 调度延迟、GC 停顿、runq 堆积用 go tool trace。
go tool trace trace.out  # 时间轴: 调度延迟/GC STW
go tool pprof cpu.prof   # 统计: 哪里热
# 关键: "何时卡"用 trace, "哪里热"用 pprof
常见优化路径
分配多 → 减逃逸 (见逃逸页) + sync.Pool; 锁热 → 缩临界区/分片 (见 sync 页); GC 频繁 → 降分配率 + 调 GOGC。
buf := pool.Get().(*bytes.Buffer)
defer pool.Put(buf) // 关键: 复用削减 alloc, GC 压力随之降
buf.Reset()
// 锁热 → 缩临界区/分片; GC 频繁 → 降分配率/调 GOGC

🏭 生产实战 real world

场景 1 · 线上 CPU 85% 告警: 30 秒采样定位热点函数

大促前网关 CPU 打满, 先抓画像再说话, 不猜:

// curl -o cpu.prof http://10.0.4.12:6060/debug/pprof/profile?seconds=30
// go tool pprof svc cpu.prof   交互里两步定位:
//   top10          → crypto/sha256.block flat 41%
//   list hashPassword → 热点行如下:
func hashPassword(pw []byte) [32]byte {
    h := sha256.Sum256(pw)
    for i := 0; i < 10000; i++ {          // 这一行占全进程 41%
        h = sha256.Sum256(h[:])               // 登录场景无需 1 万轮
    }
    return h
}
// 降到 210 轮 (OWASP 建议) 后: CPU 41% → 3%, 登录 P99 无感

场景 2 · 内存 6 小时涨 4GB 不降: heap 差分抓增长点

曲线只上不下, 两份 heap 做差分, 直接看"这 6 小时谁多占了":

// curl -o h1.prof http://svc:6060/debug/pprof/heap
// 6h 后再采 h2.prof, 然后差分 (只看增量):
// go tool pprof -base h1.prof svc h2.prof
// (pprof) top -inuse_space
//   +1.9GB  62%  main.newSessionManager  → map 只增不删
func (m *SessionManager) sweep() {
    for id, s := range m.sessions {
        if s.expired() {
            delete(m.sessions, id)          // 修复: 清理函数根本没实现
        }
    }
}

修复后 6h 内存曲线由斜线变平线; 差分法比单看一份 profile 快一个量级。

场景 3 · goroutine 2k 涨到 90k: dump 全量栈找泄漏点

debug=1 的"计数+栈摘要"最适合数出"哪类 goroutine 涨了":

// curl http://svc:6060/debug/pprof/goroutine?debug=1 | head -30
// 85000 @ 0x1023f4 ... main.(*Hub).subscribe
// 原 bug: 无缓冲 resp chan, 发送方退出后没人 close
func (h *Hub) subscribe(ctx context.Context) Msg {
    resp := make(chan Msg, 1)                  // 修复1: 缓冲 1
    h.route <- sub{out: resp}
    select {                                   // 修复2: 挂退出分支
    case m := <-resp:
        return m
    case <-ctx.Done():
        return Msg{}
    }
}

场景 4 · mutex profile 定位热点锁, 改分片

P99 高但 CPU 不忙, 九成是锁排队; mutex 画像直接点名持有者:

runtime.SetMutexProfileFraction(100)      // 默认关, 必须显式开
// 压测后: go tool pprof svc http://.../mutex
// top -cum: 68% 竞争集中在 record() 的全局锁
var mu sync.Mutex                            // 全局一把: 全维度写排队
func record(dim string, v float64) {
    mu.Lock(); agg[dim] += v; mu.Unlock()
}
// 修复: 256 把分片锁, 竞争面降到 1/256
var shards [256]sync.Mutex
func record2(dim string, v float64) {
    i := fnv32(dim) & 255
    shards[i].Lock(); agg[dim] += v; shards[i].Unlock()
}

锁语义细节看 sync 页; 这里强调: 分片是 mutex profile 给出的标准答案之一。

场景 5 · benchmark + ReportAllocs 验证优化前后

"感觉变快了"不算数, allocs/op 与 ns/op 用数据回答:

func BenchmarkRender(b *testing.B) {
    b.ReportAllocs()                           // 输出 B/op, allocs/op
    tpl := mustParse(userTemplate)
    b.ResetTimer()                            // 准备期不计入
    for i := 0; i < b.N; i++ {
        sink = render(tpl, fixture)             // 消费结果, 防优化掉
    }
}
// go test -bench=Render -count=10 | tee new.txt
// benchstat old.txt new.txt:
//   old  14231 ns/op   48 B/op   3 allocs/op
//   new   5210 ns/op    0 B/op   0 allocs/op   (sync.Pool + 预分配)

场景 6 · 生产 pprof 端口收敛: 只绑 localhost + 临时拉起

:6060 暴露公网等于把内部结构、路径、密钥线索白送攻击者:

mux := http.NewServeMux()
mux.Handle("/debug/pprof/", http.DefaultServeMux) // 只挂自己的 mux
go func() {
    ln, err := net.Listen("tcp", "127.0.0.1:6060") // 1. 只绑回环
    if err == nil { http.Serve(ln, mux) }          //    port-forward 进来采
}()
// 2. 必须对内网开放时: 网络策略 + basic auth 中间件
// 3. 平时不开, 告警时 --pprof=true 临时拉起, 排查完关掉

场景 7 · trace 发现调度延迟与 GC 停顿

pprof 只说"哪里热", "为什么周期性卡"要看时间轴:

// curl -o trace.out http://svc:6060/debug/pprof/trace?seconds=5
// go tool trace trace.out
// 时间轴: 每 200ms 一根 8ms 的 STW + 大片 runnable 排队
// 结论: 分配率过高 → GC 频繁 + G 堆积 (与 GC 页结论互相印证)
// 优化 (sync.Pool 复用 + 写缓冲合并) 后再抓一次:
//   GC 周期 200ms → 1.6s, 接口 P99 从 34ms → 11ms

场景 8 · CI 里跑 bench 防性能回退

性能劣化多半是"顺手"引入的, PR 门禁用 benchstat 把它拦下:

name: bench-guard
on: [pull_request]
jobs:
  compare:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: '1.23' }
      - run: go test -bench=. -count=10 ./... | tee new.txt
      - run: git checkout main && go test -bench=. -count=10 ./... | tee old.txt
      - run: benchstat old.txt new.txt

count=10 是给 benchstat 做统计显著性用的; 单次 bench 的波动比想象中大。

场景 9 · 容器内导出 profile, 本地分析

线上是 alpine 容器没有 go 工具链? 导出文件带回来, 二进制版本对上就行:

kubectl exec svc/gateway -n prod -- \
  curl -s -o /tmp/cpu.prof "localhost:6060/debug/pprof/profile?seconds=30"
kubectl cp prod/gateway:/tmp/cpu.prof ./cpu.prof
// 用构建产物里同一个二进制 (符号匹配是生死线):
go tool pprof -http=:8081 ./dist/gateway-v1.47.2 ./cpu.prof
// list 报 no source: 回到模块根目录跑, 或加 -source_path

场景 10 · 压测前的基线画像流程

新网关上线前固定动作: 基线 → 压测 → 差分, 不带病发布:

curl -o base_cpu.prof  http://gw:6060/debug/pprof/profile?seconds=30
curl -o base_heap.prof http://gw:6060/debug/pprof/heap
curl -o base_gor.prof  http://gw:6060/debug/pprof/goroutine
wrk -t8 -c512 -d120s http://gw.internal/api/search
curl -o load_heap.prof http://gw:6060/debug/pprof/heap
go tool pprof -base base_heap.prof ./gateway load_heap.prof

发布单记下 QPS / P99 / RSS / allocs/op / goroutine 数五项; 任一劣化超 15% 先优化再上线。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 没有基线就优化 — 凭感觉改完无法证明收益, 常常"优化"了个不热的函数。正解: 动手前 profile + top10 存档, 改完同负载差分。
# 错: 凭感觉改 render(), 改完说不清收益
curl -o base.prof "http://gw:6060/debug/pprof/profile?seconds=30" # 对: 先存档
# 改完同负载再采, go tool pprof -base base.prof 差分验证
坑 2 · CPU 采样时间太短 — 采 2 秒只碰巧覆盖一个空窗, 周期性热点 (每 10s 的批处理) 完全看不见。正解: 至少 30s, 最好覆盖一个完整业务周期。
# 错: seconds=2 — 空窗期, 10s 周期的批处理完全看不见
curl -o cpu.prof "http://gw:6060/debug/pprof/profile?seconds=30"
# 对: ≥30s, 最好覆盖一个完整业务周期
坑 3 · profile 与二进制版本不匹配 — top 里全是十六进制地址, list 全是问号。原因: 符号表对不上。正解: 用同一构建产物的二进制喂给 go tool pprof。
# 错: go tool pprof gateway-new cpu.prof (旧 profile)
#     → top 全是 0x10f3c2a 地址, list 全问号
go tool pprof ./dist/gateway-v1.47.2 cpu.prof # 对: 同一构建产物
坑 4 · 只看 alloc_space 忽略 inuse_space — alloc 大只是"分配忙", 不等于泄漏。正解: 查泄漏看 inuse_space 的增长差分; 查 GC 压力才看 alloc。
# 错: 只看 alloc_space — 大只说明"分配忙", 不等于泄漏
go tool pprof -sample_index=inuse_space h2.prof # 对: 查存量
# 查 GC 压力才看 alloc_space / alloc_objects
坑 5 · pprof 端口暴露公网 — :6060 被扫到等于泄露全部路由/参数/栈信息, 甚至被当代理打。正解: 只绑 127.0.0.1 或内网 + auth, 或临时拉起。
// 错: http.Listen(":6060", nil) — 公网可扫, 信息全泄露
go http.Listen("127.0.0.1:6060", nil) // 对: 只绑回环
// 对内网开放时: 网络策略 + basic auth 中间件
坑 6 · list 显示不了源码 — "no source information available"。原因: 不在模块目录跑, 找不到 .go 文件。正解: cd 到模块根再开 pprof, 或 -source_path 指路。
# 错: 在 ~/ 下跑 go tool pprof → no source information available
cd /path/to/module && go tool pprof svc cpu.prof # 对: 模块根
(pprof) list hashPassword
坑 7 · 火焰图纵深误读 — 盯着最高的塔尖优化, 它只是调用深不代表耗时多。正解: 横宽 = 占比, 只优化足够宽的块。
# 错: 优化火焰图最高的塔尖 — 深度≠耗时
go tool pprof -http=:8080 cpu.prof
# 对: 横宽=占比, 只优化足够宽的块
坑 8 · benchmark 忘 b.ResetTimer — 模板解析、建连等准备耗时全算进 ns/op, 数据虚高。正解: 准备代码后 b.ResetTimer(); 循环内准备用 Stop/Start 配对。
tpl := mustParse(userTemplate) // 错: 解析耗时算进 ns/op, 虚高
b.ResetTimer()                 // 对: 准备期后重置
for i := 0; i < b.N; i++ { sink = render(tpl, fx) }
坑 9 · bench 循环被编译器优化掉 — 结果没消费, 编译器直接删掉循环, 0.2ns/op 的"神优化"。正解: 赋给包级 sink 变量或 fmt.Sprint 消费。
for i := 0; i < b.N; i++ {
    render(tpl, fx)        // 错: 结果没消费 → 0.2ns/op 假象
    sink = render(tpl, fx)  // 对: 赋给包级 sink 消费
}
坑 10 · goroutine debug=1 与 debug=2 用错 — 想数泄漏却抓 debug=2, 几十万行没法聚合; 想看阻塞细节却抓 debug=1 丢运行时头。正解: 数量归因用 1, 阻塞原因用 2。
# 错: 想数泄漏抓 debug=2 — 几十万行没法聚合
curl ".../goroutine?debug=1" | head   # 对: 数量+栈摘要归因
curl ".../goroutine?debug=2" > g2.txt # 对: 阻塞原因看头
坑 11 · heap 采样率看不见小分配 — 默认 MemProfileRate=512KB 桶, 单次 8B 的小分配要积够才记一笔, 单看一条不准。正解: 关注趋势与差分, 或临时调低采样率 (有开销)。
# MemProfileRate 默认 512KB 桶: 8B 小分配积够才记一笔
# 错: 单看一条小分配下结论
# 对: 看趋势与 -base 差分; 必要时 MemProfileRate=1 (开销大)
坑 12 · mutex profile 默认 1/1000 漏小锁 — fraction 默认 0 直接没数据, 设 100 后小竞争仍可能漏。正解: 疑似锁问题时 SetMutexProfileFraction(1) 全量采, 查完改回。
// 错: 不设 fraction — 默认 0, mutex 画像直接没数据
runtime.SetMutexProfileFraction(100) // 1/100 采样
// 疑似锁问题: 临时设 1 全量采, 查完改回
坑 13 · trace 文件巨大打不开 — 高 QPS 服务 30s trace 能到几个 GB。正解: 缩短窗口 (3~5s)、聚焦单个请求路径, 或用 runtime/trace 包按需打点。
# 错: 高 QPS 服务采 30s trace — 几个 GB 打不开
curl -o trace.out ".../trace?seconds=3"  # 对: 缩短窗口
go tool trace trace.out
坑 14 · CPU profile 里 GC 占比高 — runtime.gcBgMarkWorker 排第一, 优化业务函数没用。正解: GC 热本质是分配热, 转去 heap profile 找分配点, 减分配或调 GOGC (见 GC 页)。
# 错: gcBgMarkWorker 排第一就去优化业务函数
go tool pprof -sample_index=alloc_space heap.prof # 对: 找分配点
# 减分配 + sync.Pool, 或调 GOGC (见 GC 页)
坑 15 · 容器里拿不到 profile — exec 进去 curl 直接被拒或进程收不到信号。原因: 容器 init 进程处理信号姿势不对 / 端口没监听。正解: 用 dumb-init 或让 Go 进程做 PID 1, 确认 :6060 在监听。
# 错: 容器 init 进程吞信号, exec curl 被拒
# 对: dumb-init 或让 Go 进程做 PID 1
kubectl exec svc/gw -- curl -s localhost:6060/debug/pprof/heap
坑 16 · 高峰期随手采 heap — heap profile 采样点有短暂 stop-the-world, 大堆服务高峰采集会抖一下。正解: CPU 采样随时可采; heap 挑低峰或灰度实例采。
# 错: 大堆服务高峰随手采 heap — 采样点短暂 STW 会抖
# 对: heap 挑低峰或灰度实例; CPU 采样随时可采
curl -o h.prof "http://gw-gray:6060/debug/pprof/heap"
坑 17 · 流量太小画像失真 — 晚上低峰采的 profile 全是 idle 与定时任务, 根本不是业务热点。正解: 压测制造负载, 或在高峰窗口采。
# 错: 深夜低峰采的 profile 全是 idle 与定时任务
wrk -t8 -c512 -d60s http://gw.internal/api/search  # 对: 制造负载
curl -o cpu.prof "http://gw:6060/debug/pprof/profile?seconds=30"
坑 18 · 单实例热点当全局结论 — 只采了一台, 恰好它在对账/日志轮转, 得出错误结论。正解: 多采几台对比; 有条件接持续剖析 (Pyroscope/Parca) 做全局聚合。
# 错: 只采一台, 恰好它在对账 → 错误全局结论
# 对: 多采几台对比; 常态接持续剖析聚合
go tool pprof gw-a a.prof   # 与 gw-b/b.prof 对照看
坑 19 · print 打点代替 profile 忘删 — 上线后每请求多两次 fmt.Println, 热路径被 IO 拖垮。正解: 临时打点标 TODO, 合并前 grep 清理; 常态需求直接用 pprof/metrics。
fmt.Println("hit", reqID) // 错: 上线忘删, 热路径被 IO 拖垮
// 对: 临时打点标 TODO(x), 合并前 grep 清掉;
//     常态需求用 pprof / metrics
坑 20 · 拿 pprof 答调度问题 — "延迟毛刺为什么" pprof 给不出时刻, 它是统计视角。正解: 调度/GC/STW 时序问题用 go tool trace; pprof 管"哪里热", trace 管"何时卡"。
# 错: 用 pprof 查"毛刺何时发生" — 统计视角给不出时刻
curl -o trace.out ".../trace?seconds=5"
go tool trace trace.out  # 对: 时间轴看调度/GC/STW 时序