Skip to content

pprof:先选对“在量什么”,再找热点 ​

服务变慢时,先回答一个问题:时间花在执行代码,还是花在等待?CPU profile 只统计前一种。内存上升时,也先分清当前还留着多少对象,以及过去一共分配过多少。选错剖析类型,图可以很漂亮,结论仍然会错。

这篇用一个不到一秒的本地程序采集 profile,解释怎样把采样结果还原成可检验的假设。实测环境是 Go 1.27.2、Linux/amd64。所有请求测试都在 httptest 中运行,没有连接生产服务,也没有向公网暴露端口。

七个入口,各自回答什么 ​

runtime/pprof 负责程序内采集,net/http/pprof 把这些能力放到 HTTP,go tool pprof 负责分析文件。CPU 通过 StartCPUProfile/StopCPUProfile 采集;其他内置 profile 可用 Lookup(name).WriteTo 导出。[1][2]

类型主要问题单位与时间范围容易读错的地方
CPU哪些调用栈在消耗 CPU?捕获窗口内的 CPU 采样时间等网络、sleep、排队不消耗相应 CPU;多核总 CPU 时间可以超过墙钟时间
heap最近一次完成的 GC 所反映的存活对象由哪里分配?默认 inuse_space,存活字节估计分配位置不等于持有引用的位置;不等于 RSS
allocs到目前为止,哪里制造了最多分配?默认 alloc_space,进程生命周期累计分配字节已经回收的对象仍在累计量内;不能拿累计数当泄漏量
goroutine采样时有哪些 goroutine、停在哪些调用栈?某个时刻的 goroutine 数量一个大栈簇可能是正常空闲 worker;需要随负载与时间比较
blockgoroutine 在同步操作上等了多久?累计阻塞时长,按时间抽样栈指向等待方,如 Mutex.Lock、channel 接收
mutex哪些临界区让别人等锁?其他 goroutine 的累计等待时长,按事件抽样栈通常归到持锁方结束临界区的位置,如 Unlock
threadcreateOS 线程在哪里创建?线程创建栈OS 线程不是 goroutine;不能拿它代替 goroutine profile

表格前六项是排障时最常用的类型,threadcreate 用于补查线程增长。heap 和 allocs 底层描述的是同一组内存采样,默认选中的指标不同。内存 profile 有 inuse_space、inuse_objects、alloc_space、alloc_objects 四种常用 sample index;大对象数量少、细碎对象数量多,按字节和按对象数看会得到不同排序。[1]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    A[一次对象分配] --> B[按 MemProfileRate 抽样并记录分配栈]
    B --> C[alloc_space / alloc_objects 累计]
    B --> D[经过 GC 的存活统计]
    D --> E[inuse_space / inuse_objects]
    C --> F[找分配抖动与 GC 压力]
    E --> G[找仍被保留的对象来源]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    A[一次对象分配] --> B[按 MemProfileRate 抽样并记录分配栈]
    B --> C[alloc_space / alloc_objects 累计]
    B --> D[经过 GC 的存活统计]
    D --> E[inuse_space / inuse_objects]
    C --> F[找分配抖动与 GC 压力]
    E --> G[找仍被保留的对象来源]

这里的箭头表达统计关系,不表示 GC 会从累计分配量里扣钱。alloc_space 只会继续累加;inuse_space 才会随对象回收下降。

让“留着的”和“用完的”出现在不同视图 ​

实验先把 64 块 64 KiB 的切片留在全局变量,再制造 128 块同样大小的临时切片。临时变量也故意经过全局赋值,以保证这次实验测的是堆分配;最后清空它,再完成两次 GC。

go
func churn() {
    for range 128 {
        b := make([]byte, 64<<10)
        b[0] = 1
        temporary = b
    }
    temporary = nil
}

func keep() {
    for range 64 {
        retained = append(retained, make([]byte, 64<<10))
    }
}

本次的真实输出和 pprof -top 摘录:

text
retained_bytes: 4194304

Type: inuse_space
       4MB  main.keep

Type: alloc_space
       8MB  main.churn
       4MB  main.keep

这就能区分两种修法:如果担心 GC 频繁,应检查 churn 为什么不断制造临时对象;如果担心工作结束后还占着内存,应找是谁继续引用 keep 分配的切片。heap 只告诉我们“从这里分配”,不能直接指出引用链上的缓存或全局变量。

程序把 runtime.MemProfileRate 设成 1,尽量让每次堆分配都进入记录。这是为了让小实验容易观察。默认值是 512 KiB 的采样间隔;生产排查要考虑开销,并且程序中应尽早只设置一次,因为分析工具假设采样率稳定。[3] 两次 GC 也是实验控制条件,不能照抄成生产请求路径上的动作。内存 profile 的记录可能滞后于刚发生的释放,runtime.MemProfile 文档说明最多可落后两个 GC 周期。[3]

完整报告还会看到压缩器和 profile 自身的分配。本次 allocs 总量约 13.98 MiB,其中业务演示分配约 12 MiB。采集工具也是程序的一部分,不能为了图整齐把其开销说成不存在。

CPU 的 flat、cum 怎样看 ​

实验用 350 ms 的有界计算循环,采到 340 ms CPU 样本:

text
Duration: 350.04ms, Total samples = 340ms (97.13%)
      flat  flat%        cum   cum%
     340ms   100%      340ms   100%  main.burn
         0     0%      340ms   100%  main.run

flat 是直接落在该函数的样本,cum 包括该函数调用的下层函数。run 的 flat 是零,并不意味着调用 run 没成本:它调用 burn,所以 cum 很高。先用 top 找自身热点,再用 top -cum 找承载这些热点的调用入口,用 list 关联到源码。[4]

bash
go tool pprof ./lab cpu.pb.gz
# 在交互提示符输入:
top
top -cum
list main.burn

符号化需要将样本里的地址映射回函数、文件和行号。尽量保留与采样时完全匹配的二进制、源码提交和构建信息;不要拿重编译后的同名文件冒充原二进制。某些 profile 自带符号信息,仍不应因此丢掉构建产物。[4]

火焰图的宽度不是执行时间线 ​

在自己的分析机上运行 go tool pprof -http=127.0.0.1:8080 ./lab cpu.pb.gz,选择 Flame Graph。一个框通常表示某条调用路径上的函数,父框包含子框,宽度对应当前指标的样本权重。同名函数在不同调用路径可能出现多次。横向位置用于排列调用栈,不能解释成“先执行左边,再执行右边”。颜色也不应被当成固定的慢、快或错误等级。[4][5]

读图时先确认当前指标是 CPU 时间、字节还是阻塞时间,再找宽分支。宽的父框不一定自身昂贵,要顺着子框往下看。若问题是“这次请求在哪一刻被唤醒,又隔了多久才开始运行”,需要时间线,转用 trace。本文实际验证了命令行报告,没有声称打开浏览器或截取火焰图。

block 看等的人,mutex 看让人等的临界区 ​

两个 profile 默认不开启,单纯导入 net/http/pprof 不会启用它们。入口分别是:

go
runtime.SetBlockProfileRate(1)
old := runtime.SetMutexProfileFraction(1)
defer runtime.SetBlockProfileRate(0)
defer runtime.SetMutexProfileFraction(old)

block 的参数是平均每隔多少阻塞纳秒采一个事件,1 表示记录每个阻塞事件,≤0 关闭。mutex 的参数是事件抽样比例,正数 n 表示平均采 1/n 的竞争事件,0 关闭,负数只查询。[3] 这两个数字不能按同一种“采样频率”理解。

实验安排 4 个 goroutine,每个循环 20 次持锁睡眠 1 ms,故意让锁竞争。真正的业务代码应尽量避免持锁做这类等待。本次结果:

text
block:
250.10ms  sync.(*Mutex).Lock
 86.72ms  sync.(*WaitGroup).Wait

mutex:
  250.62ms 99.92% 99.92%   250.62ms 99.92%  sync.(*Mutex).Unlock (inline)

锁工作阶段只经历了约几十毫秒墙钟时间,多个等待者的等待时间却会累加,因此 250 ms 并不奇怪。block 指向 Lock,mutex 指向 Unlock。两份报告是在提示从不同一端调查同一场竞争,并不是解锁操作本身用了 250 ms。[1]

goroutine 快照则捕到了 4 个等待 channel 的 goroutine。实验关闭 channel 后全部退出,因此这不是泄漏。真实排查要在流量回落后再取一次栈,确认相同调用栈的数量是否仍不断增长。

在线使用:调试端口要有自己的边界 ​

导入 net/http/pprof 会注册到 http.DefaultServeMux。若业务服务器使用默认 mux,就可能一起暴露调试入口。本文的代码显式创建管理 mux,业务 mux 另建,测试里验证公开 mux 访问 heap 返回 404。[2]

go
func adminMux() *http.ServeMux {
    m := http.NewServeMux()
    m.HandleFunc("GET /debug/pprof/", httppprof.Index)
    m.HandleFunc("GET /debug/pprof/profile", httppprof.Profile)
    m.HandleFunc("GET /debug/pprof/trace", httppprof.Trace)
    return m
}

本次测试输出:

text
GET status=200
POST status=405
GET status=404

前两项来自管理 mux,最后一项来自公开 mux。Go 1.22 起官方 pprof 路径要求 GET;这里的路由模式也显式限制方法。Index 会分派其下面的命名 profile,而 CPU 与 trace 用独立 handler。[2]

上线时需要补的是部署边界,单个 mux 不等于访问控制:

边界为什么需要落地检查
独立监听地址避免业务入口顺手暴露调试 API管理 listener 绑定 loopback 或受控管理网络;不要使用 :6060 作为默认公开示例
网络和身份控制同网段、同主机上的人也未必都有诊断权限防火墙/网络策略,加已批准的认证代理或 mTLS;loopback 本身不是身份认证
限制并发与捕获长度多次采集、trace、强制 GC 可能干扰服务诊断窗口、超时、并发限额、审计;代理 timeout 需覆盖批准的采样时长
保护产物栈、标签、路径、命令行可能暴露实现和业务信息不把完整 profile/trace 直接上传公共网站;限制保存、下载和保留期

HTTP 参数也影响语义:debug=0 默认二进制,debug>0 文本;goroutine?debug=2 是完整栈文本;heap?gc=1 会先触发 GC;对 heap、allocs、goroutine、block、mutex、threadcreate 指定 seconds=N 返回差分,对 profile 和 trace 则是采集 N 秒。[2] 差分的 heap 可能出现负值,含义是采集两端的存活量差,不能解释成“负分配”。

-base=before.pb.gz after.pb.gz 可做离线差分。比较前必须固定指标、同类负载、相近时间窗与采样配置,保留前后请求量。进程重启会重置累计 profile,跨重启拿累计量直接相减没有意义。百分比还会受分母变化影响:函数占比下降,绝对 CPU 时间仍可能上升。

本次验证与边界 ​

程序生成七份二进制 profile,全部通过当前 go tool pprof -top 解析;管理 mux 三条行为断言通过,go test -race 与 go vet 通过。数字来自这台共享云机器的一次短实验,只证明采集链与演示现象,不是容量估算或生产延迟承诺。

最先值得练习的动作,是对同一份内存采样切换 inuse_space 与 alloc_space,再说明这两种排序为什么不同。能解释清楚这个区别,才知道下一步应该减少分配,还是找还没释放的引用。

依据 ​

  1. Go 1.27.2 runtime/pprof:各 profile 的范围、block/mutex 栈归因、heap 与 allocs 默认视图。
  2. Go 1.27.2 net/http/pprof:注册、GET 要求、HTTP 参数与 handler。
  3. Go 1.27.2 runtime:MemProfileRate、MemProfile、SetBlockProfileRate、SetMutexProfileFraction。
  4. pprof 官方操作文档;本次命令还由 Go 1.27.2 随附 go tool pprof -help 验证。
  5. Brendan Gregg:Flame Graphs:原作者对调用栈聚合、宽度与横向排列的说明。

完整复现代码 ​

在一个新目录按以下文件名保存。需要 Go 1.27.2;无第三方依赖。执行 bash run.sh out,所有输出保存在 out。脚本启用的全量采样只发生在这个短命的本地实验进程中,不要直接搬进生产服务。

go.mod ​

go.mod
module example.com/pproflab

go 1.27.2

main.go ​

代码块 · 144 行 · 点击收起 / 展开
go
package main

import (
	"fmt"
	"net/http"
	httppprof "net/http/pprof"
	"os"
	"path/filepath"
	"runtime"
	"runtime/pprof"
	"sync"
	"time"
)

var retained [][]byte
var temporary []byte
var sink uint64

//go:noinline
func churn() {
	for range 128 {
		b := make([]byte, 64<<10)
		b[0] = 1
		temporary = b
	}
	temporary = nil
}

//go:noinline
func keep() {
	for range 64 {
		retained = append(retained, make([]byte, 64<<10))
	}
}

//go:noinline
func burn(d time.Duration) {
	x := uint64(1)
	until := time.Now().Add(d)
	for time.Now().Before(until) {
		for range 10000 {
			x = x*1664525 + 1013904223
		}
	}
	sink = x
}
func dump(dir, name string) error {
	f, err := os.Create(filepath.Join(dir, name+".pb.gz"))
	if err != nil {
		return err
	}
	err = pprof.Lookup(name).WriteTo(f, 0)
	closeErr := f.Close()
	if err != nil {
		return err
	}
	return closeErr
}
func adminMux() *http.ServeMux {
	m := http.NewServeMux()
	m.HandleFunc("GET /debug/pprof/", httppprof.Index)
	m.HandleFunc("GET /debug/pprof/profile", httppprof.Profile)
	m.HandleFunc("GET /debug/pprof/trace", httppprof.Trace)
	return m
}
func run(dir string) error {
	if err := os.MkdirAll(dir, 0700); err != nil {
		return err
	}
	// Only this short-lived laboratory enables full sampling.
	runtime.MemProfileRate = 1
	runtime.SetBlockProfileRate(1)
	old := runtime.SetMutexProfileFraction(1)
	defer runtime.SetBlockProfileRate(0)
	defer runtime.SetMutexProfileFraction(old)
	f, err := os.Create(filepath.Join(dir, "cpu.pb.gz"))
	if err != nil {
		return err
	}
	if err := pprof.StartCPUProfile(f); err != nil {
		f.Close()
		return err
	}
	burn(350 * time.Millisecond)
	pprof.StopCPUProfile()
	if err := f.Close(); err != nil {
		return err
	}
	keep()
	churn()
	runtime.GC()
	runtime.GC()
	for _, name := range []string{"heap", "allocs"} {
		if err := dump(dir, name); err != nil {
			return err
		}
	}
	ready := make(chan struct{}, 4)
	release := make(chan struct{})
	var waiting sync.WaitGroup
	for range 4 {
		waiting.Go(func() { ready <- struct{}{}; <-release })
	}
	for range 4 {
		<-ready
	}
	if err := dump(dir, "goroutine"); err != nil {
		close(release)
		waiting.Wait()
		return err
	}
	close(release)
	waiting.Wait()
	var mu sync.Mutex
	var wg sync.WaitGroup
	for range 4 {
		wg.Go(func() {
			for range 20 {
				mu.Lock()
				time.Sleep(time.Millisecond)
				mu.Unlock()
			}
		})
	}
	wg.Wait()
	for _, name := range []string{"block", "mutex", "threadcreate"} {
		if err := dump(dir, name); err != nil {
			return err
		}
	}
	fmt.Println("retained_bytes:", len(retained)*(64<<10))
	fmt.Println("profiles: cpu heap allocs goroutine block mutex threadcreate")
	return nil
}
func main() {
	dir := "out"
	if len(os.Args) > 1 {
		dir = os.Args[1]
	}
	if err := run(dir); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

main_test.go ​

go
package main

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestEndpointIsolation(t *testing.T) {
	admin := adminMux()
	public := http.NewServeMux()
	for _, tc := range []struct {
		h      http.Handler
		method string
		want   int
	}{
		{admin, "GET", 200}, {admin, "POST", 405}, {public, "GET", 404},
	} {
		w := httptest.NewRecorder()
		tc.h.ServeHTTP(w, httptest.NewRequest(tc.method, "/debug/pprof/heap", nil))
		if w.Code != tc.want {
			t.Fatalf("%s: got %d want %d", tc.method, w.Code, tc.want)
		}
		t.Logf("%s status=%d", tc.method, w.Code)
	}
}

run.sh ​

bash
#!/usr/bin/env bash
set -euo pipefail
export TZ=UTC
out=${1:-out}
mkdir -p "$out"
go version > "$out/environment.txt"
go env GOOS GOARCH CGO_ENABLED >> "$out/environment.txt"
go test -v . > "$out/test.txt"
go test -race . >> "$out/test.txt"
go vet . >> "$out/test.txt"
go build -o "$out/lab" .
"$out/lab" "$out" > "$out/run.txt"
for p in cpu heap allocs goroutine block mutex threadcreate; do
 go tool pprof -top -nodecount=8 "$out/lab" "$out/$p.pb.gz" > "$out/$p-top.txt"
done

保留各书来源与许可 · 示例运行条件以正文为准