Prometheus Remote Write 在 Elasticsearch 中的摄取原理

0 阅读10分钟

作者:来自 Elastic Felix Barnsteiner

深入了解 Elasticsearch 对 Prometheus Remote Write 的实现:protobuf 解析、指标类型推断、TSDS 映射以及数据流路由。

Elasticsearch 最近新增了对 Prometheus Remote Write 协议的原生支持。你可以将 Prometheus(或 Grafana Alloy)直接指向一个 Elasticsearch 端点,在无需任何中间适配器的情况下发送指标。

本文将介绍当一个 Remote Write 请求到达时,Elasticsearch 内部发生了什么。

如果你想理解其实现方式、评估 Elasticsearch 与其他 Prometheus 兼容后端的对比,或参与贡献,这篇文章适合你。配套文章《使用 Remote Write 将 Prometheus 指标发送到 Elasticsearch》则介绍了设置和配置相关内容。

请求生命周期:从 HTTP 到索引文档(Request lifecycle: from HTTP to indexed documents)

在深入之前,先简单说明 Prometheus 的数据模型:Prometheus 将所有指标值存储为 64 位浮点数,并将指标名称视为一个普通的 label(name)。存储引擎本身并不区分一个值是 counter 还是 gauge。在理解 Elasticsearch 如何映射这些概念时,请记住这一点。

下面是一个 Remote Write 请求在 Elasticsearch 中的完整路径:

  1. HTTP 层 —— 端点接收压缩的 protobuf 负载,检查索引压力,使用 Snappy 解压,并解析 protobuf WriteRequest。
  2. 文档构建 —— 每个时间序列中的每个 sample 都会被转换为一条 Elasticsearch 文档,包含 @timestamp、labels.* 和 metrics.* 字段。
  3. 批量索引 —— 单个请求中的所有文档通过一次 bulk 调用写入目标数据流。

下面的章节将详细介绍每个阶段。

HTTP 层(HTTP layer)

该端点接受 application/x-protobuf 的 POST 请求。传入的请求体会按照与 bulk 索引 API 相同的索引压力限制(indexing pressure limits)进行跟踪。如果集群已经处于较高的索引负载下,请求会在解析之前直接以 429 被拒绝。

Prometheus 使用 Snappy 对 Remote Write 负载进行压缩。Elasticsearch 以流式方式对请求体进行解压,而不会将其物化为一个连续的大块内存,并且会根据可配置的最大值校验声明的解压后大小,以防止解压炸弹攻击。

解压后的数据体随后会被反序列化为 protobuf 的 WriteRequest。每个 WriteRequest 包含一个 TimeSeries 列表,而每个 TimeSeries 包含一组 labels(键值对)以及一个 samples 列表(时间戳 + float64 值)。

文档构建

对于每个时间序列中的每个 sample,Elasticsearch 都会构建一个索引请求。下面是单条文档的示例结构:

`

1.  {
2.    "@timestamp": "2026-04-01T12:00:00.000Z",
3.    "data_stream": {
4.      "type": "metrics",
5.      "dataset": "generic.prometheus",
6.      "namespace": "default"
7.    },
8.    "labels": {
9.      "__name__": "http_requests_total",
10.      "job": "prometheus",
11.      "instance": "localhost:9090",
12.      "method": "GET",
13.      "status": "200"
14.    },
15.    "metrics": {
16.      "http_requests_total": 1027.0
17.    }
18.  }

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

所有来自 Prometheus 时间序列的 labels(包括 name)都会被写入 labels.* 字段。指标值则写入 metrics.<metric_name>,其中 <metric_name> 是 name label 的值。

如果时间序列中没有 name label,则会被完全丢弃,其 samples 会被计为失败。非有限值(NaN、Infinity、负 Infinity)会被静默跳过。这也包括 Prometheus 的 staleness 标记,它使用一个特殊的 NaN 位模式(0x7ff0000000000002)来表示某个时间序列已经消失。

一个 sample,一条文档

你可能会疑惑,将每个 sample 单独存储为一条文档是否会带来较大的存储开销,尤其是在 labels 较多的情况下。一种常见的优化方式是,将具有相同 labels 和时间戳的多个指标打包到同一条文档中。

随着近期 TSDB 的改进,这种优化已不再必要。Elasticsearch 已将单条文档的存储开销降低到一个程度,使得将多个指标打包在一条文档中与将每个 sample 单独写入之间几乎没有差异。关于这些 TSDB 存储优化的详细介绍,将在后续的专门文章中发布。

批量索引(Bulk indexing)

单个 Remote Write 请求中的所有文档会通过一次 bulk 请求发送到 Elasticsearch。每条文档的目标数据流为 metrics-{dataset}.prometheus-{namespace},并以仅追加(append-only)的 create 操作方式写入。

指标类型推断(Metric type inference)

Remote Write v1 并不会可靠地随 sample 一起传递指标类型。Prometheus 会通过单独的请求(大约每分钟一次)发送元数据(类型、说明文本、单位),而这些请求可能会被路由到与 sample 不同的节点。在分布式系统中等待元数据到达再缓冲 sample 并不现实,因此 Elasticsearch 选择基于命名约定来推断类型。

以 _total、_sum、_count 或 _bucket 结尾的指标名称会被映射为 counter,其余则默认映射为 gauge。这是一个广泛使用的约定,其他兼容 Prometheus 的后端系统也采用了类似方式。

`

1.  http_requests_total             → counter
2.  request_duration_seconds_sum    → counter
3.  request_duration_seconds_count  → counter
4.  request_duration_seconds_bucket → counter
5.  process_resident_memory_bytes   → gauge
6.  go_goroutines                   → gauge

`AI写代码

这种启发式方法可能出错。例如,一个名为 temperature_total 的指标(如果有人将一个 gauge 命名为这样)会被错误地分类为 counter。目前的主要影响是,一些 ES|QL 函数(例如 rate())要求指标类型为 counter,因此会拒绝被错误分类的 gauge。对于 PromQL,我们计划取消这一限制,使 rate() 无论声明类型如何都可以工作,从而降低错误推断的影响。

你可以通过创建 metrics-prometheus@custom component template 并使用自定义 dynamic templates 来覆盖默认推断。例如,将所有 *_counter 字段视为 counter:

`

1.  PUT /_component_template/metrics-prometheus@custom
2.  {
3.    "template": {
4.      "mappings": {
5.        "dynamic_templates": [
6.          {
7.            "counter": {
8.              "path_match": "metrics.*_counter",
9.              "mapping": {
10.                "type": "double",
11.                "time_series_metric": "counter"
12.              }
13.            }
14.          }
15.        ]
16.      }
17.    }
18.  }

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

自定义 dynamic templates 会与内置模板进行合并,因此对于你未显式覆盖的指标,默认的命名约定规则仍然适用。

索引模板

Elasticsearch 会安装一个内置的 index template,用于匹配 metrics-.prometheus-。该模板使字段类型推断能够在无需手动 mapping 配置的情况下正常工作。

启用了 TSDS 模式,这带来了基于时间的分区、优化的存储、去重能力,以及随着数据老化进行降采样的能力。

对于 labels 和 metrics 命名空间,都使用了 passthrough 对象字段,这有三个作用:

  1. 命名空间隔离:labels 和 metrics 分别位于独立的对象命名空间(labels.* 和 metrics.*),因此名为 status 的 label 与名为 status 的 metric 不会发生冲突。
  2. 维度识别:labels 的 passthrough 对象被配置为 time_series_dimension: true,这意味着 labels.* 下的每个字段都会自动被视为 TSDS 维度。当 Prometheus 发送一个包含新 label 的时间序列时,该字段会自动成为一个维度,无需显式定义 mapping。
  3. 透明查询:在 ES|QL 或 PromQL 中,你不需要写 labels. 或 metrics. 前缀。例如,可以直接使用 job 而不是 labels.job,或使用 http_requests_total 而不是 metrics.http_requests_total。passthrough 映射会自动完成解析。

对 metrics 的动态推断会应用前面描述的命名约定规则。当一个新的指标名称首次出现时,其字段 mapping 会在 metrics.* 下自动创建,并带有正确的 time_series_metric 标注。

启用了失败存储(failure store)。当文档索引失败(例如由于 mapping 冲突——同一指标名称出现不兼容类型)时,这些文档会被路由到单独的 failure store,而不是被静默丢弃。

数据流路由

三种 URL 模式会直接映射到对应的数据流名称:

URL patternData stream
/_prometheus/api/v1/writemetrics-generic.prometheus-default
/_prometheus/metrics/{dataset}/api/v1/writemetrics-{dataset}.prometheus-default
/_prometheus/metrics/{dataset}/{namespace}/api/v1/writemetrics-{dataset}.prometheus-{namespace}

这使你能够将来自不同 Prometheus 实例或不同环境的指标分隔到不同的数据流中。这种隔离带来几个好处:

  • 生命周期隔离(Lifecycle isolation):你可以为不同数据流设置不同的保留策略。生产环境指标可能保留 90 天,而开发环境指标可能只保留 7 天。
  • 访问控制(Access control):你可以将 API key 限定到特定数据流。例如,一个团队的 Prometheus 实例写入 metrics-teamA.prometheus-prod,而他们的 API key 只拥有该数据流的访问权限。
  • 查询性能(Query performance):PromQL 查询和 Grafana 仪表板可以限定在特定 index pattern 上,避免扫描无关数据。

错误处理与 Remote Write 规范

Remote Write 规范定义了两类响应:可重试(5xx、429)和不可重试(4xx)。Prometheus 会根据这一分类决定是否重试或丢弃失败请求。

如果 bulk 请求中的任意 sample 因索引压力被拒绝,Elasticsearch 会返回 429(Too Many Requests)。这会通知 Prometheus 进行退避(backoff)并使用指数退避策略重试。

对于部分失败(部分 sample 成功写入、部分失败),响应中会包含汇总信息,报告失败 sample 的数量,并按目标索引和状态码分组,同时附带每组的示例错误信息。

没有 name label 的时间序列会导致这些 sample 返回 400 错误。非有限值(NaN、Infinity)会被静默丢弃:Prometheus 会收到成功响应,并不会重试。

NaN 最常见于 summary 分位数在尚未有观测值时(例如在任何请求到达之前的 p99 延迟指标),以及 staleness 标记。实际影响较小:在大多数查询中,缺失 sample 与 NaN 的行为类似,因为 PromQL 的回溯窗口(lookback window)会以相同方式用最后已知值填充空缺。更重要的差异在于 staleness 标记,这将在下文介绍。

下一步:Remote Write v2 及其未来

Remote Write v2 目前仍处于实验阶段,因此当前实现基于 v1。但 v2 解决了 v1 的多个局限性。

元数据与 sample 同步(Metadata alongside samples):v2 会在同一个请求中,将 metric 类型、单位和描述信息与 time series 一起发送。这消除了对命名约定推断的依赖。

原生 histogram(Native histograms):v2 支持 Prometheus 原生直方图,它可以自然映射到 Elasticsearch 的 exponential_histogram 字段类型。传统 histogram(每个 bucket 一个 counter)不仅冗长,还会在查询时丢失精度,而 native histogram 更紧凑且更精确。

字典编码(Dictionary encoding):v2 用整数引用替代重复的 label 字符串,从而显著减少高基数 label 集合的 payload 大小。

创建时间戳(Created timestamps):counter 在 v2 中包含“创建时间”标记,用于表示 counter 初始化时间。这使得后端能够比当前“数值下降即 reset”的启发式方法更准确地检测 counter 重置。

除了 v2 之外,还有两个未来方向正在考虑中。

Staleness marker 支持:当前 staleness marker(Prometheus 在 scrape 目标消失时写入的特殊 NaN)会被丢弃。如果支持它们,将可以实现正确的 PromQL 回溯行为,并避免“5 分钟残留数据”问题(即已消失的序列仍出现在查询结果中)。

共享 metric 字段(Shared metric field):当前实现会为每个 metric name 创建一个独立字段(例如 metrics.http_requests_total、metrics.go_goroutines 等)。虽然可行,但会导致字段映射数量随 metric 名称增长,因此 Prometheus 数据流的字段上限被设置为 10,000。我们正在考虑另一种方案:只在 name label 中存储 metric 名称,而将数值写入单个共享字段。这可以彻底避免字段爆炸问题,也更贴近 Prometheus 的内部存储模型。这一方向属于提升 Elasticsearch metrics 存储效率与 Prometheus 兼容性的长期演进。

可用性(Availability)

Prometheus Remote Write 端点已在 Elasticsearch Serverless 中可用,无需额外配置。

对于自管理集群,可以使用 start-local 快速启动环境。

如果遇到问题或有反馈,可以在 Elasticsearch 仓库中提交 issue。

原文:www.elastic.co/observabili…