作者:来自 Elastic Jonas Kunz

在同一个 metric 名称下摄入 cumulative 和 delta 两种 OpenTelemetry 指标,同时 ES|QL 和 PromQL 查询会自动按每个时间序列识别指标时间类型,无需新增语法或转换 pipeline。
Elasticsearch 9.5 原生支持存储 cumulative 和 delta 两种 OpenTelemetry(OTel)counter 和 histogram,即使同一个 metric 名称下混合使用这两种类型也没有问题。你可以通过 OpenTelemetry Protocol(OTLP)摄入指标,Elasticsearch 会自动保留时间类型(temporality)元数据。ES|QL TS 和 PromQL 查询会按每个时间序列自动识别时间类型,并正确解释数据,无需新增语法、修改 OTel SDK 配置,也无需使用有状态的 OTel Collector 进行转换。现有查询和 downsample 后的数据也会继续按预期工作。
什么是 OpenTelemetry 中的指标时间类型?
指标存储通常接收客户端预聚合后的指标。例如,如果应用程序记录请求响应时间,它不会将每个请求的响应时间作为单独的数据点发送到指标后端。相反,应用程序(更准确地说,是 OTel SDK)会先将这些原始响应时间聚合成 counter 或 histogram。然后,这些预聚合后的值会按照固定的时间间隔导出,从而大幅减少数据点的数量。
**时间类型(temporality)**描述的就是这种预聚合值随时间如何表示。它有两种模型:**cumulative(累计)**和 delta(增量)。

OTel 指标中的累计时间类型(Cumulative temporality)
采用 _cumulative(累计)时间类型_时,每个数据点表示从进程启动以来,指标值累计发生的总变化量。数值会单调递增,但可能偶尔重置为 0,例如进程重启时。
以一个用于跟踪 Java 虚拟机(JVM)消耗的总 CPU 时间的 counter 为例:
| 时间戳 | 值 | 含义 |
|---|---|---|
| 10:01 | 12.4s | 从启动以来累计消耗的 CPU 时间为 12.4s |
| 10:02 | 13.1s | 从启动以来累计消耗的 CPU 时间为 13.1s |
| 10:03 | 13.9s | 从启动以来累计消耗的 CPU 时间为 13.9s |
要计算 10:01 到 10:02 之间的变化率,我们可以做减法:13.1 - 12.4 = 0.7s,表示该时间段内消耗了 0.7s 的 CPU 时间。再除以该时间段的时长,就可以得到 rate。
这是 Prometheus 和 OTel 中 counter 默认使用的时间类型。
OTel 指标中的增量时间类型(Delta temporality)
采用 _delta(增量)时间类型_时,每个数据点表示自上一次测量以来发生的变化量。各个数据点彼此独立。换句话说,每次导出之后,OTel SDK 都会将所有 series 的值重置。
上面 cumulative 示例中的相同原始观测数据,在使用 delta 时间类型时会如下所示。
| 时间戳 | 值 | 含义 |
|---|---|---|
| 10:01 | 0.5s | 该时间段内消耗的 CPU 时间为 0.5s |
| 10:02 | 0.7s | 该时间段内消耗的 CPU 时间为 0.7s |
| 10:03 | 0.8s | 该时间段内消耗的 CPU 时间为 0.8s |
要计算 rate 或 increase,可以直接使用该值,无需进行减法。
cumulative 和 delta OpenTelemetry 指标的权衡
两种时间类型都有各自的实际权衡:
-
**抗数据丢失能力:**Cumulative counter 是自描述的:即使某次导出丢失,下一个数据点仍然能够提供正确的累计总量。Delta 值是增量的,因此如果某个数据点丢失,相应的增量也会丢失。
-
**指标生产端的内存占用:**对于 cumulative 时间类型,OTel SDK 需要在内存中为每个 series 保存状态。对于 delta 时间类型,内存占用要低得多。此时,SDK 只需要跟踪自上次导出以来发生变化的 counter 或 histogram。如果存在大量 counter 或 histogram,而其中许多指标在每个周期内都没有增长,这种差异可能会非常明显。
-
**跨重启聚合:**Cumulative counter 需要进行 reset 检测,这在某些边缘情况下可能会失败。如果指标值降低,就会被检测为 reset,我们会认为应用程序发生了重启,并且 counter 又从 0 开始。但如果重启后首次上报的 counter 值高于重启前的值,就可能检测不到 reset。具体来说:
-
服务消耗了 1 秒的 CPU 时间,然后重启。
-
重启后,服务执行了一项 CPU 密集型任务,在指标再次导出之前消耗了 2 秒的 CPU 时间。
-
指标后端看到的指标值只是先为 1,然后变成 2。由于它从未观察到数值下降,因此无法检测到 reset。
-
Delta 值不存在这个问题,因为每个值都是独立的。
如果你使用的是 histogram,这些权衡的影响会更加明显:
| 权衡项 | Cumulative(累计) | Delta(增量) |
|---|---|---|
| Histogram 大小 | Bucket 会在多次导出之间持续累计,占用更多存储空间 | Bucket 在每次导出时重置,因此生成更小的 histogram |
| 最小值 / 最大值准确性 | 对于自定义时间范围,需要从 bucket 中进行近似计算(记录的值表示自进程启动以来的极值) | 每次导出的最小值和最大值都是精确的 |
| 查询性能 | 更快:只需要时间范围内的第一个和最后一个值,以及 reset 信息 | 更慢:必须合并查询时间范围内的所有 histogram |
OpenTelemetry 同时支持这两种模型,并允许你通过 OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE 环境变量,在每个 SDK 中选择时间类型。
为什么原生支持时间类型可以消除 OTel Collector 的变通方案
Prometheus 和大多数其他 metrics 后端只能选择一种时间类型:所有指标必须是 cumulative 或 delta。Elasticsearch 之前也采用这种模式,原生存储 cumulative counter 和 delta histogram,而其他情况则需要使用变通方案。Delta counter 会以 gauge 的形式存储,虽然可以正常工作,但在 rate 查询中不具备原生 counter 语义。而 cumulative histogram 则不受支持。
一种解决不支持的时间类型的变通方法,是配置指标生产端(例如 OTel SDK),让它们生成后端所支持时间类型的数据。在大规模部署环境中,这可能是一项非常具有挑战性的工作。而且有时甚至无法做到这一点,例如你从第三方服务接收 OTLP 指标时。
另一种变通方案是在摄入之前转换时间类型。在 OTel Collector 中,通常可以使用 cumulative-to-delta processor,但它明确标注了关于 **有状态(statefulness)**的警告。转换本身具有状态依赖,需要将同一个 metric series 按顺序发送到同一个 Collector,并且在 Collector 重启后保留状态。实际上这种方式可以正常工作,但在大规模环境中,会带来许多部署上的麻烦。
使用 Elasticsearch 9.5,你可以完全跳过转换 pipeline。Elasticsearch 原生支持使用这两种时间类型存储和查询指标数据。无需进行任何有状态转换,也无需对 OTel SDK 进行显式配置。
Demo:并行摄入 cumulative 和 delta OTel 指标
为了演示时间类型支持,我们将复用之前 OTel histogram metrics ES|QL 博客 中使用的 demo 环境:一个使用 OTel Java agent 进行 instrument 的 Java Renaissance benchmark。
这次的不同之处在于:我们运行两个 benchmark 实例,每个实例配置不同的时间类型:
-
renaissance-delta:使用 delta 时间类型导出指标。 -
renaissance-cumulative:使用 cumulative 时间类型导出指标。
两个实例都使用相同的 service name renaissance 上报相同的指标,但使用不同的 service.instance.id 值。下面是 docker-compose.yml 中的相关部分,该文件位于 配套代码 中:
yaml
`
1. renaissance-delta:
2. environment:
3. OTEL_SERVICE_NAME: renaissance
4. OTEL_RESOURCE_ATTRIBUTES: "service.instance.id=delta-instance"
5. OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: delta
6. OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: BASE2_EXPONENTIAL_BUCKET_HISTOGRAM
8. renaissance-cumulative:
9. environment:
10. OTEL_SERVICE_NAME: renaissance
11. OTEL_RESOURCE_ATTRIBUTES: "service.instance.id=cumulative-instance"
12. OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: cumulative
13. OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: BASE2_EXPONENTIAL_BUCKET_HISTOGRAM
`AI写代码
如果你想自己运行这个 demo,还需要填写 托管 OTLP endpoint URL 和对应的 API key:
arduino
`
1. OTEL_EXPORTER_OTLP_ENDPOINT: https://<cluster-endpoint>
2. OTEL_EXPORTER_OTLP_HEADERS: "Authorization=ApiKey <base64 api key>"
`AI写代码
使用 docker compose up --build 启动 demo 后,两个实例都会开始向 Elasticsearch 上报指标。
使用 ES|QL 和 PromQL 查询 OTel counter 指标
让我们查询两个实例的 jvm.cpu.time 前几个原始数据点,看看不同时间类型的实际表现:
vbnet
``
1. After starting the demo with `docker compose up --build`, both instances will start reporting metrics to Elasticsearch.
2. Querying OTel counter metrics with ES|QL and PromQL
3. Let's query the first few raw data points of `jvm.cpu.time` for both instances to see the different temporalities in action:
``AI写代码
这样我们就可以看到每个 service instance 的前五个数据点:

这个 benchmark 以几乎恒定的速率消耗 CPU。通过 delta 时间类型的数据可以直接看出这一点:每次导出之间的值基本保持不变。相比之下,cumulative 时间类型的值会随着时间增长,因为它们表示 benchmark 实例累计消耗的 CPU 时间。
现在,让我们看看如何使用 PromQL 正确查询这个指标:
python
`PROMQL sum by (service.instance.id) (rate(jvm.cpu.time))`AI写代码

截图显示,两个 benchmark 实例消耗的 CPU 核数都基本保持在 1 到 1.2 之间,但存在一定波动。这个查询之所以能够正常工作,是因为我们让 rate 的实现支持不同的时间类型:每个时间序列(在我们的示例中就是每个 service instance)都会将时间类型作为一个 metric 维度进行存储。rate 的实现会读取这个维度,并据此正确解释数据:对于 delta 时间类型,会对各个值进行求和;对于 cumulative 时间类型,则会计算差值。所有这些都会在后台自动完成,无需对现有查询做任何修改。
我们已经让 rate、increase 和 irate 都以这种方式工作。在 ES|QL TS 查询中使用这些函数时也是如此:
scss
`
1. TS metrics-*
2. | STATS SUM(RATE(jvm.cpu.time)) BY TBUCKET(100), service.instance.id
`AI写代码
由于 Elasticsearch 会将时间类型作为一个维度进行跟踪,因此同一个 metric 可以包含多个具有不同时间类型的 series,就像 demo 中的使用场景一样。跨 series 进行聚合也可以按预期工作,因为在执行聚合时,rate、increase 或 irate 已经负责对数据进行标准化:
PromQL 查询:聚合两个实例的总 CPU 时间
跨时间类型查询 OTel histogram 指标
指标时间类型对 histogram 的作用方式与 counter 相同:histogram 的 bucket 本质上是一组 counter,每个 counter 都用于跟踪特定范围内的值。
与我们的 histogram demo 一样,我们使用 exponential histogram,其中 bucket 边界会自动调整,以尽可能降低相对误差。
由于两者具有这种相似性,histogram 同样可以使用 cumulative 或 delta 时间类型。区别在于:每个 bucket 的 counter 要么在每次指标导出后重置,要么累计计数会在多次导出之间持续累加。
让我们查询 benchmark 实例主要 garbage collection(GC)持续时间的中位数。这个指标是一个 histogram:
ini
`PROMQL histogram_quantile(0.5, sum by (service.instance.id) (increase(jvm.gc.duration{jvm.gc.action=~".*major.*"})))`AI写代码
或者对应的 ES|QL 查询:
markdown
`
1. TS metrics-*
2. | WHERE jvm.gc.action LIKE "*major*"
3. | STATS MEDIAN(jvm.gc.duration) BY TBUCKET(100), service.instance.id
`AI写代码

同样,这两个查询都会自动加载每个 series 的时间类型,并据此正确解释 histogram。在 PromQL 中,这是通过 increase 函数实现的。需要注意的是,在 ES|QL 中,你不需要对 histogram 显式调用 increase。当你使用 PERCENTILE、MEDIAN 或 AVG 等聚合函数时,TS 命令会自动处理基于时间类型的 histogram 合并。
Elasticsearch 如何在 TSDB 中存储指标时间类型
Elasticsearch 的时间序列数据库(TSDB)会在每个文档中使用一个专用的维度字段来存储指标时间类型。index.time_series.temporality_field 索引设置允许你指定哪个字段携带时间类型信息。该字段必须是 keyword 类型,并设置 time_series_dimension: true,其允许的值为 "delta" 或 "cumulative"。
一旦时间序列索引中存在这一设置,ES|QL 和 PromQL 在执行依赖时间类型的聚合时,就会读取对应字段。如果该字段不存在,或者文档中没有该字段的值,我们会根据对应指标的类型使用默认值:counter 默认为 cumulative,而 histogram 默认为 delta。这与历史行为保持一致,并确保现有查询和现有数据无需任何修改即可继续正常工作。
当你通过 OTLP endpoint 摄入指标时,Elasticsearch 会自动为每个文档添加 temporality 维度字段,并根据 OTLP AggregationTemporality 元数据填充该字段。对于自定义摄入方式(既不是 OTLP,也不是 Prometheus remote write),你需要手动设置 index.time_series.temporality_field,并填充时间类型维度。
在 downsampling 过程中也会保留时间类型:由于它是一个维度,因此会被自动保留,并用于计算聚合值。
在 Elasticsearch 中开始使用混合时间类型的 OTel 指标
使用 Elasticsearch 9.5 后,cumulative 和 delta 不再是你必须在一开始就做出正确选择的决定。你可以并行摄入这两种时间类型的数据,即使它们使用相同的 metric 名称,然后让 ES|QL 和 PromQL 处理剩下的工作。你可以在两种时间类型之间切换,而无需修改查询。
更多详情,请参阅 指标时间类型文档。
原文:Ingesting cumulative and delta OTel metrics in Elasticsearch | Elasticsearch Labs