数据模型:时间序列、日志、Span、Profile 的内部表达
你在 Grafana 上点开一张 Dashboard,看到的是一条平滑的 p99
延迟曲线、一个柱状的 QPS 图、一张按 method
分组的成功率面板。你鼠标悬停在曲线上的一个异常尖峰,点进去——Tempo/Jaeger
在几百毫秒内吐出一条完整的分布式调用链,每个 Span
精确到微秒。你发现 redis_get_cart Span 花了
850ms,点击 Related Logs——Loki 在几秒内把同
trace_id 的十几条日志全拉了出来。你切到
Pyroscope——一张火焰图在几百毫秒内渲染完成,红色方块精确指向
runtime.mallocgc 占用的 CPU 比例。
这整个过程行云流水——但背后是五种完全不同的数据模型、四种存储引擎、三种索引策略和两种压缩算法在同时工作。它们之所以能”行云流水”,是因为每一层的数据模型都被设计成了与它的查询模式精确匹配——而你之所以在每个月末收到一张可观测性账单,也是因为你对这些数据模型的理解和它们的设计假设之间存在着偏差。
本文拆解 Metrics、Logs、Traces、Profiles、Events 五大支柱在磁盘和内存中的内部表达。这不是一个”这些工具各有什么参数”的配置指南——那是已经有文档覆盖的事。本文试图回答的问题是:当你把一条数据交给 Prometheus / Loki / Tempo / Pyroscope 之后,它经历了怎样的内部转换?为什么这些转换决定了你的存储成本和查询延迟?以及理解了这些之后,你在选择工具和设计埋点策略时应该做出什么不同的决策?
一、时序数据模型
时序数据是可观测性栈中最”古老”也最成熟的一层。从
RRDtool(1999)到 Graphite(2008)到 InfluxDB(2013)到
Prometheus(2015),时序数据库的核心数据模型变化不大:(metric_name, tags, timestamp) → value。变化的是索引结构、压缩算法和分布式架构——这三个维度决定了你能处理多少
series、查询多快、以及磁盘上的放大系数。
1.1 Prometheus 的数据模型
Prometheus 的数据模型是一个简单的 label 集合到数值序列的映射。以下是一个典型的 Prometheus 指标:
http_requests_total{method="GET", endpoint="/api/order", status="200"} 104723 @1718000000
http_requests_total{method="GET", endpoint="/api/order", status="200"} 104725 @1718000015
http_requests_total{method="GET", endpoint="/api/order", status="200"}
这一整串 label
组合唯一地标识了一条时间序列(time
series)。这条 series 是一系列
(timestamp, value) 数据点的有序列表。Prometheus
的抓取间隔(scrape interval)决定了数据点的间隔——典型配置是
15 秒或 30 秒。
在 Prometheus TSDB 内部,这条 series 经历了一条精确的路径:
WAL(Write-Ahead Log)。所有新写入的样本首先追加到 WAL。WAL 分布在 128 MB 的 segment 文件中,按序列化后的顺序写入——不做任何处理和压缩。它的唯一职责是防止 crash 丢数据:如果 Prometheus 挂了,重启时从最近的 WAL checkpoint 重放后续记录。WAL 每 2 小时做一次 checkpoint——把当前在内存中的 series 快照写入磁盘,然后截断旧的 WAL segment。
Head Block。在内存中,每一条活跃的 time
series 都对应一个 memSeries
结构。memSeries 的核心是一个按时间排序的 chunk
列表。最后一个 chunk 是 “head chunk”——它仍然
open,正在接收新的样本。当一个 head chunk 积累到 120
个样本(默认)时,它被”关闭”——不再接受新样本——然后被追加到
mmap()-ed 的 chunk 区域。每个
memSeries 在内存中大约占用 3–5 KB(含 head
chunk、label set、索引指针),这是 Prometheus
内存消耗的主要来源。
Persistent Block。当一个时间窗口内的 head chunk 全部关闭后,它们被 compaction 进程合并成 persistent block。一个 block 是一个不可变的目录,包含三个核心文件:
chunks/:实际的样本数据,按 series ID 和 chunk ID 组织。每个 chunk 包含该 series 在某个时间窗口内的所有样本,经过编码和压缩。index:倒排索引。结构是label pair → posting list (series IDs)。当你查询{method="GET"}时,Prometheus 先通过 index 找到所有包含method="GET"的 series ID,然后定位到对应的 chunk。meta.json:block 的元数据——最小时间戳、最大时间戳、series 数量、样本数量、压缩前后大小。
Compaction 的策略是层级的:小 block(2 小时)合并为中级 block,中级 block 合并为大 block,大 block 最终覆盖数天的时间范围。Compaction 的过程会去重——如果同一个 series 在两个输入 block 中有重叠的时间范围,compaction 选择保留最新的样本。
1.2 Gorilla 压缩:为什么 16 字节变成 1.37 字节
Prometheus TSDB 的压缩基于 Facebook 2015 年发表的 Gorilla 论文(Pelkonen et al., “Gorilla: A Fast, Scalable, In-Memory Time Series Database”, VLDB 2015)。Gorilla 压缩的两个核心洞察是:时间戳是等间距的(差值很小),相邻数据点的值变化也很小(XOR 结果有很多前导零)。
时间戳压缩(delta-of-delta)。在每次 scrape 间隔内,时间戳的增量是恒定的(15 秒 = 15000 毫秒)。因此时间戳的 delta-of-delta 在绝大多数情况下是 0——只需要 1 个 bit。编码过程:第一个时间戳按完整 64-bit 存储 → 后续时间戳只存 delta-of-delta(D = (t_n - t_{n-1}) - (t_{n-1} - t_{n-2}))。如果 D 为 0,1 bit 表示。如果 D 在 [-63, 64] 范围内,用 2 header bits + 最少位数存储。否则用 2 header bits + 完整 D。在一个 scrape 间隔不被跳过的正常时间序列中,每个时间戳平均只需要 ~1.5 bits。
值压缩(XOR)。浮点数值(Prometheus 使用
float64)的变化通常很小——CPU 利用率从 45.3% 变到
45.4%,差值只有 0.1。XOR
压缩利用了这个特性:第一个值完整存储(64 bits)。对于后续值
v_n,计算 v_n XOR v_{n-1}。如果
XOR 结果为 0(值没变),1 bit 表示。如果 XOR
结果的前导零和尾随零数量与之前相同——使用 2 header bits +
有效中间位。否则完整存储。在实践中,对于变化缓慢的指标(CPU、内存),平均每个值压缩到约
1–2 bytes。对于波动大的指标(QPS),平均每个值约 4–6
bytes。
综合效果:一个典型的 http_requests_total
counter,15 秒一个数据点,一天 5760
个数据点。不压缩:5760 × (8 + 8) = 92.16 KB。Gorilla
压缩后:约 4–8 KB。压缩比 10:1 到 20:1。
1.3 VictoriaMetrics 的差异
VictoriaMetrics 在数据模型层面与 Prometheus 兼容(相同的 metric name + labels 抽象),但在存储层面做了不同的工程选择:
系列 ID 化。VictoriaMetrics 为每个唯一的
metric_name{label_set} 组合分配一个整数
ID(TSID),所有后续写入都用这个 ID 而不是文本
label。这使得 label 的存储和查询开销大幅降低——Prometheus 的
index 存储的是 label_name=label_value
字符串对,VictoriaMetrics 存储的是整数映射。
列存 + MergeTree
风格分区。VictoriaMetrics
按时间分区(默认按月),每个分区内按 TSID
排序存储。同一个月内的写入先进入内存的
rawRows,然后被排序、压缩、写入磁盘的
index.bin + timestamps.bin +
values.bin 三元组。这种设计与 ClickHouse 的
MergeTree
类似——批量排序写入、后台合并、查询时利用排序做二分查找。
更好的高基数容忍度。由于不维护文本 label
的内存索引,VictoriaMetrics 在同等内存下能处理的 series
数量约为 Prometheus 的 5–10 倍。但代价是 label value 的
churn(频繁新增唯一 label 值)会导致 TSID
分配压力——每个新 label value 都需要一个新的整数 ID
和对应的倒排索引条目。
选择 Prometheus 还是 VictoriaMetrics 最终取决于你的 label 治理水平。如果你的 label 治理做得好(series 数量稳定、label 值空间固定),两者的差异主要体现在运维复杂度上(Prometheus 更简单)。如果你的 label 治理已经积重难返(几百万 series、高 churn),VictoriaMetrics 能在不改变应用代码的前提下给你几个月的缓冲期去逐步治理。
二、日志数据模型
如果说时序数据模型的主要挑战是”如何把简单结构的数据存得足够紧凑”,日志数据模型的主要挑战正好相反——“如何在根本不知道查询模式的前提下,为所有可能的查询模式提供合理的性能”。
2.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
是高基数标识符但几乎只在精确匹配查询中使用。
2.2 Elasticsearch 方式:为所有字段建索引
Elasticsearch 的默认行为是:收到一条 JSON 日志 →
为每个字段建立倒排索引 → 索引数据存储在 Lucene segment
中。倒排索引的结构是:词项 → 包含该词项的文档 ID 列表 + 词频 + 位置。当你搜索
level:ERROR AND service:checkout 时,ES
分别查找 ERROR 和 checkout 的
posting list,取交集,返回匹配的文档 ID。
优点:任何字段都可以被高效搜索——你可以搜索
message 中包含 “timeout” 的所有日志,也可以搜索
duration_ms > 500
的所有日志(数值范围查询),甚至可以做全文检索和模糊匹配。缺点:存储放大。倒排索引通常比原始数据大
1.5–3 倍。在日志场景中——每天 TB
级的写入、查询模式高度偏斜(90% 的查询只用
level、service 和
trace_id 三个字段,几乎从不对
message
做全文检索)——为每个字段建索引是巨大的浪费。
此外,ES 在高基数场景下表现尤其糟糕。如果
trace_id 被建了倒排索引,每个唯一的
trace_id(每天可能有数十亿个)都会在 Lucene
的词典和 posting list
中产生条目——词典的大小和内存消耗很快超过 JVM heap
的限制。解决办法是在 ES mapping 中将 trace_id
设为 "index": false——存储但不索引,只允许在
_source 层通过 term
精确匹配查询——性能差但可接受。
2.3 Loki 方式:只索引标签,正文暴力扫描
Grafana Loki 的设计哲学与 ES 完全相反:“label
是索引,正文是扫描”。在 Loki 中,每条日志流由一组 label(如
{service="checkout", level="ERROR"})标识。同一个
label 组合下的所有日志按时间追加到同一个”流”中,然后被切分成
chunk(默认每个 chunk 约 1–2 MB 压缩后的数据)。Chunk
被写入对象存储(S3 / GCS / Azure Blob)。
当你查询
{service="checkout", level="ERROR"} |= "connection pool exhausted"
时,Loki 的查询过程是:第一步,通过 index(boltdb 或
tsdb)找出包含
{service="checkout", level="ERROR"}
标签且时间范围匹配的所有 chunk
的路径。第二步,从对象存储下载这些 chunk。第三步,在 chunk
内对日志正文做顺序扫描和过滤。整个过程没有任何倒排索引参与正文搜索。
Loki 的设计哲学使得它的存储成本只有 ES 的 1/5 到
1/10。原因:不需要为每个日志字段建倒排索引(最大的存储放大消除),不需要在内存中维护索引(可以走廉价的
S3 对象存储),ZSTD
块压缩在日志数据上非常有效(日志的重复模式多)。代价:顺序扫描慢——Loki
的设计假设是”大部分查询的 label
过滤已经足够缩小到几十到几百个 chunk”,但如果你查询
{service="checkout"}(不加 level
过滤),可能涉及数万个 chunk 的下载和扫描。这就是为什么 Loki
官方强烈建议 label 总数不超过 15 个且要”设计好 label
set”的原因——不是 ES 那样的技术限制,而是查询性能限制。
Loki 2.9+ 引入了 Bloom Filter 支持——为每个 chunk 生成一个
Bloom Filter,记录 chunk 内包含的
token(n-gram)。查询时先在 Bloom Filter 上检查——如果 Bloom
Filter 说这个 chunk
不包含目标词,直接跳过下载。这对低频关键词查询(如搜索一个罕见的
trace_id)的加速效果极好——可以跳过 99% 以上的
chunk 下载。但对高频词(如 “error”),Bloom Filter
几乎总是返回”可能存在”,没什么加速效果。
2.4 ClickHouse 方式:列存折中
ClickHouse 的 MergeTree 引擎提供了第三种路径:列式存储 +
主键索引 + 跳数索引(skip
index)。每条日志被解析为固定的列(timestamp、level、service、message
等),每个列独立存储和压缩。查询时只需读取涉及的列——如果你只查
level 和
message,trace_id 和
duration_ms 列完全不被读取。ClickHouse
的主键索引(通常是
(service, timestamp))允许快速定位到行范围,跳数索引(bloom_filter、minmax、set)允许在扫描时跳过不满足条件的
blocks。
ClickHouse 的存储效率介于 Loki 和 ES 之间——不建全文倒排索引(比 ES 省),但存了完整的列(比 Loki 费)。查询速度优于 Loki(不需要暴力扫描整个 chunk——可以按列值做 block 级剪枝),但不如 ES(不能对任意文本做即时全文搜索)。它适合那些查询模式已知且偏结构化(“过去 24 小时 level=ERROR 中 service=checkout 的 message”)的场景,不适合完全 Ad-hoc 全文搜索的场景。
三、Trace 数据模型
Trace 数据模型与 Metrics 和 Logs 有本质不同。Metrics 的查询模式是”给我某个时间范围内某个指标的聚合值”,Logs 的查询模式是”给我满足某些过滤条件的日志行”,而 Trace 查询有两种截然不同的模式:一是”给我这个 trace_id 的所有 Span”(精确查找,高频),二是”给我过去 15 分钟内 duration > 500ms 的所有 trace”(属性搜索,低频但排障关键)。
这两种查询模式对存储结构提出了相互矛盾的要求——支持精确查找最好用
key-value
存储(trace_id → []Span),支持属性搜索最好用全文索引(为每个
Span attribute 建倒排索引)。Jaeger 和 Tempo
分别选择了天平的两端。
3.1 Span 的内部结构
一个 OpenTelemetry Span 在 OTLP protobuf 编码下的核心字段:
trace_id:16 bytes,全局唯一span_id:8 bytesparent_span_id:8 bytesname:string,如"POST /api/checkout"或"redis.GET"kind:enum(SERVER / CLIENT / PRODUCER / CONSUMER / INTERNAL)start_time_unix_nano+end_time_unix_nano:fixed64 pairstatus:code(OK / ERROR)+ messageattributes:重复的 KeyValue pair(http.method、http.status_code等)events:重复的 {time_unix_nano, name, attributes}links:重复的 {trace_id, span_id, attributes}resource:描述来源服务/进程的属性集合
一个典型的 HTTP SERVER Span 在 protobuf 序列化后约
500–800 bytes(不含大型 attribute value)。加上 8–10 个
attributes(http.method、http.url、http.status_code、service.name
等),总共约 1–2 KB。你的日志系统每天要存 TB 级的数据,你的
Trace 系统每天要存 TB × N 倍(每个请求通常产生 10–20 个
Span)。
3.2 Jaeger:全索引路线
Jaeger 将 Span
存储在后端数据库中,并利用后端的能力做搜索。在生产环境中,Elasticsearch
是最常见的选择。当 Span 被写入 ES 时,Jaeger 将 Span
的核心字段(trace_id、operation_name、start_time、duration)和
tag(attribute)全部映射为 ES
的字段并建立索引。这意味着你可以用任意 tag 组合搜索
Span——“搜所有 http.status_code=500 的
service.name=checkout 的 Span”。
代价就是前面第二节讨论过的 ES 索引膨胀问题。在高 QPS 场景下,Jaeger + ES 的存储成本通常是 Trace 预算的最大头——一个 5000 QPS 服务每天产出的 Span 可以轻松消耗每天数百 GB 的 ES 磁盘空间(包括 ES 索引放大和副本)。
Jaeger 缓解这个问题的方式是通过 ES mapping 控制:在
mapping 中将高基数
tag(request_id、user_id)设为
"index": false,只存储不建倒排索引。以及在
Jaeger Query 层面限制搜索时间范围——不允许跨天搜索(否则 ES
需要扫描多个分片,查询延迟指数上升)。
3.3 Tempo:无索引路线
Grafana Tempo 的思路是:不索引 Span 的
attribute,只索引 trace_id + 时间 + 少数
resource 属性。所有 Span 按 trace_id
分组 → 写入 Ingester 的 WAL → WAL 满后 flush 为
Block(Parquet 格式)→ 上传到对象存储(S3 / GCS / Azure
Blob)。
查询 “give me trace_id = X” 的过程:Query Frontend 通过
consistent hash ring 找到负责这个 trace_id 的
Ingester(如果还在 WAL 中)或 Querier(如果在对象存储的
Block 中)→ 直接按 trace_id 定位到 Block 中的
parquet row group → 读取该 Trace 的所有 Span → 返回。这是
Tempo 的核心路径——快(不需要查询任何索引)、省(不需要为
attribute 建索引)、但同时能力有限(不支持 attribute
搜索)。
那 “搜所有 slow trace” 怎么办?Tempo 的回答是
TraceQL——一个类似 PromQL 的查询语言,支持在
status == error、duration > 500ms、{resource.service.name = "checkout"}
等条件下搜索。TraceQL
的查询实现是:不依赖索引——而是通过并行扫描对象存储中的
Block,对每个 Block 做过滤。Grafana 把这种模式叫 “streaming
search”——扫描是并发的、分布在多个 Querier
上、结果实时流回。在数据量适中(每天几亿
Span)时,搜索延迟通常在几秒到十几秒。在数据量极大(每天几十亿
Span)时,Tempo 可以使用加速索引(Tempo 2.4+ 的 well-typed
attribute 索引)来缩小扫描范围。
Tempo 的设计取舍是明确且公开的:你能做 attribute 搜索,但别期望毫秒级响应。你能无限低成本地存储 Trace,但别把 attribute 当 SQL where clause 用。 如果团队对 attribute 搜索有强需求(“我要秒级搜索所有 user_id=xxx 的请求”),Tempo 不是正确选择——Jaeger + ES 或 Datadog 更适合。如果团队的 Trace 查询 90% 是”给我这个 trace_id”和”给我最近 15 分钟的慢请求”,Tempo 的成本优势是压倒性的(大约是 Jaeger + ES 的 1/10 到 1/20)。
四、Profile 数据模型
Profile 数据是可观测性五大支柱中数据量最小、但单条 volume 最大的。一条 CPU profile 包含数万到数十万个调用栈样本,但每分钟只采集一条。Profile 数据模型的核心概念是栈的符号化和栈合并(stack merging)。
4.1 pprof 格式
Google 的 pprof 格式是最广泛使用的 profiling 数据格式。它使用一个精巧的三层映射结构:
Sample → Location → Function → Mappings
- Function:唯一的符号化函数标识(
id、name、system_name、filename、start_line) - Location:一个代码位置(
id、address、line[]),关联到一个 Function。同一个函数在不同调用上下文中的不同行号对应不同的 Location - Sample:一条采样记录,包含
location_id[](一个调用栈,按最深到最浅排列) +value[](计数值——CPU 时间/分配字节数/锁等待时间等)
pprof 文件的体积压缩主要通过符号去重实现:所有 Sample 共享同一组 Function 和 Location 对象——符号表只存一份。在一个典型的 Go CPU profile 中,数万个样本共享几千个 Function 和一万个左右的 Location,pprof 文件压缩后通常只有几十 KB 到几百 KB。
4.2 Pyroscope 和火焰图
Pyroscope(Grafana 的持续 Profiling 产品)在 pprof
格式之上增加了一个时间维度——标签分组的 profile
时间序列。数据模型是:(timestamp, labels{service, host, ...}, stacktrace[], value)。
火焰图的本质是一个栈合并(stack merge)操作。所有 sample 的调用栈按层级纵向排列——最深帧在底部、最浅帧在顶部。每一层的 bar 宽度等于该帧在所有 sample 中出现的次数(或计数值)。合并的过程是:从下往上,把相同帧的 sample 合并在一起,合并后的总宽度传递给上一层。
火焰图的渲染本身不需要任何特殊的存储结构——只需要按时间范围扫描所有 sample,在内存中构建合并树,然后渲染成 SVG 或 Canvas。典型的延迟是几百毫秒(数千个 sample 的合并)。这解释了为什么 Profile 查询比 Metrics/Logs/Traces 都要快——它不依赖复杂的索引结构,只是对已经高度压缩的 pprof 数据做一次内存扫描和合并。
Pyroscope/Parca 的存储策略是按时间分块:每 N 分钟(通常是
1–5 分钟)的 profile 数据被打包为一个
block。标签基数(比如有几千个不同的 service
值)影响的是”按标签过滤”时的 block 扫描范围——但不像
Prometheus 那样一条 series 一个独立结构。因此 Profile
存储是五大支柱中对基数最不敏感的。
五、Events 数据模型
Events(事件)是可观测性第五支柱。与其他四个支柱不同,事件既不是时间序列(不像 Metrics),也不总是携带 trace context(不像 Traces),也不一定有统一的结构化 schema(不像 Logs 那样标准化)。但事件有一个非常明确的查询模式:“在某个时间点前后发生了什么”。
5.1 CloudEvents 规范
CNCF 的 CloudEvents 规范(v1.0)为事件定义了一个最小公共数据模型:
{
"id": "aabbcc-1234",
"source": "/k8s/cluster-a/namespace/prod",
"type": "com.example.pod.oomkilled",
"time": "2024-06-11T03:14:22Z",
"data": {"pod_name": "checkout-7f8a9b", "node": "worker-12", "memory_mb": 2048}
}CloudEvents 的核心字段只有 6
个必填(id、source、type、specversion)和约
10
个可选字段。它不是完整的存储模型——它只定义了事件的传输格式,不定义事件如何被存储和查询。但在实践中,事件的传输格式决定了它最自然的存储归宿:因为事件是
(time, type, source) → data
的键值结构,它天然适合拉平后写入日志系统(Loki /
Elasticsearch)或消息队列(Kafka / Pulsar)。
5.2 K8s Events 的特殊性
Kubernetes Event 是一种特定类型的可观测事件。它由 kube-apiserver 生成,存储在 etcd 中(默认保留 1 小时),由 K8s API 暴露。K8s Event 不是时间序列——它是离散的、不可变的记录。但它的查询模式是”时间范围 + type + involvedObject”——这也与日志系统的查询模式完全一致。
因此 K8s Event 的最佳归宿通常是日志系统(通过
kube-event-exporter 之类的工具转发到 Loki 或
ES),而不是 Prometheus。如果你把 K8s Event 尝试 Push 进
Prometheus(通过 event-to-metric
converter),你是在把离散事件强行映射到时间序列模型——对于”这个
Pod 过去一小时内被 OOMKill
了几次”这种聚合查询可能成立,但对于”上一次 OOMKill
事件发生的具体时间和上下文”这种原始事件的精确查收——Prometheus
根本做不到。
六、数据模型对比与选型启示
以下是五大支柱在关键维度上的对比:
| 维度 | Metrics | Logs | Traces | Profiles | Events |
|---|---|---|---|---|---|
| 写吞吐 | 低(每 15s 一个点) | 极高(TB/day) | 极高(B spans/day) | 低(分钟级一条) | 中-高 |
| 查询模式 | 聚合(rate/avg/p99) | 过滤+扫描 | trace_id 精确 + 属性搜索 | 火焰图合并+对比 | 时间+type 过滤 |
| 索引需求 | 倒排(label→series) | 取决于后端(ES强/Loki弱) | 取决于后端(Jaeger强/Tempo弱) | 无需 | 类似日志 |
| 压缩率 | 极高(Gorilla 10-20:1) | 中-高(ZSTD 3-5:1) | 中(ZSTD 2-3:1) | 高(符号去重+ZSTD) | 中 |
| 对基数敏感度 | 极高 | 中-高 | 中(Jaeger)/低(Tempo) | 低 | 中 |
| 典型存储成本 | $5-50/月 | $500-5000/月 | $300-8000/月 | < $10/月 | 合并到日志 |
这个表中的每个数字都会因你们的规模、保留期和工具选择而有量级差异。但比例关系是稳定的:Logs 和 Traces 占据了 85%–95% 的存储成本,Metrics 和 Profiles 几乎可以忽略。因此成本控制的主战场永远是 Logs 和 Traces:日志的索引策略、Trace 的采样策略——这两件事决定了你的可观测性账单的基准面。
七、工程坑点
7.1 用 ES 存 Trace Span——每个 Span 几十个字段全建索引
症状:Jaeger + ES 的存储成本每个月 $6000,但 Tempo + S3
只需 $400。根因:Jaeger 的默认 ES mapping 为 Span 的几乎所有
attribute 都建了倒排索引,而团队的 Span 中有 50 个
attributes——其中 30
个是低查询价值的(thread.name、host.ip、container.runtime)。这些
attribute 从来没有被搜索过但在账单上占据了 60%
的存储。缓解:审查 Jaeger ES index mapping,将未使用过的 tag
加 "index": false。
7.2 用 Loki 搜高基数字段
症状:Loki 挂了 10 分钟才返回一个对 user_id
精确匹配的查询。根因:user_id 是日志正文 JSON
中的一个字段,不是 Loki label——查询时 Loki
需要扫描目标时间窗口内所有 chunk。在 7 天 retention 和每天
20TB 日志的数据量下,一次
| json | user_id="xxx" 查询扫描的数据量达到
200GB。缓解:Loki 2.9+ 的 Bloom Filter + 缩短 Loki query 的
max_entries_limit_per_query
参数防止单个查询扫描过多 chunk。
7.3 Prometheus label 数量失控
症状:在某次发布后,prometheus_tsdb_head_series
从 80 万飙升到 350 万然后 Prometheus 在 90 分钟后
OOM。根因:一个 Rust 服务在它的 /metrics
中为每个 HTTP endpoint 输出一个带 endpoint
label 的 Histogram——但 endpoint 的值是完整的
URL path,包含动态 ID 如
/api/users/123/orders/456。十个不同的用户 ID =
十个不同的 endpoint label 值 = 十条新的时间序列。
缓解:对含动态路径段的 URL
做标准化——/api/users/:id/orders/:id。在应用代码中实现,或在
Prometheus metric_relabel_configs
中用正则替换:
metric_relabel_configs:
- source_labels: [endpoint]
regex: '/api/users/[^/]+/orders/[^/]+'
target_label: endpoint
replacement: '/api/users/:id/orders/:id'八、落地与选型清单
九、关键概念回顾
- Prometheus TSDB:WAL → Head Block (内存) → Compaction → Persistent Block (磁盘)。每条 series 在内存中是一个 3-5 KB 的 memSeries。Gorilla 压缩(delta-of-delta + XOR)将每个数据点从 16 bytes 压缩到 ~1.37 bytes。
- 日志存储路线分歧:ES 为所有字段建倒排索引(存储放大 1.5-3×),Loki 只索引标签不索引正文(存储省 5-10× 但正文查询需暴力扫描),ClickHouse 列存折中。
- Trace 存储路线分歧:Jaeger + ES 全索引路线(可搜索任何 attribute,但存储贵),Tempo 无索引路线(只索引 trace_id + 时间,存储极省但 attribute 搜索需扫描对象存储)。
- Profile 数据模型:Sample → Location → Function 三层映射。火焰图 = 栈合并。标签基数对存储效率影响极小。
- Events:
(time, type, source) → data。最自然的存储归宿是日志系统或消息队列,而非 Prometheus。
十、常见误解
“Tempo 不能做 attribute 搜索,所以 Jaeger 更强大”。Tempo 能做 attribute 搜索(TraceQL),但不是秒级。取舍是明确的设计决策——用搜索速度换存储成本。如果你的团队每天有数十亿 Span 但 attribute 搜索需求只有”看看最近 15 分钟的慢请求”,Tempo 的 TraceQL 几秒到十几秒够用。如果你的团队每天有重复性、Adhoc 的 attribute 搜索需求(“team A 告诉我上周四有一个 user_id=xxx 的报错,但我不记得具体时间”),Jaeger + ES 更合适。
“Prometheus 的内存消耗和 series 总数成正比”。对 steady state 近似成立——每个 memSeries 3-5 KB。但 churn(series 的创建/销毁速率)带来的瞬时分配和 GC 压力是非线性的。一个稳态 80 万 series 的 Prometheus 可能比 churn 率高的 40 万 series 实例更稳定。
“JSON 日志和纯文本日志只是格式不同”。数据模型层面差异巨大。JSON 日志有 schema(字段名固定但值可变),可以被管道整层解析。纯文本日志每层都需要 regex 重新解析——CPU 成本和维护复杂度成倍增加。推动日志 JSON 化是可观测性栈 ROI 最高的单一行动之一。
十一、下一步
理解了数据在存储端的内部表达之后,接下来的问题自然回到工程实践:日志管道怎么搭、Traces 栈怎么选。下一篇 日志管道:Fluent Bit、Vector、Logstash、Cribl 的取舍 会拆解四种日志 Agent 和管道的技术架构与取舍。
下一篇:日志管道:Fluent Bit、Vector、Logstash、Cribl 的取舍
参考资料
- T. Pelkonen et al., Gorilla: A Fast, Scalable, In-Memory Time Series Database, VLDB 2015
- Prometheus, TSDB Format, v2.45+, https://github.com/prometheus/prometheus/tree/main/tsdb
- Grafana Loki, Architecture, https://grafana.com/docs/loki/latest/architecture/
- Grafana Tempo, Architecture, https://grafana.com/docs/tempo/latest/architecture/
- Jaeger, Storage Backends, https://www.jaegertracing.io/docs/latest/storage/
- ClickHouse, MergeTree Engine, https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/mergetree
- Google, pprof format, https://github.com/google/pprof
- CNCF, CloudEvents Specification, v1.0, https://github.com/cloudevents/spec/blob/v1.0/spec.md
- VictoriaMetrics, Technical Papers, https://docs.victoriametrics.com/#technical-papers
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【可观测性工程】存储与成本:采样、下采样、冷热分层、对象存储
可观测性数据量以每年 2-3 倍的速度增长,存储成本很快就超过计算成本。拆解五大支柱的成本结构、采样是最大的杠杆、冷热分层与压缩的实战策略,以及降本路径图。
【可观测性工程】时序数据库内核:TSM、TSI、倒排索引与 Gorilla 压缩
深入时序数据库的存储内核:Prometheus TSDB 的 WAL 与块管理、InfluxDB 的 TSM 引擎与 TSI 倒排索引、Gorilla 压缩算法的数学原理、VictoriaMetrics mergeset 架构、ClickHouse MergeTree 作为 metrics 后端,以及国内大厂在 series churn 和 compaction 风暴上踩过的坑。
【可观测性工程】Traces 栈与采样:Jaeger、Tempo、Zipkin、SkyWalking
拆解 Jaeger、Tempo、SkyWalking 三种开源分布式追踪方案的架构本质与工程取舍:全索引 vs 无索引、采样策略(头部/尾部/自适应)、传播协议(W3C TraceContext)的断裂诊断,以及选型决策框架。
【可观测性工程】Metrics:Prometheus、VictoriaMetrics、Thanos、Mimir、M3
从 Prometheus 架构与数据模型出发,系统梳理 Remote Write、PromQL 进阶、Thanos 全局聚合、Mimir 多租户、VictoriaMetrics 性能、M3DB 原理,以及五者在大规模生产场景下的对比矩阵与迁移实践。