远程读取与流式传输
2019年10月10日作者 Bartlomiej Plotka (@bwplotka)
新的 Prometheus 版本 2.13.0 已经发布,并且一如既往地包含许多修复和改进。你可以在此处 阅读更改内容。然而,有一个功能是某些项目和用户一直在等待的:分块、流式传输版本的远程读取 API 。
在本文中,我想深入探讨我们在远程协议中所做的更改、更改的原因以及如何有效地使用它。
远程 API
从 1.x 版本开始,Prometheus 就能够通过远程 API 直接与存储进行交互。
此 API 允许第三方系统通过两种方法与指标数据进行交互
- 写入 - 接收 Prometheus 推送的样本
- 读取 - 从 Prometheus 拉取样本

这两种方法都使用 HTTP,消息使用protobufs 进行编码。两种方法的请求和响应都使用snappy 进行压缩。
远程写入 (Remote Write)
这是将 Prometheus 数据复制到第三方系统的最流行方式。在此模式下,Prometheus 通过定期将一批样本发送到给定端点来流式传输样本。
远程写入最近在三月份得到了基于 WAL 的远程写入 大幅改进,提高了可靠性和资源消耗。还值得注意的是,几乎所有在此处提及的第三方集成都支持远程写入。
远程读取
读取方法不太常见。它于2017年3月 (服务器端)添加,此后没有重大进展。
Prometheus 2.13.0 的发布包括了对读取 API 中已知资源瓶颈的修复。本文将重点介绍这些改进。
远程读取的关键思想是允许直接查询 Prometheus 存储(TSDB ),而无需进行 PromQL 计算。这类似于 PromQL 引擎用来从存储中检索数据的Querier 接口。
这基本上允许对 Prometheus 收集的 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;
}
远程读取返回匹配的时间序列,其中包含具有值和时间戳的原始样本。
问题陈述
这样一个简单的远程读取存在两个关键问题。它易于使用和理解,但在我们定义的 protobuf 格式的单个 HTTP 请求中没有流式传输功能。其次,响应包含原始样本(float64 值和int64 时间戳),而不是称为“块”的编码、压缩样本批次,这些“块”用于在 TSDB 中存储指标。
没有流式传输的远程读取的服务器算法是
- 解析请求。
- 从 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 定义,允许发送一组小的协议缓冲区消息而不是一个巨大的消息。我们将这种模式称为 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 作为远程读取的客户端。我通过使用 grpcurl 调用 Thanos sidecar 中的 gRPC 来调用测试远程读取请求。测试是从我的笔记本电脑(Lenovo X1 16GB,i7 第 8 代)在 Kubernetes in docker(使用kind )中进行的。
数据是人为生成的,代表了高度动态的 10,000 个序列(最坏情况)。
完整的测试平台可在thanosbench 仓库 中找到。
内存
无流式传输

流式传输

减少内存是我们解决方案的关键目标。Prometheus 在整个请求过程中仅分配约 50MB 内存,而不是 GB 级别的内存,而对于 Thanos 而言,内存使用量仅有微乎其微的增长。得益于流式传输的 Thanos gRPC StoreAPI,sidecar 现在只是一个简单的代理。
此外,我还尝试了不同的时间范围和序列数量,但正如预期的那样,我发现在 Prometheus 的分配中最多只有 50MB,而在 Thanos 中几乎看不到可见的内存使用。这证明了我们的远程读取对每个请求使用恒定的内存大小,无论您请求多少样本。与之前相比,每个请求的已分配内存受数据基数的影响也大大减小,因此也就不再像以前那样受获取序列数量的影响了。
这使得在用户流量下更容易进行容量规划,并借助并发限制。
CPU
无流式传输

流式传输

在我的测试中,CPU 使用率也有所提高,CPU 时间使用量减少了 2 倍。
延迟
得益于流式传输和更少的编码,我们也成功地降低了远程读取请求的延迟。
8 小时时间范围、10,000 个序列的远程读取请求延迟
| 2.12.0:平均时间 | 2.13.0:平均时间 | |
|---|---|---|
| 实际 | 0m34.701s | 0m8.164s |
| 用户 | 0m7.324s | 0m8.181s |
| 系统 | 0m1.172s | 0m0.749s |
2 小时时间范围
| 2.12.0:平均时间 | 2.13.0:平均时间 | |
|---|---|---|
| 实际 | 0m10.904s | 0m4.145s |
| 用户 | 0m6.236s | 0m4.322s |
| 系统 | 0m0.973s | 0m0.536s |
除了约 2.5 倍的低延迟外,响应还会立即流式传输,而对于非流式版本,客户端延迟为 27 秒(实际减去用户时间),这仅发生在 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 大小和一个固定大小的 bigendian uint32,用于 CRC32 Castagnoli 校验和。
对于 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 上提交工单或在邮件列表中提问。