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 / 交互式视图
三个关键点:
- 采样频率决定分辨率:99Hz × 30s ≈ 2,970 个样本;想看 1% 的差异,样本量不够。
- 符号化决定可读性:没有符号,栈就是地址;Go 二进制被
-ldflags="-s -w"剥符号后,perf 只能看到[unknown]。 - 折叠格式决定可聚合性:
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 个演示)
这段程序说明三件事:
- 折叠只做两件事:把叶子在前的栈翻成根在前,按栈聚合计数。真正的
stackcollapse-perf.pl还处理内联、进程名、容器 ID,但核心就是这个。 - 未知符号必须报警:11.1% 的
[unknown]意味着图上会有一块“无名地带”;不统计它,就会把“没符号”误读成“没热点”。 - 样本数决定可信度: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 保留事件顺序,适合排查“偶发慢/抖动/调度饥饿”。