数据模型 · 指标名 + 标签 = 一条时间序列

Prometheus 的数据模型简单到只有一句话,却撑起了整个生态。这一章讲清 series 的定义、标签为什么是维度的载体,以及一个惊人的源码细节——Prometheus 为省内存写了三套 Labels 实现,由构建标签选择。

作者 David更新于 第 7 篇(共 52 篇)

数据模型 · 指标名 + 标签 = 一条时间序列

Prometheus 的数据模型简单到可以一句话说完,却撑起了整个可观测性生态。这一章讲清它,并看一个能说明“指标便宜这件事有多重要”的源码细节。

一句话的模型

一个指标名加一组标签**,唯一确定一条时间序列(series);一条 series 就是一串按时间排列的 (时间戳, 值)。**

http_requests_total{method="POST", status="200", instance="10.1.2.3:8080"}
 └── 指标名 ──┘ └──────────── 标签集(labels) ────────────────────────┘
                    ↓ 唯一确定
                一条 series → (t1, 1027) (t2, 1031) (t3, 1042) ...

关键在于:标签值不同 = 完全不同的 series。{status="200"} 和 {status="500"} 是两条独立的序列,各自存一串数据。这个“标签组合的笛卡尔积”是模型的全部威力,也是它全部的危险:

  • 威力 —— 你可以按任意标签组合切分、聚合、过滤,这就是第 0 部分说的“提问的自由度”。
  • 危险 —— series 数 = 各标签取值数的乘积。加一个有 1000 种取值的标签,series 数就 ×1000。这就是基数爆炸,第 9 部分会专门算账,第 2 部分则会讲怎么在设计阶段避免它。

在源码里,标签的定义朴素得令人意外(model/labels/labels_common.go:44):

// Label is a key/value a pair of strings.
type Label struct {
    Name, Value string
}

就是一对字符串。而 Labels 是一组这样的对(按名字有序——有序是为了能快速比较和哈希)。

指标名其实也是个标签

一个容易被忽略、但很能说明设计的细节:

指标名不是特殊字段**,它只是一个保留名字的标签:__name__。** 也就是说 http_requests_total{method="POST"} 的完整形态其实是:

{__name__="http_requests_total", method="POST"}

源码里到处是 model.MetricNameLabel(即 __name__)这个常量(如 scrape/scrape.go:72 校验“缺指标名”时用的就是它)。这个统一带来的好处是:整个系统只需要处理“一组标签”这一种东西——索引、匹配、存储都不必为“名字”开特例。

这也解释了 PromQL 里为什么能写 {__name__=~"http_.*"} 来按名字正则匹配——名字和普通标签在底层没有区别。以 __ 开头的标签都是保留的(__address__、__scheme__、__metrics_path__ 等在 relabeling 阶段大量使用),它们在抓取完成后会被丢弃,不进入最终 series。

一个惊人的细节:三套 Labels 实现

现在看一个能体现“Prometheus 有多在乎内存”的东西。model/labels/ 目录下有:

labels_slicelabels.go     →  type Labels []Label          (最朴素)
labels_stringlabels.go    →  type Labels struct{ data string }   (打包成一个字符串)
labels_dedupelabels.go    →  type Labels struct{ ... }    (去重共享)

三套 Labels 实现,由 Go 构建标签(build tag)在编译期三选一。

为什么值得为“一组字符串对”写三套实现?因为在 Prometheus 里,标签就是内存大头。 想想:几百万条 series,每条都带着十来个标签,每个标签是两个 Go string(每个 string header 就 16 字节 + 数据)。光是标签本身就能吃掉几个 GB——而采样点因为压缩得极狠反倒不占地方。

于是有了 stringlabels 这个优化,它的注释直接说明了做法(labels_stringlabels.go):

Labels is implemented by a single flat string holding name/value pairs. Each name and value is preceded by its length, encoded as a single byte for size 0-254, or the following 3 bytes little-endian, if the first byte is 255.

翻译:把整组标签打包进一个扁平字符串,每个 name/value 前面放它的长度(0~254 用 1 字节;更长则首字节 255 + 后 3 字节小端)。于是原本 N 个标签 = 2N 个 string header(每个 16 字节),现在只剩一个 string header——省下的内存非常可观。代价是访问时要解码(源码里那个 decodeSize 函数),即用一点 CPU 换大量内存。

而 dedupelabels 更进一步:利用“很多 series 共享相同的标签名和值”(比如成千上万条 series 都有 job="api-server")做去重共享。

这三套实现的存在本身就是一个结论:在时序数据库里,标签的内存占用是头等大事。 这也从工程侧印证了基数问题的严重性——Prometheus 团队为此把字符串都手工打包了,你却随手加一个高基数标签,那点优化瞬间被吃光。

采样点:时间戳 + 值

series 上挂的采样点(sample)极简:

  • 时间戳 —— int64,毫秒精度。
  • 值 —— float64。

值是 float64,这意味着 Prometheus 的指标天生是浮点数**——包括那些“看起来是整数”的计数器。** 这带来一个实战陷阱:float64 只能精确表示 2^53 以内的整数。计数器涨到那么大(约 9×10^15)才会失精,一般够用;但如果你把纳秒时间戳或超大 ID 当指标值存,就可能踩坑。指标的值应该是“可度量的量”,不是标识符——标识符属于标签,或者压根不该进指标(基数)。

另外 Prometheus 3.x 引入了 native histograms(原生直方图),让一个采样点可以是一整个直方图结构而非单个浮点——这是对经典模型的重要扩展,第 3 部分会讲。

这个模型的边界

模型简单是优点,但要清楚它不适合什么:

  • 不适合高基数标识符 —— user_id、request_id、trace_id 放进标签会炸掉。这些属于日志或追踪。
  • 不适合事件 —— 它存的是定期采样的数值,不是“每个事件一条记录”。你无法从 Prometheus 里捞出“那一个失败的请求”。
  • 不适合长文本 —— 标签值是字符串,但不是给你放堆栈的。

这些“不适合”不是缺陷,是聚合换细节那笔交易的必然结果。 认清边界,才不会用错工具。

小结与下一章

Prometheus 的数据模型一句话:指标名 + 标签集 = 一条 series,series 是一串 (时间戳 int64, 值 float64)。标签值不同即不同 series——这既是提问自由度的来源,也是基数爆炸的源头(series 数 = 标签取值数的乘积)。指标名其实只是保留标签 __name__(model.MetricNameLabel),统一成“一组标签”让索引 / 匹配 / 存储都无需开特例;__ 前缀标签保留给 relabeling。源码里 Labels 有三套实现(slice / stringlabels:打包进一个扁平字符串、长度前缀编码 / dedupelabels:去重共享),构建期三选一——这本身就证明标签是 TSDB 的内存大头。模型不适合高基数标识符 / 事件 / 长文本。

模型知道了,但具体该用哪种指标类型?下一章讲 counter/gauge/histogram/summary,以及怎么选。👉 四种指标类型。

参考资料

  • [1] Prometheus 源码 model/labels/labels_common.go:44(Label 结构)、labels_stringlabels.go(扁平字符串编码)、labels_slicelabels.go、labels_dedupelabels.go。
  • [2] Prometheus 源码 model/labels/matcher.go、scrape/scrape.go:72(MetricNameLabel 校验)。
  • [3] Prometheus 官方文档:Data model、Metric and label naming。

本页目录