Learn
Prometheus/04-data-model

数据模型

数据模型是 Prometheus 最核心、也最容易被匆匆略过的部分。理解它,PromQL 会变得直观;不理解它,你会一直在「为什么查不出我要的结果」和「为什么内存又爆了」之间反复横跳。

好消息是:整个模型只有四个概念,而且极其简洁。

1. 时序 = 指标名 + 标签集合 + 样本序列

Prometheus 存储的一切都是时间序列(time series)。一条时间序列由两部分构成:

时间序列 = 【标识】 + 【数据】
           指标名 + 标签集合    (时间戳, 值) 的有序列表

形式化地写:

<metric_name>{<label_1>="<value_1>", <label_2>="<value_2>", ...}
    → [(t1, v1), (t2, v2), (t3, v3), ...]

一个具体例子:

http_requests_total{job="api", instance="10.0.0.5:8080", method="GET", status="200"}
    → (1714540800000, 1342)
      (1714540815000, 1360)
      (1714540830000, 1389)
      (1714540845000, 1401)
      ...

关键规则:标识部分唯一确定一条序列。 只要有任何一个标签的键或值不同,那就是另一条完全独立的序列,各自有各自的数据点。

指标名: http_requests_total
│
├─ 序列①  {method="GET",  status="200"}  → (t1,1342) (t2,1360) (t3,1389) ...
├─ 序列②  {method="GET",  status="500"}  → (t1,   3) (t2,   3) (t3,   7) ...
├─ 序列③  {method="POST", status="200"}  → (t1, 221) (t2, 230) (t3, 244) ...
└─ 序列④  {method="POST", status="500"}  → (t1,   0) (t2,   1) (t3,   1) ...
ℹ️指标名其实也是一个标签

Prometheus 内部把指标名存成一个特殊标签 __name__。也就是说:

http_requests_total{status="200"}

完全等价于:

{__name__="http_requests_total", status="200"}

这个认知有实用价值——你可以对指标名做正则匹配:

# 查所有以 node_cpu 开头的指标
{__name__=~"node_cpu.*"}

也解释了为什么 metric_relabel_configs 里用 source_labels: [__name__] 就能按指标名过滤。

2. 指标名(metric name):测的是什么

指标名回答「这个数字测量的是什么量」。它的合法字符集是:

[a-zA-Z_:][a-zA-Z0-9_:]*

即:字母、数字、下划线、冒号,且不能以数字开头。冒号 : 按约定保留给记录规则(recording rule)使用,直接采集的指标不要用冒号。

2.1 命名规范

社区有一套约定俗成的规范,遵守它能让你的指标对所有人都自解释:

<命名空间>_<被测对象>_<单位>_<后缀>
   ↓          ↓         ↓       ↓
  http    _requests_ ...    _total
  node    _memory_  _bytes
  process _cpu_seconds_     _total

规则一:全部 snake_case 小写。

✅ http_requests_total
❌ HttpRequestsTotal
❌ http-requests-total

规则二:带上单位后缀,且用基本单位。

✅ node_memory_MemFree_bytes         用 bytes,不用 KB/MB
✅ http_request_duration_seconds     用 seconds,不用 ms
✅ node_network_transmit_bytes_total
❌ request_duration_ms               避免非基本单位
❌ memory_usage                      单位不明,看的人只能猜

为什么坚持基本单位? 因为 Grafana 等展示层能自动把 bytes 换算成 KB/MB/GB、把 seconds 换算成 ms/µs,前提是你用了标准单位。用了 ms 反而失去了自动格式化能力,还容易在计算时出错(比如把 ms 和 s 混着相加)。

规则三:Counter 类型加 _total 后缀。

✅ http_requests_total          累计请求数
✅ errors_total                 累计错误数
✅ process_cpu_seconds_total    累计 CPU 时间

看到 _total 就知道这是个只增不减的计数器,必须配 rate() / increase() 使用。

规则四:加统一的命名空间前缀。

✅ mysql_global_status_threads_connected
✅ redis_connected_clients
✅ myapp_order_created_total
❌ connected_clients            太泛,会和别人撞名

规则五:同一指标名的所有序列必须是同一逻辑量。

❌ 错误做法:用 type 标签区分完全不同的东西
   system_usage{type="cpu"}      单位是百分比
   system_usage{type="memory"}   单位是字节
   → sum(system_usage) 会把百分比和字节加在一起,毫无意义
 
✅ 正确做法:拆成两个指标
   system_cpu_usage_ratio
   system_memory_usage_bytes
💡命名自检法

写完指标名,问自己:「一个从没见过我系统的人,光看这个名字能猜出它测什么、单位是什么、是不是累计值吗?」

http_request_duration_seconds —— 能。HTTP 请求耗时,单位秒,不是累计值。 req_time —— 不能。哪种请求?单位是秒还是毫秒?是平均值还是当前值?

2.2 常见后缀速查

后缀含义示例
_totalCounter 累计值http_requests_total
_seconds时间,单位秒process_cpu_seconds_total
_bytes大小,单位字节node_memory_MemFree_bytes
_ratio比率,取值 0–1node_cpu_usage_ratio
_countHistogram/Summary 的样本个数http_request_duration_seconds_count
_sumHistogram/Summary 的总和http_request_duration_seconds_sum
_bucketHistogram 的桶http_request_duration_seconds_bucket
_info元信息指标,值恒为 1node_uname_info

3. 标签(label):维度切分

标签是 Prometheus 相对于传统监控系统最大的优势。它把「一个指标」变成了「一个可以任意切片的多维数据立方体」。

3.1 标签让查询变成切片操作

有了标签,同一个指标可以回答无数个问题:

# 全部:所有维度组合的 QPS
sum(rate(http_requests_total[5m]))
 
# 切片:只看 POST 请求
sum(rate(http_requests_total{method="POST"}[5m]))
 
# 切片:只看 5xx 错误(正则匹配)
sum(rate(http_requests_total{status=~"5.."}[5m]))
 
# 分组:按状态码维度展开
sum by (status) (rate(http_requests_total[5m]))
 
# 组合:北京机房 POST 接口的 5xx 率,按实例展开
sum by (instance) (rate(http_requests_total{region="bj", method="POST", status=~"5.."}[5m]))
/
sum by (instance) (rate(http_requests_total{region="bj", method="POST"}[5m]))

四种标签匹配运算符:

运算符含义示例
=精确等于http_requests_total{status="200"}
!=不等于http_requests_total{status!="200"}
=~正则匹配(完全匹配,自动锚定)http_requests_total{method=~"GET|POST"}
!~正则不匹配http_requests_total{path!~"/health.*"}
⚠️正则是完全匹配的

Prometheus 的 =~ 会自动在两端加上 ^ 和 $。所以 status=~"5" 匹配不到 500——它要求整个值就是字符串 "5"。想匹配 5 开头的三位数要写 status=~"5.." 或 status=~"5.*"。这是新手最常见的困惑之一。

3.2 自动附加的标签

有两个标签是 Prometheus 自动加上的,不需要(也不应该)在应用里定义:

  • job:来自配置里的 job_name,标识「这是哪一类服务」。
  • instance:目标的 host:port,标识「这是哪一个具体实例」。
# 应用暴露的原始文本
http_requests_total{method="GET",status="200"} 1027
 
# Prometheus 入库后实际存储的
http_requests_total{method="GET",status="200",job="api",instance="10.0.0.5:8080"} 1027
⚠️不要在应用里手动打 instance 标签

如果你的代码里给指标加了 instance="xxx",会与 Prometheus 自动附加的标签冲突。默认情况下 Prometheus 会把你的值改名为 exported_instance,制造混乱。实例身份应该由采集端(配置和服务发现)决定,而不是应用自己声明——这样才能在换 IP、换端口时不用改代码。

3.3 标签基数(cardinality)初识

基数 = 一个指标产生的时间序列条数 = 各标签取值数的乘积。

指标 http_requests_total 有三个标签:
  method:   4 种取值 (GET/POST/PUT/DELETE)
  status:   5 种取值 (200/400/404/500/503)
  instance: 6 个实例
 
基数 = 4 × 5 × 6 = 120 条序列   ✅ 完全健康

危险在于乘法会失控:

再加一个 user_id 标签(10 万用户):
基数 = 4 × 5 × 6 × 100,000 = 12,000,000 条序列   💥 直接 OOM

按经验值 1–3 KB/序列估算,1200 万条序列需要 12–36 GB 内存。而且这还只是一个指标。

高基数带来的连锁反应:

  1. 内存爆炸 → Prometheus OOM。
  2. 重启后 WAL 重放要几十分钟 → 这期间监控完全失明。
  3. 查询变慢甚至超时 → 需要它的时候它却查不动。
  4. 磁盘暴涨 → 触发保留策略提前删数据。

3.4 什么样的标签值是危险的

判断标准很简单:这个标签的取值集合是「有限可枚举」的吗?

危险的标签值为什么应该怎么办
user_id、session_id取值无上限放日志里;指标里只统计聚合量
request_id、trace_id每次请求都不同放链路追踪系统
url 完整路径(含 ID)/order/12345 每个订单一条序列归一化为路由模板 /order/:id
email、phone无上限,还涉及隐私绝不要放指标
时间戳、UUID天然无限值本身就是时间,不需要标签
错误堆栈、异常消息文本自由度极高归一化为错误类型枚举
IP 地址(客户端)公网 IP 上亿种归一化为地区/运营商
💡URL 归一化是最常见的救火操作

Web 应用埋点最容易踩的坑就是把原始 URL 当标签:

❌ http_requests_total{path="/api/order/10001"}
   http_requests_total{path="/api/order/10002"}
   → 每个订单一条序列,一天几十万条
 
✅ http_requests_total{path="/api/order/:id"}
   → 一条序列,且这才是你真正想看的「订单查询接口的性能」

大部分 Web 框架的 Prometheus 中间件都支持自动获取「路由模板」而非「实际路径」,接入时务必确认这一点。

紧急止血手段:如果高基数指标已经进来了,可以在 Prometheus 侧用 metric_relabel_configs 在入库前拦下:

scrape_configs:
  - job_name: 'app'
    static_configs:
      - targets: ['10.0.1.5:8080']
    metric_relabel_configs:
      # 方案 A:直接丢弃整个高基数指标
      - source_labels: [__name__]
        regex: 'http_requests_by_user_total'
        action: drop
 
      # 方案 B:保留指标但删掉高基数标签(会自动合并同名序列)
      - regex: 'user_id|session_id|request_id'
        action: labeldrop

用 Web UI 的 /tsdb-status 页面可以快速定位罪魁祸首,它直接列出序列数 Top10 的指标名和标签。

4. 样本(sample):(时间戳, 值)

样本是时间序列中的一个数据点,只有两个字段:

样本 = (时间戳, 值)
        int64    float64
       毫秒精度   64 位浮点

4.1 值永远是 float64

这是个硬性约束,带来几个必须知道的推论:

推论一:存不了字符串。

想记录「当前版本号是 v2.3.1」怎么办?用 info 指标模式——把信息编码进标签,值恒为 1:

# 版本信息用 info 指标表达
myapp_build_info{version="2.3.1", commit="a1b2c3d", branch="main"} 1
node_uname_info{nodename="web-01", release="5.15.0", machine="x86_64"} 1

查询时用向量匹配把信息「贴」到其他指标上:

# 按版本查看 QPS(把 version 标签关联进来)
sum by (version) (
  rate(http_requests_total[5m])
  * on(instance) group_left(version) myapp_build_info
)

推论二:布尔状态用 0/1 表示。

up 1                          # 1=健康 0=不健康
node_systemd_unit_state{name="nginx.service", state="active"} 1

推论三:超大整数会损失精度。

float64 只能精确表示 2^53(约 9×10^15)以内的整数。极大的计数器(如纳秒级累计值)可能丢精度,这也是为什么 Prometheus 推荐用 seconds 而不是 nanoseconds。

推论四:支持特殊值。

NaN     非数字,常用于表示「无数据」
+Inf    正无穷(Histogram 的最后一个桶就是 le="+Inf")
-Inf    负无穷

4.2 时间戳通常由 Prometheus 决定

文本格式允许在值后面写一个毫秒时间戳,但绝大多数情况下应该省略:

# 推荐:省略时间戳,用抓取时刻
http_requests_total{status="200"} 1027
 
# 不推荐:显式指定时间戳(毫秒)
http_requests_total{status="200"} 1027 1714540800000

为什么推荐省略?

  • 抓取时刻由 Prometheus 统一记录,所有目标的时间基准一致,不受各机器时钟漂移影响。
  • 显式时间戳有严格限制:不能太旧(超出容忍窗口会被拒),乱序样本会被丢弃。
  • 只有 Pushgateway 转发、联邦抓取这类「代理别人的数据」的场景才需要显式时间戳。
⚠️Prometheus 不接受乱序和过旧的样本

TSDB 的 Head block 要求同一序列的样本按时间递增写入。晚到的样本(时间戳小于该序列已有的最新样本)会被直接丢弃并计入 prometheus_target_scrapes_sample_out_of_order_total。

这就是为什么 Prometheus 不适合做「补录历史数据」——它是为实时监控设计的。(2.39+ 提供了实验性的 out-of-order 支持,但需要显式开启且有窗口限制。)

4.3 陈旧性标记(staleness)

当一个序列在某次抓取中「消失」了(比如某个 Pod 被销毁),Prometheus 会写入一个特殊的 stale marker(一个特殊的 NaN 值)。这让查询能立即知道「这条序列已经不存在了」,而不是继续返回 5 分钟前的旧值。

这个机制解释了一个常见现象:Pod 删除后,Grafana 上对应的线会立刻断掉,而不是拖着一条水平直线。

5. 何时加标签,何时拆成不同指标

这是设计指标时最需要判断力的地方。给一条清晰的判断准则:

如果两组数据「加起来有意义」,就用标签区分;如果「加起来无意义」,就拆成不同指标。

5.1 该用标签的情况

同一个物理量,只是维度不同:

✅ http_requests_total{method="GET"}   1000
   http_requests_total{method="POST"}   500
   → sum() = 1500 总请求数,有意义 ✓
✅ node_network_receive_bytes_total{device="eth0"}
   node_network_receive_bytes_total{device="eth1"}
   → sum() = 网卡总接收字节数,有意义 ✓
✅ node_filesystem_avail_bytes{mountpoint="/"}
   node_filesystem_avail_bytes{mountpoint="/data"}
   → sum() = 总剩余空间,有意义 ✓

5.2 该拆成不同指标的情况

语义不同、单位不同,加起来是废话:

❌ 错误:用标签区分不同的量
   node_stats{type="cpu_percent"}    75      单位:百分比
   node_stats{type="memory_bytes"}   8.6e9   单位:字节
   node_stats{type="disk_percent"}   42      单位:百分比
   → sum() = 8600000117,毫无意义 ✗
 
✅ 正确:拆成三个指标
   node_cpu_usage_percent      75
   node_memory_used_bytes      8.6e9
   node_disk_usage_percent     42

读写方向不同(虽然单位相同,但混算容易出错):

⚠️ 这两种写法都有人用,各有取舍:
 
写法 A(拆指标,Prometheus 官方 exporter 的选择):
   node_network_receive_bytes_total{device="eth0"}
   node_network_transmit_bytes_total{device="eth0"}
 
写法 B(用标签):
   node_network_bytes_total{device="eth0", direction="receive"}
   node_network_bytes_total{device="eth0", direction="transmit"}
 
官方倾向 A:因为「收 + 发」的总和很少有人真的需要,
而分开写能让 rate(node_network_receive_bytes_total[5m]) 一目了然。

5.3 判断流程

问题 1:这两组数据的单位相同吗?
        否 → 拆成不同指标
        是 ↓
问题 2:把它们 sum() 起来,得到的数字有业务含义吗?
        否 → 拆成不同指标
        是 ↓
问题 3:这个区分维度的取值是有限可枚举的吗?
        否 → 别放进指标(放日志)
        是 → ✅ 用标签
ℹ️一个实战反例

见过一个真实的糟糕设计:

app_metric{name="qps", service="order"} 120
app_metric{name="latency_p99", service="order"} 0.85
app_metric{name="error_rate", service="order"} 0.02

所有东西挤在一个叫 app_metric 的指标里,用 name 标签区分。问题是:

  • sum(app_metric) 完全无意义
  • 无法为不同的量设置不同的告警单位和阈值语义
  • Grafana 里没法自动选择合适的单位格式
  • 后来想给延迟加 Histogram 桶时彻底做不到

这是典型的「把 Prometheus 当成 key-value 存储」的错误心智模型。

6. 从 /metrics 文本理解数据模型

最后,我们回到最本质的地方:亲眼看一段真实的 /metrics 输出,把前面所有概念对上号。

curl -s http://localhost:9100/metrics

节选一段真实的 node_exporter 输出:

# HELP node_cpu_seconds_total Seconds the CPUs spent in each mode.
# TYPE node_cpu_seconds_total counter
node_cpu_seconds_total{cpu="0",mode="idle"} 152834.71
node_cpu_seconds_total{cpu="0",mode="iowait"} 412.85
node_cpu_seconds_total{cpu="0",mode="system"} 3021.44
node_cpu_seconds_total{cpu="0",mode="user"} 8934.12
node_cpu_seconds_total{cpu="1",mode="idle"} 153011.29
node_cpu_seconds_total{cpu="1",mode="iowait"} 398.17
node_cpu_seconds_total{cpu="1",mode="system"} 2987.63
node_cpu_seconds_total{cpu="1",mode="user"} 8712.55
 
# HELP node_memory_MemTotal_bytes Memory information field MemTotal_bytes.
# TYPE node_memory_MemTotal_bytes gauge
node_memory_MemTotal_bytes 1.6633360384e+10
 
# HELP node_memory_MemAvailable_bytes Memory information field MemAvailable_bytes.
# TYPE node_memory_MemAvailable_bytes gauge
node_memory_MemAvailable_bytes 9.284698112e+09
 
# HELP node_filesystem_avail_bytes Filesystem space available to non-root users in bytes.
# TYPE node_filesystem_avail_bytes gauge
node_filesystem_avail_bytes{device="/dev/sda1",fstype="ext4",mountpoint="/"} 4.1863168e+10
node_filesystem_avail_bytes{device="/dev/sdb1",fstype="ext4",mountpoint="/data"} 2.16172544e+11
 
# HELP node_uname_info Labeled system information as provided by the uname system call.
# TYPE node_uname_info gauge
node_uname_info{machine="x86_64",nodename="web-01",release="5.15.0-91-generic",sysname="Linux"} 1
 
# HELP node_load1 1m load average.
# TYPE node_load1 gauge
node_load1 0.42
 
# HELP node_scrape_collector_duration_seconds node_exporter: Duration of a collector scrape.
# TYPE node_scrape_collector_duration_seconds gauge
node_scrape_collector_duration_seconds{collector="cpu"} 0.000412331
node_scrape_collector_duration_seconds{collector="filesystem"} 0.002187654

逐行解读:

文本片段对应概念
# HELP node_load1 1m load average.元信息:人类可读描述,会显示在 Web UI
# TYPE node_load1 gauge元信息:指标类型
node_cpu_seconds_total指标名,snake_case + seconds 单位 + _total 表明是 Counter
{cpu="0",mode="idle"}标签集合,两个维度:哪个核、哪种模式
152834.71样本值,float64
(行末无时间戳)时间戳由 Prometheus 用抓取时刻填充
node_uname_info{...} 1info 指标模式:信息在标签里,值恒为 1
1.6633360384e+10科学计数法,等于 16633360384 字节(约 15.5 GiB)

数一数序列数:

node_cpu_seconds_total       : 2 cpu × 4 mode = 8 条
node_memory_MemTotal_bytes   : 无标签         = 1 条
node_memory_MemAvailable_bytes: 无标签        = 1 条
node_filesystem_avail_bytes  : 2 个挂载点     = 2 条
node_uname_info              : 无变化维度     = 1 条
node_load1                   : 无标签         = 1 条
node_scrape_collector_...    : 2 个 collector = 2 条
                                        合计 = 16 条序列

(入库后每条还会自动附加 job 和 instance 标签,但它们在同一个目标下是常量,不改变序列数。)

基于这段文本能写出的查询:

# CPU 使用率(100% 减去 idle 的占比)
100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)
 
# 内存使用率
100 * (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)
 
# 根分区剩余空间(GB)
node_filesystem_avail_bytes{mountpoint="/"} / 1024 / 1024 / 1024
 
# 磁盘使用率超过 85% 的分区
100 * (1 - node_filesystem_avail_bytes / node_filesystem_size_bytes) > 85
💡动手是最快的理解方式

花两分钟做这件事,胜过读十遍文档:

# 1. 起一个 node_exporter
docker run -d -p 9100:9100 prom/node-exporter
 
# 2. 看看它暴露了多少条序列
curl -s http://localhost:9100/metrics | grep -v '^#' | wc -l
 
# 3. 看看有哪些指标名(去重后)
curl -s http://localhost:9100/metrics | grep -v '^#' | cut -d'{' -f1 | cut -d' ' -f1 | sort -u | head -30
 
# 4. 找出序列数最多的指标(基数排查的手工版)
curl -s http://localhost:9100/metrics | grep -v '^#' | cut -d'{' -f1 | cut -d' ' -f1 | sort | uniq -c | sort -rn | head -10

你会发现一个默认的 node_exporter 就有 500–1000 条序列。乘以你的机器数量,就是主机监控的基数成本。


🎯练习 1:数序列与算基数

给定下面这段 /metrics 输出:

# HELP cache_operations_total Total cache operations.
# TYPE cache_operations_total counter
cache_operations_total{cache="user",op="get",result="hit"} 88231
cache_operations_total{cache="user",op="get",result="miss"} 1204
cache_operations_total{cache="user",op="set",result="ok"} 3341
cache_operations_total{cache="product",op="get",result="hit"} 45120
cache_operations_total{cache="product",op="get",result="miss"} 890
 
# HELP cache_size_bytes Current cache size.
# TYPE cache_size_bytes gauge
cache_size_bytes{cache="user"} 4.194304e+06
cache_size_bytes{cache="product"} 1.2582912e+07

回答:

  1. 这段文本包含几个指标名?几条时间序列?
  2. 假设这个应用部署了 8 个实例,总共会在 Prometheus 里产生多少条序列?
  3. 写一条 PromQL 计算 user 缓存的命中率。
🎯练习 2:给指标改名

下面 6 个指标名都有问题,请指出问题并给出符合规范的写法:

1. RequestCount
2. api_latency_ms
3. disk_free
4. errors
5. memory_usage_percent_total
6. queue_size{type="length"}
🎯练习 3:标签还是拆指标?

以下 4 组数据,判断应该「用标签区分」还是「拆成不同指标」,并说明理由:

  1. Nginx 的 2xx / 3xx / 4xx / 5xx 响应数
  2. 一台机器的 CPU 温度(摄氏度)和 CPU 使用率(百分比)
  3. Kafka 各个 topic 各个 partition 的消息堆积量
  4. JVM 的堆内存已用量、堆内存上限、非堆内存已用量
🎯练习 4:诊断一次基数爆炸

某天 Prometheus 突然 OOM,重启后 prometheus_tsdb_head_series 显示 480 万条序列(平时只有 20 万)。打开 /tsdb-status 页面,看到序列数 Top1 的指标是:

api_request_duration_seconds_bucket    4,320,000

进一步查看该指标的一条样本:

api_request_duration_seconds_bucket{le="0.5",path="/user/8823/orders",method="GET",trace_id="9f2a...",instance="10.0.1.5:8080"} 12

请回答:

  1. 基数爆炸的根本原因是什么?(可能不止一个)
  2. 为什么 Histogram 类型的指标特别容易爆?
  3. 给出紧急止血方案和根治方案。

小结

  • 时序 = 指标名 + 标签集合 +(时间戳, 值)序列。标识部分唯一确定一条序列,任何标签不同就是新序列。
  • 指标名本质上是 __name__ 标签;命名遵循 snake_case、基本单位后缀(_seconds / _bytes)、Counter 加 _total、带命名空间前缀。
  • 标签用于维度切分;job 和 instance 由 Prometheus 自动附加,不要在应用里手写。
  • 基数 = 标签取值数的乘积,是 Prometheus 头号事故来源。高基数标签(ID、UUID、原始 URL)必须归一化或移出指标;Histogram 是基数放大器。
  • 样本值永远是 float64,字符串信息用 info 指标(值恒为 1)表达;时间戳通常交给 Prometheus 用抓取时刻填充,且不接受乱序。
  • 标签还是拆指标:单位相同且 sum() 有意义 → 用标签;否则 → 拆成不同指标。
  • 一切都能从 /metrics 文本里看明白,动手 curl 一次胜过读十遍文档。