# 查询路径 · 数据怎么被读出来

> 一次查询要同时读内存的 Head 和磁盘的多个 block，还要把结果合并成一条连续的 series。这一章读 storage.Querier 接口和 SeriesSet 的惰性迭代模型，并讲清 native histograms 与远程写这两个扩展，收尾第 3 部分。

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

# 查询路径 · 数据怎么被读出来

数据存好了（[压缩](https://daiw.org/manual/observability/chunk-encoding)、[索引](https://daiw.org/manual/observability/index-format)、[压实](https://daiw.org/manual/observability/blocks-compaction)）。现在反过来：**怎么读出来？**

一次查询“过去 6 小时的 `{job="api"}`”要面对一个尴尬的现实：**这段数据散落在多个地方**——最近 2 小时在[内存的 Head](https://daiw.org/manual/observability/head-and-wal) 里，更早的在**若干个 block** 里。查询必须把它们**缝合**成一条连续的 series。

## Querier 接口

看真实签名（`storage/interface.go:123`）：

```go
// Querier provides querying access over time series data of a fixed time range.
type Querier interface {
    LabelQuerier
    // Select returns a set of series that matches the given label matchers.
    // Caller can specify if it requires returned series to be sorted.
    // Prefer not requiring sorting for better performance.
    Select(ctx context.Context, sortSeries bool, hints *SelectHints,
           matchers ...*labels.Matcher) SeriesSet
}
```

<Callout type="info">
  **三个值得注意的设计：**

  - **一个 Querier 覆盖一个固定时间范围** —— 注释说得很清楚：“over time series data of **a fixed time range**”。查询开始时先“开”一个 Querier（指定 mint/maxt），它内部决定要读哪些 block。**时间范围在创建时就固定**，这让它能提前规划好数据源。
  - **`sortSeries bool`** —— **调用方声明“我需不需要结果有序”**，注释还特意提醒：“**Prefer not requiring sorting for better performance**”。为什么？因为[排序不是免费的](https://daiw.org/manual/observability/index-format)，而**很多操作根本不需要有序**（比如 `sum()` 只是把值加起来，顺序无所谓）。**把“要不要排序”的决定权交给调用方**，是个精细的性能设计——默认不排，需要时才付代价。
  - **`hints *SelectHints`** —— 查询引擎可以**给存储层提示**（比如“我只要算 `rate()`，给我 counter 的边界点就行”）。注释很坦诚：“it's up to implementation **how this is used if used at all**”——**这是个可选优化，存储层可以完全忽略**。这种“提示而非命令”的设计，让接口保持简单的同时给优化留了口子。
</Callout>

## SeriesSet：惰性迭代

`Select` 返回的不是数据，而是一个 **`SeriesSet`**——一个**迭代器**：

```go
ss := querier.Select(...)
for ss.Next() {           // 逐条 series
    s := ss.At()
    it := s.Iterator(nil)
    for it.Next() {       // 逐个采样点
        t, v := it.At()
        // ...
    }
}
```

<Callout type="info">
  **为什么是迭代器而不是“返回一个大数组”？因为**数据可能极大，不能一次性装进内存**。**

  想想一个查询可能碰到 10 万条 series × 每条几千个点 = 几亿个采样点。**如果 `Select` 返回 `[]Series` 且每条都带全部数据，直接 OOM。**

  迭代器让数据**流式**地流过：
  - **按需解压** —— [chunk 是压缩的](https://daiw.org/manual/observability/chunk-encoding)，只在真正迭代到它时才解压。
  - **可以提前退出** —— 比如 `topk(5, ...)` 可能不需要看完所有数据。
  - **内存恒定** —— 不管查多少数据，同时在内存里的只有“当前这条 series 的当前这个 chunk”。

  **这是所有大数据处理系统的通用模式**（火山模型 / Volcano model）：**把查询表达成一棵迭代器的树，数据自底向上流，每一层只处理“当前这一个”**。[第 4 部分的 PromQL 引擎](https://daiw.org/manual/observability/promql-engine)正是建在这个模型上。
</Callout>

## 缝合：Head + 多个 block

<Callout type="info">
  **一条 series 的数据可能同时存在于 Head 和多个 block 里，查询要把它们**归并**成一条连续的时间线。**

  ```
  查询 [T-6h, now] 的 series X:

  block A [T-6h, T-4h]:  ● ● ● ●
  block B [T-4h, T-2h]:            ● ● ● ●
  Head    [T-2h, now]:                      ● ● ● ●
  ─────────────────────────────────────────────────
  缝合后:                ● ● ● ● ● ● ● ● ● ● ● ●   ← 一条连续的 series
  ```

  这靠一层 **merge**（`storage/fanout.go`、`storage/merge.go`）：把多个 `SeriesSet` 归并成一个。因为[每个来源内部都是按时间有序的](https://daiw.org/manual/observability/index-format)，归并又是**线性**的——**有序性再一次救了场**。

  **一个微妙的问题：重叠怎么办？** [压实](https://daiw.org/manual/observability/blocks-compaction)期间、或者[乱序写入](https://daiw.org/manual/observability/head-and-wal)时，block 的时间范围**可能重叠**。TSDB 支持重叠 block，归并时会**去重**（同一时刻的点只取一个）。这是为什么 `meta.json` 里要记录每个 block 的精确时间范围。
</Callout>

## 两个扩展：native histograms 与远程写

第 3 部分收尾前，补两个重要的扩展。

### native histograms：桶自动生成

<Callout type="info">
  **[第 1 部分讲过经典 histogram 的痛](https://daiw.org/manual/observability/metric-types)：[桶要预先定死、选不好就废、桶数还吃基数](https://daiw.org/manual/observability/metric-ranges)。Prometheus 3.x 的 **native histograms** 就是来解这个的。**

  核心区别：**一个采样点不再是一个 float64，而是一整个直方图结构**（`tsdb/chunkenc/histogram.go`、`float_histogram.go`）。

  - **桶自动生成** —— 按**指数**规则（如每个桶是前一个的 1.1 倍），**覆盖任意量级**，不用你预先猜。
  - **精度可调** —— 一个 `schema` 参数控制桶的密度，想精确就调高。
  - **基数大降** —— 经典 histogram 里**每个桶是一条 series**（[`le` 是标签](https://daiw.org/manual/observability/defining-metrics)）；native histogram **整个直方图是一条 series**。**从 12 条变 1 条**。
  - **代价** —— [解析要走 protobuf 格式](https://daiw.org/manual/observability/exposition-format)（文本表达不了这么复杂的结构），生态支持还在铺开。

  **这是近年 Prometheus 最重要的演进**——它同时解决了[桶设计的纠结](https://daiw.org/manual/observability/metric-ranges)和[基数](https://daiw.org/manual/observability/defining-metrics)两个痛点。新项目值得直接上。
</Callout>

### 远程写：把数据送出去

<Callout type="info">
  **[Prometheus 刻意单机自足](https://daiw.org/manual/observability/prometheus-architecture)，但数据总要有个长期的去处——这就是 **remote write**（`storage/remote/`）。**

  它的位置很优雅：**remote write 就是 [`storage.Appender` 接口的另一个实现](https://daiw.org/manual/observability/prometheus-architecture)**。[抓取模块](https://daiw.org/manual/observability/scrape-loop)照样往 `Appender` 里塞数据，完全不知道背后是本地 TSDB 还是“发到远端”。**接口解耦的价值在这里兑现**——加一条远程写链路，抓取代码一行不改。

  这也是 **Thanos / Mimir / VictoriaMetrics / 各种云托管服务**的接入点：它们通过 remote write 收数据，自己做[分布式存储和全局查询](https://daiw.org/manual/observability/prometheus-architecture)，而 Prometheus 只管“抓 + 转发”。

  **remote write 的工程难点是背压和重试**：远端慢了怎么办？本地队列积压怎么办？丢数据还是阻塞抓取？源码里有一整套队列管理（`storage/remote/queue_manager.go`）——这和 [Collector 的背压问题](https://daiw.org/manual/observability/consumer-backpressure)是同一类难题，[第 6 部分会再碰到](https://daiw.org/manual/observability/consumer-backpressure)。
</Callout>

## 小结与下一部分

一次查询要把**散落在 [Head](https://daiw.org/manual/observability/head-and-wal) 和多个 [block](https://daiw.org/manual/observability/blocks-compaction)** 里的数据**缝合**成连续的 series。**`storage.Querier`**（`storage/interface.go:123`）三个设计点：[一个 Querier 覆盖固定时间范围](https://daiw.org/manual/observability/query-path)、**`sortSeries` 让调用方决定要不要排序**（[不排更快，很多操作本就不需要有序](https://daiw.org/manual/observability/index-format)）、**`hints` 是提示而非命令**（实现可忽略，留优化口子但接口保持简单）。返回 **`SeriesSet` 迭代器**而非大数组——[数据可能几亿个点，必须流式：按需解压、可提前退出、内存恒定](https://daiw.org/manual/observability/query-path)；这就是**火山模型**，[PromQL 引擎建在它上面](https://daiw.org/manual/observability/promql-engine)。缝合靠 **merge**（归并，再一次[靠有序性做到线性](https://daiw.org/manual/observability/index-format)），支持[重叠 block 并去重](https://daiw.org/manual/observability/blocks-compaction)。两个扩展：**native histograms**（[一个点是整个直方图结构 → 桶自动指数生成、基数从 12 条降到 1 条](https://daiw.org/manual/observability/metric-types)，代价是走 protobuf——**近年最重要的演进**）、**remote write**（[就是 `Appender` 的另一个实现 → 抓取代码零改动，Thanos/Mimir 由此接入](https://daiw.org/manual/observability/prometheus-architecture)；难点是[背压与重试](https://daiw.org/manual/observability/consumer-backpressure)）。

TSDB 讲完了。数据能存能读，但用户是用 **PromQL** 提问的——那门语言怎么被解析和执行？👉 第 4 部分 · PromQL：查询引擎与实战。

## 参考资料

- [1] Prometheus 源码 `storage/interface.go:123`（`Querier`/`Select`）、`storage/merge.go`、`storage/fanout.go`。
- [2] Prometheus 源码 `tsdb/chunkenc/histogram.go`、`float_histogram.go`（native histograms）；`storage/remote/queue_manager.go`（远程写）。
- [3] 关联[chunk 编码](https://daiw.org/manual/observability/chunk-encoding)、[索引](https://daiw.org/manual/observability/index-format)、[指标类型](https://daiw.org/manual/observability/metric-types)。
