# rate 的真相 · 它其实在外推

> rate（） 是最常用的 PromQL 函数，也是最被误解的。这一章读 functions.go 里 extrapolatedRate 的真实实现——它会外推到窗口边界、会猜测 counter 的零点、于是 increase（） 能给出 1.5 个这种非整数结果。以及那些真正该记住的实战规则。

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

# rate 的真相 · 它其实在外推

`rate()` 是 PromQL 里**最常用**的函数，也是**最被误解**的。

大多数人以为它就是“（末值 - 初值） / 时间”。**不是。** 它比这复杂得多——而这个复杂性会以**奇怪的结果**形式浮出水面，比如 `increase()` 告诉你“这 5 分钟涨了 **1.5 个**请求”。

这一章打开 `promql/functions.go` 的 `extrapolatedRate`，看它到底干了什么。

## 源码的自我说明

函数注释开门见山（`functions.go:445`）：

```go
// 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`（要不要处理[计数器重置](https://daiw.org/manual/observability/metric-types)）和 `isRate`（要不要除以时间变成“每秒”）。

## 为什么要外推

<Callout type="info">
  **问题：你要 `[5m]` 窗口的速率，但**采样点不会正好落在窗口的两端**。**

  ```
  窗口:  |←────────────── 5m ──────────────→|
  时间:  10:00                            10:05
  采样点:      ●      ●      ●      ●
              10:01  10:02  10:03  10:04
              ↑ 前面空了 1 分钟        ↑ 后面空了 1 分钟
  ```

  如果只用 `(最后一个点 - 第一个点) / (它们的时间差)`，你算的其实是 **10:01→10:04 这 3 分钟**的速率，而不是**用户要的 5 分钟**。窗口两端各有一段“没数据”的空白。

  **`rate()` 的选择：假设速率在整个窗口里大致恒定，把速率“外推”到窗口边界。** 于是它返回的是“**如果这个速率持续整个 5 分钟**”的结果。

  这是个**合理但有代价**的假设——代价就是下面那些怪现象。
</Callout>

## 外推的真实规则：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**.

对应的代码：

```go
extrapolationThreshold := averageDurationBetweenSamples * 1.1
if durationToStart >= extrapolationThreshold {
    durationToStart = averageDurationBetweenSamples / 2   // ← 只外推半个间隔
}
```

<Callout type="info">
  **翻译成人话，`rate()` 的外推分两种情况：**

  - **边界处的空白 < 1.1 × 平均采样间隔** —— **“这只是正常的采样错位”** → **一路外推到窗口边界**。
  - **边界处的空白 ≥ 1.1 × 平均采样间隔** —— **“这里本该有个采样点却没有，说明 series 在窗口内才开始/结束”**（比如 pod 是 3 分钟前才启动的）→ **只外推半个平均间隔**，不敢推到边界。

  **这个 `1.1` 和 `/2` 是精心设计的启发式：**
  - `1.1`（容忍 10% 抖动）—— [抓取有轻微抖动是正常的](https://daiw.org/manual/observability/scrape-loop)，不能因为差几百毫秒就判定“series 不覆盖整个窗口”。
  - `/2`（猜中点）—— 如果确实缺了采样点，**series 到底在哪开始的？不知道**。猜“在缺失区间的中点”是个无偏的估计。

  **这段注释是本专栏读到的最好的源码注释之一**——它不只说“做了什么”，还说了“**为什么这么假设**”。[读源码时这种注释要精读](https://daiw.org/manual/observability/reading-the-source)。
</Callout>

## counter 不能为负：零点猜测

还有一层更精妙的处理（`functions.go:552` 起）：

```go
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)
    }
    // ...
}
```

<Callout type="info">
  **这解决一个真实的边界问题：**一个刚启动的进程，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 刚启动](https://daiw.org/manual/observability/scrape-loop)”这类极常见的情况不会产生垃圾数据。**counter 的语义（只增、非负）被真正用来约束外推**——[类型不只是标注，是引擎会依赖的承诺](https://daiw.org/manual/observability/metric-types)，这里就是明证。
</Callout>

## 于是：那些“怪”结果都有了解释

<Callout type="warn">
  **① `increase()` 给出非整数（比如 1.5 个请求）**

  这是最常被问的“bug”。**它不是 bug，是外推的必然结果**：窗口里实际观测到 1 个请求，但外推到完整窗口后估算成 1.5 个。

  **`increase()` 返回的是“估算值”而非“精确计数”。** 如果你需要精确的计数，`increase()` **不是**正确的工具——[考虑用别的手段](https://daiw.org/manual/observability/promql-rate)（比如直接看 counter 的差值，或者接受它的估算语义）。

  **② 稀疏数据下 `rate()` 严重失真**

  如果一个 counter 很久才涨一次（比如错误计数，几小时才 +1），`rate(errors_total[5m])` 在大部分时间是 0，偶尔冒出一个被外推放大的尖峰。**低频事件不适合 `rate()` 的假设（“速率大致恒定”）。**

  **③ 窗口太小 → 数据不足 → 返回空**

  `rate()` **至少需要 2 个采样点**。如果 `[1m]` 窗口里只有 1 个点（[抓取间隔 15s 时正常有 4 个，但抖动/慢抓取时可能不足](https://daiw.org/manual/observability/scrape-loop)），**返回空**，图上出现断点。
</Callout>

## 实战规则（记住这几条）

<Callout type="info">
  **① 窗口至少是抓取间隔的 4 倍**

  这是最重要的一条经验法则。抓取间隔 15s → **窗口至少 1m，推荐 `[5m]`**。

  为什么？**窗口里至少要有 4 个点**，才能：
  - 容忍[偶尔的抓取失败](https://daiw.org/manual/observability/scrape-loop)（丢 1 个点还剩 3 个）。
  - 让“[平均采样间隔](https://daiw.org/manual/observability/promql-rate)”这个估计比较靠谱（外推逻辑依赖它）。
  - 避免“窗口里只有 1 个点 → 返回空 → 图上断线”。

  **`[5m]` 之所以成为事实标准的默认值，就是因为它对 15s/30s 的常见抓取间隔都留了足够余量。**

  **② 告警窗口别太小**

  [告警规则](https://daiw.org/manual/observability/alerting-rules)里用 `rate(x[1m])` 会让告警对单次抓取失败极度敏感（[误报](https://daiw.org/manual/observability/alerting-philosophy)）。**用 `[5m]` 起步。**

  **③ `rate()` 只用于 counter**

  对 gauge 用 `rate()` 是语义错误——[gauge 会下降，而 `rate()` 会把下降当成计数器重置](https://daiw.org/manual/observability/metric-types)，给出离谱结果。gauge 要看变化用 `deriv()` 或 `delta()`。

  **④ 先 `rate()` 再 `sum()`，不要反过来**

  ```text
  # ✅ 对
  sum(rate(http_requests_total[5m]))
  # ❌ 错:sum 会把不同实例的 counter 加起来,
  #        一旦某个实例重启归零,和会突然下降,
  #        rate 把这个下降当成计数器重置 → 结果完全错乱
  rate(sum(http_requests_total)[5m:])
  ```
  **`rate()` 必须直接作用在原始 counter 上**，因为只有那样它才能[正确识别每条 series 各自的重置](https://daiw.org/manual/observability/metric-types)。先 `sum()` 就把各实例的重置混在一起了，`rate()` 再也分不清。**这是 PromQL 最经典的错误之一。**
</Callout>

## 小结与下一章

`rate()` **不是简单的差值除以时间——它在外推**（`extrapolatedRate`，`rate`/`increase`/`delta` [共用同一实现](https://daiw.org/manual/observability/promql-rate)，只差 `isCounter`/`isRate` 两个 bool）。**外推是因为**[采样点不会正好落在窗口两端](https://daiw.org/manual/observability/promql-rate)，它假设“速率大致恒定”并推到边界。**规则**（源码真实注释）：[边界空白 < **1.1 ×** 平均采样间隔 → 一路推到边界（容忍抓取抖动）；≥ 则认为 series 在窗口内才开始/结束 → **只推半个间隔**（猜中点，无偏估计）](https://daiw.org/manual/observability/scrape-loop)。还有 **counter 零点猜测**：[counter 不能为负，所以刚启动的进程不会被外推出负值](https://daiw.org/manual/observability/metric-types)——**counter 的语义被真正用来约束外推**。于是那些“怪”结果都有解释：[`increase()` 给非整数（是估算不是精确计数）、稀疏数据下失真（低频事件不满足“速率恒定”假设）、窗口太小返回空](https://daiw.org/manual/observability/promql-rate)。**实战四条**：①**窗口 ≥ 4 × 抓取间隔**（[`[5m]` 是事实标准的原因](https://daiw.org/manual/observability/scrape-loop)）；②[告警别用 `[1m]`](https://daiw.org/manual/observability/alerting-philosophy)；③[`rate()` 只用于 counter](https://daiw.org/manual/observability/metric-types)；④**先 `rate()` 再 `sum()`**——[反过来会让各实例的重置混在一起，结果完全错乱](https://daiw.org/manual/observability/metric-types)。

`rate()` 之后通常要聚合。下一章看聚合与向量匹配。👉 [聚合与向量匹配](https://daiw.org/manual/observability/promql-aggregation)。

## 参考资料

- [1] Prometheus 源码 `promql/functions.go:445`（`extrapolatedRate` 完整实现与注释、`extrapolationThreshold` 的 1.1 倍规则、counter 零点猜测）。
- [2] Prometheus 官方文档：`rate()`、`increase()`、Query functions。
- [3] 关联[指标类型](https://daiw.org/manual/observability/metric-types)、[抓取抖动](https://daiw.org/manual/observability/scrape-loop)、[两种向量](https://daiw.org/manual/observability/promql-model)。
