OpenTelemetry Java 指标性能对比

博客文章在发布后不会更新。这篇文章已经发布一年多了,其内容可能已过时,部分链接可能无效。在依赖任何信息之前,请务必核实。

可以说,OpenTelemetry最大的价值主张在于它旨在成为一种与语言无关、通用的可观测性标准。通过提供追踪、指标和日志(以及即将推出的分析)的工具,以及在许多流行语言中的实现,OpenTelemetry通过提供一个词汇表和一套工具集,降低了多语言团队的认知负担。

虽然这些都是事实,但今天我想深入探讨一个特定的信号和语言,并讨论 OpenTelemetry Java 指标 SDK 的性能。

指标入门

指标是一个被过度使用的术语,但在可观测性领域,我们使用指标来聚合许多离散的测量。与导出所有单个测量相比,导出聚合数据在数据量方面具有更小的占地面积。然而,它迫使你放弃某些形式的分析,因为信息在聚合过程中基本上丢失了。

从性能的角度来看,记录测量所需的 CPU/内存是指标系统的一个关键特征,因为你可能正在记录数百万甚至数十亿次的测量。此外,导出聚合指标到进程外的 CPU/内存也很重要。尽管导出是周期性的,而不是应用程序的“热路径”,但它仍然消耗资源,可能导致性能间歇性波动。

一个例子

让我们介绍一个可以在后续文本中引用的示例。OpenTelemetry中最有用的指标之一是 http.server.request.duration,它记录了 HTTP 服务器响应的每个请求的响应延迟测量,并将它们聚合到直方图中。每个测量都带有一系列属性(或标签、或标记、或维度),但为简单起见,我们专注于 http.request.methodhttp.routehttp.response.status_code。有关完整详细信息,请参阅 HTTP 语义约定。基于此,您可以计算吞吐量、平均响应时间、最小和最大响应时间、百分位响应时间(例如 p95、p99 等),所有这些都按 HTTP 方法、路由、响应状态码等进行细分。

要记录到此指标的测量,对于 HTTP 服务器收到的每个请求,请执行以下操作:

  • 在请求生命周期的早期尽快记录开始时间(延迟会降低指标的准确性)。
  • 处理请求并返回响应。
  • 当响应返回时,立即记录当前时间与最初记录的开始时间之差。此持续时间就是请求延迟。
  • 从请求上下文中提取 http.request.methodhttp.routehttp.response.status_code 属性键的值。
  • 将一个测量记录到 http.server.request.duration 直方图仪表,该测量由计算出的请求延迟和属性组成。

指标系统会将这些测量聚合到单独的序列中,每个序列对应于遇到的属性键值对(http.request.methodhttp.routehttp.response.status_code)的唯一组合。定期收集指标并将其导出到进程外。此导出过程可以是“推式”,即应用程序按间隔将指标推送到某个位置,或者“拉式”,即另一个进程按间隔从应用程序拉取(或抓取)指标。在 OpenTelemetry 中,推式更受欢迎,因为 OTLP 是一种“推式”协议。

假设我们有一个简单的 HTTP 服务器,具有以下操作:

  • GET /users
  • GET /users/{id}
  • PUT /users/{id}

这些操作通常返回 HTTP 状态码 200 OK,但也有可能返回 404(或其他错误)。用于记录到 http.server.request.duration 直方图的 Java 伪代码可能如下所示:

// Initialize instrument
DoubleHistogram histogram = meterProvider.get("my-instrumentation-name")
    .histogramBuilder("http.server.request.duration")
    .setUnit("s")
    .setExplicitBucketBoundariesAdvice(Arrays.asList(1.0, 5.0, 10.0)) // set histogram bucket boundaries to the thresholds we care about
    .build();

// ... elsewhere in code, record a measurement for each HTTP request served
histogram.record(22.0, httpAttributes("GET", "/users", 200));
histogram.record(7.0, httpAttributes("GET", "/users/{id}", 200));
histogram.record(11.0, httpAttributes("GET", "/users/{id}", 200));
histogram.record(4.0, httpAttributes("GET", "/users/{id}", 200));
histogram.record(6.0, httpAttributes("GET", "/users/{id}", 404));
histogram.record(6.2, httpAttributes("PUT", "/users/{id}", 200));
histogram.record(7.2, httpAttributes("PUT", "/users/{id}", 200));

// Helper constants
private static final AttributeKey<String> HTTP_REQUEST_METHOD = AttributeKey.stringKey("http.request.method");
private static final AttributeKey<String> HTTP_ROUTE = AttributeKey.stringKey("http.route");
private static final AttributeKey<String> HTTP_RESPONSE_STATUS_CODE = AttributeKey.stringKey("http.response.status_code");

// Helper function
private static Attributes httpAttributes(String method, String route, int responseStatusCode) {
  return Attributes.of(
    HTTP_REQUEST_METHOD, method,
    HTTP_ROUTE, route,
    HTTP_RESPONSE_STATUS_CODE, responseStatusCode);
}

当需要导出时,聚合的指标将被序列化并发送到进程外。对于我们的示例,我包含了一个简单的文本编码的输出指标。实际应用程序将使用所使用协议定义的编码,例如 Prometheus 文本格式或 OTLP

2024-05-20T18:05:57Z: http.server.request.duration:
  attributes: {"http.request.method":"GET","http.route":"/users/{id}","http.response.status_code":200}
  value: {"count":3,"sum":22.0,"min":4.0,"max":11.0,"buckets":[[1.0,0],[5.0,1],[10.0,1]]}

  attributes: {"http.request.method":"GET","http.route":"/users/{id}","http.response.status_code":404}
  value: {"count":1,"sum":6.0,"min":6.0,"max":6.0,"buckets":[[1.0,0],[5.0,0],[10.0,1]]}

  attributes: {"http.request.method":"GET","http.route":"/users","http.response.status_code":200}
  value: {"count":1,"sum":22.0,"min":22.0,"max":22.0,"buckets":[[1.0,0],[5.0,0],[10.0,0]]}

  attributes: {"http.request.method":"PUT","http.route":"/users/{id}","http.response.status_code":200}
  value: {"count":2,"sum":13.4,"min":6.2,"max":7.2,"buckets":[[1.0,0],[5.0,0],[10.0,2]]}

注意,http.server.request.duration 指标有四个不同的序列,因为 http.request.methodhttp.routehttp.response.status_code 的值有四种不同的组合。每个序列都有一个直方图,包括计数(即总计数)、总和、最小值、最大值,以及一个包含桶边界和桶计数的配对数组。

我们的示例很简单,但想象一下将其扩展到记录数百万或数十亿次测量。聚合指标的内存和序列化占用空间与序列的数量成正比,并且与记录的测量次数无关。这种数据占用空间与测量次数解耦是指标系统的基本价值。

什么让一个指标系统变好?

指标系统记录测量,并收集或导出进程外的聚合状态。我们分别检查这两个操作。

记录方面,指标系统需要:

  • 根据测量的属性查找要更新的相应聚合状态。在某些系统中,API 调用者可以请求一个句柄,该句柄直接持有聚合状态的引用,而无需进行查找。这些引用有时称为绑定仪表,因为它们绑定到特定的属性集。通常,这样做是不可能的,因为属性值需要使用应用程序上下文计算(例如,在我们的示例中,HTTP 请求属性的值是根据处理请求的结果确定的)。如果指标系统不支持绑定仪表,或者属性需要使用应用程序上下文计算,指标系统通常需要在 map 中查找与测量属性对应的序列。
  • 原子地更新聚合状态,以便即使在另一个线程上发生收集,也不会读取到部分更新的状态。
  • 记录必须快速且线程安全。通常期望测量值在应用程序的热路径上进行记录,因此记录时间直接影响应用程序的 SLA。期望多个线程可能同时使用相同的属性记录测量值,因此必须谨慎确保速度不会以正确性为代价,并应减轻争用。
  • 记录不应分配内存。记录会发生数百万甚至数十亿次。如果每次记录都分配内存,系统将暴露于 GC 循环,从而影响应用程序的性能。在指标系统达到稳态(即所有序列都已收到测量值)后,记录测量值应分配零内存。例外情况是当记录的属性是不可预知的,并且必须在记录时从应用程序上下文中计算出来。但是,这些分配应该是最小的,并且可以归因于用户而不是指标系统本身。

收集或导出方面,指标系统需要:

  • 遍历所有不同的序列(即,接收到测量的不同属性),并读取聚合状态。
  • 使用某种协议编码聚合状态,并将其导出到进程外。这可能是 Prometheus 文本格式,它会被另一个进程定期拉取和读取,或者像 OTLP 这样的格式,它会被定期推送。
  • 根据导出器需要累积状态(持续增加)还是增量状态(每次收集后重置),可能需要重置每个序列的状态。
  • 收集必须最大限度地减少对记录操作的影响。收集指标意味着读取聚合指标的状态,这些指标在与收集不同的线程上原子地更新。通常,收集在性能优先级方面排在记录之后:收集应努力最大限度地减少在热路径上阻塞记录操作所花费的时间。
  • 收集应最大限度地减少内存分配。收集时(或者不是……继续阅读以了解更多信息)不可避免地会有一些分配,但确实应该尽量减少。如果不是这样,记录具有高基数的指标(即记录具有大量不同属性集的测量的系统)的系统将由于收集时的内存抖动而产生高内存分配和随后的 GC。这可能导致周期性的性能波动,影响应用程序的 SLA。

在我们的示例中,我们记录了服务器响应的每个 HTTP 请求的持续时间,并附带一组描述该请求的属性。我们查找与属性对应的序列(如果不存在则创建一个新序列),并原子地更新其内存中的状态(总和、最小值、最大值、桶计数)。收集时,我们读取这四个不同序列中的每一个的状态,在我们的简单示例中,打印出信息的字符串编码。

OpenTelemetry Java 指标

OpenTelemetry Java 项目是一个高性能指标系统,旨在快速运行,并在记录端没有(或在某些情况下只有少量)分配,并在收集端分配非常少。

让我们看看我们的例子,并分解一下幕后发生的事情。

// Record a measurement
histogram.record(7.2, httpAttributes("PUT", "/users/{id}", 200));

// Helper constants
private static final AttributeKey<String> HTTP_REQUEST_METHOD = AttributeKey.stringKey("http.request.method");
private static final AttributeKey<String> HTTP_ROUTE = AttributeKey.stringKey("http.route");
private static final AttributeKey<String> HTTP_RESPONSE_STATUS_CODE = AttributeKey.stringKey("http.response.status_code");

// Helper function
private static Attributes httpAttributes(String method, String route, int responseStatusCode) {
  return Attributes.of(
    HTTP_REQUEST_METHOD, method,
    HTTP_ROUTE, route,
    HTTP_RESPONSE_STATUS_CODE, responseStatusCode);

每次我们将测量值记录到直方图仪表时,我们都会将测量值和属性作为参数传递。在此示例中,我们使用应用程序上下文为每个请求计算属性,但如果所有不同的属性集都是预先已知的,则可以并且应该预先分配它们并将其保存在常量 Attributes 变量中,这可以减少不必要的内存分配。但即使属性是不可预知的,属性键也是可以的。在这里,我们预先为每个出现的 AttributeKey 分配了常量。OpenTelemetry Java 的 Attributes 实现非常高效,并且经过多年的基准测试和优化。

在内部,当我们记录时,我们需要查找与序列对应的聚合状态(即 OpenTelemetry Java 中的 AggregatorHandle)。大部分繁重的工作是由查找 ConcurrentHashMap<Attributes, AggregatorHandle> 来完成的,但有几点值得注意:

  • 获取 AggregatorHandle 的过程经过优化,即使在单独的线程上进行收集的同时进行记录,也能减少争用。唯一获取的锁发生在 ConcurrentHashMap 内部,它使用多个锁来减少争用。我们缓存 Attributes 的哈希码,以节省每次查找的 CPU 周期。
  • 当导出器具有 AggregationTemporality=delta 时,每个 AggregatorHandle 中的状态需要在每次收集后重置。对象池用于避免在每个导出周期重新分配新的 AggregatorHandle 实例。
  • 对于支持的每种 聚合,都有不同的 AggregatorHandle 实现。这些实现都经过优化,尽可能使用低争用工具,如 compare-and-swap、LongAdderAtomic* 等,并重用用于跨收集保存状态的任何数据结构。指数直方图实现使用低级位移来计算桶,以避免使用 Math.log - 每一纳秒都很重要!

当我们收集时,我们需要遍历所有仪表,并根据用于导出的协议读取和序列化每个 AggregatorHandle 的状态。在过去一年左右的时间里,我们做了大量工作来优化收集周期的内存分配。优化源于认识到指标 导出器永远不会并发调用。如果我们定期读取指标状态并将其发送给导出器进行序列化,并确保在再次读取指标状态之前等待导出完成,那么我们可以安全地重用用于将指标状态传递给导出器的所有数据结构。当然,一些指标读取器(如 Prometheus 指标读取器)可能会并发读取指标状态。对于这些,我们优先考虑安全性和正确性,而不是优化的内存分配。

结果是 OpenTelemetry Java 独有的一个可配置选项,称为 MemoryModeMetricReaders(或其关联的 MetricExporter)根据它们是否并发读取指标状态来指定其内存模式。目前,您可以通过 环境变量选择优化内存行为(我们称之为 MemoryMode.reusable_data)。将来,优化内存模式将默认启用,因为只有特殊情况才需要并发访问指标状态。事实证明,保存指标状态的对象(OpenTelemetry Java 中的 MetricData)几乎占收集周期的所有内存分配。通过重用这些(以及用于保存状态的其他内部对象),我们将核心指标 SDK 的内存分配减少了 99% 以上。有关更多详细信息,请参阅 这篇博文

接下来,我们将注意力转向 OTLP 序列化性能。OTLP 使用 protobuf 二进制序列化对负载进行编码。默认实现要求您首先将数据转换为代表 protobuf 消息的生成类。这些类和相关的序列化逻辑需要一个大型依赖项(com.google.protobuf:protobuf-java 是 1.7MB),并且中间表示会产生不必要的内存分配。几年前,我们手工编写了自己的 OTLP 序列化来实现这些目标,但仍有改进的空间:碰巧的是,生成 OTLP 负载要求您在序列化之前知道请求体的尺寸。这需要序列化实现迭代两次数据。第一次计算负载尺寸。第二次进行序列化。在此过程中,您需要执行 UTF-8 编码计算并存储其他中间数据,从而导致内存分配。我们重新设计了 OTLP 序列化,以便在可能的情况下以无状态的方式计算负载尺寸并进行序列化,而在不可能的情况下,则重用数据结构。该功能首次发布于 opentelemetry-java:1.38.0,可以使用前面讨论的相同的 MemoryMode 选项进行配置,并且将来将成为默认设置。(注意:序列化优化除了指标之外,还适用于 OTLP 追踪和日志序列化!)

基准测试:OpenTelemetry Java vs. Micrometer vs. Prometheus Java

我们在 OpenTelemetry Java 上进行了大量的性能工程,但与 Java 生态系统中其他流行的指标系统相比,它如何呢?让我们将其与两个最流行的(请参阅 GitHub 星标)Java 指标系统进行比较:micrometer 和 prometheus。尽管 dropwizard metrics也非常流行,但我将其排除在外,因为它缺乏维度,难以与其他系统进行比较。

在分享方法论、结果和结论之前,有一些关于跨系统比较基准测试挑战的注意事项:

  • 没有标准词汇。 OpenTelemetry 属性在 micrometer 中称为标签(tags),在 prometheus 中称为标签(labels)。Micrometer 和 prometheus 都有注册表(registry)的概念,而 OpenTelemetry 有指标读取器(metric readers)和指标导出器(metric exporters)。当存在冲突时,我使用 OpenTelemetry 的术语,因为我是 OpenTelemetry 项目的成员。
  • 有时没有完美的“苹果对苹果”的比较。 Micrometer 没有 OpenTelemetry 指数直方图的类似功能。OpenTelemetry 不支持绑定仪表,而 micrometer 和 prometheus 则大量依赖于此。Prometheus 和 micrometer 支持 OTLP,但 OpenTelemetry 是为此而构建的,这带来了一些优势。
  • 决定比较什么。 这些系统允许您做很多不同的事情。我对要进行基准测试的内容进行了相当严格的选择,使用我自己的主观理由来判断哪些方面最重要。即便如此,仍有大量原始数据需要梳理。结果包括聚合的视觉辅助工具,以帮助理解数据。
  • 我不是所有系统的专家。 作为 OpenTelemetry Java 的维护者,我对如何配置和使用它了如指掌。我按照文档所述使用 micrometer 和 prometheus,但可能会错过高级用户知道的一些配置或使用优化。我不知道我不知道什么。

方法论

现在,对方法论的描述以及如何解释数据。

  • 支持这些基准测试的代码可在 github.com/jack-berg/metric-system-benchmarks 上找到,原始结果数据可在 Google 表格中找到
  • 基准测试在我的本地机器上运行,MacBook Pro w/ M1 Max,64GB RAM,运行 Sonoma 14.3.1。
  • 有三个不同的基准测试用于比较系统的关键方面:
    • 记录:比较记录测量值的 CPU 时间和内存分配。
    • 收集:比较读取内存中指标状态的内存分配(即,不导出)。
    • 收集并导出:比较读取指标状态并推送到 OTLP 接收器的内存分配。
  • 对于每个基准测试,都会评估各种场景:
    • 比较不同的仪表:计数器、显式桶直方图(使用 OpenTelemetry 默认桶边界)和指数桶直方图。Micrometer 不支持指数桶直方图。
    • 记录到并从对应于 100 个不同属性集的序列中收集。每个属性集具有一个键值对,值为随机的 26 个字符。这反映了我直觉认为基数比属性内容更重要。
    • 比较属性已知(结果中的“Attributes Known”)和未知(图中的“Attributes Unknown”)的场景,以反映应用程序是否需要使用应用程序上下文计算属性。如果属性是预先已知的,则在支持时获取绑定仪表——OpenTelemetry 不支持绑定仪表,但 micrometer 和 prometheus 支持。如果属性是不可预知的,则在记录时计算属性。
    • 在单线程和多线程场景下运行记录基准测试。为简洁起见,下面仅显示单线程结果,因为多线程测试倾向于消除系统之间的差异,因为瓶颈已从指标系统实现决策转移开。
  • 对于 OpenTelemetry 场景,启用 MemoryMode=reusable_data,因为我们打算在不久的将来将其设为默认值。禁用示例,因为默认情况下仅在记录 span 时记录示例,而我们正在隔离指标系统的比较。
  • 使用 JMH 运行基准测试。配置基准测试以隔离错误的 CPU 或内存分配。例如,收集基准测试会提前记录所有测量值,以便我们纯粹评估收集过程。
  • 结果为每种仪表类型分解了图表。记录操作显示在一系列图表中。收集和带有导出的收集结果显示在另一系列图表中。

结果

以下图表总结了记录基准测试的结果。

record to counter benchmark results

图 1:记录到计数器的基准测试结果。

record to explicit bucket histogram benchmark results

图 2:记录到显式桶直方图的基准测试结果。

record to exponential bucket histogram benchmark results

图 3:记录到指数桶直方图的基准测试结果。

以下图表总结了收集和带有导出的收集基准测试的结果。

collect from counter benchmark results

图 4:从计数器收集的基准测试结果。

collect from explicit bucket histogram benchmark results

图 5:从显式桶直方图收集的结果。

collect from exponential bucket histogram benchmark results

图 6:从指数桶直方图收集的结果。

结论

在记录方面,当属性是预先已知的时,micrometer 和 prometheus 在计数器方面具有 11ns 的优势,它们通过使用绑定仪表直接引用聚合状态(OpenTelemetry 不支持)来避免 map 查找。尽管如此,OpenTelemetry 在显式桶直方图方面具有 32ns 的优势。这可能是因为 micrometer 和 prometheus 试图计算比 OpenTelemetry 直方图的总和、最小值、最大值和桶计数更多的汇总值。当属性是不可预知时,这种优势会减弱。

当属性值是预先已知时,所有系统在记录时都不会分配内存。这很好,任何认真的指标系统都应该具备此功能。当属性值是不可预知的(即从应用程序上下文中计算出来)时,OpenTelemetry 分配的内存量始终少于 prometheus 和 micrometer。OpenTelemetry 已经针对这种情况进行了优化,而 micrometer 和 prometheus 则侧重于预先知道属性值和绑定仪表。我认为,大多数情况下,属性值将是不可预知的,这使得 micrometer 或 prometheus 的任何优势都变得无关紧要。不过,这仍然是 OpenTelemetry 的一个潜在改进领域。

在收集方面,OpenTelemetry 以极低的内存分配表现出色。在不导出而进行收集时,OpenTelemetry 的内存分配比 micrometer 和 prometheus 少 22%-99.7%。在通过 OTLP 进行收集和导出时,OpenTelemetry 的内存分配比 micrometer 和 prometheus 少 85%-98.4%。请注意,prometheus 直接使用 OpenTelemetry OTLP 导出器库,但没有 OpenTelemetry 通过垂直集成(即导出器和核心指标系统协同工作以获得最佳结果)实现的优化。Micrometer 的 OTLP 支持将内存中的 micrometer 表示转换为生成的 Java 类,然后再进行序列化,这很方便,但在性能方面不是最优的。

总的来说,这是三个严肃的指标系统。它们在记录方面都表现出色——这证明了它们在性能工程方面的投入。在完成一系列漫长的优化之后,OpenTelemetry 在收集方面表现突出。其较低的分配将惠及所有应用程序,但对于高基数和严格性能 SLA 的应用程序尤为重要。

如果您正在阅读本文并考虑 Java 指标系统,我希望您选择 OpenTelemetry Java。它本身就是一个强大且高性能的工具,同时还提供了其他关键可观测性信号的 API、一个丰富的仪表生态系统其他各种语言的实现,以及一个得到良好支持的开放治理结构

致谢

感谢所有 opentelemetry-java 贡献者帮助我们走到今天,特别是当前和以前的 维护者和批准者。特别要感谢 Asaf Mesika,他不断提高标准。

最后修改于 2025 年 5 月 22 日:[chore] Accessible links 4 (#6052) (1609d60e)