持续性能分析(Profiling)存储架构:从 pprof 到 Pyroscope V2 与 Parca

持续性能分析(Continuous Profiling)是可观测性的重要支柱之一。与传统的采样 Profiling 不同,持续 Profiling 以固定频率全时采集应用和内核的调用栈快照,生成火焰图,帮助团队发现性能瓶颈、内存泄漏和资源热点。本文聚焦 Profiling 数据的存储架构:从 Google pprof 数据模型与火焰图构建算法开始,详细分析 Pyroscope 从 V1(TSDB+Parquet)到 V2(Metastore+Segments)的架构演进、Parca 与 FrostDB 的列式存储创新、Datadog/Splunk 等商业方案,最后通过对比表格呈现各方案的差异化选择。

新手类比:火焰图就像代码的 “X光片”——横轴是函数调用次数占比,纵轴是调用深度,最宽的 “火焰” 就是最耗时的代码路径。持续性能分析以固定间隔(如每 10ms)采样 CPU,数千次采样后,聚合结果就构成了程序在哪里花费时间的统计图景。

下方动画展示如何从采样样本聚合成火焰图:

火焰图构建:从采样到可视化阶段 1:独立调用栈样本mainhandleRequestdbQuery样本 A (50%)mainhandleRequestparseJSON样本 B (30%)mainvalidateToken样本 C (20%)↓ 合并相同路径,按采样占比分配宽度 ↓阶段 2:聚合火焰图dbQuery 50%parseJSON 30%handleRequest 80%validate 20%main 100%每个帧的宽度 = 包含该函数的样本占比最宽的火焰 = 最热的代码路径dbQuery 占 50% 采样 = 性能优化的最佳起点

动画说明:循环展示独立调用栈样本(阶段 1)如何聚合成火焰图(阶段 2)。每个函数帧的宽度与包含该函数的样本占比成正比。最宽的帧——dbQuery 占 50%——就是性能优化目标。这就是持续性能分析将数千次 10ms 间隔采样转化为可操作性能图景的方式。

pprof 数据模型与火焰图构建

pprof profile.proto 核心消息定义

所有主流项目(Pyroscope、Parca、Datadog、Splunk 等)几乎都以 Google 的 pprof protobuf 格式作为 Profile 数据的交换/序列化标准[P1][P4][P7]。pprof 格式由 5 个核心实体构成[P1][P5]

protobuf
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
message Profile {
  repeated ValueType sample_type = 1;   // 描述每个Sample.value的语义/单位
  repeated Sample sample = 2;           // 采样集合
  repeated Mapping mapping = 3;         // 地址范围→二进制/库的映射
  repeated Location location = 4;       // 采样引用的位置(程序计数器)
  repeated Function function = 5;       // 位置引用的函数
  repeated string string_table = 6;     // 字符串去重表(index 0 必为 "")
  int64 time_nanos = 9;                 // 采集时间(UTC 纳秒)
  int64 duration_nanos = 10;            // profile持续时间
}

message Sample {
  repeated uint64 location_id = 1;  // 调用栈,叶子在location_id[0](叶优先序)
  repeated int64 value = 2;         // 每个值对应sample_type的一项
  repeated Label label = 3;         // 附加上下文
}

5 个实体通过引用去重构成层级关系:

mermaid
flowchart TD
    P["Profile<br/>顶层容器"]
    S["Sample<br/>调用栈+值+标签"]
    L["Location<br/>程序计数器"]
    M["Mapping<br/>二进制映射"]
    F["Function<br/>函数符号"]

    P -->|"1 : N"| S
    S -->|"N : N"| L
    L -->|"N : 1"| M
    L -->|"N : 1"| F

    style P fill:#2196F3,color:#fff
    style S fill:#FF9800,color:#fff
    style L fill:#4CAF50,color:#fff
    style M fill:#9C27B0,color:#fff
    style F fill:#795548,color:#fff

图注:Profile 包含多个 Sample,每个 Sample 引用一组 Location(调用栈),Location 通过 Mapping 关联到具体的二进制/共享库,通过 Function 关联到函数符号。string_table 集中存储所有字符串,各字段通过索引引用。

关键设计要点[P1][P5]

  • 关系结构:Sample → Location(N→1)→ Mapping;Location → Function,典型引用去重模型
  • string_table 去重:所有字符串集中存放,字段以索引引用,避免重复存储函数名/文件名
  • 叶子优先序(leaf-first)sample.location_id[0] 是栈顶,构建火焰图时需反转为 root→leaf
  • 磁盘格式:序列化后的 proto 必须 gzip 压缩[P1]

火焰图构建算法

火焰图本质上是一棵调用栈树(call tree),每个节点是一个 callsite,叶子节点累计 self value[P5]

构建步骤

  1. 解析 proto:读取 string_table,为每个 Mapping/Function 创建索引条目
  2. Location 转帧:每个 pprof Location 转为一个 frame(含函数名 + 行号)
  3. 栈反转:pprof 使用叶优先序(leaf-first),构建火焰图时需反转为 root→leaf
  4. 树构建:对每个 Sample 沿反转后的调用链逐层查找/创建子节点,到达叶子后累加 value
  5. 宽度计算:每个节点宽度 = sum(value) / total_value × 画布宽度

具体示例

假设收到 3 个样本(每 10ms 采样一次):

样本调用栈(root → leaf)
Amain → handleRequest → dbQuery50
Bmain → handleRequest → parseJSON30
Cmain → validateToken20

共享前缀 main 的样本 A、B、C 合并到同一根节点;A 和 B 进一步共享 handleRequest 子树,但叶子不同(dbQuery vs parseJSON);C 在 main 下直接分叉到 validateToken。聚合后每个节点的宽度占比等于其 value 占总和的比例。

mermaid
flowchart TD
    R["main<br/>100%"] --> H["handleRequest<br/>80%"]
    R --> V["validateToken<br/>20%"]
    H --> D["dbQuery<br/>50%"]
    H --> P2["parseJSON<br/>30%"]

    style R fill:#1565C0,color:#fff
    style H fill:#FF9800,color:#fff
    style V fill:#E65100,color:#fff
    style D fill:#4CAF50,color:#fff
    style P2 fill:#9C27B0,color:#fff

图注:火焰图本质上是这棵调用栈树的"横向展开"——从下到上展示调用深度,每个节点的宽度与其 value 占比成正比。颜色通常随机分配暖色调以便区分相邻帧,颜色不编码任何性能信息

统计学基础

火焰图的有效性建立在采样定理之上:当采样次数足够多时(通常 > 1000 次),函数在火焰图中的宽度占比近似等于其真实 CPU 时间占比。以 10ms 间隔采样 60 秒为例,产生 6000 个样本,足以覆盖大多数热点函数。样本量不足时,低频函数可能被遗漏(称为"采样盲区"),可通过降低采样间隔或延长采集时间缓解。

Stacktrace 压缩与去重

Profile 数据的存储优化主要围绕调用栈和符号信息展开:

  • 符号去重:函数名/文件名通过 string_table 或独立符号库去重,Pyroscope 2.0 报告符号存储占用降低最高 95%[P6][P9]
  • 列式编码:将 stacktrace 当作列存储,重复值用 run-length encoding[P4][P8]
  • Gorilla XOR 编码:时间戳和数值列采用 Gorilla 论文的 XOR 压缩[P8]
  • Sample 聚合:相同调用栈 + 相同 label 的原始样本合并,value 逐元素相加[P7]

Pyroscope(Grafana Pyroscope)

项目演进与 Phlare 合并历史

Grafana Pyroscope 由两个开源连续剖析项目合并而成[P2]:Pyroscope(2021 年创立)和 Phlare(2022 年 Grafana Labs 推出)。2023 年 3 月:Grafana Labs 收购 Pyroscope 公司,Phlare 与 Pyroscope 合并,统称 Grafana Pyroscope[P2][P6]

V1 存储(TSDB + Parquet + SymDB)

V1 采用三层架构[P9]

text
1
Ingestion → Head(内存) → Local Blocks(磁盘) → Object Storage(不可变块)

核心组件[P9]

  • Head:内存写缓冲,包含 profileStore(profile 切片缓冲)、symdb.SymDB(符号数据库,对函数/位置做去重)、profilesIndex(基于 TSDB 的倒排索引)
  • 不可变 Block 结构
text
1
2
3
4
5
<ULID>/
├── meta.json        # 块元数据
├── index.tsdb       # TSDB 倒排索引(profile series)
├── profiles.parquet # 合并的 Parquet profile 数据
└── symbols.symdb/   # 符号数据库文件

下图展示 V1 的写入流水线:

mermaid
flowchart TD
    I[Ingestion] --> H[Head<br/>内存写缓冲]
    H --> L[Local Blocks<br/>本地磁盘]
    L --> O[Object Storage<br/>不可变块]
    H -.-> S[SymDB<br/>符号数据库]
    style I fill:#2196F3,color:#fff
    style H fill:#2196F3,color:#fff
    style L fill:#2196F3,color:#fff
    style O fill:#2196F3,color:#fff
    style S fill:#9C27B0,color:#fff

图注:V1 写入路径为三级流水:数据先进入 Head 内存缓冲(同时写入 SymDB 进行符号去重),再写入本地磁盘的 Local Blocks,最终上传到对象存储形成不可变 Block。每次写入涉及 3 份数据(profile、索引、符号),写复制开销较大。

V2 存储(Metastore + Segments)—— 2026 年 4 月发布

Pyroscope 2.0 是彻底重构,核心目标:高写入吞吐、低成本存储、可扩展查询[P10]最大变化:数据直接写对象存储,ingester 无需本地磁盘;读写路径解耦[P10][P6]

架构组件[P10]

  • distributor:无状态、无磁盘、水平扩展;将 profile 路由到 segment-writer 以实现同应用 profile 共置(co-locate)
  • segment-writer:无状态、无磁盘,将 profile 累积为小 block(segment)写入对象存储
  • metastore唯一有状态组件,用 Raft 共识复制,BoltDB 持久化,维护 block/shard 元数据索引[P10][P11]
  • compaction-worker:无状态,将小 segment 合并为大 block,首次 compaction 中位时间 < 15 秒[P10]
  • query-frontend / query-backend:无状态,查询执行表示为图(DAG)[P10]
mermaid
flowchart TD
    D[Distributor<br/>无状态路由] --> S[Segment-Writer<br/>写入小 block]
    S --> O[Object Storage]
    M[Metastore<br/>Raft + BoltDB] -.-> O
    C[Compaction-Worker<br/>小段合并] --> O
    style D fill:#4CAF50,color:#fff
    style S fill:#4CAF50,color:#fff
    style O fill:#4CAF50,color:#fff
    style M fill:#9C27B0,color:#fff
    style C fill:#FF9800,color:#fff

图注:V2 架构的 Distributor 和 Segment-Writer 均为无状态无磁盘的组件,数据直接写入对象存储。Metastore 是唯一有状态组件(Raft 复制 + BoltDB 持久化),负责维护 block 元数据索引。Compaction-Worker 将小 segment 合并为更大的 block,降低查询时需扫描的文件数。

V1 vs V2 对比[P9][P11]

维度V1 (TSDB+Parquet)V2 (Metastore+Segments)
Block 格式不可变块(合并 Parquet)分段存储 + Metastore 协调
符号管理SymDB 独立去重符号共置进 segments
写放大3 份(profile+索引+符号)1 份(移除写路径复制)
有状态组件Head(内存+TSDB 索引)Metastore(Raft+BoltDB)
无状态组件distributor/writer/compactor/query
查询路径倒排索引 + Parquet 扫描DAG 执行图,并行扫描
扩展瓶颈Head 内存 + 本地磁盘Metastore Raft 组(可分片)
首次 compaction分钟级< 15 秒

成本与规模成效[P6]

  • 移除写路径复制:V1 每 profile 写 3 份,V2 仅写 1 份
  • 符号共置去重:符号存储占用最高降 95%(约 20x 缩减)
  • 已处理 19.5 PB 剖析数据(自 2025 年 4 月起在 Grafana Cloud Profiles 生产运行)

Parca 与 FrostDB

Parca 整体架构

Parca 包含两大组件[P3]Parca(Server) 存储剖析数据、Parca Agent 基于 eBPF 的全系统剖析器。Series 由唯一 label 组合标识,查询使用类 Prometheus 的 label-selector,UI 采用 Icicle Graph(冰柱图)[P3][P4]

存储架构:Meta Store + Sample Store

Meta Store(基于嵌入式 KV 库 badger)[P4]:三大实体——Mappings(表示栈迹来源的目标文件)、Functions(规范化函数)、Locations(栈迹单步,引用 mapping + function + 具体行号)。写入时先在 meta store 归一化并查重元数据。

Sample Store(FrostDB):列式存储样本[P4]。逻辑表为 labels.*, timestamp, stacktrace, value,物理上利用列重复性压缩。

FrostDB:为剖析而生的列式数据库

FrostDB(原名 ArcticDB,2022 年因商标冲突更名[P15])是 Polar Signals 为 Parca 专门构建的嵌入式列式数据库[P12][P15]

设计动机[P8][P15]:Parca 早期使用 Prometheus 风格时序方案,但无法支持"给 stacktrace 附加任意 label(如 customer="X"、分布式 trace ID/span ID)并按 label 过滤聚合"的需求——这是无界基数(unbounded cardinality)问题。

核心设计选择[P12][P15]

  • 列式布局:存储用 Apache Parquet(高效编码),查询用 Apache Arrow(向量化执行基础)
  • 动态列(Dynamic Columns):在首次见到 sub-column 时动态创建列——这是 FrostDB 相对其他列式库的关键差异
  • 不可变 + 全局排序:只能写不能改,按 schema 列全局排序,无需额外索引即可二分查找
  • 快照隔离:弱一致性,无 read-after-write

为何自建而非用现成[P15]:需要 Go 可嵌入式(单静态二进制),且需要动态列能力——唯一满足的开源库是 InfluxDB IOx,但当时未就绪。

FrostDB 的表结构设计如下:

mermaid
flowchart TD
    L[labels.*<br/>动态列<br/>首次见到时创建] --> T[不可变 + 全局排序<br/>二分查找]
    TS[timestamp<br/>Gorilla XOR 编码] --> T
    ST[stacktrace<br/>列式存储<br/>Run-Length 编码] --> T
    V[value<br/>数值列] --> T
    style T fill:#4CAF50,color:#fff
    style L fill:#FF9800,color:#fff
    style TS fill:#2196F3,color:#fff
    style ST fill:#2196F3,color:#fff
    style V fill:#2196F3,color:#fff

图注:FrostDB 的逻辑表包含四类列:labels.* 为动态列,首次见到新 label 时自动创建新列,解决了无界基数问题;timestamp 采用 Gorilla XOR 编码压缩时间戳;stacktrace 以列式布局存储并应用 Run-Length 编码;value 为数值列。整张表不可变且按所有列全局排序,查询时无需额外索引即可二分定位。

Parca Agent 的 eBPF 采集机制

Parca Agent 是一个采样剖析器,每逻辑 CPU 19 Hz 采样栈迹[P16]。19 是质数,避免与机器上其他周期性事件碰撞。

eBPF 技术细节[P16]

  • 使用 BPF CO-RE(Compile Once – Run Everywhere) + libbpf,预编译所有 BPF 程序并静态嵌入二进制
  • 采样通过将 BPF 程序 attach 到 perf_event,具体是 PERF_COUNT_SW_CPU_CLOCK 事件
  • BPF maps 通信:Stack traces map(栈迹 ID → 内存地址数组)+ Counts map((PID, user-space stack ID, kernel-space stack ID) 三元组 → 观测次数)
  • 每 10 秒读取所有数据并清空 map 重置

符号化(Symbolization)[P16]

  • 内核符号:Agent 立即符号化,读取 /proc/kallsyms
  • 应用符号:含 debug symbol 的二进制,提取符号上传到 server,由 server 在读时(read time)符号化
  • 解释型/JIT 语言(Ruby/Node.js/Python/JVM):必须在 agent 内直接解析符号

eBPF 采集完整流程如下图所示:

mermaid
sequenceDiagram
    participant K as Linux Kernel
    participant B as BPF Program
    participant M as BPF Maps
    participant A as Parca Agent
    participant S as Parca Server

    K->>B: perf_event 触发(19Hz)
    B->>M: 写入 stack traces
    B->>M: 写入 counts 映射
    Note over M: PID + stack IDs to count
    A->>M: 每10s 读取并清空
    A->>A: 符号化内核符号<br/>(/proc/kallsyms)
    A->>S: 上传 .pprof
    S->>S: 符号化应用符号<br/>(debug info)

图注:Kernel 以 19Hz 频率触发 perf_event,BPF 程序采样当前 CPU 的调用栈并写入 BPF Maps(包括 Stack traces map 和 Counts map)。Agent 每 10 秒从 BPF Maps 读取数据后清空,先立即符号化内核符号(/proc/kallsyms),再将原始样本上传到 Server。Server 在查询时对应用符号进行二次符号化(基于 debug info)。解释型/JIT 语言的符号化需在 Agent 内完成。


商业 Profiling 方案

Datadog Continuous Profiler

架构[P7]:多个独立 profiler(CPU/wall time/exceptions/lock contention/allocations),每个有 sampler 和 provider。aggregator 聚合各 profiler 样本,exporter 将样本序列化为 .pprof 文件,通过 Datadog Agent 上传。序列化为 Google .pprof 格式的代码用 Rust 编写[P7]

样本去重[P7]:当 label 和调用栈相同时,exporter 将 value 相加合并为单一样本——避免调用栈与 label 重复。

Profile 与 Trace 关联[P7]:profiler 给每个 .pprof 附加元数据(process ID、host name、runtime ID)。tracer 告知 profiler 哪个 runtime ID 映射 AppDomain → service name,后端据此找到与 trace/span 相关的 profile。

Splunk AlwaysOn Profiling

基于 Splunk OpenTelemetry Collector(v0.44.0+)采集[P17]:配置 profiling pipeline(OTLP gRPC receiver + Splunk HEC exporter)[P17]

核心特点

  • OTel 原生集成:通过统一 collector 同时采集 trace、metric 和 profile,三种信号共享同一 agent 和管道
  • 语言支持:Java(JFR)、Python(cProfile)、.NET(ETW)、Node.js(CPU profiler)、Go(pprof)
  • 存储后端:profile 数据写入 Splunk Indexer,与 trace/metric 分库但统一查询
  • 查询关联:SPL(Search Processing Language)可跨信号关联——“找到 CPU 热点函数 → 关联对应 trace → 查看慢请求根因”

与 Datadog 的关键差异:Datadog 用私有 SDK + Rust 序列化器构建定制管道;Splunk 选择全量拥抱 OTel 标准,牺牲部分定制能力换取生态兼容性。


Profiling 存储对比

存储格式对比

项目存储后端列式格式索引符号存储
Pyroscope V1TSDB + Parquet + SymDBParquet row groupTSDB 倒排索引SymDB 去重[P9]
Pyroscope V2对象存储 + Metastore(Raft/BoltDB)自定义列式 segment自定义内存 index writer符号共置进 segment[P10][P11]
Parcabadger(meta) + FrostDB(sample)Parquet(Arrow 查询)全局排序 + 动态列meta store 去重[P4][P12]
Datadog托管后端.pprof 上传runtime ID 关联[P7]

采集机制对比

项目采集方式采样率Profile 类型开销
Parca AgenteBPF (perf_event, CO-RE/libbpf)19 Hz/CPUCPU(用户+内核栈)~1-2% CPU[P13]
Pyroscope SDK语言级 profiler(pprof/JFR)可配CPU/heap/goroutine/mutex/block/alloc~1-3% CPU[P13]
Datadog运行时 API(CLR/JFR/ETW)默认 60s 上传周期CPU/wall/exceptions/lock/alloc/heap~1-2% CPU[P13]

来源

Profiling 来源

[P1] [官方] Google pprof profile.proto: https://raw.githubusercontent.com/google/pprof/main/proto/profile.proto

[P2] [官方] Grafana Pyroscope 项目介绍: https://grafana.com/oss/pyroscope/

[P3] [官方] Parca 官方 Overview 文档: https://www.parca.dev/docs/overview/

[P4] [官方] Parca 官方 Storage 文档: https://www.parca.dev/docs/storage

[P5] [官方] Perfetto pprof Support 设计文档: https://perfetto.dev/docs/design-docs/pprof-support

[P6] [二级资料] InfoQ: Grafana’s Pyroscope 2.0: https://www.infoq.com/news/2026/05/pyroscope-2-profiling/

[P7] [官方] Datadog .NET Continuous Profiler Under the hood: https://www.datadoghq.com/blog/engineering/dotnet-continuous-profiler/

[P8] [官方] Polar Signals: Introducing arcticDB (FrostDB): https://www.polarsignals.com/blog/posts/2022/05/04/introducing-arcticdb/

[P9] [二级资料] deepwiki: Pyroscope Storage System: https://deepwiki.com/grafana/pyroscope/2.4-storage-system

[P10] [官方] About the Pyroscope v2 architecture: https://grafana.com/docs/pyroscope/next/reference-pyroscope-v2-architecture/about-pyroscope-v2-architecture/

[P11] [二级资料] deepwiki: Storage v2 (Metastore + Segments): https://deepwiki.com/grafana/pyroscope/2.4.2-storage-v2-(metastore-+-segments)

[P12] [官方] FrostDB pkg.go.dev README: https://pkg.go.dev/github.com/polarsignals/frostdb

[P13] [二级资料] OneUptime: How to Build Continuous Profiling Setup: https://oneuptime.com/blog/post/2026-01-30-continuous-profiling-setup/view

[P15] [官方] Polar Signals: Introducing arcticDB (设计动机): https://www.polarsignals.com/blog/posts/2022/05/04/introducing-arcticdb/

[P16] [官方] Parca 官方 Agent Design 文档: https://www.parca.dev/docs/parca-agent-design/

[P17] [官方] Splunk AlwaysOn Profiling 配置文档: https://help.splunk.com/en/splunk-observability-cloud/monitor-application-performance/alwayson-profiling/troubleshoot-alwayson-profiling