四个指标,一种哲学:Prometheus 如何用简单模型看透复杂系统

Prometheus 只用四种指标类型就能描述世间万物的监控需求:Counter、Gauge、Histogram、Summary。

这看似简单,但背后藏着一整套设计哲学:

  • 如何用最简单的模型描述最复杂的系统?
  • 如何在灵活性和约束之间找到平衡?
  • 为什么"允许 Counter 重置"反而是更优雅的设计?

这不是一篇工具手册,而是一次对 Prometheus 设计智慧的深度探讨。当你理解了这套设计哲学,你就能理解为什么 Prometheus 能用如此简单的模型,看透复杂系统的本质。


目录

  1. 设计哲学:简单优先的三个体现
  2. 四种指标:用最少的类型描述最多的场景
  3. Histogram:如何优雅地处理分布问题
  4. 易混淆概念:细节中的设计智慧
  5. 生产实践:理论到落地的最后一公里
  6. 下一步:从理解到实践

1. 设计哲学:简单优先的三个体现

Prometheus 的设计哲学可以用一句话概括:用最简单的方式解决问题

这个哲学在三个关键设计上体现得淋漓尽致。

1.1 时间序列的本质

Prometheus 是一个时间序列数据库(TSDB)。理解它的第一步,是理解什么是时间序列。

时间序列的定义

时间序列就是"数值随时间变化的记录"。更准确地说,它是一个三元组:

scss 复制代码
(指标名, 标签集合, 时间点) → 数值

当你访问 Exporter 的 /metrics 端点时,看到的是这样的文本:

ini 复制代码
# HELP http_requests_total 总HTTP请求数
# TYPE http_requests_total counter
http_requests_total{method="GET", status="200"} 1234
http_requests_total{method="POST", status="200"} 567
http_requests_total{method="GET", status="404"} 42

每一行都是一个"数据点",但这里有个问题:时间戳在哪?

时间戳由谁添加?

答案是:Prometheus 在抓取时添加时间戳

flowchart LR A[Exporter 暴露当前值] --> B[Prometheus 抓取并记录时间] B --> C[存储为时间序列] style A fill:#e1f5ff style B fill:#fff4e1 style C fill:#ffe1f5

流程是这样的:

  1. Exporter 暴露指标:只说"现在的值是 1234"
  2. Prometheus 定时抓取(默认 15 秒一次):记录"我在 2024-12-29 10:00:00 抓到的值是 1234"
  3. 存储到 TSDB:形成时间序列

为什么这样设计?

这个设计看似简单,但很巧妙:

如果由 Exporter 提供时间戳 如果由 Prometheus 添加时间戳
100 个 Exporter,100 个时钟 1 个 Prometheus,1 个时钟
时钟可能不同步 时钟统一
Exporter 需要管理时间戳 Exporter 无状态,简单
时间戳可能错误 时间戳可靠

这是 Prometheus "简单优先" 设计哲学的第一个体现:让 Exporter 尽可能简单,复杂的事情由 Prometheus 来做

1.2 "类型"只是约定,不是强制

看看 /metrics 输出的第二行:

bash 复制代码
# TYPE http_requests_total counter

这里声明了 http_requests_total 是一个 counter 类型。那么问题来了:Prometheus 真的在乎这个类型声明吗?

答案是:不在乎

客户端、传输、存储三个视角

让我们从三个层面看"类型":

flowchart TB A[客户端层 有类型约束] --> B[传输层 TYPE只是注释] B --> C[存储层 无类型只有数字] style A fill:#e1f5ff style B fill:#fff4e1 style C fill:#ffe1f5

1. 客户端层(有类型)

在代码里,你用官方库定义指标时,类型是有意义的:

go 复制代码
// Go 代码示例
counter := prometheus.NewCounter(...)  // Counter 类型
counter.Inc()   // 只能增加,不能减少
counter.Dec()   // 编译错误!Counter 没有 Dec() 方法

gauge := prometheus.NewGauge(...)     // Gauge 类型
gauge.Set(42)   // 可以随意设置
gauge.Inc()     // 也可以增加
gauge.Dec()     // 也可以减少

客户端库通过 API 的设计来"强制"类型约束。

2. 传输层(类型是注释)

但到了 /metrics 输出,类型就只是一行注释:

bash 复制代码
# TYPE my_counter counter    ← 这只是给人看的注释
my_counter 100
my_counter 50                 ← Prometheus 不会因为 Counter 减少而报错

3. 存储层(无类型)

到了 Prometheus 的存储层,所有指标都是"时间序列":

less 复制代码
my_counter{} 
  @1735462800 → 100
  @1735462815 → 50
  @1735462830 → 75

Prometheus 看到的就是"一串数字随时间变化",它不知道也不在乎这个指标声明的类型是什么。

为什么这样设计?

这又是 Prometheus "简单优先" 的体现:

  • 存储引擎简单:不需要复杂的类型系统,只需要存 (timestamp, value) 对
  • 查询灵活:你可以对任何指标用任何函数(虽然可能没意义)
  • 性能更好:不需要在查询时检查类型兼容性
  • 向后兼容:后续添加新类型不影响存储层

那类型有什么用?

类型的作用在于约定文档

  1. 帮助你选择正确的 API(客户端层)
  2. 帮助你选择正确的函数 (比如 Counter 用 rate(),Gauge 用 delta()
  3. 帮助工具理解指标(比如 Grafana 可以根据类型提供不同的可视化选项)

但这一切都是"软约束",不是"硬限制"。

1.3 Counter 可以重置:为什么允许?

这是 Prometheus 设计哲学中最容易引起困惑的一点。

一个真实的场景

你的应用运行在 Kubernetes 上,有个 Counter 指标 http_requests_total

makefile 复制代码
10:00:00 → 1000
10:01:00 → 1500
10:02:00 → Pod 重启了
10:02:15 → 0        ← Counter 重置为 0
10:03:00 → 50

这时候,有人可能会问:为什么不把 Counter 持久化,重启后继续累加?

比如可以这样设计:

ini 复制代码
Pod 重启前:http_requests_total = 1500
写入磁盘:1500
Pod 重启后:从磁盘读取 1500
继续累加:http_requests_total = 1500 + 新请求数

为什么 Prometheus 不这么做?

让我们对比两种方案:

方案 持久化 Counter 允许重置
Exporter 复杂度 需要文件/数据库存储 无状态,简单
容器友好性 需要挂载持久化存储 容器可以随意销毁重建
故障处理 文件损坏?并发冲突? 没有状态,没有故障
监控目的 精确累积值 速率和趋势

Prometheus 的选择是:允许重置,专注于速率

这个选择背后的哲学是:

"Prometheus is designed for operational monitoring, not for billing or accounting."

翻译一下:

Prometheus 是为运维监控而设计的,不是为计费或会计核算而设计的。

监控 vs 计费的区别

监控关心的问题 计费关心的问题
现在请求速率是多少? 总共处理了多少个请求?
错误率是否在上升? 总共有多少个错误?
系统是否健康? 用户应该付多少钱?

监控只需要知道:

  • 每秒处理多少请求(速率)
  • 速率是否异常(告警)
  • 趋势是上升还是下降

不需要知道

  • 从系统上线到现在总共处理了 1,234,567,890 个请求

后者应该由数据库事务、日志系统、或专门的计费系统来保证。

rate() 如何处理重置

Prometheus 的 rate() 函数会自动检测 Counter 重置:

promql 复制代码
rate(http_requests_total[5m])

原理很简单:

  1. 检测重置:如果当前值 < 上一个值,认为发生了重置
  2. 调整计算:只用重置后的数据计算速率
  3. 继续正常:下一个周期正常计算

举例:

ini 复制代码
时间    值     rate() 计算
10:00   1000   
10:01   1500   (1500-1000)/60 = 8.33/s
10:02   0      检测到重置,跳过
10:03   50     (50-0)/60 = 0.83/s
10:04   120    (120-50)/60 = 1.16/s

所以即使 Counter 重置,rate() 依然能给出正确的速率。

总结这一节

Prometheus 允许 Counter 重置,是因为:

  1. 简化 Exporter 设计:无状态更简单、更可靠
  2. 适应云原生:容器随时重启是常态
  3. 专注监控本质:关心速率和趋势,不关心精确累积值
  4. rate() 能处理:自动检测和调整

这就是 Prometheus 的第一个设计智慧:牺牲一点"精确性",换取"简单性"和"可靠性"

这个取舍背后的深层思考是:监控系统的价值不在于"记录了多少数字",而在于"能否及时发现问题"。只要速率和趋势是对的,绝对累积值不那么重要。


2. 四种指标:用最少的类型描述最多的场景

如果让你设计一套指标类型,你会设计几种?

可能会想:HTTP 请求一种、数据库查询一种、内存使用一种、CPU 使用一种...很快就会有几十种类型。

但 Prometheus 只用四种:Counter、Gauge、Histogram、Summary。

这是如何做到的?答案是:抓住本质,而不是现象

  • Counter 的本质:只增不减的累积
  • Gauge 的本质:可增可减的状态
  • Histogram 的本质:分布统计
  • Summary 的本质:预计算

只要理解了本质,一个 Counter 就能描述所有"累积"的场景。

2.1 Counter(计数器)

定义

Counter 是单调递增的计数器,只增不减(除非重置)。

典型场景

回答"发生了多少次"的问题:

ini 复制代码
真实场景:API 服务

http_requests_total{method="GET", path="/api/users", status="200"} 15234
http_requests_total{method="GET", path="/api/users", status="404"} 42
http_requests_total{method="POST", path="/api/users", status="200"} 8901
http_requests_total{method="POST", path="/api/users", status="400"} 156

error_log_total{level="error", service="user-service"} 234
error_log_total{level="error", service="order-service"} 89

db_queries_total{database="mysql", operation="select"} 89234
db_queries_total{database="mysql", operation="insert"} 12456

注意几个设计细节:

  1. 命名后缀 _total:这是约定俗成的规范,表示"总数"
  2. 标签选择methodpathstatus 是低基数的标签,不会无限增长
  3. 不要用高基数标签 :比如不要用 user_idrequest_id

常用函数

1. rate() - 计算每秒速率

promql 复制代码
# 过去 5 分钟的平均每秒请求速率
rate(http_requests_total[5m])

# 结果示例
http_requests_total{method="GET", path="/api/users"} 150.5
→ 表示过去 5 分钟,这个接口平均每秒处理 150.5 个请求

为什么几乎总是用 rate()?因为 Counter 的原始值(比如 15234)没什么意义,你关心的是"现在每秒多少个请求"。

2. increase() - 计算时间窗口内的增量

promql 复制代码
# 过去 1 小时增加了多少个请求
increase(http_requests_total[1h])

# 结果示例
http_requests_total{method="GET"} 540000
→ 过去 1 小时,增加了 54 万个请求

注意 increase()rate() 的关系:

scss 复制代码
increase(metric[1h]) = rate(metric[1h]) × 3600

3. topk() - 找出前 N 名

promql 复制代码
# 找出请求量最大的前 10 个接口
topk(10, rate(http_requests_total[5m]))

# 找出错误最多的前 5 个服务
topk(5, increase(error_log_total[1h]))

实战技巧

错误示例 1:直接用 Counter 的原始值

promql 复制代码
# 错误:直接看 Counter 的值没什么意义
http_requests_total > 10000

# 正确:看速率是否过高
rate(http_requests_total[5m]) > 100

错误示例 2:时间窗口太短

promql 复制代码
# 问题:1 分钟窗口太短,容易受瞬时波动影响
rate(http_requests_total[1m])

# 建议:至少用 5 分钟窗口
rate(http_requests_total[5m])

错误示例 3:在高基数标签上使用 Counter

promql 复制代码
# 错误:user_id 有百万级别,会产生百万条时间序列
http_requests_total{user_id="123456"}

# 正确:只按服务和接口聚合
http_requests_total{service="api", path="/users"}

Counter 的设计智慧

Counter 的设计看似简单(只增不减),但蕴含着深刻的智慧:

  1. 专注本质:监控关心"现在怎么样"(速率),不关心"总共多少"(累积值)
  2. 容忍重置:牺牲绝对精确,换取系统简单和可靠
  3. 配合 rate():设计了配套函数来处理重置,让用户无感知

这就是"简单模型看透复杂系统"的第一个例子:一个只增不减的计数器,配合一个 rate() 函数,就能描述世间所有"事件累积"的场景。

2.2 Gauge(仪表盘)

定义

Gauge 是可以任意增减的指标,反映"当前状态"。

典型场景

回答"现在是多少"的问题:

ini 复制代码
真实场景:服务器监控

node_memory_MemAvailable_bytes{instance="server-01"} 8589934592
node_cpu_usage_percent{instance="server-01", cpu="0"} 45.2
node_filesystem_free_bytes{instance="server-01", mountpoint="/"} 107374182400

真实场景:应用监控

current_connections{service="database", pool="main"} 45
current_connections{service="database", pool="readonly"} 12
queue_length{service="message-queue", queue="orders"} 156
goroutine_count{service="api-server"} 234

真实场景:业务监控

current_online_users{region="asia"} 15234
current_online_users{region="europe"} 8901
inventory_count{product="iphone-15", warehouse="shanghai"} 450

Gauge vs Counter 的选择

这是个常见的困惑。怎么判断用哪个?

问题 类型 原因
总共处理了多少请求? Counter 只增不减
现在有多少活跃连接? Gauge 可增可减
总共发生了多少错误? Counter 只增不减
现在队列里有多少任务? Gauge 可增可减
CPU 时间累计多少秒? Counter 时间累积,只增不减
CPU 使用率是多少? Gauge 百分比,可增可减

简单规则:

  • 累积的、永远增长的 → Counter
  • 当前的、可上可下的 → Gauge

常用函数

1. 直接查询当前值

promql 复制代码
# 看当前内存使用
node_memory_MemAvailable_bytes

# 看当前连接数
current_connections

2. delta() - 计算变化量

promql 复制代码
# CPU 温度在过去 2 小时的变化
delta(cpu_temp_celsius{host="server-01"}[2h])

# 内存使用在过去 1 小时的变化
delta(node_memory_usage_bytes[1h])

3. avg_over_time() - 计算平均值

promql 复制代码
# 过去 5 分钟的平均 CPU 使用率
avg_over_time(node_cpu_usage_percent[5m])

# 过去 1 小时的平均队列长度
avg_over_time(queue_length[1h])

4. predict_linear() - 预测未来

这是个很强大的函数:

promql 复制代码
# 基于过去 1 小时的数据,预测 4 小时后磁盘剩余空间
predict_linear(node_filesystem_free_bytes[1h], 4*3600)

# 如果预测值 < 0,说明磁盘会满
predict_linear(node_filesystem_free_bytes[1h], 4*3600) < 0

原理是简单线性回归:

  1. 看过去 1 小时的趋势
  2. 假设趋势延续
  3. 推算 4 小时后的值

实战技巧

技巧 1:Gauge 可以用 rate(),但要理解含义

promql 复制代码
# 对 Gauge 用 rate() 是可以的
rate(queue_length[5m])

# 但含义是:队列长度的变化速率
# 如果结果是正数:队列在增长
# 如果结果是负数:队列在减少

技巧 2:内存"可用"vs"空闲"

promql 复制代码
# 错误:MemFree 不是真正可用的内存
node_memory_MemFree_bytes

# 正确:MemAvailable 才是真正可用的
node_memory_MemAvailable_bytes

# 原因:Linux 会用空闲内存做 cache/buffer
# MemFree 只是完全未使用的
# MemAvailable = Free + 可回收的 cache

技巧 3:计算百分比

promql 复制代码
# 内存使用率
(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100

# 磁盘使用率
(1 - node_filesystem_avail_bytes / node_filesystem_size_bytes) * 100

Gauge 的设计智慧

Gauge 的本质是"快照":它记录的是某一时刻的状态。

这个设计的精妙之处在于:

  1. 不关心历史:只告诉你"现在是多少",过去的事情不重要
  2. 可增可减:真实反映系统状态的变化
  3. 天然支持预测:用 predict_linear() 可以基于趋势预测未来

这是"简单模型"的第二个例子:一个可以任意设置的数值,就能描述所有"当前状态"的场景。没有复杂的状态机,没有历史追踪,简单而直接。

2.3 Histogram(直方图)

为什么需要 Histogram

先看一个问题。

假设你的 API 平均响应时间是 100ms。那系统是否健康?

不知道。因为平均值会掩盖问题:

shell 复制代码
场景 1:所有请求都是 100ms
→ 平均值 100ms,系统很稳定

场景 2:
90% 的请求是 10ms
10% 的请求是 910ms
→ 平均值还是 100ms,但有严重的长尾问题!

这就是著名的"平均值谎言"。你需要知道分布

  • 有多少请求在 50ms 内?
  • 有多少请求在 100ms 内?
  • 有多少请求在 500ms 内?
  • 有多少请求超过 1 秒?

Histogram 就是来解决这个问题的。

Histogram 的结构

一个 Histogram 指标会生成多条时间序列:

ini 复制代码
# TYPE http_request_duration_seconds histogram

http_request_duration_seconds_bucket{le="0.005"} 0
http_request_duration_seconds_bucket{le="0.01"} 0
http_request_duration_seconds_bucket{le="0.025"} 0
http_request_duration_seconds_bucket{le="0.05"} 50
http_request_duration_seconds_bucket{le="0.1"} 150
http_request_duration_seconds_bucket{le="0.25"} 180
http_request_duration_seconds_bucket{le="0.5"} 195
http_request_duration_seconds_bucket{le="1"} 198
http_request_duration_seconds_bucket{le="2.5"} 200
http_request_duration_seconds_bucket{le="5"} 200
http_request_duration_seconds_bucket{le="+Inf"} 200

http_request_duration_seconds_sum 45.67
http_request_duration_seconds_count 200

分解一下:

  1. 多个 _bucket:每个 bucket 是一个 Counter,记录"≤ 某个值"的请求数
  2. _sum:所有请求的耗时总和(Counter)
  3. _count:总请求数(Counter)

两个关键理解

理解 1:bucket 是累积的

ini 复制代码
le="0.05" → 50   表示:≤ 0.05 秒的请求有 50 个
le="0.1"  → 150  表示:≤ 0.1 秒的请求有 150 个(包含前面的 50 个)
le="0.5"  → 195  表示:≤ 0.5 秒的请求有 195 个(包含前面的 150 个)

所以:

  • 0.05 ~ 0.1 秒之间的请求数 = 150 - 50 = 100 个
  • 0.1 ~ 0.5 秒之间的请求数 = 195 - 150 = 45 个
graph TD A["le=0.05 有50个"] --> B["le=0.1 有150个 包含前面50个"] B --> C["le=0.5 有195个 包含前面150个"] style A fill:#e1f5ff style B fill:#fff4e1 style C fill:#ffe1f5

理解 2:不存原始数据,存区间统计

Histogram 不会存每个请求的准确耗时:

makefile 复制代码
不是这样存:
request_1: 0.234 秒
request_2: 0.156 秒
request_3: 0.089 秒
...

而是这样存:
≤ 0.05 秒: 50 个
≤ 0.1 秒: 150 个
≤ 0.5 秒: 195 个
...

用两个类比理解

类比 1:存的是等第,不是分数

yaml 复制代码
不存每个学生的分数:
学生 A: 95 分
学生 B: 88 分
学生 C: 76 分
... (1000 个学生)

而是存分数段统计:
≥ 90 分(A 等): 150 人
≥ 80 分(B 等): 400 人(包含 A 等)
≥ 70 分(C 等): 700 人(包含 A+B 等)
≥ 60 分(D 等): 950 人
所有人: 1000 人

这样:

  • 存储量从 1000 条记录 → 5 条记录
  • 牺牲了精确度(不知道具体分数)
  • 保留了分布信息(知道有多少人在各个分数段)

类比 2:数据库的 GROUP BY 聚合

sql 复制代码
-- 不是存每条订单明细
SELECT order_id, amount FROM orders;  -- 100 万行

-- 而是存金额区间统计
SELECT 
  CASE 
    WHEN amount <= 100 THEN '0-100'
    WHEN amount <= 500 THEN '100-500'
    WHEN amount <= 1000 THEN '500-1000'
  END AS range,
  COUNT(*) AS count
FROM orders
GROUP BY range;  -- 只有几行

Histogram 就像是提前做好的 GROUP BY 聚合。

常用函数

histogram_quantile() - 计算分位数

promql 复制代码
# 计算 P95(95 分位数)
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))

# 计算 P99
histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))

# 计算 P50(中位数)
histogram_quantile(0.5, rate(http_request_duration_seconds_bucket[5m]))

分位数的含义:

  • P95 = 0.8 秒 → 95% 的请求在 0.8 秒内完成
  • P99 = 1.5 秒 → 99% 的请求在 1.5 秒内完成

为什么必须配合 rate()?

因为 _bucket 是 Counter(累积的),你需要转成速率:

promql 复制代码
# 错误:直接用 bucket
histogram_quantile(0.95, http_request_duration_seconds_bucket)
# 这会基于"总累积值"计算,结果是错的

# 正确:先 rate() 再计算分位数
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))
# 这是基于"过去 5 分钟的速率"计算,结果才对

计算平均响应时间

promql 复制代码
# 用 sum 和 count 计算平均值
rate(http_request_duration_seconds_sum[5m])
/
rate(http_request_duration_seconds_count[5m])

实战案例

假设你的 API 监控显示:

promql 复制代码
# 平均响应时间
avg = 150ms

# P95
histogram_quantile(0.95, ...) = 200ms

# P99
histogram_quantile(0.99, ...) = 2000ms  ← 注意这里!

分析:

  • 平均 150ms 看起来不错
  • P95 200ms 也还行
  • 但 P99 2000ms 说明有严重的长尾问题

可能的原因:

  • 某些请求触发了慢查询
  • 某些请求有 GC 停顿
  • 某些请求等待外部服务超时

这就是 Histogram 的价值:让长尾问题无所遁形

Histogram 的设计智慧

Histogram 是四种类型中最复杂的,但也是设计最精妙的:

  1. 牺牲精确,换取效率

    • 不存每个请求的准确耗时(太贵)
    • 只存区间统计(够用)
    • 从 1000 条数据压缩到 10 条
  2. 用空间换时间

    • 提前分好区间(bucket)
    • 查询时只需要聚合,不需要重新计算
    • histogram_quantile() 基于线性插值,速度快
  3. 可聚合是关键

    • 多个实例的 bucket 可以相加
    • 这是 Histogram 比 Summary 更通用的原因
    • 牺牲了客户端计算成本,换来了服务端灵活性

这是"简单模型看透复杂问题"的巅峰之作:用几个累积的计数器(bucket),配合一个聚合函数(histogram_quantile),就能看透整个系统的性能分布。

2.4 Summary(摘要)

定义

Summary 和 Histogram 类似,都是用来统计分布,但有个关键区别:分位数在哪计算

Histogram vs Summary

对比维度 Histogram Summary
分位数计算位置 Prometheus(服务端) Exporter(客户端)
存储内容 bucket 统计 预计算的分位数
查询时计算 需要用 histogram_quantile() 直接读
能否聚合 能(多实例) 不能
灵活性 可以算任意分位数 只能看预设的
客户端开销 小(只统计 bucket) 大(要计算分位数)

Summary 的结构

ini 复制代码
# TYPE http_request_duration_seconds summary

http_request_duration_seconds{quantile="0.5"} 0.232
http_request_duration_seconds{quantile="0.9"} 0.821
http_request_duration_seconds{quantile="0.99"} 1.523

http_request_duration_seconds_sum 45.67
http_request_duration_seconds_count 200

注意:

  1. 没有 _bucket
  2. 直接给出了 quantile="0.5" 等分位数的值
  3. 这些分位数是在 Exporter 里算好的

用类比理解

scss 复制代码
Histogram 方案:
Java 代码统计 bucket → 发送给 Prometheus → 查询时用 histogram_quantile() 计算

Summary 方案:
Java 代码直接计算 P50/P90/P99 → 发送给 Prometheus → 查询时直接读

就像:

ini 复制代码
Histogram = 给你食材,你自己做菜
Summary = 直接给你做好的菜

Summary 为什么不能聚合?

这是个关键限制。

假设有 2 个实例:

ini 复制代码
实例 1: P90 = 0.8 秒
实例 2: P90 = 1.2 秒

问:整体的 P90 是多少?

答案:不知道

你不能简单地:

  • 求平均:(0.8 + 1.2) / 2 = 1.0 ? 错!
  • 取最大:max(0.8, 1.2) = 1.2 ? 错!

因为分位数不能这样算。只有原始数据(或 bucket 统计)才能正确聚合。

promql 复制代码
# Histogram 可以聚合
histogram_quantile(0.9, 
  sum(rate(http_request_duration_seconds_bucket[5m])) by (le)
)
# 先把多个实例的 bucket 加起来,再计算 P90

# Summary 不能聚合
sum(http_request_duration_seconds{quantile="0.9"})
# 这样做是错的!把多个实例的 P90 加起来毫无意义

什么时候用 Summary?

适合 Summary 的场景:

  1. 单实例应用:不需要聚合
  2. 查询性能敏感:Summary 查询更快(不需要计算)
  3. 分位数固定:你确定只需要 P50/P90/P99

典型例子:Go 程序的 GC 时间监控(官方库默认用 Summary)

ini 复制代码
go_gc_duration_seconds{quantile="0"} 0.000042
go_gc_duration_seconds{quantile="0.25"} 0.000065
go_gc_duration_seconds{quantile="0.5"} 0.000077
go_gc_duration_seconds{quantile="0.75"} 0.000091
go_gc_duration_seconds{quantile="1"} 0.001752

Histogram vs Summary 选择建议

markdown 复制代码
你的场景                        →  推荐类型
----------------------------------------------
多实例,需要聚合                →  Histogram
单实例                          →  Summary 或 Histogram
不确定需要哪些分位数            →  Histogram
需要预测未来的分位数            →  Histogram
客户端资源受限                  →  Histogram
查询性能要求极高                →  Summary

实战建议:优先用 Histogram,除非有特殊原因。

Summary 的设计哲学

Summary 体现了一个有趣的权衡:客户端计算 vs 服务端灵活性

  • Histogram:客户端简单,服务端强大(可以算任意分位数)
  • Summary:客户端复杂,服务端简单(只能看预设的分位数)

Prometheus 最终选择让 Histogram 成为主力,Summary 作为特殊场景的补充。这个选择背后的思考是:

监控系统应该把复杂性留在服务端(Prometheus),让客户端(Exporter)尽可能简单。

这又回到了开头的哲学:简单优先。即使 Summary 查询更快,但它牺牲了灵活性,也增加了客户端的复杂度。相比之下,Histogram 虽然查询时需要计算,但客户端更简单,也更通用。

四种类型的本质

到这里,我们可以总结四种类型的本质了:

类型 本质 设计智慧
Counter 只增的累积器 牺牲绝对精确,专注速率和趋势
Gauge 可变的快照 不关心历史,只关心当前
Histogram 分布的压缩 牺牲精确度,换取存储和计算效率
Summary 预计算的结果 客户端复杂,服务端简单

四种类型,覆盖了监控的所有场景:

  • 需要累积?Counter
  • 需要当前状态?Gauge
  • 需要看分布?Histogram
  • 需要极致性能?Summary

这就是"用最少的类型描述最多的场景"的智慧。


3. Histogram:如何优雅地处理分布问题

3.1 高基数问题

这是使用 Histogram 时最容易踩的坑。

一个 Histogram 会生成多少时间序列?

假设你有 10 个 bucket,那么一个 Histogram 指标会生成:

ini 复制代码
10 个 _bucket + 1 个 _sum + 1 个 _count = 12 条时间序列

现在看看高基数问题:

场景 1:低基数标签

promql 复制代码
http_request_duration_seconds_bucket{service="api", path="/users", le="0.1"}

标签组合:

  • service: 5 个服务
  • path: 20 个接口
  • le: 10 个 bucket

总时间序列数:

ini 复制代码
5 × 20 × 10 = 1000 条(只是 bucket)
1000 + 5×20×2 = 1200 条(加上 sum 和 count)

这完全可以接受。

场景 2:高基数标签

promql 复制代码
# 错误示例!
http_request_duration_seconds_bucket{user_id="123456", le="0.1"}

标签组合:

  • user_id: 100 万用户
  • le: 10 个 bucket

总时间序列数:

ini 复制代码
1,000,000 × 10 = 10,000,000 条(只是 bucket)
10,000,000 + 1,000,000 × 2 = 12,000,000 条(加上 sum 和 count)

这会直接把 Prometheus 打爆!

flowchart LR A[100个服务] --> B[低基数方案 1200条] A --> C[高基数方案 100万用户] C --> D[1200万条时间序列] style B fill:#ccffcc style D fill:#ffcccc

高基数放大效应

对比 Gauge 和 Histogram:

标签组合数 Gauge Histogram (10 bucket) 放大倍数
100 100 1,200 12x
1,000 1,000 12,000 12x
10,000 10,000 120,000 12x
100,000 100,000 1,200,000 12x

Histogram 把基数放大了 (bucket数 + 2) 倍!

如何避免?

  1. 不要在高基数标签上用 Histogram
promql 复制代码
# 错误
http_duration{user_id="..."}  # user_id 是高基数
http_duration{ip="..."}       # IP 是高基数
http_duration{trace_id="..."}  # trace_id 是高基数

# 正确
http_duration{service="...", path="..."}  # 低基数
  1. 用路径模板代替具体路径
promql 复制代码
# 错误
http_duration{path="/api/users/123456"}  # 每个用户ID一个路径

# 正确
http_duration{path="/api/users/:id"}     # 模板化路径
  1. 减少 bucket 数量
go 复制代码
// 不要设置太多 bucket
buckets := []float64{
    0.001, 0.005, 0.01, 0.025, 0.05, 0.075, 0.1,
    0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0,
}  // 15 个 bucket,太多了!

// 简化到必要的 bucket
buckets := []float64{0.1, 0.5, 1.0, 5.0}  // 4 个 bucket 够用

3.2 bucket 边界如何设置?

这是个常见问题:bucket 的 le 值应该设多少?

原则 1:覆盖你关心的范围

比如你的 API:

  • 正常情况下:50-200ms
  • 可接受范围:< 500ms
  • 超时设置:5 秒

那么 bucket 可以设置为:

go 复制代码
buckets := []float64{0.05, 0.1, 0.2, 0.5, 1, 5}
// 覆盖了 50ms, 100ms, 200ms, 500ms, 1s, 5s

原则 2:分布要合理

go 复制代码
// 不好:集中在小范围
buckets := []float64{0.1, 0.11, 0.12, 0.13, 10}

// 好:均匀分布
buckets := []float64{0.1, 0.5, 1, 5, 10}

原则 3:考虑计算精度

分位数计算是基于线性插值的,bucket 越密集,精度越高:

ini 复制代码
bucket 设置: [0.1, 1.0, 10.0]

如果 P95 落在 1.0 - 10.0 之间
Prometheus 会用线性插值估算
精度较低(跨度太大)

bucket 设置: [0.1, 0.5, 1.0, 2.0, 5.0, 10.0]

如果 P95 落在 1.0 - 2.0 之间
精度会更高(跨度小)

Prometheus 默认的 bucket

Prometheus 官方库的默认 bucket 是:

go 复制代码
[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]

这个设置覆盖了从 5ms 到 10s 的范围,对大多数 API 来说够用。

3.3 _sum 和 _count 的作用

Histogram 的 _sum_count 不是强制的,但强烈建议有。

作用 1:计算平均值

promql 复制代码
rate(http_request_duration_seconds_sum[5m])
/
rate(http_request_duration_seconds_count[5m])

如果没有 _sum_count,你算不出平均值。

作用 2:计算请求速率

promql 复制代码
rate(http_request_duration_seconds_count[5m])

这等价于:

promql 复制代码
rate(http_requests_total[5m])

所以 _count 其实就是个 Counter,记录请求总数。

作用 3:检查数据一致性

promql 复制代码
# _count 应该等于最大的 bucket
http_request_duration_seconds_count
==
http_request_duration_seconds_bucket{le="+Inf"}

如果不相等,说明数据有问题。

官方客户端库会自动生成

用官方库(Go、Java、Python)不用操心,它们会自动维护 _sum_count

go 复制代码
// Go 代码
histogram := prometheus.NewHistogram(...)
histogram.Observe(0.234)  // 自动更新 _bucket、_sum、_count

只有手写 Exporter 时才需要注意。

Histogram 设计的深层思考

Histogram 的三个设计细节(bucket 累积、高基数问题、_sum/_count)都指向一个核心思想:

在性能、精度、灵活性之间找到最佳平衡点。

  • bucket 累积:让聚合变简单(直接相加)
  • 控制高基数:避免存储爆炸
  • _sum 和 _count:用两个额外的 Counter 就能算平均值

没有一个设计是完美的,但 Histogram 找到了一个"足够好"的平衡点:

  • 牺牲一点精确度(不知道准确耗时)
  • 换取存储效率(压缩 1000 倍)
  • 保留灵活性(可以算任意分位数)
  • 支持聚合(多实例可以合并)

这就是"简单模型"的力量:用简单的规则(累积的 bucket),解决复杂的问题(性能分布)。


4. 易混淆概念:细节中的设计智慧

4.1 rate() vs irate()

这是最容易混淆的一对函数。

计算方式的区别

promql 复制代码
rate(http_requests_total[5m])   # 用 5 分钟窗口内所有点计算平均速率
irate(http_requests_total[5m])  # 只用最近两个点计算瞬时速率

举个例子

假设过去 5 分钟的 Counter 值:

makefile 复制代码
时间      值
10:00    1000
10:01    1100  (+100)
10:02    1200  (+100)
10:03    1300  (+100)
10:04    1400  (+100)
10:05    1600  (+200)  ← 最后 1 分钟增加了 200

rate() 的计算:

ini 复制代码
总增量 = 1600 - 1000 = 600
时间跨度 = 5 分钟 = 300 秒
rate = 600 / 300 = 2.0/s

irate() 的计算:

ini 复制代码
最近两个点的增量 = 1600 - 1400 = 200
时间间隔 = 60 秒
irate = 200 / 60 = 3.33/s

曲线对比

makefile 复制代码
rate():  平滑曲线
时间  值
10:00  2.0
10:01  2.0
10:02  2.0
10:03  2.0
10:04  2.0
10:05  2.0  ← 仍然是平均值

irate(): 敏感曲线
时间  值
10:00  1.67
10:01  1.67
10:02  1.67
10:03  1.67
10:04  1.67
10:05  3.33  ← 立即反应了突增

何时用哪个?

场景 推荐 原因
告警规则 rate() 避免毛刺导致误报
容量规划 rate() 需要长期趋势
实时监控 Dashboard irate() 快速反应变化
SLO 计算 rate() 需要平滑数据
调试问题 irate() 需要看瞬时情况

一个实战建议

在 Grafana Dashboard 上:

  • 概览页面用 rate()(看整体趋势)
  • 详情页面用 irate()(看实时波动)

4.2 increase() vs rate()

定义

promql 复制代码
increase(http_requests_total[1h])  # 返回 1 小时的增量(总数)
rate(http_requests_total[1h])      # 返回每秒的速率

换算关系

scss 复制代码
increase(metric[1h]) = rate(metric[1h]) × 3600

因为 1 小时 = 3600 秒。

实际例子

promql 复制代码
http_requests_total

时间    值
09:00  1000
10:00  2000

increase(http_requests_total[1h]) = 2000 - 1000 = 1000
→ 1 小时增加了 1000 个请求

rate(http_requests_total[1h]) = (2000 - 1000) / 3600 = 0.278
→ 平均每秒 0.278 个请求

什么时候用哪个?

promql 复制代码
# 你想知道"1 小时内总共处理了多少请求"
increase(http_requests_total[1h])

# 你想知道"平均每秒处理多少请求"
rate(http_requests_total[1h])

# 告警:每秒请求超过 100
rate(http_requests_total[5m]) > 100

# 告警:1 小时内错误超过 1000 个
increase(error_total[1h]) > 1000

4.3 histogram_quantile() 的常见误区

误区 1:不用 rate()

promql 复制代码
# 错误
histogram_quantile(0.95, http_request_duration_seconds_bucket)

# 正确
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))

为什么?因为 bucket 是 Counter(累积值),你需要转成速率:

ini 复制代码
bucket 的原始值:
le="0.1"  1000  ← 累积有 1000 个
le="0.5"  5000  ← 累积有 5000 个

rate() 后:
le="0.1"  2.5   ← 每秒 2.5 个
le="0.5"  10    ← 每秒 10 个

histogram_quantile 需要"每秒多少个"才能算对

误区 2:丢掉 le 标签

promql 复制代码
# 错误:聚合时丢掉了 le
histogram_quantile(0.95, 
  sum(rate(http_request_duration_seconds_bucket[5m]))
)

# 正确:by (le) 保留 le 标签
histogram_quantile(0.95, 
  sum(rate(http_request_duration_seconds_bucket[5m])) by (le)
)

为什么?因为 histogram_quantile() 需要 le 标签来知道每个 bucket 的边界。

误区 3:对非 bucket 指标使用

promql 复制代码
# 错误:这不是 histogram
histogram_quantile(0.95, http_requests_total)

# histogram_quantile 只能用于 _bucket 指标

4.4 sum() vs avg() vs rate()

这三个函数经常一起用,容易混淆。

sum() - 求和

promql 复制代码
# 把所有实例的请求数加起来
sum(rate(http_requests_total[5m]))

avg() - 求平均

promql 复制代码
# 计算所有实例的平均请求速率
avg(rate(http_requests_total[5m]))

区别

bash 复制代码
场景:3 个实例

实例 1: 100 req/s
实例 2: 200 req/s
实例 3: 300 req/s

sum() = 100 + 200 + 300 = 600 req/s  ← 总请求速率
avg() = (100 + 200 + 300) / 3 = 200 req/s  ← 平均每个实例的速率

什么时候用哪个?

promql 复制代码
# 你想知道"整个集群每秒处理多少请求"
sum(rate(http_requests_total[5m]))

# 你想知道"平均每个实例每秒处理多少请求"
avg(rate(http_requests_total[5m]))

5. 生产环境的实战经验

5.1 指标命名规范

基本格式

xml 复制代码
<namespace>_<name>_<unit>_<suffix>

示例:

bash 复制代码
node_memory_MemAvailable_bytes     # 服务器内存
http_requests_total                # HTTP 请求
http_request_duration_seconds      # HTTP 耗时
mysql_queries_total                # MySQL 查询
redis_memory_used_bytes            # Redis 内存

规范细节

部分 说明 示例
namespace 所属系统 node, http, mysql, redis
name 指标含义 requests, queries, memory
unit 单位 bytes, seconds, ratio
suffix 类型后缀 _total, _bucket, _count

单位规范

  • 时间:_seconds(不要用 ms 或 minutes)
  • 大小:_bytes(不要用 KB 或 MB)
  • 比例:_ratio(0-1)或 _percent(0-100)
promql 复制代码
# 好
http_request_duration_seconds 0.234
memory_usage_bytes 8589934592
cpu_usage_ratio 0.85

# 不好
http_request_duration_ms 234
memory_usage_mb 8192
cpu_usage_percent 85

为什么统一用基本单位?

  1. 避免单位换算错误
  2. PromQL 可以用 * 1000 等转换显示

5.2 标签设计原则

原则 1:低基数

promql 复制代码
# 好:低基数标签
http_requests{method="GET", status="200"}  
→ method 有 5 个值(GET/POST/PUT/DELETE/PATCH)
→ status 有 10 个值(200/404/500...)
→ 总组合 5 × 10 = 50

# 坏:高基数标签
http_requests{user_id="123456"}
→ user_id 有百万个值
→ 百万条时间序列

原则 2:有意义

promql 复制代码
# 好:标签有实际含义
http_requests{service="api", path="/users"}

# 坏:标签没什么用
http_requests{server_index="1", cluster_id="abc123"}

原则 3:路径模板化

promql 复制代码
# 坏:每个用户一个路径
http_duration{path="/api/users/123456"}
http_duration{path="/api/users/789012"}
...

# 好:用模板
http_duration{path="/api/users/:id"}

原则 4:不要用标签存数值

promql 复制代码
# 坏:把数值放标签里
http_requests{duration="0.234"}

# 好:用专门的 Histogram 指标
http_request_duration_seconds_bucket{le="0.5"}

5.3 常见的生产问题

问题 1:Counter 突然下降怎么办?

现象:

makefile 复制代码
http_requests_total

10:00  1000
10:01  1500
10:02  500   ← 突然下降了!

可能原因:

  1. Pod/容器重启了(正常)
  2. Prometheus 抓取错误(问题)
  3. 应用 bug(问题)

排查:

promql 复制代码
# 看 up 指标,确认是否抓取成功
up{job="my-app"}

# 看是否有重启
kube_pod_container_status_restarts_total

问题 2:Histogram 的 P99 突然飙升

现象:

makefile 复制代码
P50: 100ms(正常)
P90: 200ms(正常)
P99: 5000ms(异常!)

这说明有 1% 的请求特别慢,可能原因:

  1. 数据库慢查询
  2. 缓存未命中
  3. GC 停顿
  4. 下游服务超时

排查:

promql 复制代码
# 看慢的是哪个接口
topk(5,
  histogram_quantile(0.99, 
    rate(http_duration_bucket[5m])
  )
)

# 对比 P50 和 P99 的差异
histogram_quantile(0.99, ...) 
/ 
histogram_quantile(0.5, ...)
# 如果比值 > 10,说明有严重长尾

问题 3:指标突然消失

现象:

ini 复制代码
http_requests_total{service="api"} 

10:00  有数据
10:05  没有数据了

可能原因:

  1. 服务挂了(up == 0
  2. 标签变了(比如 service 名称改了)
  3. 指标被删除了

排查:

promql 复制代码
# 看服务是否存活
up{job="my-app"}

# 看所有 http_requests_total 指标(不限制标签)
http_requests_total

# 看最近新出现的时间序列
time() - timestamp(http_requests_total) < 300

5.4 性能优化技巧

技巧 1:用 Recording Rules 预计算

如果一个查询很复杂,经常用到,可以用 Recording Rule 预先计算:

yaml 复制代码
groups:
  - name: api_rules
    interval: 30s
    rules:
      - record: job:http_requests:rate5m
        expr: sum(rate(http_requests_total[5m])) by (job)
      
      - record: job:http_p95_seconds
        expr: histogram_quantile(0.95, 
                sum(rate(http_duration_bucket[5m])) by (job, le)
              )

然后查询时直接用:

promql 复制代码
# 不用每次都算 rate() 和 sum()
job:http_requests:rate5m

# 不用每次都算 histogram_quantile()
job:http_p95_seconds

技巧 2:控制指标数量

promql 复制代码
# 查看指标数量
count(http_requests_total)

# 查看哪个标签基数最高
count by (service) (http_requests_total)
count by (path) (http_requests_total)

如果某个标签基数太高,考虑:

  1. 去掉这个标签
  2. 合并标签值(比如把所有 5xx 归为一类)
  3. 用路径模板

技巧 3:合理设置抓取间隔

yaml 复制代码
# prometheus.yml
scrape_configs:
  - job_name: 'high-frequency'
    scrape_interval: 15s  # 变化快的指标
  
  - job_name: 'low-frequency'
    scrape_interval: 60s  # 变化慢的指标

没必要所有指标都 15 秒抓一次。

生产实践的智慧

生产环境的经验告诉我们:理论再完美,也要经得起实战检验。

这一章的所有经验都指向一个原则:在理论和实际之间找到平衡

  • 命名规范:让人一眼看懂,而不是炫技
  • 标签设计:低基数优先,宁简勿繁
  • 问题排查:从简单到复杂,逐步缩小范围
  • 性能优化:先用 Recording Rules 预计算,再考虑复杂方案

这些经验的背后,还是那个哲学:简单优先,够用就好


6. 下一步:从理解到实践

通过这篇文章,我们深入探讨了 Prometheus 的设计哲学:

一种哲学:简单优先

  • 牺牲一点精确性,换取简单和可靠
  • 把复杂性留在服务端,让客户端简单
  • 在性能、精度、灵活性之间找平衡

四个指标:用最少的类型描述最多的场景

  • Counter:只增不减的累积器
  • Gauge:可增可减的快照
  • Histogram:分布的压缩
  • Summary:预计算的结果

一个模型:如何看透复杂系统

Prometheus 用四种简单的指标类型,就能描述世间所有监控场景。这不是巧合,而是抓住了本质:

  • 累积?用 Counter
  • 状态?用 Gauge
  • 分布?用 Histogram
  • 性能?用 Summary

这就是"用简单模型看透复杂系统"的智慧。


但理解哲学只是第一步,接下来你需要知道:

如何设计一个 Exporter?

  • node-exporter 是怎么设计的?
  • 为什么 CPU 时间用 Counter,CPU 使用率用 Gauge?
  • 为什么某些指标要预聚合?

如何在 Grafana 中构建复杂表达式?

  • 如何计算 CPU 使用率?
  • 如何计算内存可用率?
  • 如何预测磁盘何时满?

如何开发自定义 Exporter?

  • 监控数据库应该关注哪些指标?
  • 如何选择合适的类型?
  • 如何避免常见的坑?

让我们继续下一篇,以 node-exporter 为例,深入学习监控指标的设计艺术。

下一篇:《Prometheus Exporter 设计实战 ------ node-exporter 源码解析与 Grafana 集成》


附录:四种类型速查表

类型 用途 只增不减 典型场景 常用函数
Counter 累计次数 请求数、错误数、查询数 rate(), increase()
Gauge 当前状态 CPU、内存、连接数、队列长度 直接读、delta()
Histogram 分布统计 是(bucket) API 响应时间、请求大小 histogram_quantile()
Summary 预计算分位数 是(count) GC 时间(单实例) 直接读 {quantile}

选择决策树

css 复制代码
你的问题是什么?
├─ 发生了多少次? → Counter
├─ 现在是多少? → Gauge
└─ 分布如何(有没有长尾)?
   ├─ 多实例需要聚合 → Histogram
   └─ 单实例 → Summary 或 Histogram
相关推荐
卡尔特斯2 小时前
Go-Zero 日志使用指南
go
Cosolar2 小时前
MySQL EXPLAIN 执行计划分析:能否查看 JOIN 关联顺序
数据库·后端·mysql
TianXinCoord2 小时前
SpringBoot+MyBatis Plus+PostgreSQL整合常用数据类型(json、array)操作
后端
川西胖墩墩2 小时前
中文PC端跨职能流程图模板免费下载
大数据·论文阅读·人工智能·架构·流程图
dajun1811234563 小时前
简单快速跨职能流程图在线设计工具 中文
人工智能·架构·流程图
程序员爱钓鱼3 小时前
用Python开发“跳一跳”小游戏——从零到可玩
后端·python·面试
程序员爱钓鱼3 小时前
Python 源码打包成.whl文件的完整指南
后端·python·面试
IT_陈寒3 小时前
Vite 3.0 实战:5个优化技巧让你的开发效率提升50%
前端·人工智能·后端
、BeYourself3 小时前
Spring AI ChatClient 响应处理
后端·ai·springai