rate 的真相 · 它其实在外推
rate() 是最常用的 PromQL 函数,也是最被误解的。这一章读 functions.go 里 extrapolatedRate 的真实实现——它会外推到窗口边界、会猜测 counter 的零点、于是 increase() 能给出 1.5 个这种非整数结果。以及那些真正该记住的实战规则。
rate 的真相 · 它其实在外推
rate() 是 PromQL 里最常用的函数,也是最被误解的。
大多数人以为它就是“(末值 - 初值) / 时间”。不是。 它比这复杂得多——而这个复杂性会以奇怪的结果形式浮出水面,比如 increase() 告诉你“这 5 分钟涨了 1.5 个请求”。
这一章打开 promql/functions.go 的 extrapolatedRate,看它到底干了什么。
源码的自我说明
函数注释开门见山(functions.go:445):
// extrapolatedRate is a utility function for rate/increase/delta.
// It calculates the rate (allowing for counter resets if isCounter is true),
// extrapolates if the first/last sample is close to the boundary, and returns
// the result as either per-second (if isRate is true) or overall.关键词:extrapolates(外推)。 rate、increase、delta 共用同一个实现,区别只是两个 bool:isCounter(要不要处理计数器重置)和 isRate(要不要除以时间变成“每秒”)。
为什么要外推
问题:你要 [5m] 窗口的速率,但采样点不会正好落在窗口的两端**。**
窗口: |←────────────── 5m ──────────────→|
时间: 10:00 10:05
采样点: ● ● ● ●
10:01 10:02 10:03 10:04
↑ 前面空了 1 分钟 ↑ 后面空了 1 分钟如果只用 (最后一个点 - 第一个点) / (它们的时间差),你算的其实是 10:01→10:04 这 3 分钟的速率,而不是用户要的 5 分钟。窗口两端各有一段“没数据”的空白。
rate() 的选择:假设速率在整个窗口里大致恒定,把速率“外推”到窗口边界。 于是它返回的是“如果这个速率持续整个 5 分钟”的结果。
这是个合理但有代价的假设——代价就是下面那些怪现象。
外推的真实规则:1.1 倍阈值
源码里那段注释把规则说得极清楚(functions.go:538 起,真实注释):
If samples are close enough to the (lower or upper) boundary of the range, we extrapolate the rate all the way to the boundary in question. "Close enough" is defined as "up to 10% more than the average duration between samples" […] Essentially, we are assuming a more or less regular spacing between samples, and if we don't see a sample where we would expect one, we assume the series does not cover the whole range, but starts and/or ends within the range. We still extrapolate the rate in this case, but not all the way to the boundary, but only by half of the average duration between samples.
对应的代码:
extrapolationThreshold := averageDurationBetweenSamples * 1.1
if durationToStart >= extrapolationThreshold {
durationToStart = averageDurationBetweenSamples / 2 // ← 只外推半个间隔
}翻译成人话,rate() 的外推分两种情况:
- 边界处的空白 < 1.1 × 平均采样间隔 —— “这只是正常的采样错位” → 一路外推到窗口边界。
- 边界处的空白 ≥ 1.1 × 平均采样间隔 —— “这里本该有个采样点却没有,说明 series 在窗口内才开始/结束”(比如 pod 是 3 分钟前才启动的)→ 只外推半个平均间隔,不敢推到边界。
这个 1.1 和 /2 是精心设计的启发式:
1.1(容忍 10% 抖动)—— 抓取有轻微抖动是正常的,不能因为差几百毫秒就判定“series 不覆盖整个窗口”。/2(猜中点)—— 如果确实缺了采样点,series 到底在哪开始的?不知道。猜“在缺失区间的中点”是个无偏的估计。
这段注释是本专栏读到的最好的源码注释之一——它不只说“做了什么”,还说了“为什么这么假设”。读源码时这种注释要精读。
counter 不能为负:零点猜测
还有一层更精妙的处理(functions.go:552 起):
if isCounter {
// Counters cannot be negative. If we have any slope at all
// (i.e. resultFloat went up), we can extrapolate the zero point
// of the counter. If the duration to the zero point is shorter
// than the durationToStart, we take the zero point as the start
// of the series, thereby avoiding extrapolation to negative counter values.
durationToZero := durationToStart
if resultFloat > 0 && samples.Floats[0].F >= 0 {
durationToZero = sampledInterval * (samples.Floats[0].F / resultFloat)
}
// ...
}这解决一个真实的边界问题:一个刚启动的进程,counter 从 0 开始。
假设一个 pod 在 10:04 启动,10:04:30 第一次被抓到时 http_requests_total = 10。现在算 rate(...[5m])(窗口 10:00→10:05):
- 朴素外推 —— 会把速率一路推到 10:00,得出“10:00 时 counter 是负数”的荒谬结论(因为往回推 4 分钟,值会被推成负的)。但 counter 不可能为负!
- 零点猜测 —— 源码算出“按这个速率,counter 应该是在 X 时刻为 0 的”,如果这个零点晚于窗口起点,就拿零点当 series 的起点。于是不会外推出负值。
这是个很体贴的细节:它让“pod 刚启动”这类极常见的情况不会产生垃圾数据。counter 的语义(只增、非负)被真正用来约束外推——类型不只是标注,是引擎会依赖的承诺,这里就是明证。
于是:那些“怪”结果都有了解释
① increase() 给出非整数(比如 1.5 个请求)
这是最常被问的“bug”。它不是 bug,是外推的必然结果:窗口里实际观测到 1 个请求,但外推到完整窗口后估算成 1.5 个。
increase() 返回的是“估算值”而非“精确计数”。 如果你需要精确的计数,increase() 不是正确的工具——考虑用别的手段(比如直接看 counter 的差值,或者接受它的估算语义)。
② 稀疏数据下 rate() 严重失真
如果一个 counter 很久才涨一次(比如错误计数,几小时才 +1),rate(errors_total[5m]) 在大部分时间是 0,偶尔冒出一个被外推放大的尖峰。低频事件不适合 rate() 的假设(“速率大致恒定”)。
③ 窗口太小 → 数据不足 → 返回空
rate() 至少需要 2 个采样点。如果 [1m] 窗口里只有 1 个点(抓取间隔 15s 时正常有 4 个,但抖动/慢抓取时可能不足),返回空,图上出现断点。
实战规则(记住这几条)
① 窗口至少是抓取间隔的 4 倍
这是最重要的一条经验法则。抓取间隔 15s → 窗口至少 1m,推荐 [5m]。
为什么?窗口里至少要有 4 个点,才能:
[5m] 之所以成为事实标准的默认值,就是因为它对 15s/30s 的常见抓取间隔都留了足够余量。
② 告警窗口别太小
告警规则里用 rate(x[1m]) 会让告警对单次抓取失败极度敏感(误报)。用 [5m] 起步。
③ rate() 只用于 counter
对 gauge 用 rate() 是语义错误——gauge 会下降,而 rate() 会把下降当成计数器重置,给出离谱结果。gauge 要看变化用 deriv() 或 delta()。
④ 先 rate() 再 sum(),不要反过来
# ✅ 对
sum(rate(http_requests_total[5m]))
# ❌ 错:sum 会把不同实例的 counter 加起来,
# 一旦某个实例重启归零,和会突然下降,
# rate 把这个下降当成计数器重置 → 结果完全错乱
rate(sum(http_requests_total)[5m:])rate() 必须直接作用在原始 counter 上,因为只有那样它才能正确识别每条 series 各自的重置。先 sum() 就把各实例的重置混在一起了,rate() 再也分不清。这是 PromQL 最经典的错误之一。
小结与下一章
rate() 不是简单的差值除以时间——它在外推(extrapolatedRate,rate/increase/delta 共用同一实现,只差 isCounter/isRate 两个 bool)。外推是因为采样点不会正好落在窗口两端,它假设“速率大致恒定”并推到边界。规则(源码真实注释):边界空白 < 1.1 × 平均采样间隔 → 一路推到边界(容忍抓取抖动);≥ 则认为 series 在窗口内才开始/结束 → 只推半个间隔(猜中点,无偏估计)。还有 counter 零点猜测:counter 不能为负,所以刚启动的进程不会被外推出负值——counter 的语义被真正用来约束外推。于是那些“怪”结果都有解释:increase() 给非整数(是估算不是精确计数)、稀疏数据下失真(低频事件不满足“速率恒定”假设)、窗口太小返回空。实战四条:①窗口 ≥ 4 × 抓取间隔([5m] 是事实标准的原因);②告警别用 [1m];③rate() 只用于 counter;④先 rate() 再 sum()——反过来会让各实例的重置混在一起,结果完全错乱。
rate() 之后通常要聚合。下一章看聚合与向量匹配。👉 聚合与向量匹配。