2026-04-22 · architecture / observability
持续性能分析(Continuous Profiling):Parca、Pyroscope、Grafana Beyla
深入剖析持续性能分析(Continuous Profiling)的原理、架构与落地实践,覆盖 Parca、Pyroscope、Grafana Beyla 三大主流方案,包含 eBPF 采样、符号解析、火焰图、差异分析以及字节跳动、美团的生产案例与工程坑点。


























在 Metrics、Logs、Traces 三大可观测性信号之外,Profiling(性能剖析)正在成为生产可观测性的第四支柱。指标告诉我们「系统慢了」,链路告诉我们「慢在哪个服务」,日志告诉我们「发生了什么错误」,而 Profiling 回答一个更本质的问题:「CPU 的每一个时钟周期、堆上的每一个字节,究竟花在了哪一行代码上?」
传统上 Profiling 只在本地调试或性能攻坚阶段使用,一次采样、一个火焰图,问题解决后数据也被丢弃。这种「事件驱动」的剖析方式在微服务时代逐渐失效:当问题只在生产流量下复现、当集群规模扩展到上万个 Pod、当性能回归需要跨版本对比时,我们需要一种成本可承受、始终在线的 Profiling 方案,也就是持续性能分析(Continuous Profiling)。
本文从 Profiling 的分类与原理讲起,深入 Go pprof、Java async-profiler 与 JFR(Java Flight Recorder)、Python py-spy、Rust pprof-rs 等语言工具,再到 Pyroscope、Parca 等持续剖析系统,最后覆盖 OpenTelemetry Profiles 规范与国内大厂的落地经验、工程坑点与选型建议。
Profiling 并不是单一维度的动作,而是一族针对不同资源、不同时间域的观测方法。理解它们的区别是用好任何工具的前提。
On-CPU Profiling 回答的是:「在某段时间窗口内,进程占用 CPU 的每一个样本落在哪一个调用栈上?」它的统计单位通常是「CPU 时间」而不是「墙钟时间」(wall-clock time),因此线程在 I/O、锁、睡眠等非 CPU 阶段是不计入的。
On-CPU 是最常用的 Profiling 形态,因为:
典型工具:Linux perf record、Go pprof 的
/debug/pprof/profile、async-profiler 的
-e cpu、py-spy、Parca eBPF Agent。
Heap Profiling 关心两个问题:
Go 的 /debug/pprof/heap 会同时报告
alloc_space、alloc_objects、inuse_space、inuse_objects
四个维度;Java 通过 async-profiler -e alloc 或
JFR 的 ObjectAllocationSample 事件;C/C++ 常用
jemalloc/tcmalloc 自带的 profiler 或
heaptrack;Python 使用
tracemalloc。
Heap Profiling 的采样通常按「字节步长」进行:每分配 N KB
字节触发一次采样,这样既控制了开销,又能对大对象加权。Go
默认 MemProfileRate=512KB,Java
-XX:ObjectAllocationSampleInterval=512K。
Go 特有的 Goroutine Profiling 会枚举运行时所有 goroutine 的状态与调用栈,是调查 goroutine 泄漏、死锁、调度异常的核心工具。它是一种全量快照而不是采样——成本与当前存活的 goroutine 数量成正比。
线上一个常见故障模式是:某条路径在超时场景下没有关闭
channel 或 context,导致 goroutine 持续堆积最终 OOM。通过
/debug/pprof/goroutine?debug=2 可以看到每个
goroutine
的等待位置与栈,goroutine X [select, 23 minutes]
这样的行往往直接暴露问题。
Java 类似的能力是 jstack 与 JFR 的
ThreadDump 事件;两者都倾向于全量快照。
多线程程序中「明明 CPU 不满、磁盘不满、网络不满,但吞吐上不去」的场景往往是锁争用。Lock Profiling 采样的是:当线程获取锁失败陷入等待时,该事件对应的调用栈。
runtime.SetMutexProfileFraction(N) 打开
mutex profile,/debug/pprof/mutex。-e lock,或 JFR 的
JavaMonitorEnter、JavaMonitorWait
事件。perf lock、bcc 的
klockstat。Block Profiling 记录的是线程在同步原语(channel、条件变量、I/O 等待)上的阻塞时间。与 Lock Profiling 的区别是:Lock 只针对互斥锁竞争,而 Block 包含任何导致线程挂起的事件。
Off-CPU Profiling 更泛化,原则上覆盖一切「不在 CPU 上执行」的时间:I/O、网络、锁、sleep、调度延迟。实现上通常依靠内核调度事件:
sched:sched_switch → 线程让出 CPU,记录让出时刻与调用栈
sched:sched_wakeup → 线程被唤醒,记录唤醒时刻
差值即为该次 off-CPU 时长
Brendan Gregg 在 Netflix 早期推广了 Off-CPU
分析,典型实现是 bcc/offcputime.py 或
bpftrace。async-profiler 在
-e wall 模式下也可以把线程非 CPU
时间纳入火焰图。
Wall-clock
采样对每个线程都按固定频率采样,无论它当时是否在 CPU
上运行。得到的火焰图里,sleep、epoll_wait、futex_wait
会成为显著贡献者,这反而是排查端到端延迟的利器——它让你看到「一次请求的
30ms 到底在哪儿等了」。
py-spy、async-profiler -e wall、Pyroscope 的
wall 模式都支持这种采样。代价是:对睡得多的服务,热点会被
idle 栈淹没,需要额外过滤。
严格来说这属于 Tracing 或 eBPF
可观测性范畴,但很多团队会把它并入 Profiling
讨论,因为它回答同一类「调用栈级归因」问题。例如 BCC 的
tcptop、tcplife 按 PID +
调用栈聚合网络字节数,相当于一张「网络火焰图」。
| 类型 | 采样维度 | 核心问题 | Go 工具 | Java 工具 | Python 工具 | 内核/通用 |
|---|---|---|---|---|---|---|
| On-CPU | CPU 时间 | 谁在烧 CPU | pprof cpu | async-profiler cpu / JFR | py-spy / Austin | perf / Parca |
| Heap | 分配字节 | 谁在分配内存 | pprof heap/allocs | async-profiler alloc / JFR | tracemalloc | heaptrack |
| Goroutine/Thread | 快照 | 线程是否泄漏 | pprof goroutine | jstack / JFR | threading.enumerate() | gdb thread apply all bt |
| Lock | 阻塞时间 | 锁争用在哪 | pprof mutex | async-profiler lock / JFR | — | perf lock |
| Block/Off-CPU | 等待时间 | 请求在哪儿等 | pprof block | async-profiler wall | py-spy –idle | bcc offcputime |
| Wall-clock | 墙钟时间 | 线程整体时间去哪 | — | async-profiler wall | py-spy | — |
所有 profiler 按实现可以分成两大流派:采样(sampling)与插桩(instrumentation)。选择哪一类决定了开销、精度与可部署性。
采样的核心思想是:以远低于函数调用频率的速度,周期性打断程序,记录当前调用栈;统计意义上,某函数在样本中出现的比例近似等于它的 CPU 时间占比。
优点:
缺点:
插桩在函数入口/出口注入计时代码,精确统计每次调用的耗时与次数。Python
的 cProfile、Java 字节码增强、C++
-pg 编译出的 gprof 都属于这一类。
优点:
缺点:
一个经验是:做本地压测、基准测试、算法分析用插桩;做线上诊断、持续剖析用采样。
POSIX 提供 ITIMER_PROF 定时器,内核按「进程
CPU 时间」触发 SIGPROF
信号。信号处理函数在被中断的线程上下文中执行,可以就地捕获调用栈:
struct itimerval tv = {
.it_interval = {.tv_usec = 10000}, /* 100 Hz */
.it_value = {.tv_usec = 10000},
};
setitimer(ITIMER_PROF, &tv, NULL);
void sigprof_handler(int sig, siginfo_t *info, void *ucontext) {
/* 回溯当前调用栈并写入环形缓冲区 */
unwind_and_record(ucontext);
}Go 运行时、老版本 gperftools、Ruby stackprof 都使用类似方案。SIGPROF 的优点是与语言无关、跨平台;缺点是只在 CPU 上采样(不能抓 off-CPU)、且信号处理函数必须是 async-signal-safe,限制很多。
perf_events 是 Linux
内核提供的高精度性能监控子系统,核心能力:
cycles、instructions、cache-misses、branch-misses
等;cpu-clock、task-clock、page-faults、context-switches;--call-graph dwarf/fp/lbr。最常见的使用方式:
# 以 99Hz 采样 30 秒,采集用户栈
perf record -F 99 -p $(pgrep myapp) --call-graph=dwarf -g -- sleep 30
perf report --stdio # 文本报告
perf script > out.stacks # 折叠栈,供 FlameGraph 使用Parca、Pyroscope eBPF 模式、字节 Continuous Profiling
平台底层都是基于 perf_events 或更现代的
bpf_get_stackid/bpf_get_stack eBPF
辅助函数。
PMU 是现代 CPU 内置的性能计数器单元,以 Intel 为例,每个核心通常有 4~8 个可编程计数器 + 若干固定计数器。典型事件:
cycles:参考时钟周期数;instructions:退役指令数,配合
cycles 得到 IPC(每周期指令数);cache-misses /
LLC-load-misses:缓存缺失;branch-misses:分支预测失败;cpu/mem-loads/:内存加载事件。对高性能服务(如交易、数据库内核、网关),单纯的 on-CPU
火焰图不足以揭示瓶颈——很多时候 CPU 被「cache miss 导致的
stall」消耗,而不是真的在执行热点函数。此时需要基于 PMU
事件(如
cache-misses)采样生成「缓存火焰图」。
火焰图(flame graph) 是 Brendan Gregg 提出的调用栈可视化方式:
冰柱图(icicle graph) 是火焰图的上下翻转版,根在顶部、叶子在底部,Grafana Pyroscope 默认就是冰柱风格。对阅读「调用栈从谁发起」更直观。
还有两类常见衍生图:
Go 是把 Profiling 做进标准库、做进运行时最深的语言之一。理解 pprof 的工作方式,对于 Go 服务的线上诊断是必修课。
Go 生成的 profile 文件是一个 gzip 压缩的 protobuf,schema
定义在
github.com/google/pprof/proto/profile.proto
中。核心结构:
message Profile {
repeated ValueType sample_type = 1; // 指标维度:samples/count、cpu/nanoseconds、alloc_objects 等
repeated Sample sample = 2;
repeated Mapping mapping = 3; // 动态链接库、可执行文件段映射
repeated Location location = 4; // 指令地址 → 函数 + 源码行
repeated Function function = 5;
repeated string string_table = 6;
int64 time_nanos = 9;
int64 duration_nanos = 10;
int64 period = 12;
...
}
message Sample {
repeated uint64 location_id = 1; // 栈:叶子 → 根
repeated int64 value = 2; // 与 sample_type 等长
repeated Label label = 3; // 维度标签,如 goroutine id
}这种结构很有设计感:
sample_type 支持多维度,一份 heap profile
同时携带
alloc_space、alloc_objects、inuse_space、inuse_objects;string_table 共享字符串,体积小;location 与 function
分离,指令地址可以对应多行(内联)。大多数现代 profiling 后端(Pyroscope、Parca、Polar Signals、字节内部 CPP)都原生支持 pprof 格式,这让跨语言互操作成为可能。
Go 运行时内置多种 profile,通过
runtime/pprof 或 net/http/pprof
暴露:
| 名称 | 路径 | 含义 | 开销 |
|---|---|---|---|
profile |
/debug/pprof/profile?seconds=30 |
30 秒 CPU 采样 | 低,持续期间 ~1% |
heap |
/debug/pprof/heap |
堆快照,默认 in-use | 低 |
allocs |
/debug/pprof/allocs |
自进程启动的全部分配 | 低 |
goroutine |
/debug/pprof/goroutine |
全量 goroutine 栈 | 与 goroutine 数正比 |
mutex |
/debug/pprof/mutex |
互斥锁等待 | 需开启 fraction |
block |
/debug/pprof/block |
阻塞事件(chan、select) | 需开启 rate |
threadcreate |
/debug/pprof/threadcreate |
OS 线程创建栈 | 低 |
trace |
/debug/pprof/trace?seconds=5 |
执行跟踪(不是采样) | 高,短时使用 |
注意 mutex 与 block 默认关闭,需要显式打开:
runtime.SetMutexProfileFraction(1) // 1/N 的竞争事件被采样,1 表示全采
runtime.SetBlockProfileRate(10000) // 每 10 微秒阻塞事件采样一次生产环境 mutex profile 的 fraction 建议设成 100 或 1000,以控制开销。
最简单的接入方式:
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func main() {
go func() {
// 建议绑定到本地或内网,避免暴露在外网
log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
}()
runMyServer()
}注意两点:
0.0.0.0,否则任何人都可以 dump
goroutine、heap,泄露业务路径。推荐绑定到
127.0.0.1 + sidecar
proxy,或走内部管控平台;/debug/pprof/ 路径默认注册到
http.DefaultServeMux,如果你的业务端口共用
DefaultServeMux,相当于也暴露了 pprof——需要主动隔离。采下来一份 profile 后,通过 go tool pprof
打开:
go tool pprof -http=:8080 http://127.0.0.1:6060/debug/pprof/profile?seconds=30交互式常用命令:
(pprof) top # 按 flat 值排序前 10
(pprof) top -cum # 按 cum(累计)排序
(pprof) list <regexp> # 显示匹配函数源码级开销
(pprof) web # 生成 svg 调用图
(pprof) peek <regexp> # 显示函数的调用者/被调用者
(pprof) tree # 调用树
(pprof) disasm <regexp> # 反汇编级别热点
(pprof) traces # 每条样本的完整栈
对比两份 profile 的差分:
go tool pprof -http=:8080 -diff_base=old.pb.gz new.pb.gz差分视图对排查「上线后 CPU 涨了 10%」类问题极为有用。
读 Go CPU 火焰图有几个实战要点:
runtime.mallocgc、runtime.scanobject、runtime.gcBgMarkWorker
一整片宽柱子 = GC
压力大,通常要降低分配;runtime.findrunnable、runtime.schedule
占比高 = 调度争用或 GOMAXPROCS
配置问题;syscall.Syscall 占比高 =
系统调用多(网络、文件);reflect.Value.*、encoding/json.*
在中高并发服务中是常客,经常能通过替换 json 为
sonic/easyjson、避免反射获得数量级提升;sync.(*Mutex).Lock、sync.(*RWMutex).RLock
叶子出现高占比,应该结合 mutex profile 进一步分析;runtime.selectgo
占比高并不一定说明有问题——selectgo
本身会被采样进入,需结合业务判断。pprof 反复暴露的一些 Go 性能反模式:
s += x
在循环里重分配,应当用 strings.Builder
或预分配切片;interface{}
承载 int 会在堆上分配一个 8
字节对象,在高并发下 allocs 火焰图极宽;time.After
泄漏:select { case <-time.After(d): ... }
在大循环中会创建大量未释放的 timer,应当用
time.NewTimer 并 Stop();sync.Mutex
保护的大状态对象变成瓶颈,考虑分片锁或
sync.Map;map[K]V
每扩容一倍 rehash 一次,巨大 map 可以看到
runtime.mapassign_* 占比异常。go test -bench 天生支持 pprof:
go test -bench=BenchmarkMyFunc -benchmem \
-cpuprofile=cpu.pb.gz \
-memprofile=mem.pb.gz \
-mutexprofile=mutex.pb.gz \
-blockprofile=block.pb.gz \
./...
go tool pprof -http=:8080 cpu.pb.gz配合 benchstat 对比两个 commit:
go test -bench=. -count=10 ./... > old.txt
git checkout feature-branch
go test -bench=. -count=10 ./... > new.txt
benchstat old.txt new.txt在 CI 中强制性能回归守门(PR 引入 >5% 回归时阻塞合并)是大型 Go 项目常见实践。
Go 1.9+ 支持 pprof.Labels,在 CPU profile
中给样本打标签:
import "runtime/pprof"
func handle(ctx context.Context, req *Request) {
labels := pprof.Labels("endpoint", req.Path, "tenant", req.TenantID)
pprof.Do(ctx, labels, func(ctx context.Context) {
actualWork(ctx, req)
})
}这样火焰图可以按 endpoint / tenant 过滤或分组,等价于「请求级别归因」。Pyroscope 依赖这个机制实现按请求维度切片。
Java 曾经以 Profiling 困难著称——JVMTI 基于的 profiler(VisualVM、JProfiler 早期版本、hprof)都受困于 safepoint bias,导致结果严重失真。async-profiler 与 JFR 的出现改变了这一局面。
传统 JVMTI profiler 用 GetStackTrace 或
GetAllStackTraces 抓取栈,但这些 API 只能在
safepoint(安全点)执行。JVM 的 safepoint 是 JIT
编译器认可的「可以安全暂停全部线程」的代码位置,通常在方法入口、循环回边、非
counted loop 的循环等。
问题是:safepoint 的分布不均匀。如果一个热点循环被优化成 counted loop(JIT 认为它不会太长),中间就没有 safepoint,profiler 永远采不到循环内部。结果是火焰图把时间错误地归给循环外的函数。这就是 safepoint bias。
AsyncGetCallTrace(AGCT)是 Sun/Oracle 1.6 引入的私有 API,允许在信号处理函数里异步抓取栈,无需 safepoint。它遍历 JIT frame 元数据与解释器帧,拼出当前 Java 调用栈。虽然是私有 API,但 OpenJDK 沿用至今,并在 JEP 435 中推进成为公共 API。
async-profiler 就是基于 AGCT + perf_events 的采样 profiler,被誉为 Java 生态最准的 sampling profiler。
# 下载发布包
wget https://github.com/async-profiler/async-profiler/releases/download/v3.0/async-profiler-3.0-linux-x64.tar.gz
tar xf async-profiler-3.0-linux-x64.tar.gz
cd async-profiler-3.0-linux-x64
# 采 30 秒 CPU,输出火焰图 HTML
./bin/asprof -d 30 -f flame.html -e cpu <PID>
# 分配采样:每 512KB 采一次,输出 JFR
./bin/asprof -d 60 -e alloc --alloc 512k -f alloc.jfr <PID>
# 锁争用
./bin/asprof -d 60 -e lock -f lock.html <PID>
# Wall-clock(包含 off-CPU)
./bin/asprof -d 30 -e wall -t -f wall.html <PID>
# 通过 PMU 采 cache-misses
./bin/asprof -d 30 -e cache-misses -f cachemiss.html <PID>几个关键事件:
cpu:优先 perf_events,退化到
itimer;itimer:基于
ITIMER_PROF,容器中 perf 不可用时使用;alloc:基于 AllocationSamplingJVMTI + TLAB
慢路径;lock:MonitorContendedEnter 回调;wall:对所有线程周期采样;ctimer:CPU timer,精确 per-thread CPU
时间;-e cycles、-e cache-misses
等。JFR(Java Flight Recorder)最早是 Oracle JRockit JVM 的付费商业特性,2014 年整合进 HotSpot,2018 年通过 JEP 328 在 OpenJDK 11 中开源。现在所有主流 JDK(OpenJDK、Temurin、Corretto、Zulu)都带 JFR。
JFR 的设计理念:
.jfr 文件;启动方式:
# 启动时直接打开
java -XX:StartFlightRecording=duration=60s,filename=myapp.jfr,settings=profile MyApp
# 运行时通过 jcmd
jcmd <PID> JFR.start name=myrec settings=profile duration=60s filename=myapp.jfr
jcmd <PID> JFR.dump name=myrec filename=myapp.jfr
jcmd <PID> JFR.stop name=myrecsettings 常见值:
default.jfc:开销极低(~0.5%),事件较稀疏;profile.jfc:开销
1~2%,事件密集,生产推荐;Java Mission Control(JMC)是 Oracle 开源的 JFR 分析 GUI,2018 年独立成 Adoptium 项目。JMC 提供:
除了 JMC,开源生态还有:
async-profiler 引入了 honest profiling
概念:只在明确能还原栈的位置上报样本,无法还原的样本单独标记为
[unknown],避免瞎归因。与之相比,JFR 的
ExecutionSample 有时仍会出现
no Java frame 的样本,但好在比例可控。
在排查「火焰图底部出现大量陌生函数」时,区分 honest 样本和 fallback 样本非常重要。
典型做法:
default.jfc,常驻运行;-e cpu 或 -e alloc 做 60
秒高精度采样;Python 的动态特性让 profiling 既简单又复杂:简单在于
sys.settrace / sys.setprofile
提供了即插即用的
hook;复杂在于解释器帧结构会影响性能,开销容易失控。
cProfile 是 Python 标准库自带的插桩
profiler,基于 sys.setprofile:
import cProfile
import pstats
def main():
compute_something()
cProfile.run("main()", "profile.out")
stats = pstats.Stats("profile.out")
stats.sort_stats("cumulative").print_stats(20)或作为命令行:
python -m cProfile -o profile.out myscript.py
python -m pstats profile.outcProfile 精确统计每个函数的调用次数、总耗时、累计耗时。缺点:开销较大(几十倍不罕见),不适合生产,也不适合 CPU-bound 紧密循环(插桩开销本身比函数体还大)。
py-spy 是 Rust 写的外部采样 profiler,核心思路是:
ptrace/process_vm_readv
从目标进程读取内存;PyInterpreterState →
PyThreadState → PyFrameObject
链;用法:
# 安装
pip install py-spy
# 查看目标进程实时函数热度(类 top)
py-spy top --pid <PID>
# 采 30 秒生成火焰图
py-spy record -o profile.svg --duration 30 --pid <PID>
# 抓一份 thread dump
py-spy dump --pid <PID>py-spy 的优点:
--native 显示 C 扩展栈(需要
libunwind)。缺点:在容器内诊断需要 CAP_SYS_PTRACE 或共享
PID namespace;macOS 上需要关闭部分安全保护。
Austin 是另一个 C 写的 Python 采样 profiler,设计更轻量,支持输出 Austin 自定义格式或 collapsed 栈。它与 VS Code 插件、Austin Web 结合做成了交互式分析器。
Pyinstrument 是嵌入式 wall-clock profiler,侧重请求级剖析:
from pyinstrument import Profiler
profiler = Profiler()
profiler.start()
do_work()
profiler.stop()
print(profiler.output_text(unicode=True, color=True))输出是树状报告,按墙钟时间排序,非常适合 Web 请求的逐请求剖析(作为中间件挂到 Flask/Django)。
标准库 tracemalloc
追踪每一次内存分配的调用栈:
import tracemalloc
tracemalloc.start(25)
snapshot1 = tracemalloc.take_snapshot()
do_work()
snapshot2 = tracemalloc.take_snapshot()
for stat in snapshot2.compare_to(snapshot1, "lineno")[:10]:
print(stat)Bloomberg 开源的 memray
更强大:精确记录每次
malloc/free,支持火焰图、时间序列图、实时报告,线上模式开销可控。
一个常见的 Python 服务诊断流程:
py-spy top -p 1
实时看函数热度;py-spy record -d 60 -o hot.svg;requests、sqlalchemy);memray run --live myapp.py
复现本地,或生产采 tracemalloc 快照。Python 的 GIL 让并发 profiling 多了一层考虑:wall-clock
模式下,等 GIL 的线程会显示为「在
PyEval_AcquireLock」,看起来像热点,其实是争用信号。
Rust 虽然不像 Go 内置运行时 profiler,但生态非常活跃。
pprof-rs 是 TiKV 团队开源的 Rust CPU profiler,支持输出 pprof 格式与 flamegraph:
use pprof::ProfilerGuard;
fn main() {
let guard = pprof::ProfilerGuardBuilder::default()
.frequency(100)
.blocklist(&["libc", "libgcc", "pthread", "vdso"])
.build()
.unwrap();
run_server();
if let Ok(report) = guard.report().build() {
let file = std::fs::File::create("flamegraph.svg").unwrap();
report.flamegraph(file).unwrap();
}
}内部基于 SIGPROF + backtrace-rs,可选启用
frame-pointer feature 加速回溯(但要求编译时
-Cforce-frame-pointers=yes)。
criterion.rs 是 Rust 的 benchmark 框架,统计上严格(置信区间、回归检测):
use criterion::{criterion_group, criterion_main, Criterion};
fn bench_fib(c: &mut Criterion) {
c.bench_function("fib 20", |b| b.iter(|| fibonacci(20)));
}
criterion_group!(benches, bench_fib);
criterion_main!(benches);运行 cargo bench
会自动对比历史结果,给出「性能退化」判断。
cargo-flamegraph
封装 perf + FlameGraph,一条命令出火焰图:
cargo install flamegraph
cargo flamegraph --bin myapp -- --flag1 arg1编译建议:
[profile.release]
debug = true # 保留调试符号
lto = "thin"
codegen-units = 1否则火焰图里到处是 [unknown]。
Rust 二进制与 C 程序一样可以被 perf record
直接采样,前提是编译时带调试符号且关闭 frame pointer
omission:
RUSTFLAGS="-C force-frame-pointers=yes -C debuginfo=2" cargo build --release
perf record -F 99 -g --call-graph=dwarf ./target/release/myappParca、Pyroscope eBPF agent 都能直接对 Rust 二进制采样,只要符号齐全。
Pyroscope 最初由 Ryan Perry 等人在 2020 年开源,目标是「为 Profiling 做 Prometheus + Grafana 这件事」。2023 年 Grafana Labs 完成收购,Pyroscope 成为 Grafana Observability 栈的一部分。
┌──────────┐ ┌──────────┐ ┌────────────┐
│ Go SDK │ │ Java SDK │ │ Agent/eBPF │
└────┬─────┘ └────┬─────┘ └─────┬──────┘
│ pprof/flamebearer/JFR │
▼ ▼
┌────────────────────────────────────────┐
│ Pyroscope Distributor │
└───────────────┬────────────────────────┘
│
┌───────┴────────┐
▼ ▼
┌───────────┐ ┌───────────────┐
│ Ingester │ │ Object Store │
│ (TSDB-ish)│◀──▶│ (S3/GCS/...) │
└────┬──────┘ └───────────────┘
│
▼
┌─────────────┐
│ Querier │
└──────┬──────┘
│
▼
┌─────────────────┐
│ Grafana Plugin │
└─────────────────┘
从 Pyroscope 1.x 起,其内部架构大量借鉴了 Grafana Loki / Mimir:分离 Distributor / Ingester / Compactor / Querier,并支持多租户、对象存储后端(S3、GCS、Azure Blob、本地文件系统)。
Pyroscope 的核心存储单元是折叠栈(collapsed
stack):一条调用栈被编码成
frame1;frame2;frame3 value
的字符串,然后做字典编码 + 前缀压缩。
更精细的是 v1.x 引入的 symdb:
配合时间序列索引(按 service、profile_type、labels 切片),一次查询等价于:
Go 服务:
import "github.com/grafana/pyroscope-go"
func main() {
_, err := pyroscope.Start(pyroscope.Config{
ApplicationName: "order-service",
ServerAddress: "http://pyroscope:4040",
Logger: pyroscope.StandardLogger,
Tags: map[string]string{"env": "prod", "region": "cn-east-1"},
ProfileTypes: []pyroscope.ProfileType{
pyroscope.ProfileCPU,
pyroscope.ProfileAllocObjects,
pyroscope.ProfileAllocSpace,
pyroscope.ProfileInuseObjects,
pyroscope.ProfileInuseSpace,
pyroscope.ProfileGoroutines,
pyroscope.ProfileMutexCount,
pyroscope.ProfileBlockCount,
},
})
if err != nil {
log.Fatal(err)
}
runApp()
}Java 服务:挂载 async-profiler 版本的 pyroscope agent:
java -javaagent:./pyroscope.jar \
-DPYROSCOPE_APPLICATION_NAME=order-service \
-DPYROSCOPE_SERVER_ADDRESS=http://pyroscope:4040 \
-DPYROSCOPE_PROFILING_INTERVAL=10s \
-DPYROSCOPE_PROFILER_EVENT=itimer \
-DPYROSCOPE_LABELS=env=prod,region=cn-east-1 \
-jar app.jarPython:
import pyroscope
pyroscope.configure(
application_name = "worker",
server_address = "http://pyroscope:4040",
tags = {"env": "prod"},
)Pyroscope 支持三种部署:
-target=<role> 切换,运维简单。建议生产使用 Helm Chart
grafana/pyroscope,开启对象存储后端,保留策略按租户隔离:
pyroscope:
structuredConfig:
storage:
backend: s3
s3:
bucket_name: pyroscope-prod
endpoint: s3.amazonaws.com
limits:
ingestion_rate_mb: 20
ingestion_burst_size_mb: 40
max_profile_size_bytes: 5_000_000
max_profile_stacktraces: 16000
retention_period: 168hGrafana 10+ 内置 Pyroscope 数据源,支持:
Parca 由 Polar Signals 团队(Frederic Branczyk、Matthias Loibl 等)从 Red Hat OpenShift Monitoring 的经验中孵化,目标是「零代码侵入的多语言持续剖析」。
┌─────────────────────┐ ┌────────────────────┐
│ parca-agent (eBPF) │──────▶│ parca-server │
│ (DaemonSet) │ pprof │ (TSDB + FrostDB) │
└─────────────────────┘ └─────────┬──────────┘
│
▼
┌────────────────────┐
│ Parca Web UI │
└────────────────────┘
parca-agent 不依赖语言运行时,也不需要目标进程合作:
perf_event BPF program,附着到
PERF_COUNT_SW_CPU_CLOCK,以 19Hz / 49Hz / 99Hz
触发;bpf_get_stack
抓取用户态指令指针;这种方式的优势:
挑战主要在栈回溯。
eBPF 内核栈采样有两种机制:
%rbp 寄存器始终指向栈帧链,沿着
%rbp 遍历即可。要求目标二进制用
-fno-omit-frame-pointer 编译;.eh_frame)描述的 Canonical Frame
Address 规则逐帧还原。不依赖
FP,但解析开销大、.eh_frame
通常需要拉到用户态计算。很多发行版为了性能早期默认
-fomit-frame-pointer,导致 eBPF profiler
栈断裂。社区推动下:
parca-agent 1.x 引入了自定义的「unwind table」:提前解析
.eh_frame,把回溯规则压进 BPF map,让 BPF
内直接用表驱动回溯,从而摆脱对 FP
的依赖。这也是它的技术亮点。
对于 JIT 语言,parca-agent 会:
perf-<pid>.map
文件(async-profiler、V8、OpenJDK
-XX:+PreserveFramePointer +
-agentpath:libperfmap.so 可生成);PyFrameObject。但相比 async-profiler / py-spy 的语言原生采样,eBPF 对 JIT 语言支持仍然粗糙;许多团队采用「eBPF 覆盖原生语言 + 各语言 SDK 覆盖自己」的混合部署。
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: parca-agent
namespace: parca
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:v0.30.0
args:
- /bin/parca-agent
- --node=$(NODE_NAME)
- --remote-store-address=parca-server.parca:7070
- --remote-store-insecure
- --profiling-duration=10s
securityContext:
privileged: true
env:
- name: NODE_NAME
valueFrom: { fieldRef: { fieldPath: spec.nodeName } }
volumeMounts:
- { name: tmp, mountPath: /tmp }
- { name: run, mountPath: /run }
- { name: proc, mountPath: /host/proc, readOnly: true }
- { name: sys, mountPath: /sys/kernel/debug }
volumes:
- { name: tmp, emptyDir: {} }
- { name: run, hostPath: { path: /run } }
- { name: proc, hostPath: { path: /proc } }
- { name: sys, hostPath: { path: /sys/kernel/debug } }关键点:
hostPID: true 才能看到主机所有进程;privileged: true 或
CAP_BPF + CAP_PERFMON + CAP_SYS_PTRACE;/sys/kernel/debug 用于加载 BPF program
与读取 kallsyms。2024 年 Elastic 把其内部基于 eBPF 的 Universal Profiler 捐赠给 OpenTelemetry 社区,成为 OpenTelemetry eBPF Profiler。Parca / Polar Signals 也在持续对 OTel Profiles 信号做贡献。趋势很明显:eBPF profiling 正在被标准化。
「持续」是持续性能分析的关键词,它要求 profiler 能常年挂在生产上,而不只是在事故发生时才启动。
Google 在 2010 年就用 Google-Wide Profiling(GWP)做到了整数据中心级持续采样,年度 CPU 开销控制在 0.01% 以内。
持续 profiling 与传统 profiling 的关键区别是:
合并算法的核心是「同结构栈的值相加」:
profile_A: A;B;C 100
A;B;D 50
profile_B: A;B;C 30
A;E 70
------ 合并 --------
merged: A;B;C 130
A;B;D 50
A;E 70
Pyroscope、Parca、pprof CLI 都实现了这套合并逻辑。难点在于大规模场景下:单 tenant 每秒钟几千份 profile、每份包含上万条栈,合并必须在列式存储上高速进行——这正是 FrostDB、Pyroscope symdb 的价值。
一条慢链路(trace)可以附带一份那段时间的 profile,即 trace-to-profile 关联:
pyroscope.profile.id 或
profiling.context.span_id;这是 2024 年以来可观测性工具链的共识方向,为「慢请求定位到代码行」提供了最后一公里。
# Grafana datasource 配置
apiVersion: 1
datasources:
- name: Tempo
type: tempo
jsonData:
tracesToProfiles:
datasourceUid: pyroscope-prod
tags:
- key: service.name
value: service_name
profileTypeId: process_cpu:cpu:nanoseconds:cpu:nanoseconds
customQuery: true
query: '{service_name="$${__tags.service_name}"}'配上 span 中的
profile.thread.id、profile.id
label,Tempo 就能跨数据源跳转到火焰图。
持续 profiling 的存储是独立的技术挑战:写入吞吐比 metrics 高、查询聚合比 logs 重、schema 比 traces 复杂。
最早最简单的格式,Brendan Gregg 的 FlameGraph 工具链使用:
main;foo;bar 100
main;foo;baz 42
main;qux 37
每行「栈;栈;栈 值」。优点是人类可读、极易生成;缺点是冗长、无类型。
Google 设计的二进制格式,也是 Go / Pyroscope / Parca 的事实标准。核心创新:
一份 30 秒 CPU profile 通常 50~300KB;压缩前可达 MB 级。
Parca 的 FrostDB 把 pprof 按列式存储:
timestamp、duration、period
等元数据列;label_<name> 列;stack_trace_id 列(指向 symdb);value 列。这样按 label 过滤 + 时间范围聚合时,IO 只扫相关列。
Pyroscope 引入 symdb 后走向类似方向:
一份 CPU profile 其实是「时间窗口内的增量」(delta),每 10 秒上报一份。但 heap/goroutine 更自然是「当前累计快照」(cumulative)。存储时要区分:
OpenTelemetry Metrics 早就有 delta vs cumulative 的讨论,Profiles 信号基本沿用。
几层压缩叠加:
合计一份 30s CPU profile 落地后可能只有 10~30KB。即便如此,1000 个 pod、每 10 秒一份,一天也有 2.6 亿 profile,TB 级是常态,冷热分层与保留策略必不可少。
长期以来 OpenTelemetry 只定义了 Metrics / Logs / Traces 三种信号,Profiles 缺位是社区共识的短板。
OTel Profiles 的 OTLP schema 与 pprof 高度相似,增加了:
service.name、k8s.pod.uid
等);核心 message(简化):
message Profile {
repeated ValueType sample_type = 1;
repeated Sample sample = 2;
repeated Mapping mapping_table = 3;
repeated Location location_table = 4;
repeated Function function_table = 5;
repeated KeyValue attribute_table = 6;
repeated AttributeUnit attribute_units = 7;
repeated Link link_table = 8; // 与 Trace 的关联
...
}Link 字段是 OTel Profiles 的独特创新:让
profile sample 能反向链接到 trace span,实现「火焰图某一竖条
→ 具体 trace」的跳转。
OpenTelemetry Collector 1.x 引入
profilesreceiver 与
profilesexporter:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
exporters:
otlphttp/pyroscope:
endpoint: https://profiles.example.com/otlp
debug:
verbosity: detailed
service:
pipelines:
profiles:
receivers: [otlp]
exporters: [otlphttp/pyroscope, debug]这让生态迁移变得平滑:业务代码只接 OTel SDK,后端从 Pyroscope 切到 Parca 或自研系统,改 collector 配置即可。
截至 2026 年初:
Profiles 信号预计在 2026~2027 年成为与 Traces 同等级别的一等公民。
字节跳动公开分享过内部的持续剖析平台,其演进路线具有代表性:
/debug/pprof/profile;公开分享的效果数字:通过持续剖析发现的 Go 服务 CPU 优化项平均每季度节省万核级资源。
美团技术博客数篇文章讨论过:
国内大型 Java 服务(支付、电商、搜索)基本形成共识:
default.jfc 常驻;-e cpu、-e alloc、-e lock
三份采样;Go 社区常见做法:
go tool pprof 到目标 pod,生成 svg;前文提过的 JVM safepoint bias 是经典坑。排查方法:
编译时 strip 或 UPX
压缩会剥离符号,火焰图全是
[unknown]。对策:
.debug
symbol,separate debuginfo 放对象存储;-ldflags '-s -w';perf-<pid>.map
暴露;/proc/self/exe 的
buildid,让 agent 通过 debuginfod 回拉符号。容器默认权限非常严格,profiler 常用权限:
CAP_SYS_PTRACE:py-spy、gdb、async-profiler(attach
时);CAP_PERFMON +
CAP_SYS_ADMIN:使用 perf_events 与
BPF;CAP_BPF(内核 5.8+):装载 BPF
program;hostPID: true:跨容器看 PID;SYS_PTRACE secComp 白名单。K8s 最佳实践:
hostPID: true +
privileged: true;shareProcessNamespace: true + sidecar
CAP_SYS_PTRACE。另外,某些 PaaS 默认禁用 perf_event_paranoid
低等级,需要协调
sysctl kernel.perf_event_paranoid=1。
默认编译选项 -fomit-frame-pointer 让 BPF
栈回溯失败。解决方案:
-fno-omit-frame-pointer;--call-graph=dwarf);注意 DWARF 回溯内存/CPU 开销是 FP 的 5~10 倍,高频采样时需权衡。
同一节点上多个容器的可执行文件路径在主机命名空间下看是
/proc/<pid>/root/usr/bin/app。agent
读符号时要正确处理命名空间映射:
/proc/<pid>/root prefix
访问容器内文件;/proc/<pid>/maps
获得正确的可执行映射;.note.go.buildid
做身份比对。parca-agent、pyroscope-agent 都专门处理这部分逻辑,自研时容易忽略。
Java 在老版本 JVM 开启 -Xrunhprof 做内存
profile 会拖慢 100 倍。现代 async-profiler
-e alloc 基于 JVMTI
SampledObjectAlloc hook,每 TLAB slow path
才触发一次,开销 1~3%。尽管如此,采样间隔需合理:
--alloc 512k(默认):开销可接受;--alloc 64k:开销明显上升,仅用于短时诊断;--alloc 1:等同于全量插桩,仅用于本地调试。Go 的 MemProfileRate 也是类似考量,默认
512KB,不建议调小到 KB 级。
一个 tenant 如果:
每天 profile
数量:200 × 50 × 2 × 6 × 60 × 24 ≈ 3.5 亿。即便每份压缩后
20KB,裸数据也达 7TB/天。对策:
runtime.SetMutexProfileFraction(1)
让每次锁竞争都采样,在高竞争场景下开销很大。更实际的值是
100~1000。另外 mutex profile 只覆盖 sync.Mutex
/ sync.RWMutex,channel 争用走 block
profile,不要混淆。
alpine 镜像基于 musl libc,py-spy 有时栈回溯异常。对策:
py-spy 最新版(已改善 musl
兼容);python3-dbg)。按 Nyquist 定理,要观察 X Hz 的现象需要至少 2X Hz 采样。实际中 99Hz 足以覆盖绝大多数热点,但如果你要分析「每秒 50 次的微 GC pause」这种快速事件,99Hz 会严重欠采样。此时需要:
bpftrace);| 场景 | 首选工具 | 备选 | 原因 |
|---|---|---|---|
| Go 服务本地调优 | go tool pprof + benchmark |
go trace | 内置、零成本、精度高 |
| Go 服务线上持续剖析 | Pyroscope Go SDK | Parca + eBPF | 支持 label/exemplar,与 trace 联动 |
| Java 服务事故诊断 | async-profiler | JFR + JMC | 准确无 safepoint bias,多事件支持 |
| Java 服务常驻黑匣子 | JFR default.jfc |
Pyroscope Java agent | 开销极低、JVM 内部事件丰富 |
| Python 服务在线诊断 | py-spy | Pyinstrument | 无侵入、生产可用 |
| C++/Rust 服务 | perf + FlameGraph | Parca eBPF | 成熟生态,符号齐全即可 |
| 整机多语言持续剖析 | Parca eBPF Agent | OTel eBPF Profiler | 不依赖 SDK、覆盖面广 |
| 差分回归分析 | pprof -diff_base |
Pyroscope 差分视图 | CI 流程可自动化 |
| 与 trace 关联 | Pyroscope + Tempo | Datadog / 字节 CPP | exemplar label 打通 |
分四步走:
Step 1:按需 profiling(0~1 月)
net/http/pprof,端口绑定本地;Step 2:持续 profiling 试点(2~3 月)
service / env / region / version / endpoint;Step 3:全量推广(4~6 月)
Step 4:与可观测性打通(6 月+)
上一篇:OpenTelemetry 深入:SDK、Collector、语义约定与版本演进
下一篇:Events 与变更关联:CloudEvents、发布打点、K8s 事件
把当前热点继续串成多页阅读,而不是停在单篇消费。
2026-04-22 · architecture / observability
深入剖析持续性能分析(Continuous Profiling)的原理、架构与落地实践,覆盖 Parca、Pyroscope、Grafana Beyla 三大主流方案,包含 eBPF 采样、符号解析、火焰图、差异分析以及字节跳动、美团的生产案例与工程坑点。
2026-04-22 · architecture / observability
从 Metrics、Logs、Traces 到 Profiling、eBPF、OpenTelemetry 与 SLO 治理,面向中国工程团队的可观测性系统化手册。
2026-04-22 · architecture / observability
从控制论到云原生:拆解可观测性的五大信号支柱,对比监控与可观测性的本质区别,梳理开源/商业/SaaS 分类,以及国内互联网公司三大支柱落地现状与典型工程坑点。
2026-04-22 · architecture / observability
eBPF 如何实现零侵入、内核级、低开销的可观测性:从 kprobe/uprobe/tracepoint/fentry 钩子机制,到 bcc 工具集、bpftrace 脚本语言、libbpf+CO-RE 可移植编程,再到 Pixie、DeepFlow、Grafana Beyla 等商业化工具,结合内核版本兼容性与生产部署实战。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。