先采样再优化, 拒绝猜 — 五类画像各答一题: CPU 谁热 / 内存谁涨 / goroutine 谁泄漏 / mutex 谁堵 / block 谁在等
pprof 的哲学是先采样再优化, 拒绝猜: 服务把五类"体检报告"挂在 /debug/pprof/ 下随时可取 —— CPU 画像 100Hz 抓调用栈告诉你"时间花在哪", heap 画像告诉你"内存被谁占着、谁在涨", goroutine 画像把每个 G 卡在哪一行拍给你看, mutex/block 画像回答"谁堵住了谁"。拿到报告后 go tool pprof 三板斧 (top → list → web) 逐层下钻, 火焰图上横宽才是占比。真正的纪律在闭环: 改动前存基线, 改完 benchmark 验证, 上线后同负载再采样差分 —— 数据说没收益, 就回滚。
# 关键: 默认 100Hz, 周期性热点至少采 30s 才不漏 curl -o cpu.prof "http://svc:6060/debug/pprof/profile?seconds=30" go tool pprof svc cpu.prof # → top10 看 flat% 最热函数
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
?debug=1 是"数量+栈摘要"适合数泄漏源, ?debug=2 是完整 runtime 头适合看阻塞原因。curl "http://svc:6060/debug/pprof/goroutine?debug=1" | head # → 85000 @ ... main.(*Hub).subscribe (数量+栈摘要) # debug=2 → 完整 runtime 头, 看阻塞原因
runtime.SetMutexProfileFraction(100) 开 1/100 采样后压测才有效果。runtime.SetMutexProfileFraction(100) // 关键: 默认关 // 压测后取: go tool pprof svc http://svc:6060/debug/pprof/mutex // → top -cum 点名持锁者与等待栈
runtime.SetBlockProfileRate 开启, 是"服务明明不忙但延迟高"的排查入口。runtime.SetBlockProfileRate(100) // 关键: 默认关, ns 阈值 // 答"谁在干等": chan 收发 / IO 阻塞耗时画像 // "服务不忙但延迟高"的排查入口
/debug/pprof/; 生产只绑内网或 localhost, 要么套 basic auth, 要么告警时临时拉起。import _ "net/http/pprof" go func() { http.Listen("127.0.0.1:6060", nil) // 关键: 只绑回环 }()
top 找热点 → list 函数名 看行级 → web 出调用图; 符号解析依赖同一份二进制, list 要在模块目录里跑。go tool pprof svc cpu.prof (pprof) top10 # flat% 找最热函数 (pprof) list hashPassword # 行级耗时, 须在模块目录跑 (pprof) web # 调用图
go tool pprof -http=:8080 cpu.prof # 关键: 火焰图横宽=占比, 纵深=调用深度 # 塔尖高≠热, 只优化足够宽的块
go tool pprof -base old.prof new.prof 只显示差异 —— 内存只涨不降、优化前后对比的标准姿势。go tool pprof -base h1.prof svc h2.prof
(pprof) top -inuse_space
# → 只显示差分: +1.9GB main.newSessionManagerb.ReportAllocs() 输出每 op 分配, b.ResetTimer() 排除准备期, 循环体要消费结果防被编译器优化掉。b.ReportAllocs() // B/op, allocs/op b.ResetTimer() // 排除准备期 for i := 0; i < b.N; i++ { sink = render(tpl, fx) // 关键: 消费结果防优化掉 }
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
大促前网关 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 无感
曲线只上不下, 两份 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 快一个量级。
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{} } }
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 给出的标准答案之一。
"感觉变快了"不算数, 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 + 预分配)
: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 临时拉起, 排查完关掉
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
性能劣化多半是"顺手"引入的, 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 的波动比想象中大。
线上是 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
新网关上线前固定动作: 基线 → 压测 → 差分, 不带病发布:
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% 先优化再上线。
profile + top10 存档, 改完同负载差分。# 错: 凭感觉改 render(), 改完说不清收益 curl -o base.prof "http://gw:6060/debug/pprof/profile?seconds=30" # 对: 先存档 # 改完同负载再采, go tool pprof -base base.prof 差分验证
# 错: seconds=2 — 空窗期, 10s 周期的批处理完全看不见 curl -o cpu.prof "http://gw:6060/debug/pprof/profile?seconds=30" # 对: ≥30s, 最好覆盖一个完整业务周期
# 错: go tool pprof gateway-new cpu.prof (旧 profile) # → top 全是 0x10f3c2a 地址, list 全问号 go tool pprof ./dist/gateway-v1.47.2 cpu.prof # 对: 同一构建产物
inuse_space 的增长差分; 查 GC 压力才看 alloc。# 错: 只看 alloc_space — 大只说明"分配忙", 不等于泄漏 go tool pprof -sample_index=inuse_space h2.prof # 对: 查存量 # 查 GC 压力才看 alloc_space / alloc_objects
127.0.0.1 或内网 + auth, 或临时拉起。// 错: http.Listen(":6060", nil) — 公网可扫, 信息全泄露 go http.Listen("127.0.0.1:6060", nil) // 对: 只绑回环 // 对内网开放时: 网络策略 + basic auth 中间件
-source_path 指路。# 错: 在 ~/ 下跑 go tool pprof → no source information available cd /path/to/module && go tool pprof svc cpu.prof # 对: 模块根 (pprof) list hashPassword
# 错: 优化火焰图最高的塔尖 — 深度≠耗时 go tool pprof -http=:8080 cpu.prof # 对: 横宽=占比, 只优化足够宽的块
b.ResetTimer(); 循环内准备用 Stop/Start 配对。tpl := mustParse(userTemplate) // 错: 解析耗时算进 ns/op, 虚高 b.ResetTimer() // 对: 准备期后重置 for i := 0; i < b.N; i++ { sink = render(tpl, fx) }
fmt.Sprint 消费。for i := 0; i < b.N; i++ { render(tpl, fx) // 错: 结果没消费 → 0.2ns/op 假象 sink = render(tpl, fx) // 对: 赋给包级 sink 消费 }
# 错: 想数泄漏抓 debug=2 — 几十万行没法聚合 curl ".../goroutine?debug=1" | head # 对: 数量+栈摘要归因 curl ".../goroutine?debug=2" > g2.txt # 对: 阻塞原因看头
MemProfileRate=512KB 桶, 单次 8B 的小分配要积够才记一笔, 单看一条不准。正解: 关注趋势与差分, 或临时调低采样率 (有开销)。# MemProfileRate 默认 512KB 桶: 8B 小分配积够才记一笔 # 错: 单看一条小分配下结论 # 对: 看趋势与 -base 差分; 必要时 MemProfileRate=1 (开销大)
SetMutexProfileFraction(1) 全量采, 查完改回。// 错: 不设 fraction — 默认 0, mutex 画像直接没数据 runtime.SetMutexProfileFraction(100) // 1/100 采样 // 疑似锁问题: 临时设 1 全量采, 查完改回
runtime/trace 包按需打点。# 错: 高 QPS 服务采 30s trace — 几个 GB 打不开 curl -o trace.out ".../trace?seconds=3" # 对: 缩短窗口 go tool trace trace.out
# 错: gcBgMarkWorker 排第一就去优化业务函数 go tool pprof -sample_index=alloc_space heap.prof # 对: 找分配点 # 减分配 + sync.Pool, 或调 GOGC (见 GC 页)
# 错: 容器 init 进程吞信号, exec curl 被拒 # 对: dumb-init 或让 Go 进程做 PID 1 kubectl exec svc/gw -- curl -s localhost:6060/debug/pprof/heap
# 错: 大堆服务高峰随手采 heap — 采样点短暂 STW 会抖 # 对: heap 挑低峰或灰度实例; CPU 采样随时可采 curl -o h.prof "http://gw-gray:6060/debug/pprof/heap"
# 错: 深夜低峰采的 profile 全是 idle 与定时任务 wrk -t8 -c512 -d60s http://gw.internal/api/search # 对: 制造负载 curl -o cpu.prof "http://gw:6060/debug/pprof/profile?seconds=30"
# 错: 只采一台, 恰好它在对账 → 错误全局结论 # 对: 多采几台对比; 常态接持续剖析聚合 go tool pprof gw-a a.prof # 与 gw-b/b.prof 对照看
fmt.Println("hit", reqID) // 错: 上线忘删, 热路径被 IO 拖垮 // 对: 临时打点标 TODO(x), 合并前 grep 清掉; // 常态需求用 pprof / metrics
go tool trace; pprof 管"哪里热", trace 管"何时卡"。# 错: 用 pprof 查"毛刺何时发生" — 统计视角给不出时刻 curl -o trace.out ".../trace?seconds=5" go tool trace trace.out # 对: 时间轴看调度/GC/STW 时序