深入理解 PromQL: 从 AST 向量求值引擎、时间微积分到 Histogram 分位数底层算法全解

深入理解 PromQL: 从 AST 向量求值引擎、时间微积分到 Histogram 分位数底层算法全解

在上一篇中, 我们拆解了 Prometheus 如何通过 Pull 模型抓取数据, 并利用倒排索引与 Gorilla 算法将样本压缩进自定义二进制 TSDB. 但数据落盘只是第一步, 监控系统的真正灵魂在于如何从数以亿计的离散样本点中, 实时计算出具备业务指导意义的指标(如 QPS、错误率、P99 延迟).

很多后端开发者在写 PromQL 时, 常常靠“死记硬背模板”来拼凑语句——比如知道算 QPS 要套 rate(), 算 P99 要写一长串 histogram_quantile(0.99, sum by (le) (rate(..._bucket[5m]))), 却不清楚为什么要按这个顺序套娃, 也不明白为什么漏掉一个 le 或者少写一个 rate 算出来的数据就面目全非.

本文将从编译原理(AST 抽象语法树求值)、向量代数与数值分析的底层视角, 彻底拆解 PromQL 的语法规则与计算引擎, 并对最抽象的 Histogram(直方图) 进行源码级的数学推演.


一、 PromQL 的底层世界观: 类型系统与 AST 求值模型

与 SQL 的“声明式关系代数(基于表、行、列的集合过滤与投影)”完全不同, PromQL 是一门纯函数式的嵌套向量表达式语言(Functional Vector Expression Language).

当 Prometheus 收到一条 PromQL 查询时, 解析器(Parser)会将其解析为一棵抽象语法树(AST), 采用自底向上(Bottom-Up)的方式逐层求值: 叶节点负责从 TSDB 倒排索引中捞出原始数据, 父节点(函数或运算符)对子节点返回的向量做数学变换, 最终在根节点收敛为返回给 Grafana 的结果.

1. 内存中的四大核心数据类型

在 AST 的任意一个节点上, 流转的数据必定属于以下四种类型之一(其中前两种占据了 99% 的场景):

数据类型 语法形态示例 内存数据结构(Go 伪代码表示) 物理意义与能否直接在 Grafana 画图
瞬时向量(Instant Vector) http_requests_total{code="200"} []Series{ Labels, Sample{T_eval, float64} } 在当前评估时间点 \(T_{eval}\) 上, 每条匹配的时间序列有且仅有 1 个样本值, 可以直接画图
区间向量(Range Vector) http_requests_total{code="200"}[5m] []Series{ Labels, []Sample{ {t1,v1}, {t2,v2}... } } 在 \([T_{eval} - 5\text{m}, T_{eval}]\) 左开右闭窗口内, 每条时间序列包含的一组历史样本数组, 不能直接画图, 必须经过函数坍缩为瞬时向量
标量(Scalar) 1024 或 0.95 float64 不带任何 Label 维度的纯浮点数, 常作为函数参数或算术运算的系数
字符串(String) "instance" string 仅用于极少数函数的标签名参数指定, 不直接参与数值运算

2. 瞬时向量的隐藏机制: Lookback Delta(回溯对齐)与 Staleness(陈旧标记)

理解瞬时向量时, 有一个后端开发者极易踩坑的底层细节:
假设你发起了一次瞬时查询, 指定评估时间为 10:00:00.000. 但你的业务机器上一次被抓取的时间是 09:59:52.104, 在 10:00:00.000 这一毫秒磁盘上根本没有点!那瞬时向量是怎么拿到值的?

  • 回溯窗口(Lookback Delta, 默认 5 分钟):
    当评估一个瞬时向量在 \(T_{eval}\) 时刻的值时, PromQL 引擎会在 \([T_{eval} - 5\text{m}, T_{eval}]\) 的时间区间内向左回溯, 找到离 \(T_{eval}\) 最近的那个真实样本点, 将其数值直接借用过来, 并把时间戳强行改写为 \(T_{eval}\).
  • 陈旧标记(StaleNaN):
    如果某台业务机器下线了, 或者某次抓取失败了, 难道在接下来的 5 分钟内它的数据会一直保持死前的最后一个值(产生“幽灵数据”)吗?
    不会. Prometheus 在 IEEE 754 float64 标准中利用了一个特殊的比特位模式 0x7ff0000000000002(称为 StaleNaN). 一旦发现某个 Target 在最新一次抓取中消失或某条 Series 不再暴露, 引擎会立即在 TSDB 中写入一个值为 StaleNaN 的样本. 当 Lookback 回溯率先撞上 StaleNaN 时, 引擎立刻判定该序列已死亡, 不再继续往前找旧值, 直接将其从当前计算结果中剔除.

3. 维度选择器(Selectors)与正则引擎底层行为

在叶节点提取数据时, 我们使用花括号 {} 编写标签匹配器(Label Matchers):

# 精确匹配 (=)、精确不等 (!=)、正则匹配 (=~)、正则不匹配 (!~)
http_requests_total{env="prod", status!="200", path=~"/api/v[12]/order.*", method!~"OPTIONS|HEAD"}

这里有两个直接影响查询性能的底层实现原则:

  1. 正则匹配默认全锚定(Full-Anchored RE2):
    PromQL 使用 Google 的线性时间复杂度正则引擎 RE2(彻底杜绝回溯导致的 CPU 100% 正则炸弹). 你在 PromQL 里写的 path=~"/api/v1", 在底层会被自动编译为 ^(?:/api/v1)$. 如果你想前缀匹配, 必须显式写出 .*, 即 path=~"/api/v1.*".
  2. 禁止空匹配(Empty-Match Forbidden):
    你不能写 {__name__=~".*"} 或 {job=~".*"} 试图查出所有数据. 为了防止一次误操作把整个 TSDB 的几百万条序列全部加载进内存引发 OOM, PromQL 解析器强制规定:每个选择器中至少要包含一个“非空匹配条件”(即不能匹配空字符串 "" 的条件).

二、 区间向量微积分: rate、irate、increase 的数学公式与边界外推

为什么在 Prometheus 里统计计数, 不能直接用 http_requests_total 相减, 而必须先加 [5m] 变成区间向量, 再套上 rate() 或 increase()?
更诡异的是: 明明 http_requests_total 是一个每次只加 1 的整数计数器, 为什么 increase(http_requests_total[1m]) 算出来的“1 分钟内新增请求数”经常是一个带小数点的浮点数(例如 93.333)?

让我们直接进入 Prometheus 源码 promql/functions.go 中的 extrapolatedRate 函数, 用一组真实样本数据做一次手动推演.

1. 构造测试场景

假设抓取间隔 scrape_interval = 15s, 当前评估时间点 \(T_{eval} = \text{10:01:00}\), 我们查询过去 1 分钟的窗口 [1m], 即时间窗口为 \([\text{10:00:00}, \text{10:01:00}]\)(总跨度 \(\Delta T_{\text{window}} = 60\text{s}\)).

在这 60 秒窗口内, TSDB 一共抓到了 4 个样本点, 且在 \(S_2\) 到 \(S_3\) 之间业务进程重启过一次(Counter 归零重置):

窗口左边界 (10:00:00)                                                  窗口右边界 / T_eval (10:01:00)
  |---- 5s ----|-------- 15s --------|-------- 15s --------|-------- 15s --------|----- 10s -----|
               S1                    S2                    S3 (进程重启!)         S4
           (10:00:05)            (10:00:20)            (10:00:35)            (10:00:50)
           Value = 100           Value = 130           Value = 10            Value = 40

2. 第一步: Counter Reset 自动检测与补偿

引擎从左到右遍历这 4 个点, 维护一个累积补偿值 counterCorrection:

  • \(S_1 \to S_2\): \(130 \ge 100\), 正常增长.
  • \(S_2 \to S_3\): \(10 \lt 130\)!发现后一个点比前一个点小, 引擎判定在 \([\text{10:00:20}, \text{10:00:35}]\) 之间发生了进程重启. 于是将重启前的峰值 130 计入补偿值: counterCorrection += 130.
  • \(S_3 \to S_4\): \(40 \ge 10\), 正常增长.
  • 计算窗口内首尾样本的真实增量(\(\Delta V_{\text{samples}}\)):

\[\Delta V_{\text{samples}} = (V_{\text{last}} + \text{counterCorrection}) - V_{\text{first}} = (40 + 130) - 100 = 70 \]

3. 第二步: 边界线性外推(Linear Extrapolation)—— 浮点数的真正来源!

注意看上面的时间轴: \(S_1\) 发生在 10:00:05, \(S_4\) 发生在 10:00:50.
也就是说, 这 70 次请求增量, 实际上是在 \(\Delta T_{\text{samples}} = 50 - 5 = 45\text{s}\) 内产生的!而在 [10:00:00, 10:00:05] 的前 5 秒、以及 [10:00:50, 10:01:00] 的后 10 秒内, 业务系统肯定也在处理请求, 只是抓取时间点没刚好卡在边界上而已.

如果直接把 70 当作 60 秒的总增量, 就会严重低估真实流量. 因此, Prometheus 会根据样本点之间的平均斜率, 向窗口左右边界做线性外推(Extrapolation):

\[\text{increase}(v[\text{1m}]) = \Delta V_{\text{samples}} \times \frac{\Delta T_{\text{extrapolated}}}{\Delta T_{\text{samples}}} = 70 \times \frac{60\text{s}}{45\text{s}} = 93.3333\text{ (次请求)} \]

而 rate() 函数的底层代码, 和 increase() 调用的是完全同一个函数, 唯一的区别是在最后除以了窗口总时长 \(\Delta T_{\text{window}}\):

\[\text{rate}(v[\text{1m}]) = \frac{\text{increase}(v[\text{1m}])}{\Delta T_{\text{window}}} = \frac{93.3333}{60\text{s}} = \frac{70}{45\text{s}} = 1.5556\text{ req/s} \]

工程铁律: 因为计算斜率至少需要 2 个样本点, 如果窗口内只有 1 个点, rate() 和 increase() 会直接返回空(图上断线). 在生产环境中, 为了防止单次网络抖动丢点导致图表断线, rate() 的时间窗口大小建议至少设为 scrape_interval 的 4 倍(例如 15 秒抓取一次, 窗口至少写 [1m], 推荐 [2m] 或 [5m]).

4. 高频区间函数横向对比: rate vs irate vs *_over_time

函数名称 作用的指标类型 底层计算逻辑(以上述 \(S_1 \sim S_4\) 为例) 适用工程场景与优缺点
rate(v[d]) Counter 取窗口内所有点, 补偿重置后, 用首尾两点连线斜率外推计算整个窗口的平均每秒增长率: \((170 - 100) / (50 - 5) = 1.56\text{ req/s}\) 生产告警与趋势大盘首选. 曲线平滑, 能完整捕获窗口内的所有毛刺能量, 不会漏掉瞬时洪峰.
irate(v[d]) Counter 忽略前面的所有点, 只取窗口内最后两个点(\(S_3, S_4\))计算瞬时斜率: \((40 - 10) / (50 - 35) = 2.0\text{ req/s}\) 仅适合在短时间窗口内排查瞬时尖峰, 严禁用于大跨度趋势图或告警(当 Grafana 的 step 大于抓取间隔时, irate 会丢弃中间样本导致严重的采样失真).
increase(v[d]) Counter 等价于 rate(v[d]) * d(窗口秒数), 计算窗口内的外推总增量(返回 93.33). 统计某段时间内的请求总量、报错总数.
delta(v[d]) Gauge 不做 Counter 重置补偿(允许结果为负数), 直接用首尾两点外推差值. 仅用于 Gauge(如计算过去 10 分钟磁盘可用空间下降了多少 MB、CPU 温度升高了多少度).
avg_over_time(v[d])****max_over_time(v[d]) Gauge 对单条时间序列在窗口 [d] 内的所有样本值求算术平均或最大值. 注意区分:avg_over_time 是在“时间轴(横轴)”上对单条线求平均;而 avg() 是在“维度轴(纵轴)”上对同一时刻的多条线求平均!

三、 向量空间运算: 维度聚合与多表关联(Vector Matching)

在 SQL 中, 我们用 GROUP BY 做聚合, 用 JOIN ... ON 做多表关联. 在 PromQL 的向量空间里, 这两件事有着极其严谨的标签匹配法则.

1. 维度聚合运算符(Aggregation Operators): by 与 without

当你用 sum、avg、max、min、count、stddev(标准差)、topk 对一个瞬时向量做聚合时, 本质上是在消除维度(合并多条时间序列):

# 写法 A: 白名单模式(只保留 job 和 method 标签, 抹去 instance、pod 等其他所有标签)
sum by (job, method) (rate(http_requests_total[5m]))

# 写法 B: 黑名单模式(只抹去 instance 和 pod 标签, 保留其他所有标签)
sum without (instance, pod) (rate(http_requests_total[5m]))

2. 二元向量运算与 on / ignoring 法则

假设你想计算每个实例的 HTTP 500 错误率. 你的直觉可能会写出下面这行完全错误的 PromQL:

# 【错误示范】结果永远为空(Empty Query Result)!
rate(http_requests_total{status="500"}[5m]) 
/ 
rate(http_requests_total[5m])

为什么会返回空?
因为在 PromQL 中, 当两个瞬时向量做算术运算(+、-、*、/)或比较运算时, 引擎默认执行一对一严格标签匹配(One-to-One Exact Matching): 它会把左操作数(分子)和右操作数(分母)中除了指标名 __name__ 以外, 其余所有 Label 键值对完全一模一样的两条序列配对运算!

我们看看上面分子和分母的标签集合:

  • 分子(过滤了 500): {instance="10.0.1.5", job="order", status="500"}(只有 status="500" 的序列)
  • 分母(未过滤): 包含 {..., status="200"}、{..., status="404"}、{..., status="500"}. 如果直接相除, status="500" 只会和分母里的 status="500" 配对相除, 结果永远等于 1(100%)!
  • 而如果你先把分母按实例求和: sum by (instance, job) (rate(http_requests_total[5m])), 此时分母的标签变成了 {instance="10.0.1.5", job="order"}(已经没有 status 标签了), 而分子身上还挂着 status="500"!两边标签集合不一致, 配对失败, 直接返回空!

正确解法: 使用 ignoring 或 on 显式指定配对关联键(类似 SQL 的 JOIN ON):

# 解法 1: 两边都先按 (job, instance) 聚合, 彻底抹平 status 维度差异(最推荐)
sum by (job, instance) (rate(http_requests_total{status="500"}[5m]))
/
sum by (job, instance) (rate(http_requests_total[5m]))

# 解法 2: 如果不对分子聚合, 使用 ignoring(status) 告诉引擎在配对时忽略分子上的 status 标签
rate(http_requests_total{status="500"}[5m])
/ ignoring(status)
sum without (status) (rate(http_requests_total[5m]))

3. 多对一关联与维度注入: group_left(PromQL 版的 LEFT JOIN)

如果左右两边的向量在匹配键上不是 1:1, 而是 N:1(多对一) 怎么办?

典型工程场景:
你有一个记录各接口请求速率的指标 http_requests_total{instance="10.0.1.5", path="/pay"}(同一个 instance 下有几十个不同的 path, 这是多的一方 N).
同时, 你还有一个专门用来暴露应用元信息的静态指标 app_build_info{instance="10.0.1.5", version="v2.4.1", git_commit="a8f92b"} 1(每个 instance 只有 1 条, 数值恒为 1, 这是一的一方 1).
现在, 你想在展示每个接口 QPS 的同时, 把该实例所属的 version 标签拼接到结果里(或者按 version 汇总流量).

这时就需要使用 group_left 修饰符:

rate(http_requests_total[5m])
* on (instance) group_left (version)
app_build_info

它的底层执行过程如下:

[左向量: 多的一方 (Many)]                       [右向量: 一的一方 (One)]
{instance="10.0.1.5", path="/pay"}   (值: 50)    \
                                                  +--> 匹配 on(instance) <-- {instance="10.0.1.5", version="v2.4.1"} (值: 1)
{instance="10.0.1.5", path="/query"} (值: 120)   /

                      |
                      | 执行 * 运算 (乘以 1 数值不变), 并通过 group_left(version) 将右边的 version 标签拷贝到左边
                      v
[输出瞬时向量 (完成维度注入)]
{instance="10.0.1.5", path="/pay",   version="v2.4.1"}  (值: 50)
{instance="10.0.1.5", path="/query", version="v2.4.1"}  (值: 120)

记忆口诀: 哪一边是“多(Many)”, 就往哪一边 group(左边多用 group_left, 右边多用 group_right);group_left(label1, label2) 括号里写的标签名, 意思是把“一(One)”那一边独有的标签拷贝附加到结果集中.


四、 彻底攻克 Histogram(直方图): 从数据结构到 histogram_quantile 源码级推演

在后端监控中, 接口响应耗时(Latency)是最核心的指标. 但为什么我们不用平均值, 不用 Summary, 而必须用看起来极其拗口的 Histogram(直方图)?

1. 为什么平均值是毒药, 且客户端分位数(Summary)无法跨机器聚合?

  1. 平均值(Average)会掩盖长尾故障: 假设 1000 次请求里, 990 次耗时 1ms, 10 次因为数据库死锁耗时 10,000ms(10秒). 平均耗时只有 100.99ms, 看起来一切正常, 但实际上有 1% 的用户体验到了彻底卡死. 因此, 工程上必须看 分位数(Quantile / Percentile, 如 P95、P99)——即把所有请求耗时从小到大排序, 排在 95% 或 99% 位置上的那个耗时值.
  2. 分位数在数学上具有“不可加性(Non-additivity)”:
    假设你有两台订单服务节点:
  • 节点 A(空闲): 处理了 10 次请求, 在本地算出的 P99 耗时是 10ms.
  • 节点 B(过载): 处理了 10,000 次请求, 在本地算出的 P99 耗时是 1000ms.
    如果使用客户端直接算好分位数的 Summary 指标, Prometheus 拿到 {instance="A", quantile="0.99"} 10 和 {instance="B", quantile="0.99"} 1000 这两个数字后, 无论求平均(505ms)还是求最大(1000ms), 在数学上都是错的! 因为你丢掉了样本权重与原始分布, 根本没法知道这 10,010 次请求混在一起排序后的第 99% 个点到底是多少.

既然不能把每条请求的原始耗时日志都发给 Prometheus(那成了 ELK 日志系统, 数据量会大几个数量级), 唯一能在分布式环境下实现 O(1) 空间复杂度跨节点汇总求 P99 的数学工具, 就是“累积频率分布直方图(Cumulative Histogram)”!

2. 解剖 Histogram 的内部构造: 1 个指标裂变为 3 组 Counter

当你在 Java 或 C++ 代码里注册了 1 个名为 http_request_duration_seconds 的 Histogram 指标, 并设定分桶边界(Buckets)为 [0.05, 0.1, 0.2, 0.5, 1.0](单位: 秒)时, SDK 在底层内存里实际上帮你创建了 3 组完全独立的 Counter(只增不减的计数器):

  1. http_request_duration_seconds_bucket{le="..."}(累积直方图桶, 共 \(N+1\) 条序列):
    标签 le 是 Less than or Equal to(小于等于 \(\le\)) 的缩写. 除了你定义的 5 个边界外, SDK 会强制自动追加一个上界为正无穷大的桶 le="+Inf".
    【核心关键点】Prometheus 的 Bucket 是“累积(Cumulative)”的, 而不是独立互斥的区间!
    假设服务刚启动(所有桶初始为 0), 现在进来 1 次耗时为 0.15s(150ms) 的请求, SDK 内部会发生什么?
  • \(0.15 \le 0.05\) 吗?否 \(\to\) bucket{le="0.05"} 保持 0
  • \(0.15 \le 0.1\) 吗?否 \(\to\) bucket{le="0.1"} 保持 0
  • \(0.15 \le 0.2\) 吗?是! \(\to\) bucket{le="0.2"} 原子 +1(变为 1)
  • \(0.15 \le 0.5\) 吗?是! \(\to\) bucket{le="0.5"} 也原子 +1(变为 1)
  • \(0.15 \le 1.0\) 吗?是! \(\to\) bucket{le="1.0"} 也原子 +1(变为 1)
  • \(0.15 \le +\infty\) 吗?是! \(\to\) bucket{le="+Inf"} 也原子 +1(变为 1)
  1. http_request_duration_seconds_sum(总耗时累加器, 1 条序列):
    将本次请求的真实耗时 0.15 原子累加进去.
  2. http_request_duration_seconds_count(总请求计数器, 1 条序列):
    每次请求原子 +1(它的值永远严格等于 bucket{le="+Inf"}).

顺带收获: 有了 _sum 和 _count, 你甚至不需要額外埋点, 直接用下面这行 PromQL 就能算出过去 5 分钟的真实平均耗时:

sum(rate(http_request_duration_seconds_sum[5m])) / sum(rate(http_request_duration_seconds_count[5m]))

3. 剥洋葱式拆解: 为什么算 P95 必须写出那句“四层套娃”的 PromQL?

在生产环境中, 计算某个服务(跨所有机器实例)过去 5 分钟的 P95 响应耗时, 标准写法是:

histogram_quantile(
  0.95,
  sum by (job, le) (
    rate(http_request_duration_seconds_bucket[5m])
  )
)

让我们由内向外, 逐层看清这四层套娃缺一不可的原因:

  • 第一层(最内层): http_request_duration_seconds_bucket[5m]
    捞出所有机器、所有 le 分桶在过去 5 分钟内的原始累积计数样本数组.
  • 第二层: rate(...[5m]) —— 为什么必须套 rate?
    因为 _bucket 本质上是一个从进程启动第一天就开始累加的 Counter!如果不套 rate(或 increase), 你拿去算分位数的就是“从上个月上线以来的历史总分布”, 最近 5 分钟发生的接口毛刺会被过去一个月的海量历史基数稀释得无影无踪!套上 rate(...[5m]) 后, 我们得到的才是“仅在最近这 5 分钟时间窗口内, 落入各个 le 桶的请求增长速率分布”.
  • 第三层: sum by (job, le) (...) —— 为什么可以实现跨机器聚合, 且 by 里必须写 le?
    这是 Histogram 最精妙的地方!因为每個分桶记录的是“耗时小于等于 \(X\) 的请求个数”, 个数(频数)是完全满足加法交换律的!
    机器 A 上耗时 \(\le 0.2\text{s}\) 的请求有 300 个, 机器 B 上耗时 \(\le 0.2\text{s}\) 的请求有 500 个, 那么整个集群耗时 \(\le 0.2\text{s}\) 的请求就一定等于 \(300 + 500 = 800\) 个!
    为什么 by 里面绝对不能漏掉 le? 因为外层的 histogram_quantile 函数需要看到从 0.05、0.1、0.2 一直到 +Inf 的完整阶梯数组才能画图插值. 如果你只写了 sum by (job), 所有的桶会被粗暴地加成一个单值, 维度 le 丢失, 外层函数会直接返回 NaN!
  • 第四层(最外层): histogram_quantile(0.95, ...)
    接收聚合后的累积分布向量, 通过分桶定位 + 桶内线性插值算法, 计算出 0.95 分位点的近似耗时, 并自动抹去结果中的 le 标签.

4. 手动推演 histogram_quantile 的底层数学算法

为了让你对 histogram_quantile 不再有任何黑盒感, 我们拿一组具体数字, 严格按照 Prometheus 源码 promql/quantile.go 中的 bucketQuantile 算法手算一遍!

假设 job="order" 下有两台机器 Instance A 和 Instance B. 经过内层 rate(http_request_duration_seconds_bucket[5m])(为方便直观理解, 这里用 5 分钟内的增量次数 increase 来展示, 因为 rate 只是同比例除以了 300 秒, 分位数比例完全等价)以及 sum by (job, le) 聚合后, 内存里的数据表如下:

分桶上界标签 (le) 物理含义 Instance A (5m增量) Instance B (5m增量) sum by (job, le) 集群汇总累积频数 隐含的独立区间频数(当前桶 \(-\) 上一桶)
le="0.05" 耗时 \(\le 50\text{ms}\) 120 80 200 \((0\text{ms}, 50\text{ms}]\) 之间有 200 个请求
le="0.1" 耗时 \(\le 100\text{ms}\) 300 200 500 \((50\text{ms}, 100\text{ms}]\) 之间有 \(500 - 200 =\) 300 个请求
le="0.2" 耗时 \(\le 200\text{ms}\) 450 350 800 \((100\text{ms}, 200\text{ms}]\) 之间有 \(800 - 500 =\) 300 个请求
le="0.5" 耗时 \(\le 500\text{ms}\) 500 480 980 \((200\text{ms}, 500\text{ms}]\) 之间有 \(980 - 800 =\) 180 个请求
le="1.0" 耗时 \(\le 1000\text{ms}\) 505 490 995 \((500\text{ms}, 1000\text{ms}]\) 之间有 \(995 - 980 =\) 15 个请求
le="+Inf" 耗时 \(\le +\infty\)(总数) 510 490 1000 \((1000\text{ms}, +\infty)\) 之间有 \(1000 - 995 =\) 5 个请求

现在, 我们要计算 P95(即 \(\phi = 0.95\)). 算法分三步走:

第一步: 计算目标排名位置(Target Rank)

从 le="+Inf" 桶中得知, 过去 5 分钟全集群一共处理了 \(N = 1000\) 次请求.
如果把这 1000 次请求按耗时从小到大排成一排, 我们要找的 P95 对应的排名位置是:

\[\text{Rank} = \phi \times N_{+\text{Inf}} = 0.95 \times 1000 = 950\text{ (第 950 个请求)} \]

第二步: 扫描累积数组, 定位目标所在的区间桶

我们从上往下扫描 sum by (job, le) 那一列的累积频数, 寻找第一个累积频数大于等于 950 的桶:

  • 到 le="0.2"(200ms)为止, 累积了 800 个请求(不够 950 个);
  • 到 le="0.5"(500ms)为止, 累积了 980 个请求(超过了 950 个!).

结论锁定: 第 950 个请求的真实耗时, 一定落在 \((0.2\text{s}, 0.5\text{s}]\) 这个区间桶内部!
而且我们还能精确算出: 在 \((0.2\text{s}, 0.5\text{s}]\) 这个宽度为 \(0.3\text{s}\) 的桶里, 一共装了 \(980 - 800 = 180\) 个请求;我们要找的第 950 个请求, 正是这个桶里的第 \(950 - 800 = 150\) 个请求!

第三步: 桶内均匀分布假设与线性插值(Linear Interpolation)

但是, 这 180 个请求在 0.2s 到 0.5s 之间具体是怎么分布的?因为原始样本已经被丢弃, Prometheus 无法得知.
因此, histogram_quantile 做出了一个核心数学假设:假设落在同一个区间桶内的所有样本, 在桶的下界 \(B_{\text{low}}\) 和上界 \(B_{\text{high}}\) 之间是绝对均匀(线性)分布的!

累积请求数 (Count)
   ^
980|                                          * (le="0.5", 980)
   |                                       .  |
950|------------------------------------O     |  <-- 目标 Rank = 950 (在桶内占 150/180 = 5/6 的高度)
   |                                 .  |     |
   |                              .     |     |
800|                           *--------+-----+ (le="0.2", 800)
   |                        .  |        |     |
500|              *            |        |     |
200|     *                     |        |     |
   +-----+--------+------------+--------+-----+---------> 响应耗时 (Seconds)
   0   0.05      0.1          0.2     0.45   0.5
                               |<------>|
                        插值算出 P95 = 0.2 + 0.3 * (150/180) = 0.45s

根据相似三角形(线性插值公式):

\[\text{P}_{\phi} = B_{\text{low}} + \left( B_{\text{high}} - B_{\text{low}} \right) \times \frac{\text{Rank} - \text{Count}_{\text{low}}}{\text{Count}_{\text{high}} - \text{Count}_{\text{low}}} \]

将我们的数值代入公式:

\[\text{P}_{95} = 0.2 + (0.5 - 0.2) \times \frac{950 - 800}{980 - 800} = 0.2 + 0.3 \times \frac{150}{180} = 0.2 + 0.3 \times \frac{5}{6} = 0.45\text{s (即 450ms)} \]

这就是 histogram_quantile 在底层做的全部事情!

5. Histogram 的三大工程陷阱与高阶调优

理解了上面的插值公式, 你就能一眼看穿生产环境中关于 Histogram 的三大诡异现象:

  1. 陷阱一: 为什么我的 P99 曲线在 Grafana 上变成了一条死死卡在 1.0s 的水平直线?
    回顾刚才的表格: 假设你要算 P99.8(\(\text{Rank} = 998\)). 扫描发现 le="1.0" 只有 995 个请求, 第 998 个请求落入了 le="+Inf"(\((1.0\text{s}, +\infty)\) 区间)!
    此时桶的下界是 1.0s, 但上界是正无穷大 \(+\infty\), 没法做线性插值!Prometheus 源码对此的处理策略是:一旦目标落入 +Inf 桶, 直接返回倒数第二个有限桶的上界(即 1.0s)作为结果!
    所以, 如果你发现 P99 变成了一条紧贴着最大 Bucket 边界的水平直线, 说明你的最大分桶边界设得太小了, 真实耗时已经突破了天际, 但被截断(Clamped)在了最大桶上界!
  2. 陷阱二: 大跨度分桶带来的插值失真
    假设你的接口真实耗时全部集中在 205ms, 但由于你分桶设得太粗糙, 只设了 le="0.2"(200ms)和 le="1.0"(1000ms).
    此时这批 205ms 的请求全部掉进了 \((200\text{ms}, 1000\text{ms}]\) 这个跨度长达 800ms 的巨型桶里. 线性插值算出来的 P95 会高达 960ms, 与真实的 205ms 相差近 5 倍!
    分桶设计黄金准则: 围绕你服务的 SLO(服务等级目标) 密集布桶. 比如你的 SLO 要求 P99 控制在 200ms 以内, 那么你应该在 100ms、150ms、180ms、200ms、250ms、300ms 密集设置分桶, 而在远离核心区的低端和高端稀疏设桶.
  3. 演进方向: Native Histograms(原生稀疏直方图)
    传统 Histogram(现在称为 Classic Histogram)最大的痛点是:每增加 1 个 le 桶, 时间序列基数就翻 1 倍, 且桶边界必须在代码里写死.
    从 Prometheus 2.40+ 开始引入并逐步成熟的 Native Histograms 彻底重构了底层二进制存储: 它不再将每个桶拆成带 le 标签的独立 Series, 而是把整张直方图压缩进同一条时间序列的一个动态二进制结构中, 采用指数级动态分桶(Exponential Bucketing, 类似 DDSketch/HDRHistogram, \(\text{Bucket}_i = [2^{i \cdot 2^{-s}}, 2^{(i+1) \cdot 2^{-s}}]\)). 不仅无需手动预定义 le 边界、零基数膨胀成本, 而且在任意量级下都能保证极高的相对插值精度, 查询时也简化为了直接写 histogram_quantile(0.95, sum by (job) (rate(http_request_duration_seconds[5m]))).

五、 生产级复杂 PromQL 实战拆解

掌握了向量求值、时间微积分、向量配对与直方图原理后, 我们来看 4 个在大型生产集群中最常编写的高阶复合 PromQL 语句, 拆解它们的 AST 执行逻辑.

实战 1: 跳过线性插值误差, 直接利用 Histogram Bucket 精确计算 SLO 达标率(Apdex)

场景需求: 架构委员会规定支付接口的 SLO 为“99% 的请求耗时必须在 200ms(0.2s)以内”.
很多同学会先用 histogram_quantile(0.99, ...) 算出一个带插值误差的 P99 耗时, 再去和 0.2s 比较. 其实完全没必要!因为我们本来就有 le="0.2" 的桶, 直接用它除以总请求数 le="+Inf", 就能得到绝对精确、零插值误差的“耗时 \(\le 200\text{ms}\) 的请求占比”:

sum by (job) (rate(http_request_duration_seconds_bucket{le="0.2"}[5m]))
/ ignoring (le)
sum by (job) (rate(http_request_duration_seconds_bucket{le="+Inf"}[5m]))
  • 关键点解析: 分子带着 le="0.2", 分母带着 le="+Inf". 如果不加 ignoring (le), 由于两边 le 标签的值不同, 除法配对会返回空;加上 ignoring (le) 后, 两边直接按 job 一对一相除, 极其精准.

实战 2: 带“最小流量保护(Traffic Floor)”的复合逻辑防误报错误率告警

场景需求: 当某个服务的 HTTP 5xx 错误率超过 5% 时触发告警.
如果你只写 错误率大于 0.05, 半夜 3 点钟业务低谷期, 5 分钟内总共只来了 1 个爬虫请求且恰好报错, 错误率瞬间达到 \(1 / 1 = 100\%\), 直接把值班工程师吵醒. 因此, 我们需要要求:“错误率大于 5%”且“当前总 QPS 必须大于 10 req/s”同时成立才返回告警序列:

(
  sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
  /
  sum by (job) (rate(http_requests_total[5m]))
  > 0.05
)
and
(
  sum by (job) (rate(http_requests_total[5m])) > 10
)
  • 关键点解析:
  1. 在 PromQL 中, >、== 等比较运算符默认是过滤器(Filter): 左边的向量如果满足 > 0.05, 则原样保留其样本值进入下游;如果不满足, 直接将该序列从结果向量中剔除.
  2. 逻辑运算符 and 是集合交集运算: 它按标签(此处为 job)将左边算出的“高错误率服务集合”与右边算出的“高流量服务集合”求交集. 如果 दोनों两边都存在该 job, 则保留左操作数的值(即真实错误率数值)输出给告警引擎.

实战 3: 集群负载均衡倾斜度检测(基于标准差与变异系数 \(CV\))

场景需求: 一个微服务集群有 50 个节点, 挂在 Nginx 或 K8s Service 后面. 由于某个节点连接池配置异常或负载策略失效, 导致流量在 50 个节点间分配严重不均(有的机器快累死了, 有的机器没流量). 如何用一条 PromQL 监控所有服务的“流量不均匀度”?

stddev by (job) (rate(http_requests_total[5m]))
/
avg by (job) (rate(http_requests_total[5m]))
  • 关键点解析: 统计学中的变异系数(Coefficient of Variation, \(CV = \sigma / \mu\)).
  • 分子 stddev by (job) 计算同一个 job 下所有 instance 当前 QPS 的标准差 \(\sigma\);
  • 分母 avg by (job) 计算同一个 job 下所有 instance 当前 QPS 的平均值 \(\mu\).
  • 如果流量绝对均匀, 每个实例 QPS 相同, 标准差为 0, 比值为 0;一旦比值大于 0.3 以上, 说明集群内部出现了严重的单点流量倾斜.

实战 4: 子查询(Subquery [D:R])与磁盘耗尽时间线性回归预测

场景需求:

  1. rate(http_requests_total[5m]) 算出的是“当前这一刻的 5 分钟平均 QPS”(它是一个瞬时向量). 如果我想知道“过去 24 小时内, 这个 5 分钟 QPS 的最高峰值是多少”, 该怎么写?
    你不能直接写 max_over_time(rate(http_requests_total[5m])[24h])?不对, 在早期版本这会报语法错误, 因为 max_over_time 只能接收原始区间向量, 不能直接套在一个返回瞬时向量的函数外面!
    为了解决这个问题, PromQL 引入了
    子查询语法 [总时间跨度 : 步进分辨率]:
# 每隔 1 分钟计算一次 rate(...[5m]), 把过去 24 小时算出来的 1440 个点收集成区间向量, 再取最大值
max_over_time(
  rate(http_requests_total[5m])[24h:1m]
)
  1. 基于最小二乘法线性回归的磁盘空间预测(predict_linear):
    静态阈值告警(如“磁盘剩余小于 10% 告警”)有两个毛病: 对于 10TB 的大盘, 10% 还有 1TB, 可能还能用半年;而当业务代码发生日志死循环狂写磁盘时, 从 15% 掉到 0% 可能只需要 10 分钟, 静态告警来不及响应.
    使用 predict_linear(v[d], t), 引擎会对窗口 [d] 内的所有 Gauge 样本点做简单线性回归(Ordinary Least Squares, \(y = kx + b\)), 并根据拟合出的斜率 \(k\) 和截距 \(b\), 预测在未来 \(t\) 秒之后该指标的数值:
# 根据过去 2 小时磁盘可用字节数的下降斜率, 预测 4 小时(4 * 3600 = 14400 秒)后可用空间是否会小于 0
predict_linear(node_filesystem_free_bytes{mountpoint="/"}[2h], 4 * 3600) < 0

六、 PromQL 核心心智模型速查表

在日常编写与排查 PromQL 时, 只要在脑海中对照下面这张速查表, 就能保证语法与数学逻辑的绝对严密:

核心维度 黄金法则与底层心智模型 常见反模式(错误写法) 标准生产范式(正确写法)
Counter 指标处理 Counter 永远只增不减且随重启归零, 任何 Counter 在参与聚合或画图前, 必须先套上 rate / increase 转化为速率 sum(http_requests_total)(把各机器自开机以来的累计值直接相加, 毫无意义) sum by (job) (rate(http_requests_total[5m]))
聚合与求导顺序 先 rate 再 sum! 绝对不能先 sum 再 rate(因为 sum 输出的是瞬时向量, 且合并后会破坏单机的 Counter Reset 检测) rate(sum(http_requests_total)[5m])(语法错误, 且丢失单机重启边界) sum by (job) (rate(http_requests_total[5m]))
双向量二元运算 算术/比较运算符两边的向量, 默认要求除 __name__ 外的所有 Label 完全一致才能配对运算 metric{code="500"} / metric(左边带 code="500", 无法与分母其他 code 配对) 两边统一用 sum by (job) 对齐维度, 或使用 ignoring(code) / on(job)
Histogram 分位数 必须遵循 “选 Bucket \(\to\) 套 rate \(\to\) 按 le 聚合 \(\to\) 算 histogram_quantile” 四步铁律 avg(histogram_quantile(0.99, rate(bucket[5m])))(分位数不可求平均, 且漏了 sum by (le)) histogram_quantile(0.99, sum by (job, le) (rate(bucket[5m])))
时间轴 vs 维度轴 区分清是在同一时刻跨机器聚合, 还是在单台机器上跨时间窗口聚合 想求集群平均 CPU 却写了 avg_over_time(cpu_usage[5m]) 跨机器用 avg by (job) (...);单机跨时间用 avg_over_time(...[5m])
posted @ 2026-11-02 11:19  虾野百鹤  阅读(3)  评论(0)    收藏  举报