土法炼钢 · 系统与基础设施

【可观测性工程】数据模型:时间序列、日志、Span、Profile 的内部表达

文章导航

分类入口
architectureobservability
标签入口
#data-model#tsdb#prometheus#loki#tempo#jaeger#pprof#flamegraph#compression#gorilla#zstd#cost-model

目录

数据模型:时间序列、日志、Span、Profile 的内部表达

你在 Grafana 上点开一张 Dashboard,看到的是一条平滑的 p99 延迟曲线、一个柱状的 QPS 图、一张按 method 分组的成功率面板。你鼠标悬停在曲线上的一个异常尖峰,点进去——Tempo 或 Jaeger 在几百毫秒内吐出一条完整的分布式调用链。你发现 redis_get_cart Span 花了 850ms,点击 Related Logs——Loki 在几秒内把同 trace_id 的十几条日志全拉了出来。你切到 Pyroscope——一张火焰图在几百毫秒内渲染完成,红色方块精确指向 runtime.mallocgc 占用的 CPU 比例。

这整个过程行云流水——但背后是五种完全不同的数据模型、四种存储引擎、三种索引策略和两种压缩算法在同时工作。它们之所以能”行云流水”,是因为每一层的数据模型都被设计成了与它的查询模式精确匹配——而你之所以在每个月末收到一张可观测性账单,也是因为你对这些数据模型的理解和它们的设计假设之间存在着偏差。

本文拆解 Metrics、Logs、Traces、Profiles、Events 五大支柱在磁盘和内存中的内部表达。这不是”这些工具各有什么参数”的配置指南——那是官方文档覆盖的事。本文试图回答:当你把一条数据交给 Prometheus / Loki / Tempo / Pyroscope 之后,它经历了怎样的内部转换?为什么这些转换决定了存储成本和查询延迟?以及理解了这些之后,你在选型和设计埋点策略时应该做出什么不同的决策。

五大支柱的数据模型对比

一、三种典型查询,三种数据模型

排障场景里最常见的三类查询,背后对应三种完全不同的存储假设:

查询 典型语句 数据模型 索引策略
看 p99 延迟 24 小时趋势 PromQL histogram_quantile(0.99, ...) 时间序列 (labels, timestamp) → value label 倒排 → series ID → chunk
order_id=xxx 的完整链路 LogQL {service="order"} \| json \| order_id="xxx" 日志流 (labels, timestamp) → line 仅 label 索引;正文扫描
某 Pod 过去 5 分钟 CPU 火焰图 Profile {service="checkout"} 时间范围 (labels, timestamp, stack[]) → value 标签分块;栈符号去重

核心结论:存储结构和索引策略必须匹配查询模式。用 ES 全索引存 Trace、用 Loki 当高基数精确查找引擎、把 request_id 打进 Prometheus label——都是在用错误的数据模型回答正确的问题。


二、时序数据模型

时序数据是可观测性栈中最成熟的一层。从 RRDtool(1999)到 Prometheus(2015),核心抽象不变:metric_name + labels → (timestamp, value) 序列。变化的是索引结构、压缩算法和分布式架构——这三个维度决定 series 上限、查询延迟和磁盘放大系数。

关于 Gorilla 压缩、WAL 重放、VictoriaMetrics mergeset 的源码级细节,见本系列 时序数据库内核。本节聚焦数据模型本身以及 Prometheus TSDB block 的字段级结构

2.1 Prometheus 的数据模型

Prometheus 的一条样本在文本 exposition 格式下形如:

http_requests_total{method="GET", endpoint="/api/order", status="200"} 104723 @1718000000

整串 http_requests_total{...} 唯一标识一条时间序列(time series)。每条 series 是有序的 (timestamp_ms, float64) 列表。scrape interval(通常 15s 或 30s)决定采样密度。

内存与磁盘上的路径:

flowchart LR
  A[Scrape 样本] --> B[WAL segment]
  B --> C[Head Block memSeries]
  C --> D[关闭的 chunk mmap]
  D --> E[Compaction]
  E --> F[Persistent Block]

WAL(Write-Ahead Log):所有新样本先追加到 128 MB 的 segment 文件,crash 后从 checkpoint 重放。WAL 不做压缩,职责是 durability。

Head Block:活跃 series 对应 memSeries,含 open head chunk(默认 120 样本后关闭)和 label set。每个 memSeries 约 3–5 KB——这是 Prometheus OOM 的首要来源。

Persistent Block:时间窗口内 head chunk 全部关闭后,compaction 合并为不可变 block 目录。

Prometheus TSDB Block 内部结构

2.2 TSDB Block 字段级结构

一个 TSDB block 是 ULID 命名的目录。与 Loki chunk、Tempo block 对照时,以下字段是”同一抽象在不同后端上的投影”:

字段 / 文件 类型与含义 查询用途 压缩
meta.json JSON:minTimemaxTimestats.numSeriesstats.numSamplescompaction.level 块选择、compaction 层级
index 倒排:label pair → posting list(series_ref) PromQL label matcher 无(块级)
chunks/NNNNNN 变长 bit-stream:Gorilla 编码的 timestamp + value 按 series_ref 读样本 Gorilla 10:1–20:1
tombstones 删除区间 (series_ref, mint, maxt) 查询时跳过已删样本 极小
series ref uint64:块内 series 局部 ID 连接 index 与 chunks

series_ref 编码(Prometheus tsdb/index 约定):高 16 bit 为 chunk file 编号,低 48 bit 为文件内 offset。查询 {method="GET"} 时:index 取 posting list → 对每个 ref 定位 chunk → 解码 Gorilla stream → 时间范围过滤 → 聚合。

meta.json 示例(字段名与 Prometheus v2.45+ 一致,数值为说明性示例):

{
  "ulid": "01J2K3M4N5P6Q7R8S9T0",
  "minTime": 1718000000000,
  "maxTime": 1718007200000,
  "stats": {
    "numSamples": 12453021,
    "numSeries": 89234,
    "numChunks": 178468
  },
  "compaction": {
    "level": 2,
    "sources": ["01J2K...", "01J2L..."]
  }
}

2.3 Chunk 内部:Gorilla 编码要点

Gorilla(Pelkonen et al., VLDB 2015)利用两个假设:时间戳等间距(delta-of-delta 常为 0)、相邻值变化小(XOR 前导零多)。第一个时间戳 64 bit 完整存储;后续 timestamp 平均 ~1.5 bit;counter 类指标 value 平均 1–2 byte,剧烈波动指标 4–6 byte。

不重复推导——完整数学与 XOR 位布局见 07-tsdb-internals 第二节。此处只给工程口径:15s scrape、一天 5760 点、未压缩 \(5760 \times 16\) B ≈ 92 KB/series/day;Gorilla 后约 4–8 KB/series/day。

2.4 VictoriaMetrics 与 Prometheus 模型差异

VictoriaMetrics 兼容 Prometheus 文本模型,存储层差异:

维度 Prometheus TSDB VictoriaMetrics
Series 标识 文本 label set + 块内 ref 整数 TSID
索引 每 block 倒排 index 文件 mergeset 全局索引
分区 2h head + compaction 层级 按月分区 + 后台 merge
高基数 label 字符串索引占内存 TSID 映射,同等内存更多 series

选型含义:label 治理良好时两者成本接近;label churn 严重时 VictoriaMetrics 缓冲期更长,但不替代治理。

2.5 Metrics 存储成本公式(假设模型)

以下公式用于数量级估算,参数需按你的环境代入。不引用任何云厂商单价。

变量定义

未压缩样本量(bytes):

\[ V_{\text{raw}} = N_s \times \frac{R \times 86400}{\Delta t} \times 16 \]

压缩后 chunk 体积

\[ V_{\text{chunk}} = N_s \times \frac{R \times 86400}{\Delta t} \times b \]

含 index 的磁盘占用

\[ V_{\text{disk}} \approx f_i \times V_{\text{chunk}} \]

内存 head 占用(经验):

\[ M_{\text{head}} \approx N_s \times 4\,\text{KB} \]

数值示例(假设模型)

基数与成本的关系:成本对 \(N_s\) 线性,对 label 组合数线性。把 user_id 打进 label 使 \(N_s\)\(10^5\) 升到 \(10^7\),磁盘与内存同比例恶化——这是 Metrics 数据模型对基数最敏感的原因。


三、日志数据模型

时序数据的挑战是压缩;日志数据的挑战是在未知查询模式与有限索引之间折中

3.1 日志的通用模型

{"timestamp":"2024-06-11T03:14:22.423Z","level":"ERROR","service":"checkout",
 "message":"redis: connection pool exhausted",
 "trace_id":"0af7651916cd43dd8448eb211c80319c","duration_ms":843}

特征:level/service 低基数、高过滤频率;message 高熵;trace_id 高基数、几乎只做精确匹配。

3.2 Elasticsearch:全字段倒排

Lucene segment:词项 → (doc_id, tf, positions)。任意字段可搜,存储放大 1.5–3×。高基数 trace_id 若建索引,词典膨胀极快——mapping 中应 "index": false_source 精确查。

3.3 Loki:label 索引 + 正文扫描

Log stream:固定 label set 下的有序日志行。{service="checkout", level="ERROR"} 标识一流。

Chunk 是 Loki 的最小存储单元(与 TSDB block、Tempo block 对照见第六节)。写入路径:Ingester 缓冲 → 切 chunk(目标压缩后 1–2 MB)→ 上传对象存储;index 存 (labels fingerprint, start, end) → chunk ref

Loki 2.9+ Bloom Filter:chunk 级 n-gram bloom,低频 token 查询可跳过大量 chunk。对 "error" 这类高频词加速有限。

3.4 ClickHouse:列存折中

MergeTree:列独立压缩;主键 (service, timestamp) 范围剪枝;跳数索引 bloom_filter/minmax。适合结构化聚合查询,不适合任意全文 ad-hoc。

3.5 日志存储成本公式(假设模型)

变量

原始日增量(GB/天):

\[ V_{\text{day,raw}} = \frac{Q \times 86400 \times L}{10^9} \]

压缩后对象存储

\[ V_{\text{day,store}} = \frac{V_{\text{day,raw}}}{\sigma} \times f_e \]

总保留

\[ V_{\text{retain}} = V_{\text{day,store}} \times R \]

示例(假设模型)\(Q=50000\) 行/s,\(L=500\) B,\(\sigma=5\),Loki \(f_e=1.1\)\(R=30\)

同一流量若 \(f_e=2.5\)(ES),30 天约 32 TB——数量级差异来自索引策略,不是 ZSTD 本身。


四、Trace 数据模型

Trace 查询双峰:trace_id 精确查找(高频)与 attribute 搜索(低频、排障关键)。Jaeger 与 Tempo 分别优化两端。

4.1 Span 内部结构(OTLP)

字段 类型 典型大小 索引价值
trace_id 16 bytes 固定 分区键 / 精确查
span_id 8 bytes 固定 树结构
parent_span_id 8 bytes 固定 树结构
name string 20–80 B 中基数
kind enum 1 B 低基数
start/end_time_unix_nano fixed64×2 16 B 范围查
attributes[] KeyValue 200–2000 B 高基数风险
events[] 可变 0–几 KB 通常不索引
resource KeyValue 100–500 B 低基数

典型 HTTP SERVER Span protobuf 序列化约 500–800 B;含 8–10 个 attribute 约 1–2 KB。每个请求 10–20 个 Span 时,Trace 写入量远超 Metrics。

4.2 Jaeger:全索引路线

Span 写入 ES/Cassandra:字段映射为 ES document,tags 建倒排。可搜 http.status_code=500 AND service.name=checkout,代价是存储放大与分片压力。

4.3 Tempo:无 attribute 索引

Span 按 trace_id 分组 → Ingester WAL → flush 为 Parquet block → 对象存储。查询 trace_id=X:hash ring 定位 ingester 或 block → 读 parquet row group。不维护 attribute 倒排

TraceQL 搜索(duration > 500ms)靠并行 block 扫描 + predicate pushdown,秒级而非毫秒级——设计取舍,非实现缺陷。

4.4 Trace 存储成本公式(假设模型)

变量

日 Span 数

\[ N_{\text{span/day}} = Q \times 86400 \times S \times (p_h + p_t) \]

日存储(Tempo 类)

\[ V_{\text{tempo/day}} = \frac{N_{\text{span/day}} \times B}{\sigma_t \times 10^9} \times f_t \]

示例(假设模型)\(Q=5000\)\(S=12\)\(B=1200\)\(p_h=0.01\)\(p_t=0.005\)(错误+慢请求额外保留),\(\sigma_t=3\)\(f_t=1.1\)

\(f_j=4\)(Jaeger+ES 全索引),同参数约 136 GB/天——4× 差距来自索引,不是 Span 大小。

全量(\(p_h=1\)):\(N_{\text{span/day}} \approx 5.18 \times 10^9\),Tempo 约 2.3 TB/天——说明不采样不可规模化


五、Profile 数据模型

Profile 单条体积大、频率低。核心是 Sample → Location → Function 映射与栈合并

5.1 pprof 格式

符号去重:数万 sample 共享数千 Function,.gz 通常几十 KB–几百 KB。

5.2 Pyroscope / 火焰图

数据模型:(timestamp, labels, stacktrace[], value)。火焰图 = 按栈深度合并 sample 计数。查询 = 时间范围内扫描 block + 内存合并,不依赖复杂倒排。

5.3 Profile 成本公式(假设模型)

\[ V_{\text{profile/day}} = \frac{N_{\text{svc}} \times F \times 1440 \times P}{10^6}\ \text{GB} \]

示例:200 服务,1 次/分钟,300 KB → 约 83 GB/天 未压缩语义;相对 Logs/Traces 通常 < 5% 账单。


六、TSDB Block vs Loki Chunk vs Tempo Block:字段级对照

这是本文的核心对照表。三者在工程上都叫 “block/chunk”,但字段语义不同。

概念 Prometheus TSDB Block Loki Chunk Tempo Block (Parquet)
唯一 ID ULID 目录名 chunk_id + fingerprint ULID + tenant
时间边界 meta.minTime/maxTime from/through ns start/end unix
分区键 无(块内多 series) label fingerprint trace_id hash
主索引 index 倒排 label→ref boltdb/tsdb index: labels→ref 仅 trace_id + 时间;可选 bloom
数据文件 chunks/* Gorilla .gz 日志行序列 .parquet 列存 Span
单块典型大小 压缩后数百 MB–几 GB 1–2 MB 压缩目标 100 MB–1 GB
块内记录单位 (series_ref, t, v) log line + ts Span row
attribute 索引 label 仅(metric 名+labels) 无(正文) 无(TraceQL 扫描)
删除语义 tombstones 区间 retention 整 chunk 删 compaction 合并丢弃
对象存储 可选(Thanos/Mimir) 默认 S3/GCS 默认 S3/GCS
精确键查询 series + 时间范围 labels + 时间 + line filter trace_id O(1) 定位
** ad-hoc 属性搜索** PromQL label LogQL \|= 扫描 TraceQL 块扫描
flowchart TB
  subgraph prom [Prometheus TSDB Block]
    PI[index: label→series_ref]
    PC[chunks: Gorilla samples]
    PI --> PC
  end
  subgraph loki [Loki Chunk]
    LI[index: fingerprint→ref]
    LC[chunk body: compressed lines]
    LI --> LC
  end
  subgraph tempo [Tempo Block]
    TB[footer: min/max id time]
    TP[parquet: spans by trace_id]
    TB --> TP
  end

Loki chunk 字段级(Ingester 内存 → 持久化):

字段 含义
fingerprint label set 哈希
from / through chunk 时间边界
metric 内部流标识
encoding 压缩编码(gzip/snappy/zstd)
head / tail 块内首尾行(调试)
synced 是否已 flush 到对象存储

Tempo block 字段级(Parquet schema 简化):

列 / 元数据 含义
trace_id 16 byte 十六进制
span_id 8 byte
parent_span_id 8 byte
name operation name
start/end unix nano
duration 派生或存储
status_code OK/ERROR
attributes map 或 JSON 列
resource service.name 等
footer bloom / 统计 / 版本

七、Events 数据模型

Events 查询模式:某时间点前后发生了什么。CloudEvents 最小模型:id, source, type, time, data。存储归宿通常是 Loki/Kafka,而非 Prometheus——Prometheus 无法保留离散事件原文。

K8s Event 经 kube-event-exporter 进 Loki 是常见模式;强行 event→metric 会丢失单次事件的上下文。


八、五大支柱访问模式与成本占比

维度 Metrics Logs Traces Profiles Events
写吞吐 极高 极高 中–高
查询模式 聚合 过滤+扫描 trace_id + 属性搜索 火焰图 时间+type
索引需求 label 倒排 后端相关 Jaeger 强 / Tempo 弱 几乎无 类日志
压缩率 极高 中–高
基数敏感度 极高 中–高 中/低
典型成本占比* 10–20% 40–60% 20–35% <5% 并入 Logs

*占比为假设模型下的工程经验区间,非实测账单;见 存储与成本

80/20 法则:Logs + Traces 占 85–95% 存储;成本控制主战场是日志索引策略Trace 采样


2.6 Head Block 内存结构与查询路径

Head Block 是 Prometheus 实时查询的数据源。理解 memSeries 结构有助于解释为什么”series 数量”比”样本数量”更决定内存。

memSeries 主要字段(概念模型,对应 tsdb/head.go):

字段 含义 内存影响
lset 完整 label set 与 label 数量、字符串长度正相关
chunks 已关闭 chunk 列表 + head chunk head chunk 满前持续增长
mmappedChunks mmap 到磁盘的 chunk 引用 不占堆内存
staleNaN 标记 series 是否 stale 极小

一次典型 PromQL 查询 {service="checkout", method="GET"} 的路径:

  1. PostingsForMatchers:在 index 上求 service=checkoutmethod=GET 的 posting list
  2. 对每个 series_ref,加载 head chunk 或 mmap chunk
  3. Gorilla 解码时间范围内的样本
  4. 引擎聚合(rate、sum、histogram_quantile)

Churn 的非线性成本:若每秒创建 1000 个新 label 组合(例如把订单 ID 打进 label),即使总样本 QPS 不变,memSeries 分配速率也会拖垮 GC。这比”steady 800k series”更危险——因为除了 3–5 KB/series 的常驻内存,还有频繁的 map 插入、index 更新、WAL Series record。

2.7 Exemplars 与 Native Histograms 的数据模型扩展

OpenMetrics Exemplars 在 TSDB 中作为 (series_ref, timestamp, value, trace_id, span_id) 附加存储,不进入主 Gorilla chunk——单独 WAL record 类型与 head exemplar storage。查询 histogram_quantile 时可选附带 exemplar 用于跳转 Trace。Exemplar 基数受 trace 采样率约束;若对每个 histogram bucket 都挂 exemplar,head 内存会显著上升。

Native Histograms(Prometheus 2.40+ 可选)改变 value 编码:从单 float64 变为 bucket span + counter 结构。chunk 内 encoding 类型字段区分 XOR float 与 native histogram schema。选型含义:native histogram 减少 _bucket 系列爆炸,但 chunk 体积与查询路径更复杂——属于同一 TSDB block 模型上的 encoding 扩展,分区键仍是 label set。

2.8 Mimir / Thanos 对 Block 模型的扩展

单机 Prometheus block 通过 sidecar 上传对象存储(Thanos)或由 Mimir ingester 写入共享块存储。逻辑模型不变:index + chunks + meta.json。差异在查询层 fan-out:Querier 并行读多个 block,Store Gateway 缓存 index header。成本公式中 \(f_i\) 需加上对象存储 GET 与跨 AZ 流量——见 存储与成本


九、工程坑点

9.1 用 ES 存 Trace Span——全字段索引

Jaeger 默认 mapping 为大量 tag 建倒排,50 个 attribute 中 30 个从未被搜索却占 60% 磁盘。缓解:mapping 审查,"index": false 高基数 tag。

9.2 用 Loki 搜高基数字段

| json | user_id="xxx" 触发全 chunk 扫描。7 天 20 TB/天 保留时单次查询可扫数百 GB。缓解:Bloom Filter、缩短 query 窗口、max_entries_limit_per_query、把 trace_id 放 label(低 Cardinality 流)而非正文。

9.3 Prometheus label 失控

动态 URL path 作为 endpoint label → 每次发布 series 暴涨 → OOM。缓解:路径模板化或 metric_relabel_configs

metric_relabel_configs:
  - source_labels: [endpoint]
    regex: '/api/users/[^/]+/orders/[^/]+'
    target_label: endpoint
    replacement: '/api/users/:id/orders/:id'

9.4 用 Metrics 存 Trace 级信息

把每个 trace_id 做成 label 或 high-cardinality gauge——数据模型错配。Trace 应用 Span 模型 + 采样,Metrics 保留 RED 聚合。

9.5 ClickHouse 日志不分区

全表扫描火焰图式查询数十秒。按 (service, toDate(timestamp)) 分区。

9.6 Tempo 当 ES 用

期望毫秒级 user_id 全历史搜索——TraceQL 扫描对象存储,延迟秒级。先 Metrics/Logs 缩小 trace_id,再 Tempo 精确拉取。

9.7 忽略 block/chunk 边界与查询计划

跨过多天 block 的 PromQL 会 merge 大量 index;Loki 跨 chunk 查询放大对象存储 GET。保留期与查询窗口应对齐块边界(天/小时)。

9.8 WAL 与对象存储双份成本

Tempo/Loki ingester WAL + S3 同时占空间;compaction 失败时 WAL 膨胀。监控 compactor lag。


十、落地清单


十一、关键概念回顾


十二、常见误解

“Tempo 不能 attribute 搜索”——能,用 TraceQL,秒级非毫秒级;设计换成本。

“Prometheus 内存只和 series 数有关”——churn 带来非线性 GC;稳态 80 万 series 可能比 churn 高的 40 万更稳。

“JSON 日志只是格式不同”——JSON 有 schema,管道可 tokenize;纯文本每层 regex,CPU 与维护成本成倍。

“块越大越好”——大 block 降低元数据开销但增大 compaction 压力和单次查询 IO;各系统有 sweet spot(Loki 1–2 MB,Prometheus 小时级–天级)。


十三、下一步

理解了存储端内部表达后,工程问题回到管道与 Trace 栈:日志管道Traces 栈与采样


参考资料

  1. T. Pelkonen et al., Gorilla: A Fast, Scalable, In-Memory Time Series Database, VLDB 2015
  2. Prometheus, TSDB Format, v2.45+, https://github.com/prometheus/prometheus/tree/main/tsdb
  3. Grafana Loki, Architecture, https://grafana.com/docs/loki/latest/architecture/
  4. Grafana Tempo, Architecture, https://grafana.com/docs/tempo/latest/architecture/
  5. Jaeger, Storage Backends, https://www.jaegertracing.io/docs/latest/storage/
  6. ClickHouse, MergeTree Engine, https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/mergetree
  7. Google, pprof format, https://github.com/google/pprof
  8. CNCF, CloudEvents Specification, v1.0
  9. VictoriaMetrics, Technical Papers, https://docs.victoriametrics.com/
  10. OpenTelemetry, OTLP protobuf, https://github.com/open-telemetry/opentelemetry-proto
  11. 本系列 时序数据库内核
  12. 本系列 Logs 技术栈对比

上一篇埋点哲学:粒度、采样、基数爆炸与成本模型

下一篇日志管道:Fluent Bit、Vector、Logstash、Cribl 的取舍

读完这篇,下一步读什么

优先读同系列或同问题的下一篇,把单篇消费变成主题集群。

2026-04-22 · architecture / observability

【可观测性工程】时序数据库内核:TSM、TSI、倒排索引与 Gorilla 压缩

深入时序数据库的存储内核:Prometheus TSDB 的 WAL 与块管理、InfluxDB 的 TSM 引擎与 TSI 倒排索引、Gorilla 压缩算法的数学原理、VictoriaMetrics mergeset 架构、ClickHouse MergeTree 作为 metrics 后端,以及国内大厂在 series churn 和 compaction 风暴上踩过的坑。


By .