持续性能分析(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)如何聚合成火焰图(阶段 2)。每个函数帧的宽度与包含该函数的样本占比成正比。最宽的帧——dbQuery 占 50%——就是性能优化目标。这就是持续性能分析将数千次 10ms 间隔采样转化为可操作性能图景的方式。
pprof 数据模型与火焰图构建
pprof profile.proto 核心消息定义
所有主流项目(Pyroscope、Parca、Datadog、Splunk 等)几乎都以 Google 的 pprof protobuf 格式作为 Profile 数据的交换/序列化标准[P1][P4][P7]。pprof 格式由 5 个核心实体构成[P1][P5]:
| |
5 个实体通过引用去重构成层级关系:
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 集中存储所有字符串,各字段通过索引引用。
- 关系结构: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]。
构建步骤
- 解析 proto:读取 string_table,为每个 Mapping/Function 创建索引条目
- Location 转帧:每个 pprof Location 转为一个 frame(含函数名 + 行号)
- 栈反转:pprof 使用叶优先序(leaf-first),构建火焰图时需反转为 root→leaf
- 树构建:对每个 Sample 沿反转后的调用链逐层查找/创建子节点,到达叶子后累加 value
- 宽度计算:每个节点宽度 =
sum(value) / total_value × 画布宽度
具体示例
假设收到 3 个样本(每 10ms 采样一次):
| 样本 | 调用栈(root → leaf) | 值 |
|---|---|---|
| A | main → handleRequest → dbQuery | 50 |
| B | main → handleRequest → parseJSON | 30 |
| C | main → validateToken | 20 |
共享前缀 main 的样本 A、B、C 合并到同一根节点;A 和 B 进一步共享 handleRequest 子树,但叶子不同(dbQuery vs parseJSON);C 在 main 下直接分叉到 validateToken。聚合后每个节点的宽度占比等于其 value 占总和的比例。
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]:
| |
核心组件[P9]:
- Head:内存写缓冲,包含
profileStore(profile 切片缓冲)、symdb.SymDB(符号数据库,对函数/位置做去重)、profilesIndex(基于 TSDB 的倒排索引) - 不可变 Block 结构:
| |
下图展示 V1 的写入流水线:
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]
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 (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)问题。
- 列式布局:存储用 Apache Parquet(高效编码),查询用 Apache Arrow(向量化执行基础)
- 动态列(Dynamic Columns):在首次见到 sub-column 时动态创建列——这是 FrostDB 相对其他列式库的关键差异
- 不可变 + 全局排序:只能写不能改,按 schema 列全局排序,无需额外索引即可二分查找
- 快照隔离:弱一致性,无 read-after-write
为何自建而非用现成[P15]:需要 Go 可嵌入式(单静态二进制),且需要动态列能力——唯一满足的开源库是 InfluxDB IOx,但当时未就绪。
FrostDB 的表结构设计如下:
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 采集完整流程如下图所示:
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 V1 | TSDB + Parquet + SymDB | Parquet row group | TSDB 倒排索引 | SymDB 去重[P9] |
| Pyroscope V2 | 对象存储 + Metastore(Raft/BoltDB) | 自定义列式 segment | 自定义内存 index writer | 符号共置进 segment[P10][P11] |
| Parca | badger(meta) + FrostDB(sample) | Parquet(Arrow 查询) | 全局排序 + 动态列 | meta store 去重[P4][P12] |
| Datadog | 托管后端 | .pprof 上传 | — | runtime ID 关联[P7] |
采集机制对比
| 项目 | 采集方式 | 采样率 | Profile 类型 | 开销 |
|---|---|---|---|---|
| Parca Agent | eBPF (perf_event, CO-RE/libbpf) | 19 Hz/CPU | CPU(用户+内核栈) | ~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