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

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

文章导航

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

目录

数据模型:时间序列、日志、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 是一个不可变的目录,包含三个核心文件:

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}

这是一条典型的结构化日志。它有几个特征:时间戳用于按时间过滤,少数固定字段(levelservice)有较低的基数且几乎所有查询都需要它们作为过滤条件,正文内容(message)是高熵、不可预测的文本,trace_id 是高基数标识符但几乎只在精确匹配查询中使用。

2.2 Elasticsearch 方式:为所有字段建索引

Elasticsearch 的默认行为是:收到一条 JSON 日志 → 为每个字段建立倒排索引 → 索引数据存储在 Lucene segment 中。倒排索引的结构是:词项 → 包含该词项的文档 ID 列表 + 词频 + 位置。当你搜索 level:ERROR AND service:checkout 时,ES 分别查找 ERRORcheckout 的 posting list,取交集,返回匹配的文档 ID。

优点:任何字段都可以被高效搜索——你可以搜索 message 中包含 “timeout” 的所有日志,也可以搜索 duration_ms > 500 的所有日志(数值范围查询),甚至可以做全文检索和模糊匹配。缺点:存储放大。倒排索引通常比原始数据大 1.5–3 倍。在日志场景中——每天 TB 级的写入、查询模式高度偏斜(90% 的查询只用 levelservicetrace_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)。每条日志被解析为固定的列(timestamplevelservicemessage 等),每个列独立存储和压缩。查询时只需读取涉及的列——如果你只查 levelmessagetrace_idduration_ms 列完全不被读取。ClickHouse 的主键索引(通常是 (service, timestamp))允许快速定位到行范围,跳数索引(bloom_filterminmaxset)允许在扫描时跳过不满足条件的 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 编码下的核心字段:

一个典型的 HTTP SERVER Span 在 protobuf 序列化后约 500–800 bytes(不含大型 attribute value)。加上 8–10 个 attributes(http.methodhttp.urlhttp.status_codeservice.name 等),总共约 1–2 KB。你的日志系统每天要存 TB 级的数据,你的 Trace 系统每天要存 TB × N 倍(每个请求通常产生 10–20 个 Span)。

3.2 Jaeger:全索引路线

Jaeger 将 Span 存储在后端数据库中,并利用后端的能力做搜索。在生产环境中,Elasticsearch 是最常见的选择。当 Span 被写入 ES 时,Jaeger 将 Span 的核心字段(trace_idoperation_namestart_timeduration)和 tag(attribute)全部映射为 ES 的字段并建立索引。这意味着你可以用任意 tag 组合搜索 Span——“搜所有 http.status_code=500service.name=checkout 的 Span”。

代价就是前面第二节讨论过的 ES 索引膨胀问题。在高 QPS 场景下,Jaeger + ES 的存储成本通常是 Trace 预算的最大头——一个 5000 QPS 服务每天产出的 Span 可以轻松消耗每天数百 GB 的 ES 磁盘空间(包括 ES 索引放大和副本)。

Jaeger 缓解这个问题的方式是通过 ES mapping 控制:在 mapping 中将高基数 tag(request_iduser_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 == errorduration > 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

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 个必填(idsourcetypespecversion)和约 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.namehost.ipcontainer.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'

八、落地与选型清单

九、关键概念回顾

十、常见误解

“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 的取舍

参考资料

  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, https://github.com/cloudevents/spec/blob/v1.0/spec.md
  9. VictoriaMetrics, Technical Papers, https://docs.victoriametrics.com/#technical-papers

同主题继续阅读

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

2026-04-22 · architecture / observability

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

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


By .