# 抓取循环 · 心跳所在

> scrapeLoop 是 Prometheus 的心脏。这一章读 scrape/ 源码：用哈希把目标打散避免惊群、自动附加的 8 个元指标（排查采集问题的第一现场）、sample_limit 的自保、以及那个精妙的 staleness 标记。

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

# 抓取循环 · 心跳所在

[目标发现了](https://daiw.org/manual/observability/service-discovery)、[标签改好了](https://daiw.org/manual/observability/relabeling)，现在真正去抓。**`scrapeLoop` 是 Prometheus 的心脏**——每个目标一个，按周期跳动。这一章读它，并给出**排查采集问题的实战套路**，收尾第 1 部分。

## 结构：池 + 循环

源码里两个核心类型：

- **`scrapePool`**(`scrape/scrape.go:84`)—— 管理**一个 job 下所有目标**的抓取。配置变化时，它负责增删 loop。
- **`scrapeLoop`**(`scrape/scrape.go:828`)—— **一个目标一个**，它的 `run()`（`scrape.go:1263`）就是那个永不停歇的循环：等到点 → 抓 → 解析 → [relabel](https://daiw.org/manual/observability/relabeling) → 交给 [`storage.Appender`](https://daiw.org/manual/observability/prometheus-architecture)。

## 用哈希把目标打散：避免惊群

一个精妙的细节。假设你有 5000 个目标、抓取间隔 15s——如果它们**同时**被抓，每 15 秒就会有一次 5000 并发的尖峰，把 Prometheus 和网络都打出锯齿。

看 `Target.offset()` 怎么解决（真实源码 `scrape/target.go:170`）：

```go
func (t *Target) offset(interval time.Duration, offsetSeed uint64) time.Duration {
    now := time.Now().UnixNano()
    var (
        base   = int64(interval) - now%int64(interval)
        offset = (t.hash() ^ offsetSeed) % uint64(interval)   // ← 关键
        next   = base + int64(offset)
    )
    if next > int64(interval) {
        next -= int64(interval)
    }
    return time.Duration(next)
}
```

<Callout type="info">
  **每个目标的抓取时刻，由它自己**标签的哈希**决定一个在 `[0, interval)` 内的固定偏移——于是 5000 个目标被**均匀摊开**在 15 秒里，负载平滑，没有惊群。**

  为什么用**哈希**而不是随机数？因为哈希是**确定性**的：
  - **同一个目标，偏移永远相同** —— 于是它的采样点在时间轴上是**等间距**的（每次都是第 7.3 秒被抓），而不是忽早忽晚。这对 [`rate()` 这类基于时间差的计算](https://daiw.org/manual/observability/metric-types)很重要——**抖动会让速率算出来忽高忽低**。
  - **重启后不变** —— Prometheus 重启，目标的偏移还是那个，时间轴上不会出现错位。
  - **`^ offsetSeed`** —— 异或一个种子。这样**多个 Prometheus 实例**（比如两台做冗余）可以配不同 seed，**错开抓取同一批目标**，避免同时压向被抓服务。

  **确定性的哈希打散，是分布式系统里一个反复出现的技巧**——[上一章的 `hashmod` 分片](https://daiw.org/manual/observability/relabeling)也是同一个思路：用哈希取代随机，换来可预测和可复现。
</Callout>

## 自动附加的 8 个元指标：排查第一现场

每次抓取，Prometheus 会**自动写入**一批关于“这次抓取本身”的指标（源码 `scrape/scrape.go:2044` 起）：

| 指标 | 含义 | 实战用途 |
| --- | --- | --- |
| `up` | 抓成功=1，失败=0 | **最重要的指标**：`up == 0` 就是目标挂了 |
| `scrape_duration_seconds` | 这次抓取耗时 | 接近 `scrape_timeout` 就危险了 |
| `scrape_samples_scraped` | 抓回来多少个样本 | **基数监控**：突然暴涨=有人加了高基数标签 |
| `scrape_samples_post_metric_relabeling` | [metric relabel](https://daiw.org/manual/observability/relabeling) 之后剩多少 | 和上一个对比，看 relabel 丢了多少 |
| `scrape_series_added` | 本次新增多少条 series | **持续不为 0 = [基数在流失](https://daiw.org/manual/observability/defining-metrics)** |
| `scrape_timeout_seconds` | 配置的超时 | 和 duration 对比 |
| `scrape_sample_limit` | 配置的样本上限 | 见下 |
| `scrape_body_size_bytes` | 响应体大小 | 端点是不是太胖了 |

<Callout type="info">
  **这 8 个指标是“**关于监控的监控**”——排查采集问题时，它们是你的第一现场。几条立刻能用的查询：**

  ```text
  # 谁挂了?
  up == 0

  # 抓取要超时了?(耗时超过超时的 80%)
  scrape_duration_seconds > 0.8 * scrape_timeout_seconds

  # 谁在制造基数?(按 job 看每次抓回的样本数)
  topk(10, scrape_samples_scraped)

  # 基数在持续增长?(series 不断新增 = 有高基数标签在流失)
  rate(scrape_series_added[10m]) > 0
  ```

  **`scrape_series_added` 持续大于 0 是个强烈的警报信号**：正常情况下，一个稳定的服务应该反复吐出**同一批 series**，新增应该趋近于 0。如果它一直在涨，说明**每次抓取都在产生新的标签组合**——典型元凶是标签里混进了[时间戳、UUID、递增 ID、错误消息](https://daiw.org/manual/observability/defining-metrics)。这是 [TSDB 内存爆炸](https://daiw.org/manual/observability/three-pillars)的**前兆**，能在事故前抓到它。
</Callout>

## sample_limit：防止一个目标拖垮全局

<Callout type="warn">
  **`sample_limit` 是一道**自保闸门**：如果一次抓取返回的样本数超过限制，Prometheus 会**丢弃整次抓取**并把 `up` 记为 0。**

  这个设计初看很粗暴（为什么不留一部分？），但它是对的：**一个失控的服务不应该有能力打垮整个监控系统**。如果某个应用因为 bug 吐出 100 万条 series，没有闸门的话 Prometheus 会照单全收，然后 [TSDB 内存爆掉](https://daiw.org/manual/observability/three-pillars)、**整个集群的监控全瞎**——为了一个坏服务牺牲所有服务的可观测性，显然不划算。

  **丢弃整次而非部分，是为了避免“部分数据”带来的误导**——半截的指标可能让你算出完全错误的结论，还不如明确地“这个目标现在没数据（`up=0`）”。**宁可明确地失败，也不要含糊地成功**，这是监控系统该有的态度。

  实战建议：**给 `sample_limit` 设一个值**（比如 10000），别裸奔。配合上面的 `scrape_samples_scraped` 告警，你能在撞上限之前就发现苗头。
</Callout>

## staleness：怎么知道一条 series “没了”

一个容易忽略但很精妙的机制。目标消失了（pod 被删），它的 series 该怎么办？

<Callout type="info">
  **Prometheus 会给消失的 series 写入一个特殊的 **staleness marker**（陈旧标记）——一个特殊的 NaN 值，表示“**这条 series 到此为止了**”。**

  为什么需要它？如果只是“不再写新数据”，那么查询时 Prometheus 会因为 **lookback**（默认往回找 5 分钟）而**继续返回最后那个值**——于是一个已经死掉的 pod，在图上还会有一条“平的线”持续 5 分钟。更糟的是，如果这条 series 参与了告警计算，你会基于**幽灵数据**做决策。

  staleness marker 让查询**立刻知道**“这条 series 结束了”，不再返回它。这解决了：
  - 目标消失（pod 删除、SD 里没了）。
  - 某条指标不再被暴露（应用不再吐这个 series 了）。

  **这是“[数据缺失](https://daiw.org/manual/observability/three-pillars)”和“数据为 0”之间的区别**——两者语义完全不同，而 staleness marker 让 Prometheus 能表达前者。实战意义：[写告警时](https://daiw.org/manual/observability/three-pillars)要分清“值是 0”（服务活着但没流量）和“没有数据”（服务没了）——后者常常需要用 `absent()` 或 `up == 0` 来单独处理。
</Callout>

## 实战：采集问题排查顺序

把这一章的东西串成一个套路。当“我的指标没了”时，**按这个顺序查**：

```mermaid
flowchart TD
    Q["我的指标查不到"] --> A{"up == 0 ?"}
    A -->|"是"| A1["目标抓失败<br/>→ 看 Prometheus 的 targets 页面错误<br/>→ 网络?端口?TLS?"]
    A -->|"否(能抓到)"| B{"curl /metrics 有这个指标吗?"}
    B -->|"没有"| B1["应用根本没吐<br/>→ 埋点问题,不是采集问题"]
    B -->|"有"| C{"metric_relabel_configs<br/>drop 掉了?"}
    C -->|"是"| C1["改 relabel 规则"]
    C -->|"否"| D{"撞 sample_limit 了?<br/>scrape_samples_scraped"}
    D -->|"是"| D1["整次被丢 → 提限或减基数"]
    D -->|"否"| E["查标签:<br/>relabel 是否改了标签名?"]
    style A1 fill:#ef4444,color:#fff
    style B1 fill:#f59e0b,color:#000
```

**这个顺序的逻辑是“从外到内、从便宜到贵”：先看 `up`（一个查询搞定）→ 再 `curl` 端点（确认是采集问题还是埋点问题）→ 最后才查 relabel 规则（最烧脑）。**

## 小结与下一部分

**`scrapeLoop`**（`scrape/scrape.go:828`，`run():1263`）是 Prometheus 的心脏，一个目标一个，由 **`scrapePool`**（`:84`）管理。**`Target.offset()`**（`target.go:170`）用**标签哈希**给每个目标算一个固定偏移，把目标**均匀摊开**避免惊群——用哈希而非随机是为了[采样等间距（`rate()` 才准）、重启后不变、多实例可用 seed 错开](https://daiw.org/manual/observability/scrape-loop)。每次抓取自动附加 **8 个元指标**（`scrape.go:2044` 起），它们是“[关于监控的监控](https://daiw.org/manual/observability/scrape-loop)”、排查第一现场——尤其 **`up == 0`**（挂了）和 **`scrape_series_added` 持续 > 0**（[基数流失的前兆](https://daiw.org/manual/observability/defining-metrics)）。**`sample_limit`** 是自保闸门：超限则[丢弃整次抓取并 `up=0`](https://daiw.org/manual/observability/scrape-loop)——[宁可明确失败，不要含糊成功](https://daiw.org/manual/observability/scrape-loop)。**staleness marker**（特殊 NaN）让查询知道“[这条 series 到此为止](https://daiw.org/manual/observability/scrape-loop)”，避免死掉的目标因 lookback 留下 5 分钟的幽灵数据。

第 1 部分讲完了“**指标怎么被拿到**”。但一个更前置、也更重要的问题是：**你到底该定义哪些指标？** 下一部分是本专栏最实战的部分之一。👉 第 2 部分 · 指标体系：RED、USE 与黄金信号。

## 参考资料

- [1] Prometheus 源码 `scrape/scrape.go`（`scrapePool:84`、`scrapeLoop:828`、`run:1263`、元指标 `:2044` 起）、`scrape/target.go:170`（`offset`）。
- [2] Prometheus 官方文档：Configuration — `sample_limit`;Querying basics — staleness。
- [3] 关联[服务发现](https://daiw.org/manual/observability/service-discovery)、[relabeling](https://daiw.org/manual/observability/relabeling)、[基数控制](https://daiw.org/manual/observability/defining-metrics)。
