在可观测性的三大支柱——指标(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 系统互通的基础。
三、Parca 架构深度解析
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:nanosecondsmemory:alloc_space:bytes:space:bytesmemory:inuse_space:bytes:space:bytesgoroutine: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_idlabel 关联到 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/pprofpull 模式;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-features15.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 项:
十八、常见问题答疑
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/
上一篇:网络可观测性
下一篇:内核追踪
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【可观测性工程】持续性能分析(Profiling):pprof、Pyroscope、Parca、async-profiler、JFR
从 CPU/heap/goroutine/lock/off-CPU 等 Profiling 种类出发,比较采样与插桩两类 profiler 的工作原理,深入 Go pprof、Java async-profiler/JFR、Python py-spy、Pyroscope、Parca eBPF Profiling,以及 OpenTelemetry Profiles 的最新进展,给出国内字节美团的真实落地经验与工程坑点。
【可观测性工程】eBPF 可观测性全景:bcc、bpftrace、libbpf 的工程路径
eBPF 如何实现零侵入、内核级、低开销的可观测性:从 kprobe/uprobe/tracepoint/fentry 钩子机制,到 bcc 工具集、bpftrace 脚本语言、libbpf+CO-RE 可移植编程,再到 Pixie、DeepFlow、Grafana Beyla 等商业化工具,结合内核版本兼容性与生产部署实战。
可观测性工程
从 Metrics、Logs、Traces 到 Profiling、eBPF、OpenTelemetry 与 SLO 治理,面向中国工程团队的可观测性系统化手册。
【可观测性工程】可观测性全景:Metrics、Logs、Traces、Profiles、Events 五大支柱
从控制论到云原生:拆解可观测性的五大信号支柱,对比监控与可观测性的本质区别,梳理开源/商业/SaaS 分类,以及国内互联网公司三大支柱落地现状与典型工程坑点。