数据模型 · 指标名 + 标签 = 一条时间序列
Prometheus 的数据模型简单到只有一句话,却撑起了整个生态。这一章讲清 series 的定义、标签为什么是维度的载体,以及一个惊人的源码细节——Prometheus 为省内存写了三套 Labels 实现,由构建标签选择。
数据模型 · 指标名 + 标签 = 一条时间序列
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。
这个模型的边界
模型简单是优点,但要清楚它不适合什么:
- 不适合高基数标识符 —— 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。