Go 性能剖析:用 pprof 找热点
AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。
「这个接口有点慢」不是结论。性能工作要从一个可复现的测量开始,再到 profile 里找到真正吃 CPU 或占内存的那几行代码。pprof 是 Go 自带的答案,不需要装任何第三方库。
环境准备
只需要 Go 1.24 与 go tool pprof(随工具链分发)。benchstat 用来比较两次基准结果,单独安装:
go install golang.org/x/perf/cmd/benchstat@latest
确认 $(go env GOPATH)/bin 在 PATH 里,否则 benchstat 找不到。
先有一个能重复跑的基准:
package parse
import (
"strings"
"testing"
)
func Parse(line string) (string, string) {
key, value, ok := strings.Cut(line, "=")
if !ok {
return "", ""
}
return strings.TrimSpace(key), strings.TrimSpace(value)
}
func BenchmarkParse(b *testing.B) {
line := " user_id = 42 "
b.ReportAllocs()
for b.Loop() {
Parse(line)
}
}
Go 1.24 的 b.Loop() 会保证循环体里的调用不被编译器优化掉,也自动处理计时器的启停;旧写法 for i := 0; i < b.N; i++ 仍可用,但需要自己防止结果被内联消除。
go test -bench BenchmarkParse -benchmem ./parse
-benchmem 会额外报告 B/op 与 allocs/op,内存问题往往先在这里露头。
采集 profile
基准测试可以直接产出 profile,不需要改业务代码:
go test -bench BenchmarkParse -benchmem \
-cpuprofile cpu.out -memprofile mem.out ./parse
生成两个文件:cpu.out 是 CPU 采样,mem.out 是堆分配采样。几个注意点:
- 默认采样频率足够定位热点,不要为了「更准」把
-memprofilerate调到 1,那会让基准慢一个数量级。 - 内存 profile 记录的是采样时刻的堆状态。想让
inuse_space稳定,采集前先runtime.GC()。 - 单次基准的 profile 只代表这一次运行,先确认
-benchtime够长(默认 1s 通常够用)。
读懂 pprof
go tool pprof cpu.out
进入交互模式,最常用的四个命令:
(pprof) top
(pprof) top -cum
(pprof) list Parse
(pprof) web
top按 flat(函数自身耗时,不含子调用)排序,直接指向最热的代码。top -cum按 cumulative(含子调用)排序,用来看哪条调用链最贵。list Parse显示该函数的逐行耗时,前提是二进制带调试信息。web生成调用图,需要 Graphviz 的dot;没装就用-http。
不装 Graphviz 也能看图形界面:
go tool pprof -http=:8080 cpu.out
浏览器会打开 Graph、Flame Graph、Source 三个视图。火焰图横轴是栈的合并宽度(越宽表示采样越多),纵轴是调用深度;找「宽而平」的帧——那是自身耗时高的函数,而不是被它调用的子函数。
一个常见误读:火焰图上层的函数宽,不代表它慢,可能只是被调用得多。结合 top 的 flat 排序一起看。
内存与分配
go tool pprof -sample_index=alloc_space mem.out
默认视图是 inuse_space(当前存活对象占用),另外三个索引常用来区分问题:
-sample_index |
含义 |
|---|---|
inuse_space |
采样时仍存活的对象占用字节 |
inuse_objects |
仍存活的对象数量 |
alloc_space |
累计分配字节(含已回收) |
alloc_objects |
累计分配对象数 |
「内存一直涨」看 inuse_space;「GC 频繁、CPU 被 GC 吃掉」看 alloc_space 与 allocs/op。
比较两次 profile 可以直接看差异:
go tool pprof -diff_base before.out after.out
正数表示改动后变多,负数表示变少。比肉眼对比两份 top 可靠得多。
生产环境采样
标准库自带 net/http/pprof,匿名导入即可把端点注册到 http.DefaultServeMux:
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func main() {
go func() {
// 只监听回环地址,不要用 0.0.0.0
log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
}()
// 业务服务
_ = http.ListenAndServe(":8080", nil)
}
常用端点:
curl 'http://127.0.0.1:6060/debug/pprof/profile?seconds=30' -o cpu.out
curl 'http://127.0.0.1:6060/debug/pprof/heap' -o heap.out
curl 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=1'
seconds=30 是 CPU 采样时长,期间会有额外开销,别在流量高峰拉长时间。goroutine?debug=1 输出全部栈,排查 goroutine 泄漏比 profile 更直接。
安全的做法:
- 只监听
127.0.0.1,通过 SSH 隧道或内网访问,绝不挂到公网 mux。 - 或者用独立端口 + 防火墙白名单,不与业务端口共用。
- 生产二进制若用
-trimpath -s -w构建,list可能定位不到源码,排障版本单独保留符号。
阻塞与锁竞争需要先开采样,默认是关闭的:
runtime.SetBlockProfileRate(1)
runtime.SetMutexProfileFraction(1)
1 表示每次都记录,开销明显,只在排障时打开;对应端点是 /debug/pprof/block 与 /debug/pprof/mutex。
用 benchstat 确认改动有效
单次 go test -bench 的数字噪声很大,至少跑 10 次取统计量:
go test -run '^$' -bench . -count=10 -benchmem ./parse > old.txt
# 修改代码
go test -run '^$' -bench . -count=10 -benchmem ./parse > new.txt
benchstat old.txt new.txt
-run '^$' 跳过普通测试,只跑基准。benchstat 会给出中位数、变化百分比与 geomean,并标注差异是否显著。
Windows 上注意重定向编码:PowerShell 5 的 > 默认写 UTF-16,benchstat 读不了。用 cmd /c "go test ... > old.txt",或 | Out-File -Encoding utf8 old.txt。
判定标准要事先定:优化目标是 20% 以上,低于噪声区间的「提升」不值得合并。
常见错误与注意事项
1. 基准里结果没被使用
编译器会把没有副作用的调用优化掉,测出来接近 0。用 b.Loop(),或把结果赋给包级 var Sink any。
2. 用一次运行下结论
CPU 频率、调度、其他进程都会影响结果。-count=10 + benchstat 是底线。
3. 把 -benchtime 调得太短
样本太少,profile 里全是噪声。默认 1s 起步,热点不明显再加大。
4. 在生产长时间开 CPU profile
采样有开销,30 秒是常见上限;高峰期限流或改到低峰执行。
5. 把 /debug/pprof 暴露到公网
它相当于一个远程诊断后门,能看到内存内容与调用栈。只绑回环地址。
6. 只看 CPU 不看分配
GC 压力常常才是延迟来源。-benchmem、alloc_space 与 GOGC 一起看。
7. 在共享 CI 机器上比绝对值
虚拟机的 CPU 配额随时变化。CI 里只比同一台机器上的前后差异,或只看趋势。
8. 优化没有测量支撑的地方
凭直觉改代码,往往只是把开销挪到另一处。先 profile,再改,再用 benchstat 验证。
小结
- 先有可复现的基准(
b.Loop()+-benchmem),再谈优化。 -cpuprofile/-memprofile从测试产出 profile;生产用net/http/pprof,只绑回环地址。top看 flat 热点,top -cum看调用链,list看行级,火焰图看整体形状。- 内存看
-sample_index:泄漏用inuse_space,GC 压力用alloc_space。 - 改动是否有效由
benchstat判定,-count=10起步。