在可观测性的三大支柱——指标(Metrics)、日志(Logs)、追踪(Traces)之外,近年来第四根支柱正在迅速崛起:持续性能分析(Continuous
Profiling)。它回答的不再是”系统发生了什么”,而是”系统为什么慢、为什么耗
CPU、为什么耗内存”,并且以接近零开销的方式持续地、永远在线地采集。
本文是【可观测性工程】系列的第十六篇,系统梳理 Continuous
Profiling
的理论基础、主流开源方案(Parca、Pyroscope、Grafana
Beyla)的架构实现、跨语言符号解析、与 Traces
的关联、字节跳动与美团的国内案例,以及生产落地过程中踩过的坑。目标是让读者在读完之后能够做出合理的选型决策,并落地一套可运维的持续性能分析平台。
一、什么是持续性能分析
1.1 从 one-shot
profiling 说起
传统的性能分析是”按需触发”的:当线上出现 CPU
高、延迟毛刺、OOM 时,工程师登录机器,使用
性能分析工具(profiler)采集一段数据,然后离线分析。典型工具包括:
Go 语言的 pprof(go tool pprof)
Java 的 JProfiler、YourKit、async-profiler
Python 的 py-spy、cProfile、pyinstrument
C/C++ 的 perf、gperftools、Intel VTune
通用的 Linux 性能事件(perf events)
这种”一次性采样(one-shot
profiling)“模式有几个致命缺陷:
第一,事后采样。当用户反馈”昨天下午三点系统很慢”时,现场已经不再存在。即便通过压测复现,压测流量与真实流量往往差异巨大,得到的热点函数并不能代表线上实际瓶颈。
第二,依赖人工操作。SRE 需要登录具体的 Pod 或机器,执行
kubectl exec 或 ssh,运行
go tool pprof 命令,然后把结果 scp
到本地。这在多活多地域、数千实例的生产环境中几乎无法规模化。
第三,采样不全。线上问题往往是分布式问题,某一个实例的性能剖面并不能代表整个服务的情况。one-shot
很难做到全实例、全时段覆盖。
第四,与其他观测信号割裂。当 Trace 显示某条请求慢了 500
毫秒时,你无法直接跳转到”这条请求慢的那几百毫秒里 CPU
在执行哪些函数”。
1.2 Continuous
Profiling 的定义
持续性能分析,简单来说就是:以极低的采样频率、全实例、永远在线地采集性能剖面,并长期存储、支持查询与对比 。
这一概念的奠基之作是 Google 在 OSDI 2010
发表的论文《Google-Wide Profiling: A Continuous Profiling
Infrastructure for Data Centers》。论文作者包括 Gang
Ren、Eric Tune、Tipp Moseley 等。核心思想是:
以 99Hz 的低频率(每秒 99
次)对整个数据中心的所有机器采样;
使用统计采样(statistical
sampling),而非精确插桩,从而将开销控制在 1% 以内;
将所有采样数据集中存储,长期保留;
提供查询接口,支持按服务、按版本、按时间范围、按函数维度聚合。
这套系统在 Google 内部被称为 Google-Wide
Profiling(GWP),是 pprof 协议、pprof
工具链的源头。如今几乎所有开源持续性能分析方案都是 GWP
思想的衍生。
1.3 为什么是 99Hz 而不是
100Hz
读者可能注意到,几乎所有性能分析工具默认频率都是 99Hz
而不是整数
100Hz。这背后有一个非常工程化的原因:避免采样频率与内核定时器、cron
任务、GC 周期等固定频率产生共振 。
以 100Hz 采样为例,如果某个系统定时任务恰好也是以 100Hz
或其整数倍的频率触发,那么每次采样都会”稳定命中”这个定时任务所在的代码路径,导致采样结果严重偏向于这些周期性任务,而忽略真正的业务热点。
选择 99
这个质数作为采样频率,能有效避开绝大多数常见的系统周期,使得采样在统计意义上更接近于随机分布。这个小细节在
Brendan Gregg 的《Systems
Performance》一书中有详细讨论,也是 Linux perf
社区的通用约定。
1.4 存储成本的估算公式
很多人对持续性能分析望而却步,是因为担心存储成本。实际上,只要采样频率与压缩得当,持续性能分析的存储成本远低于日志。下面给出一个粗略估算公式:
单实例每秒采样数 = 采样频率 × 在线 CPU 核数 / 聚合窗口
单条 stack trace 压缩后大小 ≈ 100 ~ 300 字节(pprof + zstd)
单实例日存储量 = 单实例每秒采样数 × 86400 × 单条大小
以一台 16 核机器、99Hz 采样、10 秒聚合为例:
每 10 秒采样数 = 99 × 16 = 1584 条
聚合后 unique stack 假设为 300 条(大多数栈会重复)
每 10 秒写入量 ≈ 300 × 200B = 60KB
每日写入量 ≈ 60KB × 8640 = 518MB / 实例 / 天
如果集群有 1000 台机器,每天新增约 500GB 的 profile
数据。在经过列存+字典编码+zstd 压缩之后,落盘大概在
50~100GB。按 30 天保留,大约需要 1.5~3TB
存储。这与日志动辄几十 TB 的体量相比,完全可以接受。
1.5 与 one-shot
profiler 的能力对比
维度
one-shot profiler
Continuous Profiling
采样时机
事后人工触发
永远在线
覆盖范围
单机/单实例
全集群
采样频率
通常 100~1000Hz
10~99Hz
开销
采样期间 5~20%
长期 < 1%
数据保留
一次性文件
数周至数月
多版本对比
手工对比
内置 diff
与 Trace 关联
无
通过 Exemplar
跨语言支持
分别部署
统一采集器
从这张表可以看出,两者并不是替代关系,而是互补关系。one-shot
在单点深入分析时仍然有用(例如用 JProfiler
分析内存引用链);而 Continuous Profiling
解决的是”日常巡检+历史回溯”的问题。
二、采样频率、聚合窗口与数据模型
2.1
采样机制的底层:perf_event_open
Linux 下几乎所有现代 profiler 都基于内核提供的
perf_event_open(2)
系统调用。它允许用户态程序向内核注册一个”性能事件”,当事件发生时由内核回调采集。
// 伪代码:注册一个 99Hz 的 CPU 采样事件
struct perf_event_attr attr = {
. type = PERF_TYPE_SOFTWARE,
. config = PERF_COUNT_SW_CPU_CLOCK,
. sample_freq = 99 ,
. freq = 1 ,
. sample_type = PERF_SAMPLE_CALLCHAIN | PERF_SAMPLE_TID,
. exclude_kernel = 0 ,
. exclude_user = 0 ,
};
int fd = perf_event_open(& attr, - 1 /* all pids */ , cpu, - 1 , 0 );
这里的关键字段:
sample_freq = 99:每秒采样 99 次;
freq = 1:告诉内核 sample_freq
是”频率”而非”周期”;
sample_type = PERF_SAMPLE_CALLCHAIN:每次采样带上调用栈;
exclude_kernel = 0:同时采集用户态和内核态栈。
当内核每秒产生 99 个采样事件时,eBPF 程序或 mmap 的 ring
buffer 会被触发,采集当前 CPU 正在运行的线程的用户态 PC +
内核态栈。
2.2
采样的两种模式:CPU 时钟 vs 墙上时钟
CPU 时钟采样(cpu-clock)只在线程真正占用 CPU
时才会触发。因此采集到的火焰图反映的是”CPU
热点”,无法发现因为 IO 等待、锁等待导致的”墙上慢”。
墙上时钟采样(wall-clock)则无论线程是否在 CPU
上都会采样,能够反映真实的用户请求延迟构成。代价是开销更高,而且需要配合
off-cpu profiling(基于 sched switch 的 eBPF
程序)实现。
生产环境中建议:
默认开 cpu-clock,用于发现 CPU 瓶颈;
对怀疑有锁、IO、系统调用问题的服务额外开 off-cpu;
对 Go 服务可以借助运行时内置的 goroutine、mutex、block
profile。
2.3 聚合窗口的选择
持续性能分析并不会把每一次 stack trace
都原样写入存储。那样做既浪费空间,也没有分析价值。典型做法是按时间窗口聚合:
聚合窗口(aggregation window)= 通常 10 秒 或 15 秒
在一个窗口内,所有相同的 stack trace
会被合并为一条记录,value 字段累加。10
秒窗口在”实时性”和”压缩率”之间是一个较好的平衡点。
窗口太短(例如 1 秒)会导致大量 unique stack
无法合并,存储膨胀;窗口太长(例如 1
分钟)会丢失短生命周期的性能抖动信号。
2.4 pprof 数据模型
开源生态中事实标准的 profile 数据格式是 Google 的 pprof
protobuf 格式,其 schema 核心结构如下:
message Profile {
repeated ValueType sample_type = 1 ;
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 ;
ValueType period_type = 11 ;
int64 period = 12 ;
}
message Sample {
repeated uint64 location_id = 1 ;
repeated int64 value = 2 ;
repeated Label label = 3 ;
}
message Location {
uint64 id = 1 ;
uint64 mapping_id = 2 ;
uint64 address = 3 ;
repeated Line line = 4 ;
}
几个关键设计:
字符串池(string_table) :所有字符串只存一份,Sample/Location/Function
中通过索引引用,天然节省空间;
Location 与 Function
分离 :同一个函数可能出现在栈中多个位置(递归、inline),但
Function 只存一份;
多种 sample_type :一个 profile
可以同时包含 cpu、alloc_objects、inuse_space
等多种维度;
整体采用 zstd 或 gzip
压缩 ,压缩率通常在 5~10 倍。
pprof 格式的 proto 定义可在 Google 的
github.com/google/pprof 仓库
proto/profile.proto 中找到,是所有 Continuous
Profiling 系统互通的基础。
3.1 项目背景
Parca 由 Polar Signals 公司于 2021 年开源(CEO Frederic
Branczyk 是前 Prometheus 核心维护者),定位是”基于 eBPF 的
always-on profiling 系统”。它的设计哲学完全向 Prometheus
看齐:
采集端 Agent 走 pull/push 混合模式;
存储端使用列式存储;
查询端使用类 PromQL 的语法;
可视化深度集成 Grafana。
3.2 整体架构
Parca 由两大组件构成:
+-------------------+ +---------------------+
| Parca Agent | | Parca Server |
| (每台主机一个) | push | (中心服务) |
| - eBPF CO-RE +------->+ - ingester |
| - perf_event_open| | - FrostDB (列存) |
| - 符号聚合 | | - Parquet on S3 |
| - gRPC client | | - Query engine |
+-------------------+ +----------+----------+
|
v
+---------------------+
| Parca UI / API |
| flamegraph / diff |
+---------------------+
Parca Agent 通常以 Kubernetes DaemonSet 形式部署,每台
Node 一个。Agent 使用 eBPF 在内核态采集 stack
trace,用户态程序读取 ring
buffer,按实例/容器/服务聚合后,通过 gRPC 推送到 Parca
Server。
3.3 Parca Agent 的 eBPF
程序
Parca Agent 的核心是一个 CO-RE(Compile Once, Run
Everywhere)的 BPF 程序,挂载到
perf_event,每次触发时遍历用户态调用栈。简化后的 C
代码如下:
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#define MAX_STACK_DEPTH 127
struct stack_trace_t {
u32 pid;
u32 tgid;
u64 user_stack[ MAX_STACK_DEPTH];
u64 kernel_stack[ MAX_STACK_DEPTH];
int user_stack_len;
int kernel_stack_len;
char comm[ 16 ];
};
struct {
__uint( type, BPF_MAP_TYPE_RINGBUF);
__uint( max_entries, 256 * 1024 );
} events SEC( ".maps" );
struct {
__uint( type, BPF_MAP_TYPE_STACK_TRACE);
__uint( max_entries, 10000 );
__uint( key_size, sizeof ( u32));
__uint( value_size, MAX_STACK_DEPTH * sizeof ( u64));
} stack_traces SEC( ".maps" );
SEC( "perf_event" )
int do_sample( struct bpf_perf_event_data * ctx) {
u64 id = bpf_get_current_pid_tgid();
u32 tgid = id >> 32 ;
u32 pid = id;
if ( tgid == 0 ) return 0 ;
struct stack_trace_t * event;
event = bpf_ringbuf_reserve(& events, sizeof (* event), 0 );
if (! event) return 0 ;
event-> pid = pid;
event-> tgid = tgid;
bpf_get_current_comm(& event-> comm, sizeof ( event-> comm));
event-> user_stack_len = bpf_get_stack(
ctx, event-> user_stack,
sizeof ( event-> user_stack),
BPF_F_USER_STACK
) / sizeof ( u64);
event-> kernel_stack_len = bpf_get_stack(
ctx, event-> kernel_stack,
sizeof ( event-> kernel_stack),
0
) / sizeof ( u64);
bpf_ringbuf_submit( event, 0 );
return 0 ;
}
char LICENSE[] SEC( "license" ) = "GPL" ;
这段代码的关键点:
BPF_MAP_TYPE_RINGBUF 是 Linux 5.8+
引入的高效无锁 ring buffer,比旧的 perf buffer
性能更好;
bpf_get_stack 是 eBPF 提供的原生 stack
walker,要求目标进程保留帧指针(frame pointer);
内核栈和用户栈分开采集,后续在用户态拼接;
bpf_get_current_comm
拿到当前任务的名字(comm 字段,最长 15
字节)。
用户态的 Go 程序从 ring buffer 中消费事件,做 PID →
container → pod → service 的映射,然后聚合成 pprof。
3.4 FrostDB 列式存储
Parca 并没有使用 Prometheus 的
TSDB,而是自己开发了一套名为 FrostDB
的列式存储。设计动机是:profile 数据相比于 metric
有更多维度(stack、label、function、line),而且主要的查询模式是”按某几个维度聚合”,列存比行存有天然优势。
FrostDB 的关键特性:
基于 Apache Arrow 内存格式,查询时零拷贝;
落盘使用 Apache Parquet,天然支持 Spark/Trino
等外部查询;
支持字典编码(Dictionary
Encoding),对高基数的字符串(如函数名)有极好的压缩率;
支持动态 schema,可以在运行时添加新的 label;
使用 LSM 思想,内存中有 active table,周期性 flush
到对象存储(S3/MinIO)。
列存对于火焰图的聚合查询特别友好。例如”查询过去 1
小时内某服务所有 CPU 采样,按 function 聚合”这种
query,在列存下只需要读取 function 和
value 两列,IO 大幅下降。
3.5 符号解析流程
采样得到的是裸的虚拟地址(例如
0x7f8a3c4e1234),要变成人类可读的函数名+文件名+行号,需要经过符号解析(symbolization)。Parca
的符号解析流程如下:
stack trace (raw addresses)
|
v
/proc/<pid>/maps --> 确定地址属于哪个可执行文件/so
|
v
读取 ELF 的 .symtab / .dynsym --> 函数名
|
v
读取 DWARF (.debug_info, .debug_line) --> 文件名、行号、inline
|
v
如果本地没有 debug 信息:
查询 debuginfod 服务 --> 按 build-id 下载 .debug 文件
几个关键点:
build-id 是 ELF
中的一段唯一标识,由链接器生成,是符号解析跨主机通用的
key。file 命令或
readelf -n <binary> 可以查看;
debuginfod 是 Fedora/Red Hat
推动的去符号分发协议(HTTP API),Ubuntu/Debian
也都已支持。Parca Agent 可以配置上游 debuginfod(如
https://debuginfod.elfutils.org/)用于解析系统库;
frame pointer
是用户态栈回溯最简单的方式。编译时加
-fno-omit-frame-pointer。Go 从 1.7
起默认保留帧指针(amd64);
DWARF-based unwinding 不依赖帧指针,但
CPU 开销更大,而且在 eBPF 内实现复杂。Parca 从 2023
年起逐步支持 DWARF unwinding(在 Agent 里预计算 unwind table
并通过 BPF map 下发内核)。
四、Pyroscope
架构深度解析
4.1 项目历史
Pyroscope 由 Dmitry Filimonov 团队于 2020 年开源,2023
年被 Grafana Labs 收购并合入 Grafana Phlare 项目,之后又与
Phlare 合并为统一的”Grafana Pyroscope”。因此现在你看到的
Pyroscope 事实上是 Pyroscope + Phlare 的融合产物。
4.2
存储设计:Segment 与 Profile Merge
Pyroscope 的存储模型比 Parca 更”原生 profile”:它引入了
Segment 概念,把时间序列上的 profile
组织成树状结构,每个叶子节点是一个小时间窗口(10
秒),内部节点是更大时间范围的聚合。
Segment tree (按时间维度聚合):
[0 - 1h]
/ \
[0-30m] [30-60m]
/ \ / \
[0-15]..[15-30][30-45].[45-60]
这种结构使得跨时间范围的查询不需要扫描所有叶子节点,可以直接读取已预聚合好的内部节点,查询延迟稳定。
Profile merge
算法 的核心是:对同一时间窗口内的多个 pprof,按
(function, line) 对齐,累加 value。由于 pprof
自身使用字符串池,merge 时需要做字符串表的重映射:
// 伪代码:两个 pprof merge
func Merge( a, b * Profile) * Profile {
out := & Profile{}
strMap := map [ string ] int64 {}
addStr := func ( s string ) int64 {
if id, ok := strMap[ s]; ok { return id }
id := int64 ( len ( out. StringTable))
out. StringTable = append ( out. StringTable, s)
strMap[ s] = id
return id
}
// 重建 function / location 表
fnMap := map [ funcKey] uint64 {}
// ... 遍历 a.Function, b.Function 去重 ...
// 合并 sample,按 location_id stack 对齐,value 累加
for _, s := range append ( a. Sample, b. Sample...) {
key := stackKey( s. LocationID)
if existing, ok := sampleMap[ key]; ok {
for i := range s. Value {
existing. Value[ i] += s. Value[ i]
}
} else {
sampleMap[ key] = cloneSample( s)
}
}
// ... assemble output ...
return out
}
这个 merge 操作在 ingester 收到 push、在 querier 合并多个
segment 时都会执行。Pyroscope 为此做了大量内存池优化,避免
GC 压力。
4.3 Pyroscope
SDK:推送模式
Pyroscope
同时支持推送(push)和拉取(pull)两种采集模式。推送模式下,应用自身引入
SDK,周期性地(默认 10s)把本地 pprof 上传到 Pyroscope
server。
Go SDK 示例:
package main
import (
"log"
"os"
"runtime"
"github.com/grafana/pyroscope-go"
)
func main() {
runtime. SetMutexProfileFraction( 5 )
runtime. SetBlockProfileRate( 5 )
_, err := pyroscope. Start( pyroscope. Config{
ApplicationName: "my.backend.service" ,
ServerAddress: "http://pyroscope:4040" ,
Logger: pyroscope. StandardLogger,
Tags: map [ string ] string {
"env" : os. Getenv( "ENV" ),
"region" : os. Getenv( "REGION" ),
"version" : os. Getenv( "APP_VERSION" ),
},
ProfileTypes: [] pyroscope. ProfileType{
pyroscope. ProfileCPU,
pyroscope. ProfileAllocObjects,
pyroscope. ProfileAllocSpace,
pyroscope. ProfileInuseObjects,
pyroscope. ProfileInuseSpace,
pyroscope. ProfileGoroutines,
pyroscope. ProfileMutexCount,
pyroscope. ProfileMutexDuration,
pyroscope. ProfileBlockCount,
pyroscope. ProfileBlockDuration,
},
})
if err != nil {
log. Fatalf( "failed to start pyroscope: %v" , err)
}
runApp()
}
关键点:
SetMutexProfileFraction 和
SetBlockProfileRate 必须在 SDK
启动前设置,否则对应 profile 类型是空的;
Tags 会作为 label 写入存储,用于多维度聚合;
各种 ProfileType 覆盖了 Go 运行时几乎所有内置
profile;
推送模式的好处是穿透 NAT、穿透 k8s
网络策略简单,坏处是应用需要改动。
Java、Python、Ruby、.NET、Rust 等都有官方或社区
SDK,接入方式大同小异。
4.4 拉取模式
拉取模式下,Pyroscope server 主动去 scrape 目标的
/debug/pprof/* 端点,类似 Prometheus scrape
metrics。配置示例:
scrape_configs :
- job_name : 'my-go-service'
scrape_interval : 15s
profiling_config :
pprof_config :
cpu :
enabled : true
path : /debug/pprof/profile
delta : true
memory :
enabled : true
path : /debug/pprof/heap
goroutine :
enabled : true
path : /debug/pprof/goroutine
mutex :
enabled : true
path : /debug/pprof/mutex
block :
enabled : true
path : /debug/pprof/block
kubernetes_sd_configs :
- role : pod
relabel_configs :
- source_labels : [ __meta_kubernetes_pod_label_app ]
action : keep
regex : my-go-service
- source_labels : [ __meta_kubernetes_pod_name ]
target_label : instance
- source_labels : [ __meta_kubernetes_namespace ]
target_label : namespace
拉取模式适用于:
已经暴露了 net/http/pprof 端点的 Go
服务;
需要统一在服务端控制采集频率、避免客户端
misconfig;
有 Kubernetes service discovery 的环境。
4.5 ProfileQL
Pyroscope 提供了一套类似 PromQL 的查询语言(早期叫
FlameQL,现在统称 ProfileQL)。常见查询:
# 查询某个服务过去 1 小时的 CPU profile
process_cpu:cpu:nanoseconds:cpu:nanoseconds{service_name="checkout",env="prod"}
# 对比两个版本的 CPU 差异
diff(
process_cpu:cpu:nanoseconds{version="v1.2.0"},
process_cpu:cpu:nanoseconds{version="v1.3.0"}
)
# 按 pod 维度 group by,得到每个 pod 的火焰图
sum by (pod) (process_cpu:cpu:nanoseconds{service_name="checkout"})
# topN: 找出 CPU 消耗最大的 10 个函数
topk(10, process_cpu:cpu:nanoseconds{service_name="checkout"})
ProfileQL 的 profile 类型命名规范是
<class>:<name>:<unit>:<period_name>:<period_unit>,例如:
process_cpu:cpu:nanoseconds:cpu:nanoseconds
memory:alloc_space:bytes:space:bytes
memory:inuse_space:bytes:space:bytes
goroutine:goroutine:count:goroutine:count
查询结果默认渲染成火焰图,也可以下钻为调用树(call
tree)、top table 等多种视图。
五、Grafana
Beyla:零侵入的自动化
5.1 设计动机
Parca 和 Pyroscope 都需要显式配置采集目标,而且在
Java/Python 等语言下还需要处理符号解析、JVMTI、PyFrame
遍历等复杂问题。Beyla
的目标是彻底零侵入 :部署一个
DaemonSet,自动发现所有 HTTP/gRPC 服务,自动生成 RED
指标(Rate/Error/Duration)、分布式 Trace,以及(从 1.4
版本起)CPU profile。
5.2 架构描述
Beyla 的架构(文字描述)可以画成:
+--------------------------------------------------+
| Node (Linux host) |
| |
| +--------+ +--------+ +--------+ |
| | Pod A | | Pod B | | Pod C | |
| | Go app | | Java | | Python | |
| +---+----+ +---+----+ +---+----+ |
| | | | |
| | kprobe/uprobe/tracepoint/perf_event |
| v v v |
| +------------------------------------------+ |
| | Beyla eBPF programs | |
| | - sock_sendmsg / sock_recvmsg 挂钩 | |
| | - http / grpc 协议解析 (bpf parser) | |
| | - perf_event 99Hz CPU sampling | |
| +-------------------+----------------------+ |
| | |
| v |
| +------------------------------------------+ |
| | Beyla user-space agent | |
| | - service discovery (crio/containerd/k8s)| |
| | - RED metrics generator | |
| | - OTLP exporter (traces + metrics + prof)| |
| +------------------+-----------------------+ |
| | |
+----------------------+---------------------------+
|
v
+---------------------------+
| OTel Collector / Grafana |
| Tempo / Mimir / Pyroscope |
+---------------------------+
5.3
零侵入的关键:eBPF uprobe + 协议解析
Beyla 通过 uprobe 挂载到常见语言的标准库函数(如 Go 的
crypto/tls.(*Conn).Write、glibc 的
SSL_write、Node.js 的 TLS
写入函数),拦截到真正的 HTTP/gRPC 报文。然后在 eBPF
内实现一个迷你 HTTP parser,直接在内核态提取
method、path、status code、latency,不需要修改应用代码。
CPU profile 部分则复用了标准的 perf_event_open 99Hz
采样,与 Parca Agent 的做法类似。Beyla 的亮点在于它把 RED
metric、Trace、Profile 用同一个 eBPF Agent 统一采集,并通过
OTLP Profiling Signal(见下文第七节)统一推送到 Grafana
Stack。
5.4 限制
Beyla 并不是银弹:
对加密 HTTP/2(TLS)场景,需要 uprobe 挂载到 TLS
库。如果应用静态链接了自己的 TLS 库且编译时 strip
了符号,Beyla 可能识别不到;
Java 应用由于 JIT 生成代码的虚拟地址不稳定,需要额外的
perf-map-agent 支持;
不支持业务级别的自定义 span,因此要和传统的
OpenTelemetry SDK 搭配使用。
六、火焰图:可视化与解读
6.1 火焰图的发明
火焰图(Flame Graph)由 Netflix 的 Brendan Gregg 在 2011
年发明,是目前最广泛使用的性能剖面可视化方式。它有几个关键特征:
X
轴不代表时间 ,而是把所有采样到的同一栈按字典序排列,宽度代表该栈出现的频次(即
CPU 占用比例);
Y
轴是调用栈深度 ,越往上越接近叶子函数;
颜色没有严格的语义 ,传统上是”暖色随机”,便于区分相邻
frame;
点击某个 frame
可以下钻(zoom) ,把它作为新的根节点重新渲染。
6.2 读图要点
读火焰图时有几个常见误区:
宽度 = CPU
占用,不是慢 。一个函数很宽只说明它(或它的子函数)占了大量
CPU,不代表它慢;
塔尖很尖 ≠ 问题 。塔尖是叶子函数,出现
syscall、memcpy、runtime.mallocgc
很常见;
“平顶山”才是可优化点 。一片宽而平的区域,说明某一个函数自身代码(而非其子调用)占了大量
CPU,这种”self time”是优化的首选目标;
高度很高但很窄
通常是深递归或链式调用,不一定是问题。
6.3 差异火焰图(diff
profile)
差异火焰图是持续性能分析真正的”杀手级”功能:对比两个时间窗口或两个版本的
profile,用颜色编码差异。
约定俗成的颜色含义:
红色 :该函数在”新版本/新窗口”中变得更宽(更慢
/ 更耗 CPU),颜色越深差异越大;
蓝色 :该函数在新版本中变得更窄(变快 /
节省 CPU);
灰色/白色 :两边差异不显著。
典型用例:
上线前后对比 :新版本发布后,对比发布前
30 分钟和发布后 30
分钟的火焰图,快速定位”是我这次上线搞慢了谁”;
A/B 实验 :两个 version label 的 profile
直接 diff,看实验组 vs 对照组的 CPU 差异;
大促前后 :对比日常流量与大促流量,发现只有高
QPS 下才暴露的热点(锁、GC、慢路径);
灰度流量对比 :同一个二进制在不同
region/机型上的 profile 差异。
七、跨语言符号解析
每种语言的运行时特性不同,对应的 profiling
踩坑点也各不相同。
7.1 Go
Go 是 Continuous Profiling 最友好的语言:
1.7 起 amd64 默认保留帧指针(arm64 从 1.12 起);
运行时自带 net/http/pprof;
二进制中自带完整符号表和 DWARF(除非主动 strip);
gopclntab 中的 PC → function name 映射即便 strip
后也部分保留。
一定不要 在生产构建中加
-ldflags="-s -w"。-s 会去掉 symbol
table,-w 会去掉
DWARF。去掉后二进制体积确实小了 20%~30%,但线上 profile
里全是 0x7f8a...
这种地址,符号解析基本失效。
推荐的生产构建:
go build -trimpath \
-ldflags = "-X main.Version= $( git rev-parse HEAD) -buildid=" \
-o mybinary ./cmd/mybinary
-trimpath
去除编译机上的绝对路径,既保留了符号,又不会泄露构建环境信息。
7.2 Java
Java 的挑战主要来自 JIT:
JIT 编译后的代码位于 C heap 里动态分配的 code
cache,没有符号;
JIT 会反复重新编译(tiered
compilation),同一个方法的地址会变化;
Inlining 非常激进,栈回溯很容易失真。
主流方案:
async-profiler (jvm-profiling-tools):最成熟的
Java profiler,内部使用 AsyncGetCallTrace(不像 JVMTI
GetStackTrace 那样需要 safepoint);
perf-map-agent :把 JIT 生成的 code
cache 信息导出为
/tmp/perf-<pid>.map,格式是
<start_addr> <size> <symbol>,perf
和 Parca/Pyroscope Agent 都认这个格式;
JVM
启动参数必加:-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints -XX:+PreserveFramePointer。
-XX:+PreserveFramePointer 是 JDK 8u60+
引入的关键参数,它让 JIT 生成的代码也保留帧指针,否则 eBPF
的 bpf_get_stack 在遇到 Java frame
时会中断。
7.3 Python
CPython 的栈回溯无法直接通过 frame pointer 完成,因为
Python 的”调用栈”是解释器层面的 PyFrameObject
链表,不是 CPU 层面的 stack。
基于 eBPF 做 Python profiling 的思路(py-spy、bcc 的
pyperf、Parca 的 python unwinder 都是这个套路):
// 伪代码:在 eBPF 中遍历 CPython 的 PyFrameObject 链表
// 需要先通过 bpf_probe_read_user 读取 PyThreadState
static __always_inline int walk_python_stack(
struct PyThreadState * tstate,
u64 * out_stack, int max_depth
) {
struct PyFrameObject * frame;
bpf_probe_read_user(& frame, sizeof ( frame), & tstate-> frame);
int depth = 0 ;
#pragma unroll
for ( int i = 0 ; i < max_depth; i++) {
if (! frame) break ;
struct PyCodeObject * code;
bpf_probe_read_user(& code, sizeof ( code), & frame-> f_code);
if (! code) break ;
// co_name 是 PyUnicodeObject,需要进一步解析
// 这里简化为直接记录 code 指针,用户态再符号化
out_stack[ depth++] = ( u64) code;
// f_back 指向上一个 frame
bpf_probe_read_user(& frame, sizeof ( frame), & frame-> f_back);
}
return depth;
}
这里需要解决的难点:
不同 Python 版本 PyFrameObject
结构体 offset 不同 (3.11 大改,引入了新的 frame
结构)。eBPF 程序必须根据目标 Python 解释器版本动态调整
offset;
找到 PyThreadState
的根指针 。一般通过读取 Python 解释器 .data
段的全局符号
_PyRuntime.gilstate.tstate_current;
读取 PyUnicodeObject 名字 在内核态比较
tricky,因为可能跨页。通常做法是只记录指针,在用户态通过
/proc/<pid>/mem 读取。
7.4 Rust
Rust 的情况类似
Go:默认符号保留较好,但帧指针不默认开启。推荐在
Cargo.toml 中加:
[profile.release]
debug = 1 # 保留行号级 DWARF
strip = "none" # 不要 strip
panic = "unwind"
[profile.release.package." *" ]
codegen-units = 1
# 对所有依赖强制开启帧指针
[build]
rustflags = [ "-C" , "force-frame-pointers=yes" ]
或者通过环境变量:
RUSTFLAGS = "-C force-frame-pointers=yes" cargo build --release
没有帧指针时可以靠 DWARF CFI(Call Frame Information)做
unwind,但在 eBPF 里实现 DWARF unwind
复杂度很高,生产环境还是推荐帧指针路线。
7.5 Node.js / V8
Node.js 通过 --perf-basic-prof 或
--perf-prof(配合 perf-map-agent
类似机制)可以生成 V8 JIT 的 symbol map。Pyroscope 的 Node
SDK 内部就是组合了 node --prof + 解析
ticks。CPU 较高时对 V8 有一定开销,建议采样频率控制在
49~99Hz。
八、Traces
与 Profile 的关联:Exemplar 与 OpenTelemetry Profiling
Signal
8.1 从 Exemplar 说起
Prometheus 在 2.40 版本引入了 Exemplar 概念:直方图的一个
bucket
除了计数之外,还可以携带一个(或多个)“示例”样本,样本里可以带
trace_id。这样在 Grafana 上看到 P99
延迟升高时,可以直接跳转到慢请求对应的 Trace。
Continuous Profiling 可以沿用同样的思路:Trace 的 span
里记录当时的 profile snapshot 指针(pprof label 上带
trace_id)。
8.2 pprof label 传递
trace_id
Go 的 pprof 从 1.9 起支持
pprof.Do,可以把任意 label 附加到当前 goroutine
的所有采样中:
import (
"context"
"runtime/pprof"
)
func handleRequest( ctx context. Context, req * Request) {
traceID := trace. SpanContextFromContext( ctx). TraceID(). String()
pprof. Do( ctx, pprof. Labels(
"trace_id" , traceID,
"endpoint" , req. URL. Path,
"customer" , req. CustomerID,
), func ( ctx context. Context) {
doExpensiveWork( ctx, req)
})
}
采集到的 pprof 中,每个 sample 都会带上这些
label。Pyroscope 和 Parca 都支持按 label
过滤/聚合,因此可以实现”查询 trace_id = abc123 的
profile”。
8.3 OpenTelemetry
Profiling Signal
2024 年,CNCF 与 OpenTelemetry 社区正式发布了 OTel
Profiling Signal(与 Traces、Metrics、Logs 并列的第四种
signal)。其设计借鉴了 Parca 和 Pyroscope
的经验,核心是:
在 OTLP 协议中新增 profiles.proto,复用
pprof 数据模型;
通过 OTel Collector 做 profile
的统一接收、路由、转换;
与现有的 traceparent 标准对齐:profile
sample 可以通过 span_id/trace_id
label 关联到 span;
Elastic、Grafana、Splunk、Datadog
等主流厂商均已承诺支持。
这意味着未来的 Continuous Profiling 生态会逐步从”每家一个
wire format”走向”OTLP 统一”,对用户是显著利好。
九、字节跳动 Flame
平台案例
9.1 背景
字节跳动在 2021 年 ArchSummit
等公开技术大会上分享了其内部的持续性能分析平台”Flame”。截至
2021 年,Flame 覆盖了字节内部数十万服务实例,支持
Go、Java、Python、C++
四种主要语言。这些信息基于字节跳动工程师的公开技术分享。
9.2 采集层
Flame 采集层的关键设计:
99Hz
always-on :所有生产服务默认开启,采样频率
99Hz,开销控制在 1% 以内;
语言分治 :Go 走
net/http/pprof pull 模式;Java 走
async-profiler 定时采样;C++ 走 perf + perf-map;Python 走
py-spy 定时 attach;
Agent 容器化 :以 sidecar 或 DaemonSet
方式部署,避免污染业务镜像;
上下文染色 :Agent 在抓取时附上
service、cluster、psm(product-subsys-module)、version、region
等 label;
上传带宽控制 :单实例日上传量 <
100MB,通过采样丢弃与 delta 压缩实现。
9.3 Java JIT 符号问题
字节公开分享过一个很有参考价值的坑:Java
服务在刚启动的前 5~10 分钟,JIT 正在预热,生成的 code cache
地址不稳定 ,导致 perf-map
文件频繁变动。直接采样得到的火焰图里会出现大量”未知
frame”或错乱的 frame。
他们的解法:
启动后延迟 5 分钟再开启采样;
perf-map-agent 改造为每 30 秒 dump 一次而不是 attach
一次;
对比分析时,忽略 JIT 初始化相关的函数前缀(例如
C1、C2 编译线程的 frame)。
9.4 diff 分析与上线关联
Flame 最被工程师看重的功能是”上线差异分析”:
所有上线事件通过 CMDB 打点到 Flame;
上线完成后 5 分钟自动触发 diff job:用上线后 3~5 分钟的
profile 对比上线前 10 分钟的 profile;
差异超过阈值的函数(默认 5%)自动推送到飞书/IM,@ 该服务
owner;
如果差异过大(>20%),触发告警,联动自动回滚系统。
这套机制在上线回归问题定位上效果显著,公开分享中提到,部分线上问题从发现到定位的时间从”小时级”下降到”分钟级”。
9.5 存储与查询
字节 Flame 没有使用 Parca/Pyroscope,而是自研了一套基于
ClickHouse 的列存后端。理由是:
ClickHouse 是字节已有的大规模分析存储,运维熟悉;
profile 本质上是”高维多标签的统计聚合查询”,ClickHouse
天然适配;
保留周期 30 天,热数据 SSD、冷数据对象存储。
查询层自研了一套类 SQL 的 DSL,语法类似:
SELECT flamegraph(stack, value )
FROM profiles
WHERE service = 'toutiao.feed.rank'
AND event_time BETWEEN '2021-08-01 10:00' AND '2021-08-01 10:15'
AND profile_type = 'cpu'
GROUP BY pod
十、美团
async-profiler 实践案例
10.1 背景
美团在 2020~2022 年间也陆续公开分享了其 Java
持续性能分析的实践。美团以 Java 为主的技术栈让
async-profiler 成为天然选择。该实践的目标是:
常态化 Java 服务 CPU profile 采集;
与 CAT(美团自研 APM)深度集成;
支持 GC 分析、内存分析、锁分析。
10.2 采集策略
美团的采集参数在公开技术分享中有所提及:
采样频率 100Hz (美团选择了整数 100Hz
而非 99Hz,工程团队认为对 Java 场景影响不大);
采样周期 60 秒 :每隔 5 分钟采集一次 60
秒窗口,而不是 always-on;理由是 Java 业务的 CPU
波动相对小,且 60s 足以捕捉稳态;
async-profiler agent 注入 :通过
-agentpath:/opt/async-profiler/libasyncProfiler.so=...
在 JVM 启动参数里注入;
支持 mixed mode :同时采集 Java
frame、C++ frame(JVM 内部)、内核 frame,让跨层问题(例如
GC pause 里的 mmap 调用)能一眼看穿。
10.3 CAT 集成
CAT(Central Application Tracking)是美团开源的 APM
系统。美团把 Flame 火焰图作为 CAT 的一个子页面:
从 CAT Transaction 列表点击某个慢调用,下钻到 Trace
详情;
Trace 详情页面展示该实例当前窗口的 CPU 火焰图;
支持按时间段、按机器、按 trace_id label 过滤;
对比功能支持”此版本 vs 上一版本”、“此 IDC vs 另一个
IDC”。
10.4 GC 优化效果
公开分享中提到一个典型案例:某核心服务在 CMS GC
调优前,火焰图显示大量 CPU 花在
ParNew::copy_to_survivor_space 和
CMSConcMarkingTask::do_scan_and_mark 上,占比约
15%。切换到 G1 后:
火焰图中 GC 相关 frame 占比下降到 4%;
P99 延迟下降约 30%;
CPU 利用率在同 QPS 下下降 12%。
这个案例的价值在于,只有持续性能分析才能提供”调优前后”的量化对比 ,传统
JMX、JStat 只能给出 GC 时间总量,无法细化到”GC
内部哪个阶段消耗最大”。
十一、数据模型与存储:深入
pprof、列存、压缩与保留
11.1 profile 数据的特征
Profile 数据与 metric、log、trace
相比有一些独特的特征:
高维高基数 :典型一个 profile
有数千到数万个 unique function,每个 stack
深度可达数十;
高度重复 :绝大多数采样的 stack
集中在少数几个热点路径上;
时间局部性强 :同一服务在相邻 10
秒窗口内的 profile 高度相似;
查询以聚合为主 :原始 sample
没人看,大家看的是聚合后的火焰图。
这些特征决定了列存 + 字典编码 + 轻量聚合预计算
是最合适的存储方案。
11.2 pprof 压缩实战
pprof 格式本身已经通过 string_table
做了字符串去重。但在持续写入场景下,还有额外的优化空间:
跨 profile 字符串共享 :同一服务的几万个
profile 中,函数名几乎完全相同。如果把 string_table
抽出来作为”服务级全局字典”,单个 profile
只需要存索引,压缩率可再提高 3~5 倍;
delta 编码 :相邻时间窗口的 profile
高度相似,存 diff 比存全量节省 80%+;
Parquet + zstd :列存 +
高压缩比算法,磁盘占用可以做到原始 pprof 的 1/20。
11.3 保留策略
典型的分层保留:
层级
精度
保留时间
存储
hot
原始 10s 粒度
7 天
本地 SSD
warm
聚合到 1 分钟
30 天
对象存储(标准)
cold
聚合到 10 分钟
180 天
对象存储(低频)
archive
聚合到 1 小时
2 年
归档存储(Glacier/COS 归档)
十二、集成
CI/CD:把 profile 变成 release gate
持续性能分析真正的价值不只是”排障”,更是”防患于未然”。把
profile diff 嵌入 CI/CD 可以实现:
性能基线(baseline) :每次主干合并后自动采集
profile,作为下一次 PR 的基线;
PR profile
对比 :合并前在预发环境用压测流量跑 5 分钟,和基线
diff。若 CPU 回归 > 5% 自动 block;
canary 自动回滚 :金丝雀发布时,自动对比
canary pod 和 stable pod 的
profile,回归超过阈值自动回滚;
发布报告 :每次发布自动生成火焰图对比报告,附在
release notes 里。
一个简化的 GitHub Actions 样例:
name : perf-regression-gate
on :
pull_request :
types : [ opened , synchronize ]
jobs :
perf-diff :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- name : Deploy to perf cluster
run : ./scripts/deploy-perf.sh ${{ github.sha }}
- name : Run load test
run : ./scripts/loadtest.sh --duration 5m
- name : Fetch profile
run : |
curl -o pr.pb.gz http://pyroscope/pprof?query=...
curl -o base.pb.gz http://pyroscope/pprof?query=baseline...
- name : Diff
id : diff
run : |
go tool pprof -top -diff_base=base.pb.gz pr.pb.gz > diff.txt
regression=$(awk '/^Showing/{next} $1 ~ /^\+/{sum+=$1} END{print sum}' diff.txt)
echo "regression=$regression" >> $GITHUB_OUTPUT
- name : Fail if regression > 5%
if : steps.diff.outputs.regression > 5
run : exit 1
十三、profiling
overhead 测量方法
在决定推广持续性能分析到全集群之前,必须先量化它的开销。标准做法:
A/B 对比 :同服务两组 pod,一组开
profiling 一组不开,对比 CPU 使用率、P50/P99 延迟;
压测台 :固定 QPS 下测量 CPU
增量,分别在 49Hz / 99Hz / 499Hz / 999Hz 下各跑 10
分钟;
nanosecond 级
benchmark :对关键热点函数用 Go benchmark / JMH
跑对比。
公开数据与笔者实测(Go 服务,amd64):
采样频率
CPU 开销
P99 延迟增量
19Hz
< 0.1%
~0ms
49Hz
~0.3%
~0ms
99Hz
0.5% ~ 1%
< 0.5ms
499Hz
3% ~ 5%
2~5ms
999Hz
8% ~ 12%
5~15ms
结论:99Hz 是 sweet spot 。超过 500Hz
开销显著上升,不适合生产。
十四、多租户 profiling
平台
对于中大型组织,一个统一的 profiling
平台需要考虑多租户:
租户隔离 :不同业务线的 profile
数据互相不可见;存储层用 tenant_id label 做 partition;
限流/配额 :限制单租户每秒 ingest
bytes、每天总量;
权限模型 :读写分离,研发只能读自己服务的
profile,SRE 可全量;
查询 QoS :大查询异步化,防止单个
Grafana 面板拖垮全局;
审计日志 :profile
中可能包含敏感的函数名(例如
EncryptPassword),查询需要审计。
Parca 和 Pyroscope 官方都在演进多租户能力,但成熟度还不如
Cortex/Mimir。对于体量大的企业,可能需要自研或基于 Mimir
思路 fork。
十五、工程坑点
15.1 符号表缺失
问题描述 :二进制构建时加了
strip 或 -ldflags="-s -w",导致
profile 里全是地址,火焰图无法解读。
解决方案 :
Go:不要加
-s -w。若出于二进制大小考虑,可保留 debug info
单独上传到 debuginfod 服务器;
C/C++:用 objcopy --only-keep-debug 把
debug 信息分离成单独文件,线上二进制 strip,调试文件上传
debuginfod;
本地启动 debuginfod server:
# 启动 debuginfod,从 /var/debug 目录按 build-id 索引提供
debuginfod -d /var/cache/debuginfod.sqlite -F /var/debug -p 8002
# 验证
curl -I http://localhost:8002/buildid/$( readelf -n ./mybinary | \
awk '/Build ID/{print $3}' ) /debuginfo
Parca Agent 配置上游 debuginfod:
# parca-agent 启动参数
--debuginfo-strip=true
--debuginfod-urls=http://debuginfod.internal:8002,https://debuginfod.elfutils.org/
15.2 容器内 eBPF 权限
问题描述 :在 Kubernetes Pod 内运行 eBPF
程序需要特权(capability)。若只给
privileged: true 则等同于
root,安全风险高;若只给 CAP_SYS_ADMIN 又过度。
解决方案 :对 Linux 5.8+,使用 CAP_BPF +
CAP_PERFMON + CAP_SYS_RESOURCE 组合,最小化权限。
DaemonSet 示例:
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.33.0
securityContext :
privileged : false
capabilities :
add :
- SYS_ADMIN # 某些场景仍需,eBPF map 创建
- BPF # 5.8+ 核心能力
- PERFMON # 5.8+ perf_event
- SYS_RESOURCE # 调整 rlimit
- SYS_PTRACE # 读取 /proc 进程内存
volumeMounts :
- name : sys
mountPath : /sys
- name : debugfs
mountPath : /sys/kernel/debug
- name : modules
mountPath : /lib/modules
readOnly : true
- name : run
mountPath : /run
- name : procfs
mountPath : /host/proc
readOnly : true
volumes :
- name : sys
hostPath :
path : /sys
- name : debugfs
hostPath :
path : /sys/kernel/debug
- name : modules
hostPath :
path : /lib/modules
- name : run
hostPath :
path : /run
- name : procfs
hostPath :
path : /proc
关键点:
hostPID: true 让 Agent 看到宿主机所有
PID,这是做跨容器符号解析的前提;
挂载 /proc 到 /host/proc
而非覆盖 Agent 自己的 /proc;
/sys/kernel/debug 用于读取 BPF tracing
相关文件;
/lib/modules 和内核头文件相关,对非 CO-RE
的 BPF 程序必要(Parca Agent 已 CO-RE,可选)。
15.3 多层容器符号路径问题
问题描述 :Agent 运行在宿主机
namespace,而业务二进制位于某个 Pod 的 mount namespace
里。Agent 直接用 /usr/bin/myapp
路径是找不到的。
解决方案 :通过
/proc/<pid>/root/<path>
访问目标容器的 rootfs。例如:
# 目标 PID 是 12345,容器内路径是 /usr/bin/myapp
ls -l /proc/12345/root/usr/bin/myapp
# 读取该二进制的 build-id
readelf -n /proc/12345/root/usr/bin/myapp | grep "Build ID"
Agent 在解析 /proc/<pid>/maps
时,要把里面的绝对路径 prefix 上
/proc/<pid>/root。Parca Agent 和
Pyroscope 的 eBPF agent 都已经处理了这个 case,但自研
profiler 经常踩坑。
对于 initContainer
生命周期短、早已退出的情况,/proc/pid/root
在进程退出后会消失。需要在采样时立即拷贝符号表,不能事后解析。
15.4 高频采样导致性能影响
问题描述 :出于”越快越准”的直觉,有人把采样频率设成
1000Hz 甚至更高。结果线上 CPU 使用率飙升 10%+,P99
延迟显著恶化。
解决方案 :
默认 99Hz ,这是 Google-Wide Profiling
的经验值;
最高不超过
299Hz ,除非短期(分钟级)定点排障;
对高密度容器化环境(一个 Node 上 50+ Pod),把频率降到
49Hz;
使用 perf stat -e cycles,instructions
对比开关 profiling 前后的 cycle count。
验证命令示例:
# 开 profiling 前
perf stat -a -e cpu-cycles,instructions,cache-misses sleep 60
# 开 profiling 后(99Hz)
perf stat -a -e cpu-cycles,instructions,cache-misses sleep 60
# 对比 cycles 差值即为 profiling 的 CPU 开销
15.5 eBPF 内核版本依赖
问题描述 :CentOS 7(3.10 内核)上 eBPF
功能极为有限,无法运行现代 BPF 程序。不同 feature
对应的最低内核:
Feature
最低内核版本
eBPF maps & verifier
3.18
perf_event + BPF
4.4
kprobe + BPF
4.6
BTF (CO-RE 基础)
4.18
函数参数访问 bpf_trace_printk 等
4.15
bpf_perf_event_output
4.4
BPF_MAP_TYPE_RINGBUF
5.8
sleepable BPF
5.10
CAP_BPF / CAP_PERFMON
5.8
fentry/fexit(更低开销 tracing)
5.5
解决方案 :
生产 OS 全面升级到 5.10+(Ubuntu 22.04 / AlmaLinux 9
等);
升级不动的历史机器,单独部署传统 perf profiler;
feature detection 命令:
# 检查 BTF 是否可用
ls -l /sys/kernel/btf/vmlinux
# 检查可用的 BPF_MAP 类型
bpftool feature probe | grep map_type
# 检查内核是否启用 BPF JIT
sysctl kernel.unprivileged_bpf_disabled
sysctl net.core.bpf_jit_enable
# 综合:Parca Agent 自带的 feature probe
parca-agent --detect-features
15.6 Java JIT
预热期符号不稳定
问题描述 :Java 应用启动后的前 5~10
分钟,HotSpot JIT 正在 tiered compile,C1/C2
反复重编译。perf-map 文件内容频繁变化,导致:
栈回溯中途 frame 对应不到函数;
同一个方法在不同时刻采样到的名字不同;
火焰图里出现大量 <unknown> 或错位的
frame。
解决方案 :
延迟启动 profiling :应用就绪后等待 5
分钟再开启采样;
use async-profiler 而非
perf :async-profiler 内部直接调用
AsyncGetCallTrace,不依赖 perf-map;
固定代码缓存分代 :JVM 参数加
-XX:ReservedCodeCacheSize=512m -XX:InitialCodeCacheSize=256m,减少
code cache 分配抖动;
PreserveFramePointer
必开 :-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints -XX:+PreserveFramePointer;
perf-map-agent 周期导出 :
# 定时更新 /tmp/perf-<pid>.map
while true ; do
for pid in $( pgrep java) ; do
jcmd $pid Compiler.CodeHeap_Analytics || true
java -cp perf-map-agent.jar net.virtualvoid.perf.AttachOnce $pid
done
sleep 30
done
十六、选型决策矩阵
下表汇总了三大方案及常见商业方案的主要维度对比,供选型参考:
维度
Parca
Pyroscope (Grafana)
Beyla
Datadog Profiler
Pixie
是否开源
是(Apache 2.0)
是(AGPLv3)
是(Apache 2.0)
否
是
采集方式
eBPF agent
SDK + pull/push
eBPF agent
SDK agent
eBPF
零侵入
强
中
极强
弱
强
存储
FrostDB / Parquet
自研(Phlare 融合后)
不存储,转发
云端
自研内存
查询语言
类 PromQL
ProfileQL
无
云端 UI
PxL
语言支持
C/C++/Go/Rust/Java/Python
全家桶
Go/C++/Java/Node/Rust
全家桶
类似 Parca
与 Trace 关联
通过 label
原生 Exemplar
原生 OTLP
原生
原生
学习曲线
中
低
极低
低
高
生产成熟度
中
高
中(较新)
高
中
商业支持
Polar Signals
Grafana Labs
Grafana Labs
Datadog
New Relic
简要推荐:
已经是 Grafana Stack 用户 :首选 Grafana
Pyroscope,和 Tempo/Mimir/Loki 生态无缝;
需要零侵入、想要 RED + Trace + Profile
三合一 :Beyla;
重度 eBPF 栈、希望存储解耦(落地到 S3/Parquet
做二次分析) :Parca;
预算充足、不想运维 :Datadog Profiler 或
Grafana Cloud Profiles;
K8s 原生观测 +
自研数据管线 :Pixie。
十七、生产落地清单
以下是一份在大中型互联网公司落地持续性能分析平台时的
checklist,至少覆盖 15 项:
选定核心方案(Parca /
Pyroscope / Beyla /
商业方案),并在一个试点业务线跑通端到端;
默认采样频率设置为
99Hz(或 49Hz 对密集容器场景);
聚合窗口设置为 10 秒或
15 秒;
所有 Go 二进制构建去掉
-ldflags="-s -w",保留符号;
所有 Java 服务加上
-XX:+PreserveFramePointer -XX:+DebugNonSafepoints;
所有 Rust 二进制在
release 配置中加
force-frame-pointers=yes;
Python 服务评估 py-spy
或 pyperf 方案,确认 CPython 版本支持;
部署 debuginfod
内网服务,覆盖 glibc、openssl、libstdc++
等系统库符号;
Kubernetes
集群升级到内核 5.10+,确认 BTF、BPF_MAP_RINGBUF
可用;
Parca/Pyroscope agent 以
DaemonSet 部署,使用最小
capabilities(BPF+PERFMON+SYS_ADMIN);
存储层做分层保留策略(hot 7 天、warm 30
天、cold 180 天);
在 Grafana 中建立标准
dashboard:服务粒度的 CPU / alloc / goroutine
火焰图;
与 Trace
系统打通:pprof.Do / pprof label 注入 trace_id,并在
Exemplar 跳转实现;
与 CI/CD 打通:PR 级别的
profile diff gate,主干合并后自动更新
baseline;
与发布系统打通:发布前后自动触发 diff
并推送报告;
运维预案:profile
数据丢失降级策略、高基数 label 的熔断策略;
安全:租户隔离、审计日志、profile
查询权限控制;
开销监控:定期对比开关
profiling 的 CPU/延迟对比,持续优化;
培训:给一线研发做火焰图解读培训,尤其是
diff 图颜色含义;
On-call
手册:典型问题的火焰图特征库(GC、锁、系统调用、syscall
storms)。
十八、常见问题答疑
18.1 能替代 APM 吗
不能,至少目前不能。APM 提供的是”请求级”的 RED
指标和调用链,持续性能分析提供的是”函数级”的资源占用。两者信息维度不同,互为补充。未来随着
OTel Profiling Signal 成熟,在同一个平台里同时看 Trace 和
Profile 会成为主流。
18.2 和 APM
vendor 自带的 profiler 的区别
APM vendor 的 profiler 通常是”on-demand 采样 + 云端存储 +
收费按采集量”。开源持续性能分析是”always-on + 自建存储 +
免费”。对于流量稳定、规模较大的团队,开源方案的长期 TCO
更低;对于流量波动大、团队规模小、想”开箱即用”的团队,商业方案更合适。
18.3 对 Serverless
支持如何
当前所有开源方案对 Serverless(Lambda、Cloud
Run)支持都比较弱,原因是:
无法部署 DaemonSet;
函数冷启动使得 profile 采样时间过短;
vendor 不允许 perf_event_open。
目前更可行的做法是在函数内置 SDK(例如 Lambda
Extension),但采样频率要低(10~20Hz),采集时长要与调用生命周期对齐。
18.4 对 Windows 支持如何
原生 eBPF 在 Windows 上通过 eBPF-for-Windows
项目实验性支持,但生产成熟度远不如 Linux。对 Windows
Server,一般还是用 vendor 方案(如 Datadog .NET
Profiler)。
十九、演进趋势
展望未来 2~3 年,持续性能分析领域可能的演进方向:
OTLP Profiling Signal 全面普及 :成为与
Traces/Metrics/Logs 同级的第四 pillar;
eBPF unwinder 标准化 :DWARF-based
unwinding 在内核态成熟,frame pointer 要求逐步弱化;
AI 辅助分析 :LLM
自动解读火焰图,生成优化建议;
更细粒度的关联 :单 request 级别的
profile(per-request profiling),类似 Datadog 的 Code
Hotspots;
Serverless / WebAssembly
原生支持 :针对短生命周期的采样协议;
硬件事件扩展 :从 CPU cycles 扩展到
cache miss、branch miss、TLB miss、memory
bandwidth,做真正的微架构级优化;
隐私增强 :对 profile
数据做差分隐私处理,防止从函数名/调用栈反推业务逻辑。
二十、小结
持续性能分析不再是锦上添花,而是现代可观测性体系的必需品。它把性能分析从”事后救火”变成”常态体检”,使得工程师能够用和看日志、指标一样的方式看”代码在哪里耗费资源”。
本文从 Google-Wide Profiling 的理论起源,到
Parca、Pyroscope、Grafana Beyla
三大开源方案的架构细节,再到跨语言符号解析、工程坑点、字节跳动与美团的国内实践,力图给出一份可以直接照着落地的完整指南。
在选型上,没有绝对正确的答案:
如果你已经在用 Grafana Stack,Pyroscope
是最平滑的选择;
如果你看重 eBPF 原生和存储解耦,Parca 更符合;
如果你追求极致零侵入,Beyla 值得尝试;
如果预算充足、追求省心,商业 APM 的 profiler
也未尝不可。
真正重要的是,尽早把”持续性能分析”纳入研发和 SRE
的日常工作流:每一次上线都有 diff
报告,每一个告警都能关联到火焰图,每一次优化都能量化评估。这才是持续性能分析最大的价值所在。
二十一、参考资料
Ren G., Tune E., Moseley T., Shi Y., Rus S., Hundt R.
《Google-Wide Profiling: A Continuous Profiling
Infrastructure for Data Centers》. IEEE Micro, 2010. 论文链接
Brendan Gregg. 《Flame Graphs》. 博客与书籍. https://www.brendangregg.com/flamegraphs.html
Brendan Gregg. 《Systems Performance: Enterprise and the
Cloud, 2nd Edition》. Pearson, 2020.
Parca 官方文档. https://www.parca.dev/docs/overview
Polar Signals. 《Continuous Profiling with eBPF》. https://www.polarsignals.com/blog
Grafana Pyroscope 官方文档. https://grafana.com/docs/pyroscope/latest/
Grafana Beyla 官方文档. https://grafana.com/docs/beyla/latest/
Google pprof 项目与 proto 定义. https://github.com/google/pprof/blob/main/proto/profile.proto
async-profiler 项目主页. https://github.com/async-profiler/async-profiler
perf-map-agent 项目主页. https://github.com/jvm-profiling-tools/perf-map-agent
py-spy 项目主页. https://github.com/benfred/py-spy
CNCF / OpenTelemetry. 《OTEP 0212: Profiling Signal》.
https://github.com/open-telemetry/oteps/pull/239
Linux 内核文档 perf_event_open(2) manpage.
https://man7.org/linux/man-pages/man2/perf_event_open.2.html
eBPF 官方文档. https://ebpf.io/what-is-ebpf/
Frederic Branczyk. 《Continuous Profiling in
Production》. KubeCon 演讲. https://www.youtube.com/@polarsignals
debuginfod 项目. https://sourceware.org/elfutils/Debuginfod.html
字节跳动技术团队. 《字节跳动持续性能分析平台 Flame
实践》. ArchSummit 2021 公开分享.
美团技术团队. 《Java 应用性能分析实践》. 美团技术博客.
https://tech.meituan.com/
Datadog. 《Continuous Profiler Documentation》. https://docs.datadoghq.com/profiler/
Apache Arrow / Parquet 官方文档. https://arrow.apache.org/ / https://parquet.apache.org/
上一篇:网络可观测性
下一篇:内核追踪
同主题继续阅读 把当前热点继续串成多页阅读,而不是停在单篇消费。
2026-04-22 · architecture / observability
从 CPU/heap/goroutine/lock/off-CPU 等 Profiling 种类出发,比较采样与插桩两类 profiler 的工作原理,深入 Go pprof、Java async-profiler/JFR、Python py-spy、Pyroscope、Parca eBPF Profiling,以及 OpenTelemetry Profiles 的最新进展,给出国内字节美团的真实落地经验与工程坑点。
2026-04-22 · architecture / observability
eBPF 如何实现零侵入、内核级、低开销的可观测性:从 kprobe/uprobe/tracepoint/fentry 钩子机制,到 bcc 工具集、bpftrace 脚本语言、libbpf+CO-RE 可移植编程,再到 Pixie、DeepFlow、Grafana Beyla 等商业化工具,结合内核版本兼容性与生产部署实战。
2026-04-22 · architecture / observability
从 Metrics、Logs、Traces 到 Profiling、eBPF、OpenTelemetry 与 SLO 治理,面向中国工程团队的可观测性系统化手册。
2026-04-22 · architecture / observability
从控制论到云原生:拆解可观测性的五大信号支柱,对比监控与可观测性的本质区别,梳理开源/商业/SaaS 分类,以及国内互联网公司三大支柱落地现状与典型工程坑点。