你打开一张 CPU 火焰图,看到 std::sort 占了
38% 的 CPU 时间。优化完排序算法,性能提升了——但是用户的 P99
延迟纹丝不动。
为什么?
因为那个请求的 200ms 里,CPU 只忙了 40ms。剩下的 160ms,进程在等锁、等磁盘、等 DNS 解析——这些时间,火焰图一个字都不会告诉你。
这就是 on-cpu profiling 的根本盲区。过去 15 年,Linux
性能分析工具链从 perf
的硬件计数器采样,一路演化到 Parca
的持续性能分析平台,每一步都在试图补上前一代的盲区。本文要讲的就是这条演化路线:每个工具解决了什么问题、留下了什么问题、以及你在
2027 年应该怎么选。
- 火焰图只看 on-cpu 时间,而真实延迟 = on-cpu + off-cpu
- Off-CPU 分析通过
sched_switchtracepoint 追踪进程休眠,补上了火焰图的盲区- 持续性能分析(Parca/Pyroscope)让你在问题发生后也能”时间旅行”回去看火焰图
如果你还没看过 eBPF 的基础知识,建议先读 eBPF:Linux 内核的隐藏武器。
一、Linux 性能分析工具的演化
Linux 性能分析工具的演化,本质上是一部”谁有权在内核里插代码”的历史。
perf(2009):内核 PMC 采样器
perf 是 Linux 内核自带的性能分析器,直接访问
CPU 的 Performance Monitoring
Counters(PMC)。它的工作方式很简单:每隔 N 个 CPU
周期中断一次,记录当前的调用栈。
# 对进程采样 30 秒,99Hz 频率
perf record -F 99 -p $PID -g -- sleep 30
# 生成火焰图(需要 Brendan Gregg 的 FlameGraph 工具)
perf script | stackcollapse-perf.pl | flamegraph.pl > cpu.svgperf
稳定、可靠、开销低。但它的问题是太底层了——你想做点复杂的聚合逻辑,比如”只统计持锁超过
10μs
的栈”,就得自己写用户态后处理脚本,内核态的数据管不了。
ftrace(2008):函数追踪框架
ftrace 比 perf
还早一年进入内核,它不是采样器,而是追踪器——在函数入口和出口插桩,记录每次调用。
# 追踪 do_sys_open 函数
echo do_sys_open > /sys/kernel/debug/tracing/set_ftrace_filter
echo function > /sys/kernel/debug/tracing/current_tracer
cat /sys/kernel/debug/tracing/trace_pipeftrace 的优势是零依赖(内核自带),劣势是用起来像在跟
/sys
文件系统搏斗。它更适合内核开发者调试,而不是应用开发者做性能分析。
BCC(2015):Python 前端 + eBPF 后端
BCC(BPF Compiler Collection)是第一个让普通开发者能写 eBPF 程序的工具集。Python 写逻辑,C 写 eBPF 内核代码,编译后注入内核。
# BCC 示例:追踪 TCP 连接延迟
from bcc import BPF
prog = """
#include <net/sock.h>
BPF_HASH(start, struct sock *);
int kprobe__tcp_v4_connect(struct pt_regs *ctx, struct sock *sk) {
u64 ts = bpf_ktime_get_ns();
start.update(&sk, &ts);
return 0;
}
int kretprobe__tcp_v4_connect(struct pt_regs *ctx) {
// 计算连接建立耗时
struct sock *sk = (struct sock *)PT_REGS_RC(ctx);
u64 *tsp = start.lookup(&sk);
if (tsp) {
u64 delta = bpf_ktime_get_ns() - *tsp;
bpf_trace_printk("connect latency: %d us\\n", delta / 1000);
start.delete(&sk);
}
return 0;
}
"""
b = BPF(text=prog)
b.trace_print()BCC 带来了 100
多个现成工具(execsnoop、biolatency、tcplife……),一行命令就能用。但它的问题是每次运行都要编译
eBPF 代码,启动慢,而且对内核头文件有依赖。
bpftrace(2018):DTrace 风格的高级语言
bpftrace
是给”我只想快速查个东西”的人准备的。一行命令,不用写
C,不用等编译:
# 统计系统调用次数(按进程)
bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }'
# 追踪块设备 I/O 延迟分布
bpftrace -e 'kprobe:blk_account_io_done { @us = hist(nsecs - @start[arg0]); }
kprobe:blk_account_io_start { @start[arg0] = nsecs; }'如果你想深入了解 bpftrace 的实战用法,可以看 eBPF 追踪实战:用 bpftrace 在生产环境找到那个慢请求。
持续性能分析:Parca / Pyroscope(2021+)
传统 profiling
的最大问题是:只在”出问题时”才做。但生产环境的性能问题往往是间歇性的,等你手动上去
perf record,问题已经过去了。
Parca 和 Pyroscope 的思路是:始终采样,事后分析。一个 eBPF agent 常驻在每台机器上,以极低开销(<1% CPU)持续采集调用栈,存进时序数据库。什么时候出了问题,回头去看那个时间段的火焰图就行。
工具定位对比表
| 工具 | 出现时间 | 类型 | 适用场景 | 上手难度 | 运行时开销 |
|---|---|---|---|---|---|
| perf | 2009 | 采样器 | CPU 热点分析、PMC 统计 | 中 | 极低 |
| ftrace | 2008 | 追踪器 | 内核函数追踪、延迟分析 | 高 | 低 |
| BCC | 2015 | eBPF 工具集 | 复杂自定义分析、工具开发 | 中 | 低 |
| bpftrace | 2018 | eBPF 脚本 | 快速排查、临时调查 | 低 | 低 |
| Parca | 2021 | 持续分析平台 | 全时段性能监控、回溯分析 | 低 | 极低(<1%) |
| Pyroscope | 2021 | 持续分析平台 | 多语言应用性能分析 | 低 | 极低(<1%) |
二、火焰图的威力和局限
火焰图大概是过去十年最成功的性能可视化工具了。但很多人只知道用它,不知道它的原理——更不知道它的盲区。
火焰图原理:栈采样 → 折叠 → SVG 渲染
火焰图的生成过程很简单:
- 采样:以固定频率(通常 99Hz)中断 CPU,记录当前的调用栈
- 折叠:把相同的栈路径合并,统计每条路径出现的次数
- 渲染:横轴是采样次数(不是时间),每个矩形是一个函数帧
# 完整的火焰图生成流程
perf record -F 99 -p $PID -g -- sleep 30
perf script > out.perf
# 折叠栈
stackcollapse-perf.pl out.perf > out.folded
# 渲染 SVG
flamegraph.pl out.folded > flamegraph.svg关键点:火焰图的横轴不是时间轴。矩形的宽度代表的是这个函数在采样中出现的比例,不是它执行的时间顺序。这是很多人的误解。
CPU 火焰图只看 on-cpu 时间
标准的 CPU 火焰图只统计进程在 CPU
上运行的时间。也就是说,只有进程处于
TASK_RUNNING 状态时才会被采样到。
一个请求的生命周期是这样的:
请求到达 → [解析] → [查数据库] → [等锁] → [计算] → [写日志] → 响应
CPU 等待I/O 等待 CPU 等待I/O
on-cpu off-cpu off-cpu on-cpu off-cpu
如果这个请求花了 200ms,其中只有 40ms 在 CPU 上跑,那火焰图只能看到那 40ms 的故事。剩下的 160ms——可能是性能瓶颈的真正所在——完全不可见。
看不到的时间
CPU 火焰图看不到这些:
- 锁等待:
mutex_lock、futex等待,进程被调度走 - I/O
阻塞:磁盘读写、网络收发、
epoll_wait - sleep:
nanosleep、usleep、定时器等待 - Page fault:缺页中断导致的磁盘 I/O
- DNS 解析:
getaddrinfo发起的网络 I/O
在我见过的生产系统里,应用的 wall clock time 中 60-80% 是 off-cpu 时间。你优化了火焰图上最宽的那个函数,P99 可能纹丝不动——因为瓶颈根本不在 CPU 上。
采样偏差
除了 off-cpu 盲区,火焰图还有采样偏差问题:
- 短函数被漏掉:一个只执行 100ns 的函数,在 99Hz 采样下几乎不可能被捕获到
- 调用频率不可见:一个函数被调用 100 万次但每次只要 1μs,和一个函数被调用 1 次但要 100ms,在火焰图上可能看起来一样
- 编译器内联:被 inline 的函数不会出现在栈上
三、Off-CPU 分析
如果 on-cpu 火焰图只能看到一半的故事,那另一半怎么看?答案是 Off-CPU 分析。
核心等式
Wall Clock Time = On-CPU Time + Off-CPU Time
这个等式看似简单,但它意味着你需要两种火焰图才能看到完整的性能画面:
- On-CPU 火焰图:进程在 CPU
上执行了多久(
perf record) - Off-CPU
火焰图:进程被调度走后等了多久(
sched_switchtracepoint)
用 sched_switch 追踪进程休眠
Linux 内核在每次进程切换时都会触发
sched_switch tracepoint。通过 hook
这个事件,我们可以记录:
- 进程被调度走的时刻(off-cpu 开始)
- 进程被调度回来的时刻(off-cpu 结束)
- 被调度走时的调用栈(off-cpu 的原因)
进程 A 运行中 → sched_switch → 进程 A 休眠 → sched_switch → 进程 A 恢复运行
记录栈 + 时间 计算休眠时长
BCC 的 offcputime 工具
BCC 自带了 offcputime 工具,开箱即用:
# 追踪进程 off-cpu 时间,持续 10 秒
offcputime-bpfcc -df -p $PID 10 > offcpu.stacks
# 生成 off-cpu 火焰图
flamegraph.pl --color=io --title="Off-CPU Time" offcpu.stacks > offcpu.svgoffcputime 的核心逻辑是这样的:
// BCC offcputime 核心逻辑(简化)
BPF_HASH(start, u32, u64);
BPF_STACK_TRACE(stack_traces, 16384);
// 进程被调度走时触发
int oncpu(struct pt_regs *ctx, struct task_struct *prev) {
u32 pid = prev->pid;
u64 ts = bpf_ktime_get_ns();
// 记录被调度走的时间和栈
start.update(&pid, &ts);
return 0;
}
// 进程被调度回来时触发
int wakeup(struct pt_regs *ctx, struct task_struct *p) {
u32 pid = p->pid;
u64 *tsp = start.lookup(&pid);
if (tsp == 0) return 0;
// 计算 off-cpu 时间
u64 delta = bpf_ktime_get_ns() - *tsp;
// 聚合:栈 + 休眠时间
// ...
start.delete(&pid);
return 0;
}bpftrace 实现 off-cpu 分析
用 bpftrace 写一个简化版的 off-cpu 分析器,只需要几行:
bpftrace -e '
tracepoint:sched:sched_switch
{
// prev_state: 1=TASK_INTERRUPTIBLE(可中断睡眠), 2=TASK_UNINTERRUPTIBLE(不可中断睡眠)
if (args->prev_state == 1 || args->prev_state == 2) {
@start[args->prev_pid] = nsecs;
@stack[args->prev_pid] = kstack;
}
}
tracepoint:sched:sched_switch
/args->next_pid != 0 && @start[args->next_pid]/
{
$delta = nsecs - @start[args->next_pid];
@offcpu_us[@stack[args->next_pid]] = sum($delta / 1000);
delete(@start[args->next_pid]);
delete(@stack[args->next_pid]);
}
END
{
clear(@start);
clear(@stack);
}
'这段脚本做了三件事: 1. 进程被调度走时记录时间戳和内核栈 2. 进程被调度回来时计算休眠时长 3. 按栈聚合 off-cpu 时间
常见发现
在实际使用 off-cpu 分析时,我经常遇到这些”意外”发现:
意外的锁竞争:一个”无锁”的 Go
程序,off-cpu 火焰图里全是 runtime.lock——原来是
Go runtime 内部的 channel 操作在争锁。这种问题用 on-cpu
火焰图完全看不到。类似问题在内存分配器的
arena
设计中也有讨论,分配器内部的锁可能成为意想不到的瓶颈。
DNS 解析阻塞:一个 HTTP 服务的 P99
毛刺,off-cpu 分析发现是 getaddrinfo 在做 DNS
查询——每次超时都要等 5 秒。解决方案:本地 DNS 缓存 +
异步解析。
日志写入:同步写日志到磁盘,高负载下
write 系统调用阻塞 50ms+。Off-cpu
火焰图里一眼就能看到 ext4_file_write_iter
的高塔。
On-CPU + Off-CPU 联合分析实战
理论讲完了,来看一个真实的排查过程。场景:一个 Go 写的 API 网关,P99 延迟从 15ms 飙到 120ms,但 CPU 使用率只从 40% 涨到 55%。
第一反应:看 on-cpu 火焰图
# 用 Parca 或者手动抓 30 秒的 on-cpu 火焰图
bpftrace -e 'profile:hz:49 /comm == "api-gateway"/ { @[ustack()] = count(); }' -d 30 > oncpu.btOn-CPU 火焰图显示:
40% — runtime.mallocgc → malloc 相关
25% — net/http.(*conn).serve
20% — encoding/json.Marshal
15% — 其他
40% 的时间在 malloc? 这不正常。但这只是 CPU 忙碌时的视图——进程不在 CPU 上的时候在干嘛?
第二步:看 off-cpu 火焰图
# 用 BCC 的 offcputime 抓 off-cpu 数据
offcputime-bpfcc -p $(pgrep api-gateway) -d 30 -f > offcpu.stacks
flamegraph.pl --color=io < offcpu.stacks > offcpu.svgOff-CPU 火焰图显示:
60% — runtime.lock2 → sync.(*Mutex).Lock
→ connPool.Get → 连接池获取
20% — runtime.netpollblock → net.(*netFD).Read
15% — runtime.gopark → time.Sleep
5% — 其他
60% 的等待时间在 Mutex 上! 连接池用了一把全局锁,高并发下严重争抢。
拼在一起看:
| 维度 | 占比 | 根因 |
|---|---|---|
| On-CPU: malloc | 40% | 高频小对象分配,Go 默认分配器在多核下性能退化 |
| Off-CPU: mutex 等待 | 60% | 连接池全局锁,P99 延迟的主要来源 |
解决方案:
- malloc 问题:Go 1.21+
的分配器已有改善。对热路径做对象池化(
sync.Pool),减少 GC 压力。参考内存分配器竞技场的 arena 思路。 - Mutex 问题:把全局连接池改成分片设计——按目标 host 哈希到 N 个子池,每个子池一把锁。锁竞争从 O(并发数) 降到 O(并发数/N)。
- 效果:P99 从 120ms 降到 18ms。On-CPU 的 malloc 占比从 40% 降到 15%,Off-CPU 的 mutex 等待从 60% 降到 8%。
关键教训:如果你只看 on-cpu 火焰图,你会去优化 malloc——换 jemalloc、做对象池。这些都有帮助,但 P99 不会降太多,因为真正的瓶颈在锁上,而锁等待时间不消耗 CPU。 必须两张图一起看。
四、BCC vs bpftrace:深度对比
BCC 和 bpftrace 都是基于 eBPF 的工具,但它们的定位完全不同。
BCC:Python 可编程,适合复杂工具
BCC 的强项是可编程性。你可以用 Python 写复杂的数据处理逻辑,用 C 写 eBPF 内核代码,两者通过 BPF Map 通信。
# BCC 示例:追踪文件系统读延迟分布
from bcc import BPF
prog = """
#include <linux/fs.h>
BPF_HASH(start, u32, u64);
BPF_HISTOGRAM(dist);
int trace_read_entry(struct pt_regs *ctx) {
u32 tid = bpf_get_current_pid_tgid();
u64 ts = bpf_ktime_get_ns();
start.update(&tid, &ts);
return 0;
}
int trace_read_return(struct pt_regs *ctx) {
u32 tid = bpf_get_current_pid_tgid();
u64 *tsp, delta;
tsp = start.lookup(&tid);
if (tsp != 0) {
delta = bpf_ktime_get_ns() - *tsp;
dist.increment(bpf_log2l(delta / 1000));
start.delete(&tid);
}
return 0;
}
"""
b = BPF(text=prog)
b.attach_kprobe(event="vfs_read", fn_name="trace_read_entry")
b.attach_kretprobe(event="vfs_read", fn_name="trace_read_return")
print("Tracing vfs_read()... Hit Ctrl-C to end.")
b.trace_print()BCC 适合的场景: - 需要复杂的用户态后处理(聚合、过滤、报警) - 要把数据对接到 Prometheus、Grafana - 开发可复用的运维工具
bpftrace:one-liner 友好,适合临时调查
bpftrace 的强项是即时性。你不需要写文件、不需要编译、不需要引入依赖——一行命令搞定:
# 谁在读文件?读了多大?
bpftrace -e 'tracepoint:syscalls:sys_exit_read /args->ret > 0/ {
@bytes[comm] = sum(args->ret);
}'
# 哪些进程在做 DNS 查询?
bpftrace -e 'uprobe:/lib/x86_64-linux-gnu/libc.so.6:getaddrinfo {
printf("%s is resolving DNS\n", comm);
}'
# TCP 重传统计
bpftrace -e 'tracepoint:tcp:tcp_retransmit_skb {
@retrans[comm, args->sport, args->dport] = count();
}'工具生态:BCC 的 100+ 预置工具
BCC 附带了超过 100 个开箱即用的工具,覆盖了几乎所有子系统:
| 子系统 | 工具 | 用途 |
|---|---|---|
| CPU | profile |
CPU 栈采样 |
| CPU | offcputime |
Off-CPU 时间分析 |
| CPU | cpudist |
CPU 使用时间分布 |
| 内存 | memleak |
内存泄漏检测 |
| 磁盘 | biolatency |
块设备 I/O 延迟分布 |
| 磁盘 | biosnoop |
每次 I/O 操作追踪 |
| 网络 | tcplife |
TCP 连接生命周期 |
| 网络 | tcpretrans |
TCP 重传追踪 |
| 文件系统 | ext4slower |
慢 ext4 操作 |
| 调度器 | runqlat |
运行队列延迟 |
性能开销对比
| 维度 | BCC | bpftrace |
|---|---|---|
| 启动时间 | 慢(需要编译 eBPF C 代码) | 较快(LLVM JIT) |
| 运行时开销 | 低(内核态执行) | 低(内核态执行) |
| 内存占用 | 较高(Python 运行时) | 低 |
| 编译依赖 | 需要内核头文件(或 BTF) | 需要内核头文件(或 BTF) |
内核版本要求
| 特性 | 最低内核版本 | BCC 支持 | bpftrace 支持 |
|---|---|---|---|
| kprobe | 4.1 | ✅ | ✅ |
| tracepoint | 4.7 | ✅ | ✅ |
| uprobe | 4.17 | ✅ | ✅ |
| BTF(免头文件) | 5.2 | ✅ | ✅ |
| BPF ring buffer | 5.8 | ✅ | ✅ |
| bpf_d_path | 5.10 | ✅ | ✅ |
一句话总结:bpftrace 是你的瑞士军刀,BCC 是你的工具箱。临时排查用 bpftrace,长期工具用 BCC。
五、持续性能分析:Parca 和 Pyroscope
到目前为止,所有工具都有一个共同的问题:你得在问题发生时手动去跑。但生产环境的性能问题,最讨厌的特点就是——你永远不知道它什么时候会出现。
传统 profiling 的问题
想象一下这个场景:
- 凌晨 3 点,报警响了:P99 延迟飙升到 2 秒
- 你爬起来 SSH 到机器上
- 跑
perf record采样 30 秒 - 生成火焰图——一切正常
- 因为问题已经过去了
这就是传统 profiling
的致命缺陷:它是响应式的。你不可能 24/7
盯着 perf 的输出。
Continuous Profiling:始终采样,事后分析
Parca 和 Pyroscope 的解决方案是把 profiling 变成和 metrics 一样的基础设施——始终运行,始终采集,需要时再查。
核心思路:
每台机器运行 Agent(eBPF 采样)
↓
低频采样(19Hz / 49Hz)
↓
栈数据压缩上报
↓
Server 端存储(列式存储 / 时序数据库)
↓
UI 查询:选择时间段 → 看火焰图
Parca 架构详解
Parca 是 Polar Signals 开源的持续性能分析平台,架构分三层:
Agent 层(parca-agent):
# 安装 parca-agent(基于 eBPF,无需修改应用)
parca-agent \
--remote-store-address=parca-server:7070 \
--remote-store-insecure \
--node=node-01 \
--sampling-ratio=49Agent 使用 eBPF perf_event
做栈采样,不需要修改应用代码,不需要重启进程,不需要特殊的编译选项。它自动发现机器上的所有进程,持续采集调用栈。
Server 层(parca):
# 启动 parca server
parca \
--config-path=parca.yaml \
--storage-active-memory=4096Server 接收 Agent 上报的 profile 数据,存储在自研的列式存储引擎中(基于 FrostDB),支持高效的时间范围查询和聚合。
查询层(UI + API):
Parca 提供 Web UI 和 gRPC API,核心功能包括: - 时间段选择:选择任意时间范围查看火焰图 - 差异火焰图:对比两个时间段的性能差异 - 标签过滤:按节点、容器、命名空间过滤 - Icicle chart:倒置火焰图,从根到叶查看调用链
采样频率和开销控制
持续 profiling 最大的顾虑是开销。Parca 的做法:
| 配置 | 值 | 原因 |
|---|---|---|
| 采样频率 | 19Hz 或 49Hz | 低于 perf 的
99Hz,减少中断开销 |
| 栈深度限制 | 127 帧 | 避免深栈采样的内存开销 |
| 上报间隔 | 10 秒 | 批量上报减少网络开销 |
| CPU 开销 | <1% | 实测在大多数工作负载下 |
| 内存开销 | ~50MB | Agent 常驻内存 |
为什么用 49Hz 而不是 50Hz?因为 避免与系统定时器频率对齐(Linux 默认 HZ=250 或 1000),对齐会导致采样偏差——你总是在同一个位置采到样本。
Parca 的低开销机制:为什么生产环境敢一直开着
“持续采样会不会拖慢服务?” 这是每个 SRE 的第一反应。Parca 的答案是:开销控制在 1% 以内,代价几乎不可感知。它是怎么做到的:
1. 采样频率:19Hz,不是 100Hz
Parca Agent 默认用 perf_event 以 19Hz
采样(每秒 19 次中断)。为什么是 19 而不是 100?
- 100Hz 在高并发服务上中断开销不可忽视(每核每秒 100 次上下文切换)
- 19 是素数,不会和系统调度器的时间片(通常是 4ms/250Hz 的倍数)对齐,避免采样偏差
- 19Hz 足够做统计分析——10 秒就有 190 个样本,60 秒有 1140 个样本,热点函数一定会被采到
- 对比:
perf record -F 99用 99Hz(也是素数),但那是手动采样,跑几分钟就停。持续跑的话太贵
2. 内核态栈去重:BPF Stack Map
关键优化:栈采样和聚合都在内核里完成,不是每次中断都把完整栈拷贝到用户态。
perf_event 中断触发
→ BPF 程序调用 bpf_get_stackid()
→ 内核在 BPF_MAP_TYPE_STACK_TRACE 中查找这个栈
→ 如果已存在:返回 stack_id,计数器 +1(零拷贝)
→ 如果不存在:存储栈帧,返回新 stack_id
→ 用户态只需要定期读取 (stack_id, count) 对
这意味着:如果同一个热点函数被采到 1000 次,内核只存储一份栈数据。用户态每次读取的数据量和不同栈的数量成正比,而不是和采样次数成正比。
3. 用户态:pprof 兼容格式 + 高效压缩
Agent 把从内核读到的 (stack_id → 栈帧, count) 数据转换成 pprof 格式(Protocol Buffers),然后:
- 时间戳压缩:用 delta-of-delta 编码。连续的 10 秒采样间隔,第一个存绝对时间戳,后续只存差值的差值(通常是 0 或很小的整数),压缩率极高。
- 栈 ID 编码:变长整数编码(varint)。大多数 stack_id 是小整数,1-2 字节就够。
- 批量上报:每 10 秒批量发送一次,减少网络 round-trip。
4. 最终开销拆解:
| 开销来源 | 量级 | 说明 |
|---|---|---|
| perf_event 中断处理 | ~2μs × 19/s/核 | 每核每秒约 38μs,占 CPU 0.004% |
| BPF 程序执行 | ~1μs × 19/s/核 | 栈查找 + 计数更新 |
| 用户态聚合 + 上报 | ~10ms/10s | 每 10 秒一次批量处理 |
| 总计 | <1% CPU | 实测通常在 0.1-0.5% |
差异火焰图
差异火焰图是持续 profiling 最杀手级的功能。它对比两个时间段的 profile 数据,用颜色标记变化:
- 红色:变慢了(这个函数占比增加)
- 蓝色:变快了(这个函数占比减少)
- 灰色:没变化
使用场景:
发版前 vs 发版后 → 定位性能退化
高峰期 vs 低谷期 → 理解负载变化
正常节点 vs 异常节点 → 定位单点问题
这在多语言性能对比场景中特别有用——你可以直接看到不同实现在生产环境中的真实 CPU 消耗差异,而不是只依赖基准测试。
与 Prometheus / Grafana 的集成
Parca 支持把 profile 数据关联到 Prometheus 指标:
# parca.yaml 配置示例
object_storage:
bucket:
type: FILESYSTEM
config:
directory: /var/lib/parca
# Grafana 数据源配置
# Parca 提供 gRPC API,可通过 Grafana 插件查询
# 在 Grafana 面板中关联 metrics 和 profiles:
# "CPU 使用率升高" → 点击 → 查看该时段火焰图这意味着你可以在 Grafana 里看到 CPU 使用率飙升的告警,然后一键跳转到那个时间段的火焰图——不需要 SSH,不需要手动 profiling,数据已经在那了。
六、工具选型指南
说了这么多工具,实际工作中怎么选?
按场景选工具
"服务的 P99 突然变高了"
└→ 先看 on-cpu 火焰图(bpftrace / perf)
└→ CPU 没问题?看 off-cpu 火焰图(offcputime)
└→ 要持续监控?上 Parca
"内存泄漏了"
└→ BCC memleak / bpftrace 追踪 malloc/free
"磁盘 I/O 变慢了"
└→ BCC biolatency / biosnoop
"想搞清楚系统调用模式"
└→ bpftrace one-liner
临时排查:bpftrace one-liner
90% 的性能问题,一行 bpftrace 就能定位方向:
# 谁在消耗 CPU?
bpftrace -e 'profile:hz:99 { @[comm] = count(); }'
# 系统调用延迟分布
bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @start[tid] = nsecs; }
tracepoint:raw_syscalls:sys_exit /@start[tid]/ {
@ns[comm, args->id] = hist(nsecs - @start[tid]);
delete(@start[tid]);
}'
# 进程创建追踪
bpftrace -e 'tracepoint:syscalls:sys_enter_execve {
printf("%s -> %s\n", comm, str(args->filename));
}'深度调查:BCC 工具 / 自定义脚本
当 one-liner 不够用,需要更复杂的分析逻辑时,上 BCC:
# 全方位性能画像(BCC 工具套装)
# 1. CPU 热点
profile-bpfcc -F 99 -p $PID 30 | flamegraph.pl > cpu.svg
# 2. Off-CPU 时间
offcputime-bpfcc -df -p $PID 30 > offcpu.stacks
flamegraph.pl --color=io offcpu.stacks > offcpu.svg
# 3. 内存分配热点
memleak-bpfcc -p $PID -a 10
# 4. I/O 延迟
biolatency-bpfcc -D 10
# 5. TCP 连接追踪
tcplife-bpfcc -p $PID持续监控:Parca / Pyroscope
对于生产环境,最终你需要的是持续性能分析:
# Kubernetes 部署 parca-agent(DaemonSet)
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: parca-agent
namespace: monitoring
spec:
selector:
matchLabels:
app: parca-agent
template:
metadata:
labels:
app: parca-agent
spec:
hostPID: true
containers:
- name: parca-agent
image: ghcr.io/parca-dev/parca-agent:latest
args:
- "--remote-store-address=parca.monitoring:7070"
- "--remote-store-insecure"
securityContext:
privileged: true
volumeMounts:
- name: modules
mountPath: /lib/modules
- name: debugfs
mountPath: /sys/kernel/debug
volumes:
- name: modules
hostPath:
path: /lib/modules
- name: debugfs
hostPath:
path: /sys/kernel/debug生产安全考量
| 考量 | 建议 |
|---|---|
| CPU 开销 | 持续 profiling 控制在 <1%;临时 profiling 可以到 5% |
| 内核版本 | 推荐 5.8+(ring buffer 支持);最低 4.9(基础 eBPF) |
| 权限 | 需要
CAP_SYS_ADMIN 或
CAP_BPF(5.8+) |
| 安全审计 | eBPF 程序受 verifier 约束,不会导致内核崩溃 |
| 数据量 | 持续 profiling 每节点约 10-50MB/天(压缩后) |
| 敏感信息 | 调用栈可能包含函数名、文件路径——注意数据访问权限 |
回顾一下这条演化路线:
perf 让我们看到了 CPU
在忙什么。ftrace
让我们追踪到了内核函数调用。BCC
让我们能在内核里写自定义分析逻辑。bpftrace
让我们一行命令就能开始排查。Parca/Pyroscope
让我们再也不用担心”问题发生时我不在”。
但最重要的认知变化是这一个:你需要同时看 on-cpu 和 off-cpu 才能看到完整的性能画面。火焰图很好,但它只是故事的一半。
下次 P99 延迟飙升的时候,别只看 CPU 火焰图了。先问一句:进程不在 CPU 上的时候,它在等什么?
参考资料
- Brendan Gregg, BPF Performance Tools, Addison-Wesley, 2019
- Brendan Gregg, Off-CPU Analysis
- Parca 官方文档
- Pyroscope 官方文档
- BCC 工具列表
- bpftrace 参考指南
- Brendan Gregg, Flame Graphs
延伸阅读:
- eBPF:Linux 内核的隐藏武器——eBPF 虚拟机、verifier、Map 的核心原理
- eBPF 追踪实战:用 bpftrace 在生产环境找到那个慢请求——bpftrace 的完整实战案例
- 内存分配器:arena 与碎片——理解分配器内部锁对 off-cpu 时间的影响
- Go vs C vs Rust 性能对决——多语言性能对比,配合持续 profiling 看更准