查询路径 · 数据怎么被读出来
一次查询要同时读内存的 Head 和磁盘的多个 block,还要把结果合并成一条连续的 series。这一章读 storage.Querier 接口和 SeriesSet 的惰性迭代模型,并讲清 native histograms 与远程写这两个扩展,收尾第 3 部分。
查询路径 · 数据怎么被读出来
一次查询“过去 6 小时的 {job="api"}”要面对一个尴尬的现实:这段数据散落在多个地方——最近 2 小时在内存的 Head 里,更早的在若干个 block 里。查询必须把它们缝合成一条连续的 series。
Querier 接口
看真实签名(storage/interface.go:123):
// 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
}三个值得注意的设计:
- 一个 Querier 覆盖一个固定时间范围 —— 注释说得很清楚:“over time series data of a fixed time range”。查询开始时先“开”一个 Querier(指定 mint/maxt),它内部决定要读哪些 block。时间范围在创建时就固定,这让它能提前规划好数据源。
sortSeries bool—— 调用方声明“我需不需要结果有序”,注释还特意提醒:“Prefer not requiring sorting for better performance”。为什么?因为排序不是免费的,而很多操作根本不需要有序(比如sum()只是把值加起来,顺序无所谓)。把“要不要排序”的决定权交给调用方,是个精细的性能设计——默认不排,需要时才付代价。hints *SelectHints—— 查询引擎可以给存储层提示(比如“我只要算rate(),给我 counter 的边界点就行”)。注释很坦诚:“it's up to implementation how this is used if used at all”——这是个可选优化,存储层可以完全忽略。这种“提示而非命令”的设计,让接口保持简单的同时给优化留了口子。
SeriesSet:惰性迭代
Select 返回的不是数据,而是一个 SeriesSet——一个迭代器:
ss := querier.Select(...)
for ss.Next() { // 逐条 series
s := ss.At()
it := s.Iterator(nil)
for it.Next() { // 逐个采样点
t, v := it.At()
// ...
}
}为什么是迭代器而不是“返回一个大数组”?因为数据可能极大,不能一次性装进内存**。**
想想一个查询可能碰到 10 万条 series × 每条几千个点 = 几亿个采样点。如果 Select 返回 []Series 且每条都带全部数据,直接 OOM。
迭代器让数据流式地流过:
- 按需解压 —— chunk 是压缩的,只在真正迭代到它时才解压。
- 可以提前退出 —— 比如
topk(5, ...)可能不需要看完所有数据。 - 内存恒定 —— 不管查多少数据,同时在内存里的只有“当前这条 series 的当前这个 chunk”。
这是所有大数据处理系统的通用模式(火山模型 / Volcano model):把查询表达成一棵迭代器的树,数据自底向上流,每一层只处理“当前这一个”。第 4 部分的 PromQL 引擎正是建在这个模型上。
缝合:Head + 多个 block
一条 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 归并成一个。因为每个来源内部都是按时间有序的,归并又是线性的——有序性再一次救了场。
一个微妙的问题:重叠怎么办? 压实期间、或者乱序写入时,block 的时间范围可能重叠。TSDB 支持重叠 block,归并时会去重(同一时刻的点只取一个)。这是为什么 meta.json 里要记录每个 block 的精确时间范围。
两个扩展:native histograms 与远程写
第 3 部分收尾前,补两个重要的扩展。
native histograms:桶自动生成
第 1 部分讲过经典 histogram 的痛:桶要预先定死、选不好就废、桶数还吃基数。Prometheus 3.x 的 native histograms 就是来解这个的。
核心区别:一个采样点不再是一个 float64,而是一整个直方图结构(tsdb/chunkenc/histogram.go、float_histogram.go)。
- 桶自动生成 —— 按指数规则(如每个桶是前一个的 1.1 倍),覆盖任意量级,不用你预先猜。
- 精度可调 —— 一个
schema参数控制桶的密度,想精确就调高。 - 基数大降 —— 经典 histogram 里每个桶是一条 series(
le是标签);native histogram 整个直方图是一条 series。从 12 条变 1 条。 - 代价 —— 解析要走 protobuf 格式(文本表达不了这么复杂的结构),生态支持还在铺开。
远程写:把数据送出去
Prometheus 刻意单机自足,但数据总要有个长期的去处——这就是 remote write(storage/remote/)。
它的位置很优雅:remote write 就是 storage.Appender 接口的另一个实现。抓取模块照样往 Appender 里塞数据,完全不知道背后是本地 TSDB 还是“发到远端”。接口解耦的价值在这里兑现——加一条远程写链路,抓取代码一行不改。
这也是 Thanos / Mimir / VictoriaMetrics / 各种云托管服务的接入点:它们通过 remote write 收数据,自己做分布式存储和全局查询,而 Prometheus 只管“抓 + 转发”。
remote write 的工程难点是背压和重试:远端慢了怎么办?本地队列积压怎么办?丢数据还是阻塞抓取?源码里有一整套队列管理(storage/remote/queue_manager.go)——这和 Collector 的背压问题是同一类难题,第 6 部分会再碰到。
小结与下一部分
一次查询要把散落在 Head 和多个 block 里的数据缝合成连续的 series。storage.Querier(storage/interface.go:123)三个设计点:一个 Querier 覆盖固定时间范围、sortSeries 让调用方决定要不要排序(不排更快,很多操作本就不需要有序)、hints 是提示而非命令(实现可忽略,留优化口子但接口保持简单)。返回 SeriesSet 迭代器而非大数组——数据可能几亿个点,必须流式:按需解压、可提前退出、内存恒定;这就是火山模型,PromQL 引擎建在它上面。缝合靠 merge(归并,再一次靠有序性做到线性),支持重叠 block 并去重。两个扩展:native histograms(一个点是整个直方图结构 → 桶自动指数生成、基数从 12 条降到 1 条,代价是走 protobuf——近年最重要的演进)、remote write(就是 Appender 的另一个实现 → 抓取代码零改动,Thanos/Mimir 由此接入;难点是背压与重试)。
TSDB 讲完了。数据能存能读,但用户是用 PromQL 提问的——那门语言怎么被解析和执行?👉 第 4 部分 · PromQL:查询引擎与实战。