15.9 性能剖析工具链 (火焰图 / perf / eBPF)

一句话

剖析工具链不是“工具列表”,而是一条数据流:采集 → 符号化 → 折叠 → 聚合 → 渲染;每一段都有损耗,知道损耗在哪,才知道图上的结论能信多少。

先破除四个误区

误区 现实
“perf 能替代 pprof” perf 看得到内核/系统调用/其他语言,但看不到 Go 的 goroutine 语义、GC 细节、channel 等待;pprof 反之。两者互补,不是替代
“火焰图 = 性能报告” 火焰图只呈现“样本落在哪些栈”;它不告诉你采样率、符号缺失、负载是否一致。脱离这些,图会骗人
“eBPF 万能” eBPF 受内核版本、BTF、权限、容器限制;它能观测事件,但不会替你解释业务语义
“有图就能定位” 定位靠“先分段、再归因”(见 34.3 性能分析方法 (火焰图 / pprof / perf));图只是归因的一步

四层观测,各看一半

层 工具 能回答 盲区
应用内 pprof、runtime/trace Go 函数热点、堆、阻塞、调度/GC 时序 系统调用、内核态、其他进程
系统调用/进程 perf 用户态+内核态热点、IPC、cache miss、调度 Go 语义(goroutine/channel/GC)
内核/事件 eBPF(bpftrace/BCC) IO 延迟、off-CPU、TCP 重传、任意内核事件 需要权限/内核支持;解释成本高
分布式 Trace/Metrics(50.3 链路、50.4 度量) 跨服务耗时、错误率、容量趋势 函数级细节

一句话选型:应用内的事问 pprof,出了进程问 perf,内核/等待问 eBPF,跨服务问 trace。

数据流:从采样到一张图

perf record -F 99 -g -p PID -- sleep 30
        │
        ▼
   perf.data(二进制,含样本、调用栈、符号引用)
        │  perf script
        ▼
   文本栈(每个样本一段,叶子在前)
        │  stackcollapse-perf.pl
        ▼
   folded(root;child;leaf  count)  ← 火焰图的唯一输入格式
        │  flamegraph.pl / speedscope / 其他渲染器
        ▼
   SVG / 交互式视图

三个关键点:

  1. 采样频率决定分辨率:99Hz × 30s ≈ 2,970 个样本;想看 1% 的差异,样本量不够。
  2. 符号化决定可读性:没有符号,栈就是地址;Go 二进制被 -ldflags="-s -w" 剥符号后,perf 只能看到 [unknown]。
  3. 折叠格式决定可聚合性:root;child;leaf count 是纯文本,可 grep、可 diff、可进版本库。

perf:把系统调用和内核态也抓进来

# 1) 先看整体:IPC、cache miss、上下文切换、page fault
perf stat -p <pid> sleep 10

# 2) 采样调用栈(-F 99 避免与定时器锁步)
perf record -F 99 -g -p <pid> -- sleep 30
perf report --stdio | head -50

# 3) 导出并生成火焰图
perf script > out.perf
stackcollapse-perf.pl out.perf > out.folded
flamegraph.pl out.folded > out.svg

(本机无 Linux,以上命令未验证;stackcollapse-perf.pl/flamegraph.pl 来自 Brendan Gregg 的 FlameGraph 仓库。)

Go 相关的四个注意点:

注意点 说明
别剥符号 -ldflags="-s -w" 会让 perf 解析不到函数名;发布版保留符号,或用 perf buildid-cache 配 debug 文件
调用栈方式 Go 在 amd64/arm64 默认保留帧指针,-g(fp)通常可用;栈不完整时用 --call-graph dwarf(更慢、采样文件更大)
内联 内联函数可能不出现在栈里;要看 -list 或关闭内联复现(见 15.5 pprof (CPU / 内存 / 阻塞 / 互斥))
容器 需要 perf_event_paranoid 足够低或 CAP_PERFMON/CAP_SYS_ADMIN;PID namespace 里要抓对宿主 PID

eBPF:看“等待”和“内核事件”

perf 擅长 on-CPU;eBPF 擅长事件驱动:谁在等、等了多久、IO 多大、TCP 重传多少。

# off-CPU:进程不在 CPU 上时,阻塞在哪(BCC 工具)
offcputime -p <pid> 10

# 调度延迟:等 CPU 的时间分布
runqlat 10

# 块设备 IO 延迟直方图(毫秒)
biolatency -m 10

# bpftrace:按 99Hz 采样用户态栈(需要 root/CAP_BPF)
bpftrace -e 'profile:hz:99 /pid == 1234/ { @[ustack] = count(); }'

(以上未验证;不同发行版的工具名/参数可能不同。)

💡 工具名差异是「命令找不到」的头号原因:Debian/Ubuntu 的 BCC 工具带 -bpfcc 后缀(offcputime-bpfcc、runqlat-bpfcc、biolatency-bpfcc),RHEL/Fedora 用原名;bpftrace 自带的多行脚本是 *.bt(如 execsnoop.bt,对应 BCC 的 execsnoop-bpfcc)。钩子怎么选、verifier 限制与 off-CPU 数据怎么读,见 11.7 eBPF 与内核态观测。

能力 perf eBPF
on-CPU 采样 强 可以(profile:hz)
off-CPU/等待 perf sched 部分支持 强(offcputime)
内核事件过滤 有限 强(按 PID/cgroup/路径过滤)
开销 采样可调,通常低 通常更低,但程序写错会放大
部署 一次性排查 适合常开/持续采集
门槛 低 高(内核版本、BTF、权限)

🔑 eBPF 的价值不是“更先进”,而是能在生产常开、按事件过滤、把等待时间也量出来。它补的是 pprof/perf 的盲区,不是替代它们。

runtime/trace:调度与 GC 的时序

pprof 回答“时间花在哪些函数”,trace 回答“什么时候、为什么没跑”:

现象 pprof 看不到 trace 能看到
goroutine 大量增加 只看得到栈,不知道调度 调度延迟、G 的状态迁移
GC 停顿 CPU profile 里是 runtime 噪声 STW、辅助 GC、GC 周期
请求偶发慢 平均热点掩盖长尾 单请求时间线、阻塞事件
# 采 5 秒调度/GC 时序
curl -s -o trace.out 'http://127.0.0.1:6060/debug/pprof/trace?seconds=5'
go tool trace trace.out

(go tool trace 会启动本地 Web UI;本机 Go 安装缺少 trace 工具,未实测。)调度器内部的 G/M/P 状态迁移见 15.1 GMP 调度模型,线程与调度器基础见 11.1 进程、线程与调度。

一个能跑的“折叠器”:把栈变成火焰图的输入

perf script 的输出是“每个样本一段、叶子在前”;火焰图要的是“root;child;leaf count、根在前”。下面这段程序完成折叠与聚合,并统计未知符号占比。

package main

import (
	"fmt"
	"sort"
	"strconv"
	"strings"
)

// 这里模拟 `perf script` 的输出:每个采样块 = 一行头 + 若干帧(叶子在前)。
// 真实命令:perf script | stackcollapse-perf.pl | flamegraph.pl > out.svg
const perfScript = `
order 4242 [002] 10:00:01.000: 1000000 cycles:
	ffffffff81000000 main.hotHash
	0000000000401000 main.BenchmarkHash
	0000000000402000 testing.(*B).runN
	0000000000403000 runtime.goexit

order 4242 [003] 10:00:01.010: 1000000 cycles:
	ffffffff81000000 main.hotHash
	0000000000401000 main.BenchmarkHash
	0000000000402000 testing.(*B).runN
	0000000000403000 runtime.goexit

order 4242 [004] 10:00:01.020: 1000000 cycles:
	ffffffff81000000 main.hotHash
	0000000000401000 main.BenchmarkHash
	0000000000402000 testing.(*B).runN
	0000000000403000 runtime.goexit

order 4242 [005] 10:00:01.030: 1000000 cycles:
	ffffffff81000000 runtime.chanrecv1
	0000000000405000 main.BenchmarkBlock
	0000000000402000 testing.(*B).runN

order 4242 [006] 10:00:01.040: 1000000 cycles:
	ffffffff81000000 runtime.chanrecv1
	0000000000405000 main.BenchmarkBlock
	0000000000402000 testing.(*B).runN

order 4242 [007] 10:00:01.050: 1000000 cycles:
	ffffffff81000000 sync.(*Mutex).Unlock
	0000000000406000 main.BenchmarkMutex.func1
	0000000000402000 testing.(*B).runN

order 4242 [008] 10:00:01.060: 1000000 cycles:
	ffffffff81000000 runtime.mallocgc
	0000000000407000 main.allocHot
	0000000000402000 testing.(*B).runN

order 4242 [009] 10:00:01.070: 1000000 cycles:
	ffffffff81000000 runtime.epollwait
	0000000000408000 runtime.netpoll

order 4242 [010] 10:00:01.080: 1000000 cycles:
	0000000000409000 0x409abc
`

type sample struct{ frames []string } // 叶子在前

func parse(raw string) []sample {
	var out []sample
	var cur sample
	flush := func() {
		if len(cur.frames) > 0 {
			out = append(out, cur)
			cur = sample{}
		}
	}
	for _, line := range strings.Split(raw, "\n") {
		line = strings.TrimRight(line, " \r")
		if line == "" {
			flush()
			continue
		}
		if strings.HasPrefix(line, "\t") {
			fields := strings.Fields(line)
			if len(fields) == 0 {
				continue
			}
			sym := fields[len(fields)-1]
			if strings.HasPrefix(sym, "0x") {
				sym = "[unknown]"
			}
			cur.frames = append(cur.frames, sym)
			continue
		}
		// 头行:先结束上一个样本
		flush()
	}
	flush()
	return out
}

func fold(s sample) string {
	rootFirst := make([]string, len(s.frames))
	for i, f := range s.frames {
		rootFirst[len(s.frames)-1-i] = f
	}
	return strings.Join(rootFirst, ";")
}

func main() {
	samples := parse(perfScript)
	counts := map[string]int{}
	self := map[string]int{}
	for _, s := range samples {
		counts[fold(s)]++
		self[s.frames[0]]++
	}

	fmt.Println("== 折叠后的栈(folded,可直接喂 flamegraph.pl)==")
	type kv struct {
		stack string
		n     int
	}
	var rows []kv
	for k, v := range counts {
		rows = append(rows, kv{k, v})
	}
	sort.Slice(rows, func(i, j int) bool {
		if rows[i].n != rows[j].n {
			return rows[i].n > rows[j].n
		}
		return rows[i].stack < rows[j].stack
	})
	for _, r := range rows {
		fmt.Printf("%-80s %d\n", r.stack, r.n)
	}

	fmt.Println("\n== self time(叶子函数)==")
	var leaves []kv
	for k, v := range self {
		leaves = append(leaves, kv{k, v})
	}
	sort.Slice(leaves, func(i, j int) bool {
		if leaves[i].n != leaves[j].n {
			return leaves[i].n > leaves[j].n
		}
		return leaves[i].stack < leaves[j].stack
	})
	total := len(samples)
	unknown := 0
	for _, l := range leaves {
		pct := float64(l.n) / float64(total) * 100
		fmt.Printf("%-36s %3d  %5.1f%%\n", l.stack, l.n, pct)
		if l.stack == "[unknown]" {
			unknown += l.n
		}
	}
	fmt.Printf("\n样本 %d;未知符号 %d(%.1f%%)\n", total, unknown, float64(unknown)/float64(total)*100)
	fmt.Printf("采样频率 99Hz × 30s ≈ %s 个样本(这里压缩成 %d 个演示)\n", strconv.Itoa(99*30), total)
}

本机运行结果:

== 折叠后的栈(folded,可直接喂 flamegraph.pl)==
runtime.goexit;testing.(*B).runN;main.BenchmarkHash;main.hotHash                 3
testing.(*B).runN;main.BenchmarkBlock;runtime.chanrecv1                          2
[unknown]                                                                        1
runtime.netpoll;runtime.epollwait                                                1
testing.(*B).runN;main.BenchmarkMutex.func1;sync.(*Mutex).Unlock                 1
testing.(*B).runN;main.allocHot;runtime.mallocgc                                 1

== self time(叶子函数)==
main.hotHash                           3   33.3%
runtime.chanrecv1                      2   22.2%
[unknown]                              1   11.1%
runtime.epollwait                      1   11.1%
runtime.mallocgc                       1   11.1%
sync.(*Mutex).Unlock                   1   11.1%

样本 9;未知符号 1(11.1%)
采样频率 99Hz × 30s ≈ 2970 个样本(这里压缩成 9 个演示)

这段程序说明三件事:

  1. 折叠只做两件事:把叶子在前的栈翻成根在前,按栈聚合计数。真正的 stackcollapse-perf.pl 还处理内联、进程名、容器 ID,但核心就是这个。
  2. 未知符号必须报警:11.1% 的 [unknown] 意味着图上会有一块“无名地带”;不统计它,就会把“没符号”误读成“没热点”。
  3. 样本数决定可信度:9 个样本只能演示流程;真实场景 2,970 个样本,1% 的差异仍然在噪声边缘。

症状 → 工具:一张决策表

现象 首选 备选
应用 CPU 高、想知道哪个函数 pprof CPU(15.5 pprof (CPU / 内存 / 阻塞 / 互斥)) perf record
CPU 不高但请求慢 block/off-CPU eBPF offcputime、链路追踪
内核态占比高、syscall 多 perf record/report bpftrace syscall 计数
IO 延迟/重传/排队 eBPF biolatency/runqlat iostat、ss
goroutine 调度/GC 停顿 runtime/trace GODEBUG=schedtrace=1000
跨服务哪一段慢 分布式 Trace 日志/指标分段
想知道一周内热点变化 持续剖析(50.8 持续性能剖析) 定时抓 profile 存档

生产落地:指名 → 可抄 → 可验 → 可退

指名:应用内用 pprof;系统/内核用 perf;常开的事件观测用 bpftrace/BCC;持续剖析用 Parca / Pyroscope(见 50.8 持续性能剖析);调度/GC 用 go tool trace。内核态观测的系统性方法见 11.7 eBPF 与内核态观测。

可抄(本机无 Linux,命令未验证):

# 一条命令:进程 CPU 火焰图
perf record -F 99 -g -p "$(pidof order)" -- sleep 30
perf script | stackcollapse-perf.pl | flamegraph.pl > order-cpu.svg

# off-CPU:请求慢但 CPU 不高时先跑这个
offcputime -p "$(pidof order)" 10

# 容器里没权限时先检查
cat /proc/sys/kernel/perf_event_paranoid   # 用户态采样一般要 ≤2

可验:本节折叠器真跑过,输出如上;它验证“数据流 + 未知符号占比”两件事。线上至少验证:火焰图里 Go 函数名可读(不是地址)、[unknown] 占比 <10%、采样期间负载与时间段有记录(否则无法对比)。

可退:perf/eBPF 都是旁路观测,停止即恢复;但 eBPF 程序写错可能放大开销,先在单实例/短时间验证。采集前记录基线,采集后对比,不要拿不同负载的两张图下结论。

反模式

反模式 后果
只看 on-CPU 火焰图,忽略等待 慢在锁/IO 的服务被误判为“没热点”
剥符号后发布,再拿 perf 排查 满屏地址,无法归因
采样 5 秒就下结论 样本不足,噪声当热点
不同负载的两张图做 diff 差异来自流量,不来自代码
在生产全量开高频率 perf 开销与磁盘暴涨
eBPF 脚本不限 PID/cgroup 采到全机事件,图无法读
把火焰图当“性能报告” 缺采样率/负载/符号信息,结论不可复现

自检

Q1 perf 和 pprof 各自看不到什么?

▸ 答案:pprof 看不到内核态、系统调用、其他进程,也看不到 goroutine 的调度时序;perf 看得到这些,但看不到 Go 的 goroutine/channel/GC 语义,栈里的 Go 函数还可能因内联/剥符号而不完整。两者互补:应用内归因用 pprof,出了进程用 perf。

Q2 火焰图的横轴到底表示什么?

▸ 答案:横轴是样本聚合后的占比,不是时间线;宽度 = 该栈在全部样本中的比例,左右顺序没有时间含义。纵轴是调用深度,不是“越深越慢”。(图的朝向因工具而异,看根节点确认,见 34.3 性能分析方法 (火焰图 / pprof / perf)。)

Q3 为什么 perf 抓 Go 程序会出现 [unknown]?

▸ 答案:常见三种原因:① 二进制被 -ldflags="-s -w" 剥掉符号;② 栈回溯方式不对(fp 不完整,需要 --call-graph dwarf);③ 内联/汇编函数没有独立符号。解决办法:保留符号、改用 dwarf、对未知部分单独统计占比,别把“没符号”当成“没热点”。

Q4 什么时候该用 eBPF,而不是 perf?

▸ 答案:需要按事件过滤、常开、看等待/内核事件时用 eBPF:off-CPU 时间、IO 延迟分布、TCP 重传、按 cgroup/PID 过滤。perf 更适合一次性深挖 on-CPU 热点。eBPF 的门槛是内核版本/BTF/权限,容器里要先确认能力。

Q5 runtime/trace 能回答 pprof 回答不了的什么问题?

▸ 答案:时序问题——goroutine 为什么没被调度、GC 什么时候 STW、某个请求在时间线上卡在哪一段。pprof 是统计聚合,丢失时间顺序;trace 保留事件顺序,适合排查“偶发慢/抖动/调度饥饿”。

关联