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

【可观测性工程】数据模型:时间序列、日志、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 流量——见 存储与成本


三附、日志索引演进:BoltDB-Shipper 与 TSDB Index

Loki 索引经历两代:boltdb-shipper(每 24h 一个 index table 上传 S3)与 TSDB index(Loki 2.8+,与 Prometheus TSDB index 思路类似)。对 chunk 字段无影响,影响的是 label → chunk ref 的查找延迟与 cardinality 上限。

索引类型 存储 适合规模 注意
boltdb-shipper 本地 bolt + S3 shipper 中小 单 ingester 索引热
tsdb index TSDB 格式 index 文件 需 compactor 维护

无论哪种索引,正文仍在 chunk body——LogQL |= 过滤不会 magically 变成倒排查。

3.6 日志查询执行计划(概念)

查询 {namespace="prod", app="checkout"} |= "timeout" [1h]

  1. Index lookup:1h 内匹配 label 的 chunk 列表(可能数千个)
  2. 并行 fetch chunk from object storage
  3. 解压 → 逐行 regex/line filter
  4. 合并排序 → limit

瓶颈通常在 step 2–3。缩小 label 范围(加 level="ERROR")比优化 regex 更有效。

3.7 结构化日志 schema 设计

推荐固定字段(低基数 label 或 JSON 键):

{
  "timestamp": "ISO8601",
  "level": "INFO|WARN|ERROR",
  "service": "checkout",
  "trace_id": "32hex",
  "span_id": "16hex",
  "message": "human readable",
  "error.type": "optional",
  "duration_ms": 123
}

禁止user_id、完整 URL path 作为 Loki label。需要按 user 查时,走 ES/ClickHouse 或短窗口 + Bloom 加速的 LogQL。


四附、Tempo WAL 与 Ingester 生命周期

Tempo Ingester 在 flush 前将 trace 保留在内存 + 本地 WAL:

sequenceDiagram
  participant D as Distributor
  participant I as Ingester
  participant W as Local WAL
  participant S as Object Storage
  D->>I: Push spans by trace_id
  I->>W: Append WAL records
  I->>I: Complete trace buffer
  I->>S: Flush Parquet block
  I->>W: Truncate WAL

Complete trace 判定:收到 root span 且 idle timeout,或 max block duration 到达。尾部采样在 Collector 层合并 span 后再 push——Ingester 不负责采样决策。

Block footer 含 blockIDtenantIDtotalRecords、可选 bloom filter(vParquet 格式演进见 Tempo 文档)。与 Prometheus block 的 meta.json 类比,但 min/max trace ID 不是 primary key——primary 是 hash(trace_id) 到 ingester ring。

4.5 Jaeger 存储后端数据模型差异

后端 分区键 Span 布局 搜索
Elasticsearch 日期 index + trace_id hash 每 span 一 document 全字段索引
Cassandra trace_id 宽行存 span 列 有限
Badger trace_id prefix KV 值 blob 本地开发

Jaeger ES document 典型字段:traceID, spanID, operationName, startTime, duration, tags, logs, process。每个 tag 键在 mapping 中可能动态映射——动态 mapping 是生产事故来源,应预定义 mapping template。

4.6 SkyWalking Segment 模型(对照 OTLP Span)

SkyWalking 原生协议用 Segment(类似一组 Span)而非纯 OTLP Span。Segment 含 spans[]service, serviceInstance, endpoint。存储进 ES 时按 segment 或 span 扁平化取决于 OAP 版本与配置。与 Tempo block 对照:SkyWalking 默认索引 endpoint 与 latency——介于 Jaeger 全索引与 Tempo 无索引之间。


五附、Parca 与 Pyroscope 存储块

Parca 使用 parquet 存 profile sample,按 (labels, timestamp) 分区——与 Tempo 块模型类似但列是 stack trace ID。Pyroscope 合并后写入对象存储,查询时按时间 + label matcher 选 block,再内存 merge 火焰图。Profile 不需要 trace 级索引——这是 Profile 账单通常最低的原因。


六附、跨信号关联的数据模型

关联键 Metrics Logs Traces Profiles
trace_id Exemplar JSON 字段 主键 可选 link
span_id Exemplar JSON 字段 主键
service.name label label/JSON resource label

Grafana 互跳要求 同一 trace_id 字符串格式(32 hex 小写)在 Logs 与 Traces 一致。数据模型设计阶段应统一,而非在 UI 层 patch。


七附、成本估算 Worksheet(假设模型)

以下三组场景均为推导示例,非某客户真实账单。

场景 A:200 微服务,中等流量

参数
总入口 QPS 50000
Prometheus series 1200000
日志行/s 80000
Span/请求 10
Trace 头部采样 1%

Metrics 15 天磁盘(§2.5 公式):约 150 GB 量级。
Logs 30 天 Loki(§3.5):约 20 TB 量级。
Traces 7 天 Tempo(§4.4):约 150 GB 量级。

结论:Logs 占主导;优先 Loki label 治理与日志采样。

场景 B:高基数事故

某团队将 pod_uid 作为 metric label(K8s 每次重启新 UID):

修复:用 pod 名称(低 churn)或去掉 pod 级 label,Pod 级详情走 Logs/Traces。

场景 C:Jaeger ES vs Tempo

同场景 A Trace 参数,Jaeger \(f_j=4\) vs Tempo \(f_t=1.1\)

差额买运维时间与 ES 集群——不是”谁更好”,是查询 SLA 与预算 trade-off。


八附、OpenTelemetry 统一数据模型与后端映射

OTel 定义 cross-signal 的 ResourceInstrumentation ScopeSignal-specific payload。Collector 处理器可能修改 attribute——存储前须明确 canonical label set。

OTel 概念 Prometheus 映射 Loki 映射 Tempo 映射
Resource.service.name service label stream label resource column
Span.name span name
Log.severity level label
Metric name metric name

Attribute 翻译损失:OTel 允许 nested map;Prometheus 只认 flat label。Collector attributes processor 做 flatten 时可能引入高基数键——在 埋点哲学 层禁止。


九附、更多工程坑点

9.9 Histogram _bucket 爆炸

每个额外 label 维度复制全部 bucket series。le label 有 ~20 个 bucket 时,一个 histogram 变 20+ series。Native histogram 或 recording rule 预聚合可缓解。

9.10 Loki structured_metadata 误用

Loki 3.x structured metadata 允许有限高基数——若把 trace_id 塞 metadata 仍可能拖慢查询。阅读版本文档边界。

9.11 Tempo ingester 内存与 trace 大小

单 trace 上万 span(循环内创建 span)撑爆 ingester buffer。应在 SDK 层限制 inner span。

9.12 对象存储 LIST 成本

Loki/Tempo 大范围查询触发大量 S3 LIST/GET。Compaction 减少小文件数量;查询必须带时间边界。

9.13 副本与 erasure coding

S3 3 副本使 \(V_{\text{retain}}\) 乘以副本因子;与压缩比分开核算。

9.14 Cold query vs hot cache

首次查询 block 延迟高;querier 缓存 hit 后延迟降一个数量级。Dashboard 频繁刷新相同查询——缓存友好; ad-hoc 大扫描——贵且慢。

9.15 Profile 符号表缺失

pprof 只有地址无 symbol——火焰图显示 0x...。部署时保留 debug info 或 sidecar symbolizer。


十附、扩展落地清单

九、工程坑点

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 小时级–天级)。


十四、查询语言与数据模型的耦合

PromQL、LogQL、TraceQL 的能力边界由底层 block/chunk 结构决定——不是语法糖问题。 ### 14.1 PromQL rate/avg - 后端:TSDB chunk 顺序扫描 + index - 要点:聚合友好;高 cardinality 分组贵 理解这一点可以避免「在 Loki 里写 SQL 心态」或「在 Tempo 里期望 ES 速度」。 ### 14.2 PromQL @ modifier - 后端:block 时间边界 - 要点:跨 block 需 merge 理解这一点可以避免「在 Loki 里写 SQL 心态」或「在 Tempo 里期望 ES 速度」。 ### 14.3 LogQL json parser - 后端:chunk 内逐行 - 要点:无 index 加速 理解这一点可以避免「在 Loki 里写 SQL 心态」或「在 Tempo 里期望 ES 速度」。 ### 14.4 LogQL metric queries - 后端:log→metric 规则 - 要点:依赖 label 低基数 理解这一点可以避免「在 Loki 里写 SQL 心态」或「在 Tempo 里期望 ES 速度」。 ### 14.5 TraceQL {duration>1s} - 后端:Parquet 列扫描 - 要点:并发 querier 扫描 block 理解这一点可以避免「在 Loki 里写 SQL 心态」或「在 Tempo 里期望 ES 速度」。 ### 14.6 Jaeger tag query - 后端:ES inverted index - 要点:毫秒级但存储贵 理解这一点可以避免「在 Loki 里写 SQL 心态」或「在 Tempo 里期望 ES 速度」。

十五、数据保留与 Compaction 对模型的影响

保留期不是「删旧文件」这么简单——compaction 改变 block 粒度,影响查询 IO 模式。 ### 15.x Prometheus retention 与 block 形态 - 默认 15 天 retention;compaction level 1/2/3 合并 2h→12h→1d 块。 - 删除 tombstone 后 block 重写;查询 ancient sample 需 --storage.tsdb.retention.time 对齐。 - retention.size 与 time 同时生效——先触达者执行。

15.x Loki retention 与 block 形态

15.x Tempo retention 与 block 形态

十六、从 Dapper 到 OTLP:Trace 模型标准化

Google Dapper(2010)定义 trace tree + span annotation;OTLP 将其 protobuf 化并与 Logs/Metrics 共享 Resource。 标准化收益:Collector 一次接收,多后端 fan-out;成本是每 span 固定 protobuf overhead ~几十字节。 - trace_id:OTLP 必选或推荐;Jaeger/Tempo 存储列均映射自此。 - span_id:OTLP 必选或推荐;Jaeger/Tempo 存储列均映射自此。 - parent_span_id:OTLP 必选或推荐;Jaeger/Tempo 存储列均映射自此。 - name:OTLP 必选或推荐;Jaeger/Tempo 存储列均映射自此。 - kind:OTLP 必选或推荐;Jaeger/Tempo 存储列均映射自此。 - attributes:OTLP 必选或推荐;Jaeger/Tempo 存储列均映射自此。 - events:OTLP 必选或推荐;Jaeger/Tempo 存储列均映射自此。 - links:OTLP 必选或推荐;Jaeger/Tempo 存储列均映射自此。 - status:OTLP 必选或推荐;Jaeger/Tempo 存储列均映射自此。 - resource:OTLP 必选或推荐;Jaeger/Tempo 存储列均映射自此。

十七、Metrics 与 Logs 的边界

信号 何时用 数据模型原因
Counter/Gauge 聚合 QPS、错误率、资源使用率 固定 label 空间,15s 采样
Histogram 延迟分位数 bucket 预聚合,避免存每条请求
Structured Log 单次请求上下文、堆栈 高熵 message,不可预聚合
Access Log 转 metric 按 status 聚合 QPS LogQL metric 或 Collector 计数

十八、Profile 与 Trace 的联合排障模型

Trace 回答「哪段调用慢」;Profile 回答「慢在哪个函数」。数据模型上 Trace span name 应能与 profile 栈顶帧语义对齐(同 operation 命名)。 Grafana 中 trace_id → profile 跳转依赖 exemplar 或 label 一致——见 Profiling

十九、术语表(数据模型视角)

series:Prometheus:唯一 label set 对应一条时间序列。 chunk:Prometheus/Loki:压缩后的时间窗口数据单元。 block:Prometheus/Tempo:不可变持久化目录或 parquet 文件组。 stream:Loki:相同 label set 的日志序列。 posting list:倒排索引:label value → series/chunk ID 列表。 fingerprint:Loki:label set 哈希。 row group:Parquet:列存行组,TraceQL 扫描单位。 head chunk:Prometheus:仍接收样本的 open chunk。 WAL:预写日志,crash recovery。 compaction:后台合并小 block 为大 block。 tombstone:Prometheus 逻辑删除标记。 bloom filter:Loki/Tempo:chunk 级概率跳过。 exemplar:histogram 上附带的 trace 指针。 native histogram:Prometheus 新 histogram 编码。 segment:SkyWalking/Jaeger 原生 trace 分组单元。 resource:OTel 进程级属性,跨 signal 共享。

十三、下一步

理解了存储端内部表达后,工程问题回到管道与 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 的取舍

附录 A.1 Prometheus 查询与 block 边界

跨 block 查询时 Querier 需 merge index header;minTime/maxTime 过滤减少扫描块数。

详见 07-tsdb-internals20-storage-cost

附录 A.2 Loki 分片与 ingester 环

hash ring 决定 chunk 归属;扩缩容触发 flush 与 rebalance。

详见 07-tsdb-internals20-storage-cost

附录 A.3 Tempo vParquet 列布局

attribute 列可选;TraceQL 只读必要列降低 IO。

详见 07-tsdb-internals20-storage-cost

附录 A.4 日志采样与数据模型

采样在 Agent 层减少 chunk 写入 QPS,不改变 Loki 索引结构。

详见 07-tsdb-internals20-storage-cost

附录 A.5 Metric 下采样

recording rule 生成低分辨率 series,新 series 仍是 TSDB 模型。

详见 07-tsdb-internals20-storage-cost

附录 A.6 Prometheus 查询与 block 边界

跨 block 查询时 Querier 需 merge index header;minTime/maxTime 过滤减少扫描块数。

详见 07-tsdb-internals20-storage-cost

附录 A.7 Loki 分片与 ingester 环

hash ring 决定 chunk 归属;扩缩容触发 flush 与 rebalance。

详见 07-tsdb-internals20-storage-cost

附录 A.8 Tempo vParquet 列布局

attribute 列可选;TraceQL 只读必要列降低 IO。

详见 07-tsdb-internals20-storage-cost

附录 A.9 日志采样与数据模型

采样在 Agent 层减少 chunk 写入 QPS,不改变 Loki 索引结构。

详见 07-tsdb-internals20-storage-cost

附录 A.10 Metric 下采样

recording rule 生成低分辨率 series,新 series 仍是 TSDB 模型。

详见 07-tsdb-internals20-storage-cost

附录 A.11 Prometheus 查询与 block 边界

跨 block 查询时 Querier 需 merge index header;minTime/maxTime 过滤减少扫描块数。

详见 07-tsdb-internals20-storage-cost

附录 A.12 Loki 分片与 ingester 环

hash ring 决定 chunk 归属;扩缩容触发 flush 与 rebalance。

详见 07-tsdb-internals20-storage-cost

附录 A.13 Tempo vParquet 列布局

attribute 列可选;TraceQL 只读必要列降低 IO。

详见 07-tsdb-internals20-storage-cost

附录 A.14 日志采样与数据模型

采样在 Agent 层减少 chunk 写入 QPS,不改变 Loki 索引结构。

详见 07-tsdb-internals20-storage-cost

附录 A.15 Metric 下采样

recording rule 生成低分辨率 series,新 series 仍是 TSDB 模型。

详见 07-tsdb-internals20-storage-cost

附录 A.16 Prometheus 查询与 block 边界

跨 block 查询时 Querier 需 merge index header;minTime/maxTime 过滤减少扫描块数。

详见 07-tsdb-internals20-storage-cost

附录 A.17 Loki 分片与 ingester 环

hash ring 决定 chunk 归属;扩缩容触发 flush 与 rebalance。

详见 07-tsdb-internals20-storage-cost

附录 A.18 Tempo vParquet 列布局

attribute 列可选;TraceQL 只读必要列降低 IO。

详见 07-tsdb-internals20-storage-cost

附录 A.19 日志采样与数据模型

采样在 Agent 层减少 chunk 写入 QPS,不改变 Loki 索引结构。

详见 07-tsdb-internals20-storage-cost

附录 A.20 Metric 下采样

recording rule 生成低分辨率 series,新 series 仍是 TSDB 模型。

详见 07-tsdb-internals20-storage-cost

附录 A.21 Prometheus 查询与 block 边界

跨 block 查询时 Querier 需 merge index header;minTime/maxTime 过滤减少扫描块数。

详见 07-tsdb-internals20-storage-cost

附录 A.22 Loki 分片与 ingester 环

hash ring 决定 chunk 归属;扩缩容触发 flush 与 rebalance。

详见 07-tsdb-internals20-storage-cost

附录 A.23 Tempo vParquet 列布局

attribute 列可选;TraceQL 只读必要列降低 IO。

详见 07-tsdb-internals20-storage-cost

附录 A.24 日志采样与数据模型

采样在 Agent 层减少 chunk 写入 QPS,不改变 Loki 索引结构。

详见 07-tsdb-internals20-storage-cost

附录 A.25 Metric 下采样

recording rule 生成低分辨率 series,新 series 仍是 TSDB 模型。

详见 07-tsdb-internals20-storage-cost

附录 A.26 Prometheus 查询与 block 边界

跨 block 查询时 Querier 需 merge index header;minTime/maxTime 过滤减少扫描块数。

详见 07-tsdb-internals20-storage-cost

附录 A.27 Loki 分片与 ingester 环

hash ring 决定 chunk 归属;扩缩容触发 flush 与 rebalance。

详见 07-tsdb-internals20-storage-cost

附录 A.28 Tempo vParquet 列布局

attribute 列可选;TraceQL 只读必要列降低 IO。

详见 07-tsdb-internals20-storage-cost

附录 A.29 日志采样与数据模型

采样在 Agent 层减少 chunk 写入 QPS,不改变 Loki 索引结构。

详见 07-tsdb-internals20-storage-cost

附录 A.30 Metric 下采样

recording rule 生成低分辨率 series,新 series 仍是 TSDB 模型。

详见 07-tsdb-internals20-storage-cost

附录 A.31 Prometheus 查询与 block 边界

跨 block 查询时 Querier 需 merge index header;minTime/maxTime 过滤减少扫描块数。

详见 07-tsdb-internals20-storage-cost

附录 A.32 Loki 分片与 ingester 环

hash ring 决定 chunk 归属;扩缩容触发 flush 与 rebalance。

详见 07-tsdb-internals20-storage-cost

附录 A.33 Tempo vParquet 列布局

attribute 列可选;TraceQL 只读必要列降低 IO。

详见 07-tsdb-internals20-storage-cost

附录 A.34 日志采样与数据模型

采样在 Agent 层减少 chunk 写入 QPS,不改变 Loki 索引结构。

详见 07-tsdb-internals20-storage-cost

附录 A.35 Metric 下采样

recording rule 生成低分辨率 series,新 series 仍是 TSDB 模型。

详见 07-tsdb-internals20-storage-cost

附录 A.36 Prometheus 查询与 block 边界

跨 block 查询时 Querier 需 merge index header;minTime/maxTime 过滤减少扫描块数。

详见 07-tsdb-internals20-storage-cost

附录 A.37 Loki 分片与 ingester 环

hash ring 决定 chunk 归属;扩缩容触发 flush 与 rebalance。

详见 07-tsdb-internals20-storage-cost

附录 A.38 Tempo vParquet 列布局

attribute 列可选;TraceQL 只读必要列降低 IO。

详见 07-tsdb-internals20-storage-cost

附录 A.39 日志采样与数据模型

采样在 Agent 层减少 chunk 写入 QPS,不改变 Loki 索引结构。

详见 07-tsdb-internals20-storage-cost

附录 A.40 Metric 下采样

recording rule 生成低分辨率 series,新 series 仍是 TSDB 模型。

详见 07-tsdb-internals20-storage-cost

附录 A.41 Prometheus 查询与 block 边界

跨 block 查询时 Querier 需 merge index header;minTime/maxTime 过滤减少扫描块数。

详见 07-tsdb-internals20-storage-cost

附录 A.42 Loki 分片与 ingester 环

hash ring 决定 chunk 归属;扩缩容触发 flush 与 rebalance。

详见 07-tsdb-internals20-storage-cost

附录 A.43 Tempo vParquet 列布局

attribute 列可选;TraceQL 只读必要列降低 IO。

详见 07-tsdb-internals20-storage-cost

附录 A.44 日志采样与数据模型

采样在 Agent 层减少 chunk 写入 QPS,不改变 Loki 索引结构。

详见 07-tsdb-internals20-storage-cost

附录 A.45 Metric 下采样

recording rule 生成低分辨率 series,新 series 仍是 TSDB 模型。

详见 07-tsdb-internals20-storage-cost

附录 A.46 Prometheus 查询与 block 边界

跨 block 查询时 Querier 需 merge index header;minTime/maxTime 过滤减少扫描块数。

详见 07-tsdb-internals20-storage-cost

附录 A.47 Loki 分片与 ingester 环

hash ring 决定 chunk 归属;扩缩容触发 flush 与 rebalance。

详见 07-tsdb-internals20-storage-cost

附录 A.48 Tempo vParquet 列布局

attribute 列可选;TraceQL 只读必要列降低 IO。

详见 07-tsdb-internals20-storage-cost

附录 A.49 日志采样与数据模型

采样在 Agent 层减少 chunk 写入 QPS,不改变 Loki 索引结构。

详见 07-tsdb-internals20-storage-cost

附录 A.50 Metric 下采样

recording rule 生成低分辨率 series,新 series 仍是 TSDB 模型。

详见 07-tsdb-internals20-storage-cost

附录 A.51 Prometheus 查询与 block 边界

跨 block 查询时 Querier 需 merge index header;minTime/maxTime 过滤减少扫描块数。

详见 07-tsdb-internals20-storage-cost

附录 A.52 Loki 分片与 ingester 环

hash ring 决定 chunk 归属;扩缩容触发 flush 与 rebalance。

详见 07-tsdb-internals20-storage-cost

附录 A.53 Tempo vParquet 列布局

attribute 列可选;TraceQL 只读必要列降低 IO。

详见 07-tsdb-internals20-storage-cost

附录 A.54 日志采样与数据模型

采样在 Agent 层减少 chunk 写入 QPS,不改变 Loki 索引结构。

详见 07-tsdb-internals20-storage-cost

附录 A.55 Metric 下采样

recording rule 生成低分辨率 series,新 series 仍是 TSDB 模型。

详见 07-tsdb-internals20-storage-cost

附录 A.56 Prometheus 查询与 block 边界

跨 block 查询时 Querier 需 merge index header;minTime/maxTime 过滤减少扫描块数。

详见 07-tsdb-internals20-storage-cost

附录 A.57 Loki 分片与 ingester 环

hash ring 决定 chunk 归属;扩缩容触发 flush 与 rebalance。

详见 07-tsdb-internals20-storage-cost

附录 A.58 Tempo vParquet 列布局

attribute 列可选;TraceQL 只读必要列降低 IO。

详见 07-tsdb-internals20-storage-cost

附录 A.59 日志采样与数据模型

采样在 Agent 层减少 chunk 写入 QPS,不改变 Loki 索引结构。

详见 07-tsdb-internals20-storage-cost

同主题继续阅读

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

2026-04-22 · architecture / observability

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

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


By .