使用 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 包含在标准库中,你无需安装任何东西就可以开始。

golangpprofperformanceprofiling