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

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

- 作者：David（道雾轩）
- 专栏：指标采集与观测（https://daiw.org/manual/observability.md）
- 最后更新：2026-07-15
- 原文：https://daiw.org/manual/observability/data-model
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

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

Prometheus 的数据模型**简单到可以一句话说完**，却撑起了整个可观测性生态。这一章讲清它，并看一个能说明“[指标便宜](https://daiw.org/manual/observability/three-pillars)这件事有多重要”的源码细节。

## 一句话的模型

<Callout type="info">
  **一个**指标名**加一组**标签**，唯一确定一条**时间序列**（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 部分说的“提问的自由度”](https://daiw.org/manual/observability/why-observability)。
  - **危险** —— series 数 = 各标签**取值数的乘积**。加一个有 1000 种取值的标签，series 数就 ×1000。这就是**基数爆炸**，[第 9 部分](https://daiw.org/manual/observability/three-pillars)会专门算账，[第 2 部分](https://daiw.org/manual/observability/defining-metrics)则会讲怎么在设计阶段避免它。
</Callout>

在源码里，标签的定义朴素得令人意外（`model/labels/labels_common.go:44`）：

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

就是一对字符串。而 `Labels` 是一组这样的对（**按名字有序**——有序是为了能[快速比较和哈希](https://daiw.org/manual/observability/data-model)）。

## 指标名其实也是个标签

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

<Callout type="info">
  **指标名**不是特殊字段**，它只是一个**保留名字的标签**：`__name__`。** 也就是说 `http_requests_total{method="POST"}` 的完整形态其实是：

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

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

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

## 一个惊人的细节：三套 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）在编译期三选一。**

<Callout type="info">
  **为什么值得为“一组字符串对”写三套实现？因为在 Prometheus 里，**标签就是内存大头**。** 想想：几百万条 series，每条都带着十来个标签，每个标签是两个 Go string（每个 string header 就 16 字节 + 数据）。**光是标签本身就能吃掉几个 GB**——而采样点因为[压缩得极狠](https://daiw.org/manual/observability/three-pillars)反倒不占地方。

  于是有了 **`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"`）做**去重共享**。

  **这三套实现的存在本身就是一个结论：在时序数据库里，标签的内存占用是头等大事。** 这也从工程侧印证了[基数问题的严重性](https://daiw.org/manual/observability/three-pillars)——Prometheus 团队为此把字符串都手工打包了，你却随手加一个高基数标签，那点优化瞬间被吃光。
</Callout>

## 采样点：时间戳 + 值

series 上挂的采样点（sample）极简：

- **时间戳** —— int64，毫秒精度。
- **值** —— float64。

<Callout type="warn">
  **值是 `float64`，这意味着 Prometheus 的指标**天生是浮点数**——包括那些“看起来是整数”的计数器。** 这带来一个实战陷阱：`float64` 只能**精确表示 2^53 以内的整数**。计数器涨到那么大（约 9×10^15）才会失精，一般够用；但如果你把**纳秒时间戳**或**超大 ID** 当指标值存，就可能踩坑。**指标的值应该是“可度量的量”，不是标识符**——标识符属于标签，或者压根不该进指标（[基数](https://daiw.org/manual/observability/defining-metrics)）。

  另外 Prometheus 3.x 引入了 **native histograms**（原生直方图），让一个采样点可以是**一整个直方图结构**而非单个浮点——这是对经典模型的重要扩展，[第 3 部分](https://daiw.org/manual/observability/three-pillars)会讲。
</Callout>

## 这个模型的边界

模型简单是优点，但要清楚它**不适合什么**：

- **不适合高基数标识符** —— user_id、request_id、trace_id 放进标签会[炸掉](https://daiw.org/manual/observability/defining-metrics)。这些属于[日志或追踪](https://daiw.org/manual/observability/three-pillars)。
- **不适合事件** —— 它存的是**定期采样的数值**，不是“每个事件一条记录”。你无法从 Prometheus 里捞出“那一个失败的请求”。
- **不适合长文本** —— 标签值是字符串，但不是给你放堆栈的。

**这些“不适合”不是缺陷，是[聚合换细节](https://daiw.org/manual/observability/three-pillars)那笔交易的必然结果。** 认清边界，才不会用错工具。

## 小结与下一章

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

模型知道了，但具体该用**哪种指标类型**？下一章讲 counter/gauge/histogram/summary，以及怎么选。👉 [四种指标类型](https://daiw.org/manual/observability/metric-types)。

## 参考资料

- [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。
