使用 pprof 分析 Go:找出 CPU、内存和 goroutine 的瓶颈
1. 概述
当 Go 服务运行缓慢或内存占用过高时,盲目猜测通常既费时又容易找错地方。Go 在标准库中内置了 pprof 工具。这个工具能告诉你确切地哪个函数占用 CPU、在哪里分配了内存,以及哪个 goroutine 被卡住了。
读完这篇文章后,你将学会如何为 HTTP 服务启用 pprof、从基准测试和 CLI 程序中收集数据、用 go tool pprof 读取结果,以及比较两份数据来验证优化效果。
2. pprof 是什么,有哪些 profile 类型
pprof 包含两个部分:
- 收集端:标准库中的
runtime/pprof和net/http/pprof包。它们以 protobuf 格式记录数据。 - 分析端:
go tool pprof命令。它读取 profile 文件或 URL,然后以表格、源代码或网页界面形式展示结果。
常用的 profile 类型:
| Profile | 测量内容 | 默认启用? |
|---|---|---|
profile(CPU) |
按函数统计 CPU 时间,每秒采样 100 次 | 是,按需 |
heap |
存活的内存和总分配内存 | 是 |
allocs |
类似 heap,但默认查看总分配量 |
是 |
goroutine |
所有当前 goroutine 的栈跟踪 | 是 |
block |
channel、select、sync.Cond 上的等待时间 |
否 |
mutex |
sync.Mutex/RWMutex 的竞争 |
否 |
trace |
详细的事件跟踪(使用 go tool trace) |
是,按需 |
3. 为 HTTP 服务启用 pprof
最快的方法是导入 net/http/pprof 来获得副作用。这个包会将 /debug/pprof/* 处理器注册到 http.DefaultServeMux。
package main
import (
"log"
"net/http"
_ "net/http/pprof" // 将 /debug/pprof/* 注册到 DefaultServeMux
)
func main() {
// 单独的 pprof 服务器,仅在 localhost 上监听
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// 应用服务器使用独立的 mux,不暴露 pprof
appMux := http.NewServeMux()
appMux.HandleFunc("GET /invoice", invoiceHandler)
log.Fatal(http.ListenAndServe(":8080", appMux))
}
需要注意的几点:
- 代码行
_ "net/http/pprof"只是注册处理器,你不需要调用这个包中的任何函数。 - pprof 服务器传入
nil作为处理器,即使用DefaultServeMux,其中包含 pprof 路由。 - 主应用使用独立的
appMux,因此端口8080上没有/debug/pprof。
快速检查:
curl -s http://localhost:6060/debug/pprof/ | head -n 20
注意: 永远不要将
/debug/pprof暴露到互联网。这个端点会泄露栈跟踪和代码结构。恶意行为者甚至可以用它来使服务过载(30 秒的 CPU profile 会消耗资源)。应该绑定到localhost,或放在内网和身份验证层之后。
4. 收集和读取 CPU profile
假设服务有如下生成发票的函数:
type Item struct {
SKU string
Price float64
Qty int
}
type Order struct {
ID int
Items []Item
}
func BuildInvoice(o Order) string {
s := ""
for _, it := range o.Items {
s += fmt.Sprintf("%s x%d = %.2f\n", it.SKU, it.Qty, it.Price*float64(it.Qty))
}
return s
}
在每次循环中,s += ... 会创建一个新字符串并复制所有旧内容到新字符串。对于有很多行的订单,开销会按 O(n²) 增长。
当服务承受负载时(例如用 hey 或 wrk 向 /invoice 发送请求),在 30 秒内收集 CPU profile:
go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=30"
收集完毕后,你进入交互模式。输入 top:
(pprof) top 8
Showing nodes accounting for 2.41s, 85.46% of 2.82s total
flat flat% sum% cum cum%
0.92s 32.62% 32.62% 0.92s 32.62% runtime.memmove
0.41s 14.54% 47.16% 1.38s 48.94% runtime.concatstrings
0.33s 11.70% 58.87% 0.33s 11.70% runtime.memclrNoHeapPointers
0.27s 9.57% 68.44% 0.71s 25.18% runtime.mallocgc
0.18s 6.38% 74.82% 0.52s 18.44% fmt.(*pp).doPrintf
0.13s 4.61% 79.43% 0.13s 4.61% strconv.fmtF
0.10s 3.55% 82.98% 2.47s 87.59% main.BuildInvoice
0.07s 2.48% 85.46% 0.21s 7.45% runtime.scanobject
两个最重要列的阅读方法:
- flat:直接花在函数内部的 CPU 时间。
- cum(累积):函数花的时间加上它调用的所有函数的时间。
这里 main.BuildInvoice 的 flat 较小,但 cum 接近 88%。大部分时间花在 memmove 和 concatstrings 上,即字符串拼接。要查看每一行的详细信息,使用 list:
(pprof) list BuildInvoice
ROUTINE ======================== main.BuildInvoice in /app/invoice.go
100ms 2.47s (flat, cum) 87.59% of Total
. . 18:func BuildInvoice(o Order) string {
. . 19: s := ""
10ms 10ms 20: for _, it := range o.Items {
90ms 2.46s 21: s += fmt.Sprintf("%s x%d = %.2f\n", ...)
. . 22: }
第 21 行是罪魁祸首。如果想查看火焰图,可以打开网页界面:
go tool pprof -http=:8081 "http://localhost:6060/debug/pprof/profile?seconds=30"
浏览器将打开 Top、Graph、Flame Graph 和 Source 视图。在火焰图中,方块越宽,占用的 CPU 就越多。
修复版本使用 strings.Builder 和 fmt.Fprintf:
func BuildInvoiceFast(o Order) string {
var b strings.Builder
b.Grow(len(o.Items) * 32) // 事先估计大小,避免多次增长
for _, it := range o.Items {
fmt.Fprintf(&b, "%s x%d = %.2f\n", it.SKU, it.Qty, it.Price*float64(it.Qty))
}
return b.String()
}
5. Heap profile:谁在占用内存?
go tool pprof http://localhost:6060/debug/pprof/heap
一个 heap profile 包含四个“采样索引”。你可以通过 -sample_index 标志选择索引:
inuse_space(默认):在最近一次 GC 时存活的字节数。用于查找内存泄漏。inuse_objects:存活的对象数。alloc_space:程序运行以来已分配的总字节数。用于查找给 GC 造成压力的地方。alloc_objects:已分配的总对象数。
go tool pprof -sample_index=alloc_space http://localhost:6060/debug/pprof/heap
(pprof) top 3
flat flat% sum% cum cum%
1.84GB 91.20% 91.20% 1.84GB 91.20% main.BuildInvoice
0.12GB 5.95% 97.15% 0.12GB 5.95% fmt.Sprintf
0.03GB 1.49% 98.64% 0.03GB 1.49% encoding/json.Marshal
结果显示 BuildInvoice 总共分配了 1.84GB,尽管存活的数量可能很少。这是 GC 要做很多工作的信号。这种情况与你在第 4 节的 CPU profile 中看到的 runtime.mallocgc 和 runtime.scanobject 的数量相符。
注意: Heap profile 也是采样数据(默认每 512KB 分配取一个样本),所以数字只是估计值。不要逐字节比较,要看比例。
6. Goroutine profile:检测 goroutine 泄漏
如果 goroutine 数量一直在增加,你可以通过浏览器或 curl 快速查看,而不需要 go tool pprof:
curl -s "http://localhost:6060/debug/pprof/goroutine?debug=1" | head -n 15
goroutine profile: total 10432
10398 @ 0x43a5d6 0x4071c5 0x406d78 0x6b2f45 0x46c0e1
# 0x6b2f44 main.(*PaymentService).notify+0x64 /app/payment.go:57
有 10,398 个 goroutine 都卡在 payment.go:57。这几乎肯定是泄漏。导致这个问题的代码通常是这样的:
func (s *PaymentService) notify(ctx context.Context, p Payment) {
result := make(chan error) // unbuffered
go func() {
result <- s.gateway.Send(p) // 如果没有人接收就会永久卡住
}()
select {
case err := <-result:
log.Println(err)
case <-ctx.Done():
return // 内部 goroutine 被遗弃
}
}
当 ctx 先超时时,没有人再读取 result,所以内部 goroutine 在发送操作上永久卡住。简单的修复方法是使用 make(chan error, 1) 使发送操作不会阻塞。
使用 debug=2,端点会打印每个 goroutine 的完整栈跟踪,包括等待时间(例如 [chan send, 42 minutes])。这些信息在调查死锁时非常有用。
7. Block 和 mutex profile
这两个 profile 默认关闭,因为它们有开销。你可以在 main 中启用它们:
import "runtime"
func init() {
// 记录平均每 5 次中 1 次的 mutex 竞争事件
runtime.SetMutexProfileFraction(5)
// 对每 10µs 的阻塞取一个样本
runtime.SetBlockProfileRate(10_000)
}
然后像往常一样收集 profile:
go tool pprof http://localhost:6060/debug/pprof/mutex
go tool pprof http://localhost:6060/debug/pprof/block
Mutex profile 告诉你哪个 lock 导致 goroutine 排队等待,例如保护共享的 map[int]Customer 缓存的 sync.Mutex。Block profile 告诉你 goroutine 在哪个 channel 或 select 上等待时间很长。
注意:
SetBlockProfileRate(1)会记录所有阻塞事件。这个级别只适合本地调试。在生产环境中,应该使用更高的速率,或只是临时启用。
8. 从基准测试进行 profiling
对于特定的函数,最简洁的方法是编写基准测试,然后让 go test 生成 profile。下面的代码使用 b.Loop(),需要 Go 1.24 或更高版本。
package main
import (
"fmt"
"testing"
)
func sampleOrder(n int) Order {
items := make([]Item, n)
for i := range items {
items[i] = Item{SKU: fmt.Sprintf("SKU-%04d", i), Price: 19.99, Qty: i%5 + 1}
}
return Order{ID: 1, Items: items}
}
func TestBuildInvoiceFastMatchesOriginal(t *testing.T) {
order := sampleOrder(200)
if got, want := BuildInvoiceFast(order), BuildInvoice(order); got != want {
t.Fatalf("输出不同:\n got=%q\nwant=%q", got, want)
}
}
func BenchmarkBuildInvoice(b *testing.B) {
order := sampleOrder(500)
b.ReportAllocs()
for b.Loop() {
BuildInvoice(order)
}
}
func BenchmarkBuildInvoiceFast(b *testing.B) {
order := sampleOrder(500)
b.ReportAllocs()
for b.Loop() {
BuildInvoiceFast(order)
}
}
测试 TestBuildInvoiceFastMatchesOriginal 确保优化版本的输出与原始版本完全相同。不要跳过这一步:优化但结果错误没有意义。运行基准测试并生成 profile:
go test -run='^$' -bench=BuildInvoice -cpuprofile=cpu.out -memprofile=mem.out
BenchmarkBuildInvoice-8 1543 772410 ns/op 6342817 B/op 1002 allocs/op
BenchmarkBuildInvoiceFast-8 19821 60318 ns/op 36472 B/op 504 allocs/op
使用 strings.Builder 的版本快约 12 倍,分配少约 170 倍。标志 -run='^$' 跳过常规测试,只运行基准测试。文件 cpu.out 和 mem.out 用以下方式打开:
go tool pprof -http=:8081 cpu.out
9. 使用 runtime/pprof 对 CLI 程序进行 profiling
对于运行一次然后退出的工具,例如订单导入作业,你可以直接使用 runtime/pprof:
package main
import (
"log"
"os"
"runtime"
"runtime/pprof"
)
func main() {
cpuFile, err := os.Create("cpu.out")
if err != nil {
log.Fatal(err)
}
defer cpuFile.Close()
if err := pprof.StartCPUProfile(cpuFile); err != nil {
log.Fatal(err)
}
defer pprof.StopCPUProfile()
importOrders("orders.csv") // 主要工作
memFile, err := os.Create("mem.out")
if err != nil {
log.Fatal(err)
}
defer memFile.Close()
runtime.GC() // 在写入前更新 inuse 数据
if err := pprof.WriteHeapProfile(memFile); err != nil {
log.Fatal(err)
}
}
这里有两个常见的错误:
- 在
StartCPUProfile之后调用log.Fatal或os.Exit会跳过defer。这样StopCPUProfile不会运行,profile 文件会是空的或损坏的。 - 忘记在
WriteHeapProfile之前调用runtime.GC()。inuse_*数据仅在每次 GC 后更新,所以 profile 可能反映的是旧状态。
10. 比较两份 profile
优化后,你应该重新测量,而不是凭感觉。标志 -diff_base 从新 profile 中减去旧 profile:
go tool pprof -diff_base=cpu_before.out cpu_after.out
(pprof) top 3
flat flat% sum% cum cum%
-0.89s 31.56% 31.56% -0.89s 31.56% runtime.memmove
-0.40s 14.18% 45.74% -1.35s 47.87% runtime.concatstrings
-0.25s 8.87% 54.61% -0.66s 23.40% runtime.mallocgc
负值表示时间已经减少。为了得到有意义的结果,两份 profile 需要在相同条件下收集:相同的负载、相同的时长、相同的机器。
此外,从 Go 1.21 开始,CPU profile 还支持基于数据的优化(PGO)。你从生产环境收集一个代表性的 CPU profile,将其命名为 default.pgo 并放在 main 包目录中,然后 go build 会自动使用这个 profile 来内联和优化热代码路径。通常能获得几个百分点的改进,而且你不需要修改任何代码。
11. 常见错误和何时不应使用
- Profile 时间太短或没有负载。 CPU profile 以 100Hz 采样。如果只在服务空闲时收集 2 秒的数据,你只会看到
runtime和噪声。 - 混淆
flat和cum。 函数的cum高不一定是罪魁祸首,它可能只是调用了罪魁祸首。 - 混淆
inuse_space和alloc_space。 想找泄漏看inuse。想减少 GC 压力看alloc。 - 用 CPU profile 测量 I/O。 Goroutine 等待数据库或 HTTP 的时间不消耗 CPU,所以不会出现在 CPU profile 中。应该使用 block profile、
go tool trace或分布式追踪。 - 将 pprof 公开在应用端口。 参考第 3 节。
注意: pprof 回答“哪里消耗”的问题,不回答“是否应该优化”的问题。如果端点已满足 SLA 且函数仅占 2% 的 CPU,重写以增加复杂性通常不值得。
12. 结论
在这篇文章中,你已经:
- 在
localhost上的单独服务器上安全地启用了net/http/pprof; - 使用
top和list读取 CPU profile,并区分flat和cum; - 使用
inuse_space和alloc_space读取 heap profile; - 通过
debug=1和debug=2检测 goroutine 泄漏; - 启用了 block 和 mutex profile;
- 从基准测试和 CLI 程序生成了 profile;
- 使用
-diff_base比较优化前后的数据。
最有效的流程仍然是:测量 → 找热点 → 修复 → 编写测试保持行为 → 重新测量。由于 pprof 包含在标准库中,你无需安装任何东西就可以开始。