土法炼钢兴趣小组的算法知识备份

持续性能分析(Continuous Profiling):Parca、Pyroscope、Grafana Beyla

文章导航

分类入口
architectureobservability
标签入口
#continuous-profiling#parca#pyroscope#beyla#ebpf#flamegraph#pprof#observability#performance

目录

在可观测性的三大支柱——指标(Metrics)、日志(Logs)、追踪(Traces)之外,近年来第四根支柱正在迅速崛起:持续性能分析(Continuous Profiling)。它回答的不再是”系统发生了什么”,而是”系统为什么慢、为什么耗 CPU、为什么耗内存”,并且以接近零开销的方式持续地、永远在线地采集。

本文是【可观测性工程】系列的第十六篇,系统梳理 Continuous Profiling 的理论基础、主流开源方案(Parca、Pyroscope、Grafana Beyla)的架构实现、跨语言符号解析、与 Traces 的关联、字节跳动与美团的国内案例,以及生产落地过程中踩过的坑。目标是让读者在读完之后能够做出合理的选型决策,并落地一套可运维的持续性能分析平台。

一、什么是持续性能分析

1.1 从 one-shot profiling 说起

传统的性能分析是”按需触发”的:当线上出现 CPU 高、延迟毛刺、OOM 时,工程师登录机器,使用 性能分析工具(profiler)采集一段数据,然后离线分析。典型工具包括:

这种”一次性采样(one-shot profiling)“模式有几个致命缺陷:

第一,事后采样。当用户反馈”昨天下午三点系统很慢”时,现场已经不再存在。即便通过压测复现,压测流量与真实流量往往差异巨大,得到的热点函数并不能代表线上实际瓶颈。

第二,依赖人工操作。SRE 需要登录具体的 Pod 或机器,执行 kubectl execssh,运行 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 等。核心思想是:

  1. 以 99Hz 的低频率(每秒 99 次)对整个数据中心的所有机器采样;
  2. 使用统计采样(statistical sampling),而非精确插桩,从而将开销控制在 1% 以内;
  3. 将所有采样数据集中存储,长期保留;
  4. 提供查询接口,支持按服务、按版本、按时间范围、按函数维度聚合。

这套系统在 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);

这里的关键字段:

当内核每秒产生 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 程序)实现。

生产环境中建议:

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;
}

几个关键设计:

  1. 字符串池(string_table):所有字符串只存一份,Sample/Location/Function 中通过索引引用,天然节省空间;
  2. Location 与 Function 分离:同一个函数可能出现在栈中多个位置(递归、inline),但 Function 只存一份;
  3. 多种 sample_type:一个 profile 可以同时包含 cpu、alloc_objects、inuse_space 等多种维度;
  4. 整体采用 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 看齐:

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";

这段代码的关键点:

  1. BPF_MAP_TYPE_RINGBUF 是 Linux 5.8+ 引入的高效无锁 ring buffer,比旧的 perf buffer 性能更好;
  2. bpf_get_stack 是 eBPF 提供的原生 stack walker,要求目标进程保留帧指针(frame pointer);
  3. 内核栈和用户栈分开采集,后续在用户态拼接;
  4. 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 的关键特性:

  1. 基于 Apache Arrow 内存格式,查询时零拷贝;
  2. 落盘使用 Apache Parquet,天然支持 Spark/Trino 等外部查询;
  3. 支持字典编码(Dictionary Encoding),对高基数的字符串(如函数名)有极好的压缩率;
  4. 支持动态 schema,可以在运行时添加新的 label;
  5. 使用 LSM 思想,内存中有 active table,周期性 flush 到对象存储(S3/MinIO)。

列存对于火焰图的聚合查询特别友好。例如”查询过去 1 小时内某服务所有 CPU 采样,按 function 聚合”这种 query,在列存下只需要读取 functionvalue 两列,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 文件

几个关键点:

  1. build-id 是 ELF 中的一段唯一标识,由链接器生成,是符号解析跨主机通用的 key。file 命令或 readelf -n <binary> 可以查看;
  2. debuginfod 是 Fedora/Red Hat 推动的去符号分发协议(HTTP API),Ubuntu/Debian 也都已支持。Parca Agent 可以配置上游 debuginfod(如 https://debuginfod.elfutils.org/)用于解析系统库;
  3. frame pointer 是用户态栈回溯最简单的方式。编译时加 -fno-omit-frame-pointer。Go 从 1.7 起默认保留帧指针(amd64);
  4. 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()
}

关键点:

  1. SetMutexProfileFractionSetBlockProfileRate 必须在 SDK 启动前设置,否则对应 profile 类型是空的;
  2. Tags 会作为 label 写入存储,用于多维度聚合;
  3. 各种 ProfileType 覆盖了 Go 运行时几乎所有内置 profile;
  4. 推送模式的好处是穿透 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

拉取模式适用于:

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>,例如:

查询结果默认渲染成火焰图,也可以下钻为调用树(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 并不是银弹:

  1. 对加密 HTTP/2(TLS)场景,需要 uprobe 挂载到 TLS 库。如果应用静态链接了自己的 TLS 库且编译时 strip 了符号,Beyla 可能识别不到;
  2. Java 应用由于 JIT 生成代码的虚拟地址不稳定,需要额外的 perf-map-agent 支持;
  3. 不支持业务级别的自定义 span,因此要和传统的 OpenTelemetry SDK 搭配使用。

六、火焰图:可视化与解读

6.1 火焰图的发明

火焰图(Flame Graph)由 Netflix 的 Brendan Gregg 在 2011 年发明,是目前最广泛使用的性能剖面可视化方式。它有几个关键特征:

  1. X 轴不代表时间,而是把所有采样到的同一栈按字典序排列,宽度代表该栈出现的频次(即 CPU 占用比例);
  2. Y 轴是调用栈深度,越往上越接近叶子函数;
  3. 颜色没有严格的语义,传统上是”暖色随机”,便于区分相邻 frame;
  4. 点击某个 frame 可以下钻(zoom),把它作为新的根节点重新渲染。

6.2 读图要点

读火焰图时有几个常见误区:

6.3 差异火焰图(diff profile)

差异火焰图是持续性能分析真正的”杀手级”功能:对比两个时间窗口或两个版本的 profile,用颜色编码差异。

约定俗成的颜色含义:

典型用例:

  1. 上线前后对比:新版本发布后,对比发布前 30 分钟和发布后 30 分钟的火焰图,快速定位”是我这次上线搞慢了谁”;
  2. A/B 实验:两个 version label 的 profile 直接 diff,看实验组 vs 对照组的 CPU 差异;
  3. 大促前后:对比日常流量与大促流量,发现只有高 QPS 下才暴露的热点(锁、GC、慢路径);
  4. 灰度流量对比:同一个二进制在不同 region/机型上的 profile 差异。

七、跨语言符号解析

每种语言的运行时特性不同,对应的 profiling 踩坑点也各不相同。

7.1 Go

Go 是 Continuous Profiling 最友好的语言:

一定不要在生产构建中加 -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:

  1. JIT 编译后的代码位于 C heap 里动态分配的 code cache,没有符号;
  2. JIT 会反复重新编译(tiered compilation),同一个方法的地址会变化;
  3. Inlining 非常激进,栈回溯很容易失真。

主流方案:

-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;
}

这里需要解决的难点:

  1. 不同 Python 版本 PyFrameObject 结构体 offset 不同(3.11 大改,引入了新的 frame 结构)。eBPF 程序必须根据目标 Python 解释器版本动态调整 offset;
  2. 找到 PyThreadState 的根指针。一般通过读取 Python 解释器 .data 段的全局符号 _PyRuntime.gilstate.tstate_current
  3. 读取 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 的经验,核心是:

  1. 在 OTLP 协议中新增 profiles.proto,复用 pprof 数据模型;
  2. 通过 OTel Collector 做 profile 的统一接收、路由、转换;
  3. 与现有的 traceparent 标准对齐:profile sample 可以通过 span_id/trace_id label 关联到 span;
  4. Elastic、Grafana、Splunk、Datadog 等主流厂商均已承诺支持。

这意味着未来的 Continuous Profiling 生态会逐步从”每家一个 wire format”走向”OTLP 统一”,对用户是显著利好。

九、字节跳动 Flame 平台案例

9.1 背景

字节跳动在 2021 年 ArchSummit 等公开技术大会上分享了其内部的持续性能分析平台”Flame”。截至 2021 年,Flame 覆盖了字节内部数十万服务实例,支持 Go、Java、Python、C++ 四种主要语言。这些信息基于字节跳动工程师的公开技术分享。

9.2 采集层

Flame 采集层的关键设计:

  1. 99Hz always-on:所有生产服务默认开启,采样频率 99Hz,开销控制在 1% 以内;
  2. 语言分治:Go 走 net/http/pprof pull 模式;Java 走 async-profiler 定时采样;C++ 走 perf + perf-map;Python 走 py-spy 定时 attach;
  3. Agent 容器化:以 sidecar 或 DaemonSet 方式部署,避免污染业务镜像;
  4. 上下文染色:Agent 在抓取时附上 service、cluster、psm(product-subsys-module)、version、region 等 label;
  5. 上传带宽控制:单实例日上传量 < 100MB,通过采样丢弃与 delta 压缩实现。

9.3 Java JIT 符号问题

字节公开分享过一个很有参考价值的坑:Java 服务在刚启动的前 5~10 分钟,JIT 正在预热,生成的 code cache 地址不稳定,导致 perf-map 文件频繁变动。直接采样得到的火焰图里会出现大量”未知 frame”或错乱的 frame。

他们的解法:

  1. 启动后延迟 5 分钟再开启采样;
  2. perf-map-agent 改造为每 30 秒 dump 一次而不是 attach 一次;
  3. 对比分析时,忽略 JIT 初始化相关的函数前缀(例如 C1C2 编译线程的 frame)。

9.4 diff 分析与上线关联

Flame 最被工程师看重的功能是”上线差异分析”:

  1. 所有上线事件通过 CMDB 打点到 Flame;
  2. 上线完成后 5 分钟自动触发 diff job:用上线后 3~5 分钟的 profile 对比上线前 10 分钟的 profile;
  3. 差异超过阈值的函数(默认 5%)自动推送到飞书/IM,@ 该服务 owner;
  4. 如果差异过大(>20%),触发告警,联动自动回滚系统。

这套机制在上线回归问题定位上效果显著,公开分享中提到,部分线上问题从发现到定位的时间从”小时级”下降到”分钟级”。

9.5 存储与查询

字节 Flame 没有使用 Parca/Pyroscope,而是自研了一套基于 ClickHouse 的列存后端。理由是:

  1. ClickHouse 是字节已有的大规模分析存储,运维熟悉;
  2. profile 本质上是”高维多标签的统计聚合查询”,ClickHouse 天然适配;
  3. 保留周期 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 成为天然选择。该实践的目标是:

  1. 常态化 Java 服务 CPU profile 采集;
  2. 与 CAT(美团自研 APM)深度集成;
  3. 支持 GC 分析、内存分析、锁分析。

10.2 采集策略

美团的采集参数在公开技术分享中有所提及:

10.3 CAT 集成

CAT(Central Application Tracking)是美团开源的 APM 系统。美团把 Flame 火焰图作为 CAT 的一个子页面:

  1. 从 CAT Transaction 列表点击某个慢调用,下钻到 Trace 详情;
  2. Trace 详情页面展示该实例当前窗口的 CPU 火焰图;
  3. 支持按时间段、按机器、按 trace_id label 过滤;
  4. 对比功能支持”此版本 vs 上一版本”、“此 IDC vs 另一个 IDC”。

10.4 GC 优化效果

公开分享中提到一个典型案例:某核心服务在 CMS GC 调优前,火焰图显示大量 CPU 花在 ParNew::copy_to_survivor_spaceCMSConcMarkingTask::do_scan_and_mark 上,占比约 15%。切换到 G1 后:

  1. 火焰图中 GC 相关 frame 占比下降到 4%;
  2. P99 延迟下降约 30%;
  3. CPU 利用率在同 QPS 下下降 12%。

这个案例的价值在于,只有持续性能分析才能提供”调优前后”的量化对比,传统 JMX、JStat 只能给出 GC 时间总量,无法细化到”GC 内部哪个阶段消耗最大”。

十一、数据模型与存储:深入 pprof、列存、压缩与保留

11.1 profile 数据的特征

Profile 数据与 metric、log、trace 相比有一些独特的特征:

  1. 高维高基数:典型一个 profile 有数千到数万个 unique function,每个 stack 深度可达数十;
  2. 高度重复:绝大多数采样的 stack 集中在少数几个热点路径上;
  3. 时间局部性强:同一服务在相邻 10 秒窗口内的 profile 高度相似;
  4. 查询以聚合为主:原始 sample 没人看,大家看的是聚合后的火焰图。

这些特征决定了列存 + 字典编码 + 轻量聚合预计算 是最合适的存储方案。

11.2 pprof 压缩实战

pprof 格式本身已经通过 string_table 做了字符串去重。但在持续写入场景下,还有额外的优化空间:

  1. 跨 profile 字符串共享:同一服务的几万个 profile 中,函数名几乎完全相同。如果把 string_table 抽出来作为”服务级全局字典”,单个 profile 只需要存索引,压缩率可再提高 3~5 倍;
  2. delta 编码:相邻时间窗口的 profile 高度相似,存 diff 比存全量节省 80%+;
  3. 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 可以实现:

  1. 性能基线(baseline):每次主干合并后自动采集 profile,作为下一次 PR 的基线;
  2. PR profile 对比:合并前在预发环境用压测流量跑 5 分钟,和基线 diff。若 CPU 回归 > 5% 自动 block;
  3. canary 自动回滚:金丝雀发布时,自动对比 canary pod 和 stable pod 的 profile,回归超过阈值自动回滚;
  4. 发布报告:每次发布自动生成火焰图对比报告,附在 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 测量方法

在决定推广持续性能分析到全集群之前,必须先量化它的开销。标准做法:

  1. A/B 对比:同服务两组 pod,一组开 profiling 一组不开,对比 CPU 使用率、P50/P99 延迟;
  2. 压测台:固定 QPS 下测量 CPU 增量,分别在 49Hz / 99Hz / 499Hz / 999Hz 下各跑 10 分钟;
  3. 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 平台需要考虑多租户:

  1. 租户隔离:不同业务线的 profile 数据互相不可见;存储层用 tenant_id label 做 partition;
  2. 限流/配额:限制单租户每秒 ingest bytes、每天总量;
  3. 权限模型:读写分离,研发只能读自己服务的 profile,SRE 可全量;
  4. 查询 QoS:大查询异步化,防止单个 Grafana 面板拖垮全局;
  5. 审计日志:profile 中可能包含敏感的函数名(例如 EncryptPassword),查询需要审计。

Parca 和 Pyroscope 官方都在演进多租户能力,但成熟度还不如 Cortex/Mimir。对于体量大的企业,可能需要自研或基于 Mimir 思路 fork。

十五、工程坑点

15.1 符号表缺失

问题描述:二进制构建时加了 strip-ldflags="-s -w",导致 profile 里全是地址,火焰图无法解读。

解决方案

  1. Go:不要加 -s -w。若出于二进制大小考虑,可保留 debug info 单独上传到 debuginfod 服务器;
  2. C/C++:用 objcopy --only-keep-debug 把 debug 信息分离成单独文件,线上二进制 strip,调试文件上传 debuginfod;
  3. 本地启动 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
  1. 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

关键点:

  1. hostPID: true 让 Agent 看到宿主机所有 PID,这是做跨容器符号解析的前提;
  2. 挂载 /proc/host/proc 而非覆盖 Agent 自己的 /proc
  3. /sys/kernel/debug 用于读取 BPF tracing 相关文件;
  4. /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 延迟显著恶化。

解决方案

  1. 默认 99Hz,这是 Google-Wide Profiling 的经验值;
  2. 最高不超过 299Hz,除非短期(分钟级)定点排障;
  3. 对高密度容器化环境(一个 Node 上 50+ Pod),把频率降到 49Hz;
  4. 使用 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

解决方案

  1. 生产 OS 全面升级到 5.10+(Ubuntu 22.04 / AlmaLinux 9 等);
  2. 升级不动的历史机器,单独部署传统 perf profiler;
  3. 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 文件内容频繁变化,导致:

  1. 栈回溯中途 frame 对应不到函数;
  2. 同一个方法在不同时刻采样到的名字不同;
  3. 火焰图里出现大量 <unknown> 或错位的 frame。

解决方案

  1. 延迟启动 profiling:应用就绪后等待 5 分钟再开启采样;
  2. use async-profiler 而非 perf:async-profiler 内部直接调用 AsyncGetCallTrace,不依赖 perf-map;
  3. 固定代码缓存分代:JVM 参数加 -XX:ReservedCodeCacheSize=512m -XX:InitialCodeCacheSize=256m,减少 code cache 分配抖动;
  4. PreserveFramePointer 必开-XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints -XX:+PreserveFramePointer
  5. 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

简要推荐:

十七、生产落地清单

以下是一份在大中型互联网公司落地持续性能分析平台时的 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)支持都比较弱,原因是:

  1. 无法部署 DaemonSet;
  2. 函数冷启动使得 profile 采样时间过短;
  3. 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 年,持续性能分析领域可能的演进方向:

  1. OTLP Profiling Signal 全面普及:成为与 Traces/Metrics/Logs 同级的第四 pillar;
  2. eBPF unwinder 标准化:DWARF-based unwinding 在内核态成熟,frame pointer 要求逐步弱化;
  3. AI 辅助分析:LLM 自动解读火焰图,生成优化建议;
  4. 更细粒度的关联:单 request 级别的 profile(per-request profiling),类似 Datadog 的 Code Hotspots;
  5. Serverless / WebAssembly 原生支持:针对短生命周期的采样协议;
  6. 硬件事件扩展:从 CPU cycles 扩展到 cache miss、branch miss、TLB miss、memory bandwidth,做真正的微架构级优化;
  7. 隐私增强:对 profile 数据做差分隐私处理,防止从函数名/调用栈反推业务逻辑。

二十、小结

持续性能分析不再是锦上添花,而是现代可观测性体系的必需品。它把性能分析从”事后救火”变成”常态体检”,使得工程师能够用和看日志、指标一样的方式看”代码在哪里耗费资源”。

本文从 Google-Wide Profiling 的理论起源,到 Parca、Pyroscope、Grafana Beyla 三大开源方案的架构细节,再到跨语言符号解析、工程坑点、字节跳动与美团的国内实践,力图给出一份可以直接照着落地的完整指南。

在选型上,没有绝对正确的答案:

真正重要的是,尽早把”持续性能分析”纳入研发和 SRE 的日常工作流:每一次上线都有 diff 报告,每一个告警都能关联到火焰图,每一次优化都能量化评估。这才是持续性能分析最大的价值所在。

二十一、参考资料

  1. 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. 论文链接
  2. Brendan Gregg. 《Flame Graphs》. 博客与书籍. https://www.brendangregg.com/flamegraphs.html
  3. Brendan Gregg. 《Systems Performance: Enterprise and the Cloud, 2nd Edition》. Pearson, 2020.
  4. Parca 官方文档. https://www.parca.dev/docs/overview
  5. Polar Signals. 《Continuous Profiling with eBPF》. https://www.polarsignals.com/blog
  6. Grafana Pyroscope 官方文档. https://grafana.com/docs/pyroscope/latest/
  7. Grafana Beyla 官方文档. https://grafana.com/docs/beyla/latest/
  8. Google pprof 项目与 proto 定义. https://github.com/google/pprof/blob/main/proto/profile.proto
  9. async-profiler 项目主页. https://github.com/async-profiler/async-profiler
  10. perf-map-agent 项目主页. https://github.com/jvm-profiling-tools/perf-map-agent
  11. py-spy 项目主页. https://github.com/benfred/py-spy
  12. CNCF / OpenTelemetry. 《OTEP 0212: Profiling Signal》. https://github.com/open-telemetry/oteps/pull/239
  13. Linux 内核文档 perf_event_open(2) manpage. https://man7.org/linux/man-pages/man2/perf_event_open.2.html
  14. eBPF 官方文档. https://ebpf.io/what-is-ebpf/
  15. Frederic Branczyk. 《Continuous Profiling in Production》. KubeCon 演讲. https://www.youtube.com/@polarsignals
  16. debuginfod 项目. https://sourceware.org/elfutils/Debuginfod.html
  17. 字节跳动技术团队. 《字节跳动持续性能分析平台 Flame 实践》. ArchSummit 2021 公开分享.
  18. 美团技术团队. 《Java 应用性能分析实践》. 美团技术博客. https://tech.meituan.com/
  19. Datadog. 《Continuous Profiler Documentation》. https://docs.datadoghq.com/profiler/
  20. Apache Arrow / Parquet 官方文档. https://arrow.apache.org/ / https://parquet.apache.org/

上一篇:网络可观测性

下一篇:内核追踪

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-22 · architecture / observability

可观测性工程

从 Metrics、Logs、Traces 到 Profiling、eBPF、OpenTelemetry 与 SLO 治理,面向中国工程团队的可观测性系统化手册。


By .