远程读取与流式传输的融合
2019年10月10日作者 Bartlomiej Plotka (@bwplotka)
新的 Prometheus 2.13.0 版本现已发布,一如既往地包含许多修复和改进。您可以在此处 查看变更详情。然而,有一个功能是许多项目和用户一直期待的:远程读取 API 的分块流式传输版本 。
在本文中,我想深入探讨我们在远程协议中所做的更改、更改的原因以及如何有效地使用它。
远程 API
自 1.x 版本以来,Prometheus 就具备了使用远程 API直接与其存储进行交互的能力。
该 API 允许第三方系统通过以下两种方法与指标数据进行交互:
- 写入 (Write) - 接收由 Prometheus 推送的样本
- 读取 (Read) - 从 Prometheus 拉取样本

这两种方法都使用 HTTP,消息采用 protobufs 编码。两种方法的请求和响应都使用 snappy 进行压缩。
远程写入 (Remote Write)
这是将 Prometheus 数据复制到第三方系统最流行的方式。在这种模式下,Prometheus 通过定期向给定端点发送一批样本来流式传输数据。
远程写入功能在 3 月份通过基于 WAL 的远程写入 得到了极大的改进,提高了可靠性和资源利用率。值得注意的是,几乎所有此处提到的第三方集成都支持远程写入。
远程读取
读取方法不太常见。它于2017 年 3 月 (服务端)添加,自那时起并未进行重大开发。
Prometheus 2.13.0 的发布包含了对读取 API 中已知资源瓶颈的修复。本文将重点介绍这些改进。
远程读取的核心思想是允许在不评估 PromQL 的情况下直接查询 Prometheus 存储(TSDB )。这类似于 PromQL 引擎用于从存储检索数据的 Querier 接口。
这本质上允许读取 TSDB 中 Prometheus 所收集的时间序列。远程读取的主要用例包括:
- Prometheus 不同数据格式之间的无缝升级,即实现Prometheus 读取另一个 Prometheus 的数据 。
- Prometheus 能够从第三方长期存储系统(例如 InfluxDB)中读取数据。
- 第三方系统从 Prometheus 查询数据,例如 Thanos 。
远程读取 API 暴露了一个简单的 HTTP 端点,该端点需要以下 protobuf 载荷。
message ReadRequest {
repeated Query queries = 1;
}
message Query {
int64 start_timestamp_ms = 1;
int64 end_timestamp_ms = 2;
repeated prometheus.LabelMatcher matchers = 3;
prometheus.ReadHints hints = 4;
}
通过此载荷,客户端可以请求与给定 matchers 匹配的特定序列,以及使用 end 和 start 定义的时间范围。
响应同样简单:
message ReadResponse {
// In same order as the request's queries.
repeated QueryResult results = 1;
}
message Sample {
double value = 1;
int64 timestamp = 2;
}
message TimeSeries {
repeated Label labels = 1;
repeated Sample samples = 2;
}
message QueryResult {
repeated prometheus.TimeSeries timeseries = 1;
}
远程读取返回匹配的时间序列,其中包含数值和时间戳的原始 (raw) 样本。
问题陈述
这种简单的远程读取存在两个关键问题。虽然易于使用和理解,但在我们定义的 protobuf 格式的单个 HTTP 请求中没有流式传输能力。其次,响应包含的是原始样本(float64 值和 int64 时间戳),而不是 TSDB 内部存储指标所使用的编码压缩样本批次(即“块/chunks”)。
无流式传输的远程读取服务器算法如下:
- 解析请求。
- 从 TSDB 选择指标。
- 对于所有已解码序列
- 对于所有样本
- 添加到响应 protobuf
- 序列化响应。
- Snappy 压缩。
- 发送 HTTP 响应。
为了将整个响应封包为一个巨大的 protobuf 消息并发送给客户端,该响应必须先以原始的、未压缩的格式进行缓冲。随后,客户端再次完整缓冲整个响应,以便从接收到的 protobuf 中进行反序列化。只有在此之后,客户端才能使用原始样本。
这意味着什么?这意味着对于仅 8 小时的数据请求,如果匹配 10,000 个序列,客户端和服务器各自可能需要分配高达 2.5GB 的内存!
下图显示了在远程读取请求期间,Prometheus 和 Thanos Sidecar (远程读取客户端)的内存使用指标。


值得注意的是,查询 10,000 个序列即使对于 Prometheus 原生 HTTP query_range 端点来说也不是一个好主意,因为浏览器无法处理获取、存储和渲染数以百计兆字节的数据。此外,对于仪表板和渲染目的,获取如此多的数据并不实用,因为人类无法读取它们。这就是为什么我们通常构建的查询不超过 20 个序列。
这很好,但一种非常常见的技术是构建查询,使其返回聚合后的 20 个序列。然而,在底层,查询引擎可能需要触及数千个序列来评估响应(例如在使用 聚合运算符时)。因此,对于像 Thanos 这样(在其他数据之上)使用来自远程读取的 TSDB 数据的系统,请求往往是非常繁重的。
解决方案
要解释此问题的解决方案,了解 Prometheus 在被查询时如何遍历数据会很有帮助。核心概念可以通过 Querier 的 Select 方法所返回的类型 SeriesSet 来展示。接口定义如下:
// SeriesSet contains a set of series.
type SeriesSet interface {
Next() bool
At() Series
Err() error
}
// Series represents a single time series.
type Series interface {
// Labels returns the complete set of labels identifying the series.
Labels() labels.Labels
// Iterator returns a new iterator of the data of the series.
Iterator() SeriesIterator
}
// SeriesIterator iterates over the data of a time series.
type SeriesIterator interface {
// At returns the current timestamp/value pair.
At() (t int64, v float64)
// Next advances the iterator by one.
Next() bool
Err() error
}
这些接口集合允许进程内部的“流式”工作流。我们不再需要持有包含样本的预计算序列列表。通过此接口,每个 SeriesSet.Next() 的实现都可以按需获取序列。同样,在每个序列中,我们也可以通过 SeriesIterator.Next 动态地分别获取每个样本。
有了这个契约,Prometheus 可以最大限度地减少分配的内存,因为 PromQL 引擎可以优化迭代样本以评估查询。同样,TSDB 实现 SeriesSet 的方式也是从文件系统中存储的块中逐个优化地获取序列,从而减少内存分配。
这对远程读取 API 很重要,因为我们可以通过向客户端发送单个序列的几个块形式的响应片段,重用相同的流式传输模式。由于 protobuf 没有原生的定界逻辑,我们扩展了 proto 定义,以允许发送一组小的 protocol buffer 消息,而不是一个巨大的消息。我们将这种模式称为 STREAMED_XOR_CHUNKS 远程读取,而旧模式称为 SAMPLES。扩展协议意味着 Prometheus 不再需要缓冲整个响应。相反,它可以按顺序处理每个序列,并为每个 SeriesSet.Next 或一批 SeriesIterator.Next 迭代发送一个帧,从而可能为下一个序列重复使用相同的内存页!
现在,STREAMED_XOR_CHUNKS 远程读取的响应是一组 Protobuf 消息(帧),如下所示:
// ChunkedReadResponse is a response when response_type equals STREAMED_XOR_CHUNKS.
// We strictly stream full series after series, optionally split by time. This means that a single frame can contain
// partition of the single series, but once a new series is started to be streamed it means that no more chunks will
// be sent for previous one.
message ChunkedReadResponse {
repeated prometheus.ChunkedSeries chunked_series = 1;
}
// ChunkedSeries represents single, encoded time series.
message ChunkedSeries {
// Labels should be sorted.
repeated Label labels = 1 [(gogoproto.nullable) = false];
// Chunks will be in start time order and may overlap.
repeated Chunk chunks = 2 [(gogoproto.nullable) = false];
}
正如您所见,帧不再包含原始样本。这是我们做的第二个改进:我们将样本以块的形式批处理发送(观看此视频 以了解关于块的更多信息),这些块与我们存储在 TSDB 中的块完全相同。
最终得到的服务器算法如下:
- 解析请求。
- 从 TSDB 选择指标。
- 对于所有序列
- 对于所有样本
- 编码为块
- 如果帧 >= 1MB;则跳出
- 编码为块
- 对于所有样本
- 序列化
ChunkedReadResponse消息。 - Snappy 压缩
- 发送消息
您可以在此处 找到完整的设计文档。
基准测试
这种新方法与旧方案相比性能如何?
让我们比较 Prometheus 2.12.0 和 2.13.0 之间的远程读取特性。关于本文开头展示的初步结果,我使用 Prometheus 作为服务器,使用 Thanos sidecar 作为远程读取的客户端。我通过对 Thanos sidecar 运行 gRPC 调用来测试远程读取请求,使用的是 grpcurl。测试是在我的笔记本电脑(Lenovo X1 16GB, i7 8th)上使用 Docker 中的 Kubernetes(使用 kind )进行的。
数据是人工生成的,代表了高度动态的 10,000 个序列(最坏情况场景)。
完整的测试基准可在 thanosbench 仓库 中找到。
内存
无流式传输

有流式传输

减少内存是我们解决方案旨在实现的关键项。Prometheus 在整个请求过程中仅缓冲大约 50MB 的数据,而不是分配数 GB 的内存;而对于 Thanos,内存使用量几乎可以忽略不计。多亏了流式的 Thanos gRPC StoreAPI,sidecar 现在只是一个非常简单的代理。
此外,我尝试了不同的时间范围和序列数量,但正如预期的那样,我看到 Prometheus 的分配量最大为 50MB,而对于 Thanos,并没有明显的分配。这证明了我们的远程读取每个请求使用恒定的内存,无论您请求多少个样本。每个请求分配的内存受数据基数的影响也大大降低,不再像以前那样取决于获取的序列数量。
这使得在并发限制的帮助下,更容易进行针对用户流量的容量规划。
CPU
无流式传输

有流式传输

在我的测试中,CPU 使用率也得到了改善,CPU 时间缩短了一半。
延迟
得益于流式传输和更少的编码,我们还成功降低了远程读取请求的延迟。
8小时范围且有10,000个序列的远程读取请求延迟
| 2.12.0: 平均时间 | 2.13.0: 平均时间 | |
|---|---|---|
| real | 0m34.701s | 0m8.164s |
| user | 0m7.324s | 0m8.181s |
| sys | 0m1.172s | 0m0.749s |
2小时时间范围:
| 2.12.0: 平均时间 | 2.13.0: 平均时间 | |
|---|---|---|
| real | 0m10.904s | 0m4.145s |
| user | 0m6.236s | 0m4.322s |
| sys | 0m0.973s | 0m0.536s |
除了降低约 2.5 倍的延迟外,响应还会立即进行流式传输,而与非流式版本(客户端延迟为 27s,即 real 减去 user 时间,仅仅是因为 Prometheus 和 Thanos 端在进行处理和序列化)形成鲜明对比。
兼容性
远程读取的扩展方式是向后和向前兼容的。这要归功于 protobuf 和被旧服务器忽略的 accepted_response_types 字段。同时,如果旧客户端不提供 accepted_response_types,服务器也能正常工作,假设使用的是旧的 SAMPLES 远程读取。
远程读取协议的扩展方式是向后和向前兼容的。
- v2.13.0 之前的 Prometheus 会安全地忽略较新客户端提供的
accepted_response_types字段,并假设为SAMPLES模式。 - v2.13.0 之后的 Prometheus 对于不提供
accepted_response_types参数的旧客户端,将默认使用SAMPLES模式。
用法
要在 Prometheus v2.13.0 中使用新的流式远程读取,第三方系统必须在请求中添加 accepted_response_types = [STREAMED_XOR_CHUNKS]。
然后 Prometheus 将流式传输 ChunkedReadResponse,而不是旧消息。每个 ChunkedReadResponse 消息遵循 varint 大小和用于 CRC32 Castagnoli 校验和的固定大小大端序 uint32。
对于 Go,建议使用 ChunkedReader 从流中直接读取。
请注意,storage.remote.read-sample-limit 标志对于 STREAMED_XOR_CHUNKS 不再起作用。storage.remote.read-concurrent-limit 的工作方式与之前相同。
还有一个新选项 storage.remote.read-max-bytes-in-frame,它控制每个消息的最大大小。建议将其保持为默认的 1MB,因为 Google 建议 protobuf 消息不超过 1MB 。
如前所述,Thanos 从此改进中受益匪浅。流式远程读取已在 v0.7.0 中添加,因此只要将 Prometheus 2.13.0 或更新版本与 Thanos sidecar 一起使用,该版本或后续任何版本都将自动使用流式远程读取。
后续步骤
2.13.0 版本引入了扩展的远程读取和 Prometheus 服务端实现。然而,在撰写本文时,为了充分利用扩展的远程读取协议,还有一些事项需要完成:
总结
总之,分块流式远程读取的主要好处是:
- 客户端和服务器都能够使用每个请求几乎恒定的内存大小。这是因为 Prometheus 在远程读取期间发送的是一个又一个小的帧,而不是整个响应。这极大地有助于容量规划,尤其是对于像内存这样不可压缩的资源。
- Prometheus 服务器在远程读取期间不再需要将块解码为原始样本。客户端对于编码也是如此,前提是系统正在重用原生 TSDB XOR 压缩(正如 Thanos 所做的那样)。
一如既往,如果您有任何问题或反馈,请随时在 GitHub 上提交工单或在邮件列表中提问。