Metric
在可观测体系中,我们使用metric(度量/指标)来聚合许多离散的测量值。导出聚合指标比导出所有单次测量值,在数据量方面小得多。然而,聚合指标可能变得难以分析,因为信息在聚合过程中从根本上丢失了。
从性能方面看,度量系统(Metric system)中需要特别关注消耗在记录测量值方面的CPU/内存开销,因为可能会记录几百万、几十亿个测量值。此外,消耗在导出聚合指标的CPU/内存开销也很重要,虽然数据导出是周期性的,不在应用程序的“热路径”上,但它仍然会消耗资源,这可能会导致性能的周期性波动。
一个例子
让我们介绍一个示例。http.server.request.duration指标记录了http的每个请求的响应延迟的测量值,并将其聚合到Histogram中。它具有http.request.method、http.route和http.response.status_code等标签/属性。这些标签/属性的含义可以参阅HTTP语义约定。可以根据这个指标计算http接口的吞吐量、平均响应时间、最小和最大响应时间、百分位响应时间(即p95、p99等),并且它们都可以按照HTTP方法、路由、响应状态代码等维度进一步细分。
为了记录此指标的测量值,对于HTTP服务器收到的每个请求:
- 在请求生命周期中尽早记录开始时间(延迟记录会降低度量的准确性)。
- 处理请求并返回响应。
- 响应返回后,立即记录当前时间与最初记录的开始时间的差值。此持续时间是请求延迟。
- 从请求上下文中提取http.request.method、http.route和http.response.status_code属性的值。
- 将测量值记录到http.server.request.duration直方图指标中,包括计算出的请求延迟和属性。
度量系统会将这些测量值按照不同属性键值对(http.request.method、http.route、http.response.status_code)为维度,记录到不同的数据序列中。定期收集指标并导出。导出过程可以是“Push”,应用程序在周期性的将指标推送到某个位置,也可以是“pull”,由其他进程周期性的从应用程序中拉取(或抓取)指标。在Opentelemetry中首选Push,因为OTLP是一种“基于推送”的协议。
假设我们有一个简单的HTTP服务器,具有以下操作:
GET /users
GET /users/{id}
PUT /users/{id}
这些接口会返回200 OK HTTP状态码,也可能返回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指标有4个不同的数据序列,因为http.request.method、http.route和http.response.status_code有四种不同的值组合。每个数据序列都是一个直方图,由计数(即总计数)、总和、最小值、最大值和一个包含桶边界和桶计数的二维数组组成。
我们的例子中数据量很少,但想象一下数百万或数十亿次测量,测量值会极大增加,但数据序列个数增长很小。聚合指标和序列化占用的内存空间与数据序列的个数成正比,并且无论记录了多少测量值,都保持不变。数据足迹与测量值数量的解耦是度量系统的基本价值。
如何判断一个度量系统是好是坏?
度量系统记录测量值,然后收集并导出聚合状态。让我们分别描述这两个操作。
在记录数据层面,度量系统需要:
- 根据指标属性找到对应的聚合数据对象。在某些系统中,调用方可以获得一个句柄,该句柄直接引用聚合数据对象。这种引用被称为bound instruments,它们绑定到一组特定的属性。通常情况下这很难实现,因为属性值需要使用应用程序上下文来计算(例如,在上面的示例中,HTTP请求属性的值要从请求结果中解析出来)。如果度量系统不支持bound instruments,度量系统需要从Map中查找与测量属性对应的序列。
- 原子性地更新聚合数据对象,多线程场景下,避免读取到已部分更新的状态。
- 记录过程必须快速且线程安全。人们期望在应用程序热路径上记录测量值,记录过程的耗时直接影响应用程序SLA。同时预期多个线程可能会同时记录同一个测量值,因此必须确保记录过程既快速又准确,并且应减轻线程之间的资源竞争。
- 记录过程不应分配额外内存。记录过程会发生数百万或数十亿次。如果每次记录过程都分配内存,系统将面临GC消耗,这将影响应用程序的性能。在度量系统达到稳定状态(所有数据序列都被创建出来)后,后续的记录过程不应该分配内存。例外情况是,指标属性必须根据应用程序上下文实时计算。这些内存分配极小,而且归因于用户,而不是指标系统本身。
在数据收集和导出层面,度量系统需要:
- 遍历所有数据序列,并读取聚合数据对象。
- 使用某种协议对聚合数据对象进行编码,将其导出到其他进程。这可以是prometheus文本格式数据,以便于其他进程定期抓取;也可以OTLP格式,定期推送到其他进程。
- 每个数据序列的状态可能需要重置,这取决于是要导出累积状态的指标,还是要导出每次收集后重置的增量状态的指标。
- 收集过程必须尽量减少对记录操作的影响。收集指标意味着去读取聚合数据对象,后者是在不同的线程上进行原子更新的。在性能优先级方面,记录过程更高,收集/导出应减少在热路径上阻塞记录操作。
- 收集/导出过程应尽量减少内存分配。在收集时,一些分配是不可避免的,但确实应该尽量减少。由于收集/导出过程中内存中的对象会发生移动和消失,高基数的指标(即具有大量不同属性组合)的内存分配会更频繁,产生更多gc。这可能会导致周期性的性能波动,从而影响应用程序SLA。
在我们的示例中,我们使用一组属性记录每个HTTP请求的持续时间。我们查找与属性对应的数据序列(如果不存在,则创建一个新的序列),并原子性地更新它在内存中的状态(sum、min、max、bucket计数)。收集/导出过程中,我们读取所有数据序列的状态,然后打印到字符串中。
OpenTelemetry Java Metrics
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中的AggregatorHandle),实际查找的是ConcurrentHashMap<Attributes,AggregatorHandle>,一些实现细节:
- 优化了获取AggregatorHandle的过程以减少竞争,在不同的线程中进行数据记录和数据收集/导出。唯一的锁发生在ConcurrentHashMap中,它使用多个锁来减少争用。ConcurrentHashMap中缓存了Attributes的哈希值,以节省查找过程的CPU消耗。
- 当AggregationTemporality=delta时,每个AggregatorHandle中的数据需要在收集/导出后重置。在这里使用了对象池,以避免在每个导出周期重新分配新的AggregatorHandle实例。
- 每种聚合方式都有不同的AggregatorHandle实现。这些实现都经过了优化,尽可能使用并发api,如compare and swap、LongAdder、Atomic*等,并复用了需要长期保存状态的数据对象。exponential histogram的AggregatorHandle中使用bit shifting来计算桶,以避免使用Math.log——每纳秒都很重要!
在收集/导出数据时,我们需要遍历,读取并按照协议导出/序列化AggregatorHandle的数据。在过去的一年,团队在优化收集周期的内存分配上做了很多工作。因为指标导出器永远不会被同时调用,如果定期读取指标状态然后序列化,并确保在上一次数据导出后再去读取指标状态,那就可以安全地复用数据传输过程的数据对象。当然,一些metric reader(如prometheus metric reader)可能会同时读取指标状态。对于这些,我们优先考虑安全性和正确性,而不是优化的内存分配。
上述实现逻辑引入了Opentelemetry Java中独有的配置选项,称为MemoryMode。MetricReader(或其关联的MetricExporter)根据是否同时读取指标来指定内存模式。当前版本中可以通过环境变量配置内存行为(我们称之为MemoryMode.reusable_data)。未来默认情况下将启用优化内存模式,因为只有特殊情况才需要并发访问指标。事实证明,收集/导出周期中的几乎所有内存分配都是指标对象(Opentelemetry Java中的MetricData)。通过重用这些(以及用于保持状态的其他内部对象),我们将核心的Metric SDK的内存分配减少了99%以上。
接下来讨论OTLP的序列化性能。OTLP使用protobuf 对有效载荷进行编码。这要求用户将数据转换为protobuf消息格式。这些消息类和相关的序列化逻辑引入了外部依赖(com.google.protopuf:protobuf-java是1.7mb),数据中间产物还带来了不必要的内存分配。生成OTLP有效载荷需要在序列化之前知道请求体的大小,这需要对数据迭代两次:第一次计算有效载荷大小,第二次才将它序列化。在此过程中需要执行一些中间操作,比如计算UTF-8编码和存储其他中间数据等,从而导致内存分配。团队重新设计了OTLP序列化以计算有效载荷大小,同时尽可能以无状态方式对其进行序列化,必须有状态的情况下重用数据结构。这个feature首次在opentelemetry java:1.38.0中发布,此行为可以使用前面所说的MemoryMode选项进行配置,并将在未来成为默认设置。(注意:序列化优化不仅适用于metric,还适用于OTLP trace和log数据的序列化!)
Opentelemetry Java vs.Micrometer vs.Prometheus Java
这个小节将Opentelemetry java metric与两种最流行的Java 度量系统进行比较:micrometer和prometheus。dropwizard虽然也很受欢迎,但它缺乏维度,很难与其他系统进行比较,所以在对比测试中排除了它。
对比测试面临以下问题:
- 术语不统一:Opentelemetry中的attribute在micrometer中称为tag,在prometheus中称为label;micrometer和prometheus中有registry 的概念,而Opentelemetry中有metric reader和metric exporter。在本文中使用Opentelemetry的术语;
- 有时候这种对比无法完全一一对应:Opentelemetry 的exponential histogram类型指标中没有micrometer analog。Opentelemetry也不支持bound instruments,而micrometer和prometheus则非常倾向于此。Prometheus和micrometer支持OTLP,而Opentelemetry专门为OTLP设计,这让它有一定的优势。
- 确定对比项:这些系统的功能很多,本文中主观选择了一些重要的对比项目。
- 作者不是全知全能的,在Opentelemetry方面较为擅长,而micrometer和prometheus方面只能参照文档进行配置,这可能错过一些高级特性。
方法论
我们来介绍一下这次基准测试的方法论以及如何解读数据:
- 基准测试的代码可以在github上找到: github.com/jack-berg/metric-system-benchmarks
- 基准测试运行在本地机器,MacBook Pro with M1 Max,64GB内存,操作系统版本是Sonoma 14.3.1。
- 3个不同的基准测试用例:
- 记录:比较数据记录过程中消耗的CPU和内存。
- 收集:比较收集指标数据过程中产生的内存分配(即无导出)。
- 收集和导出:比较收集指标数据并推送到OTLP collector这个过程中的内存分配。
- 对于每个基准测试,评估了各种场景:
- 比较不同的指标类型:计数器(counter)、显式桶直方图(explicit bucket histogram,带有Opentelemetry默认桶边界)和指数桶直方图(exponential bucket histogram)。Micrometer不支持指数桶直方图。
- 记录并收集与100个不同的数据序列,每个序列的属性集合都不一样。属性集的键值对是随机的26个字符。这反映了作者的直觉,即指标的基数比属性的内容更重要。
- 对比了属性已知(结果中的“属性已知”)和未知(图表中的“特性未知”)的场景。如果属性已知,它们可以通过bound instruments获取-Opentelemetry不支持bound instruments,但micrometer和prometheus支持。如果属性未知,就需要在记录数据时计算属性。
- 在单线程和多线程场景中运行基准测试。为简洁起见,下面只显示了单线程结果,因为多线程的瓶颈不在度量系统的实现方式上,多线程测试往往会消除系统之间的差异。
- Opentelemetry java中启用了MemoryMode=reusable_data,后续它将作为默认配置。禁用examplar,因为默认只在创建span的时候记录examplar,本次测试不涉及trace链路,因此将它禁用。
- 使用JMH运行基准测试。增加配置以隔离错误的CPU或内存分配。例如,在对数据收集过程的基准中,会提前记录所有测量值,以便于单纯只评估收集过程。
- 结果以图表形式显示。记录、收集、收集/导出这三种场景的基准测试结果分别用不同的图表显示。
结论
以下是数据记录过程的基准测试结果:
图1:将数据写入Counter类型指标,每次操作的内存分配次数。越低越好。
图2:将数据写入explicit bucket histogram类型指标,每次操作的内存分配次数。越低越好。
图3:将数据写入exponential bucket histogram类型的指标,每次操作的内存分配次数;越低越好。
以下是数据收集/导出过程的基准测试结果:
图4:从counter类型指标中收集数据,每次操作的内存分配次数;越低越好。
图5:从explicit bucket histogram类型指标中收集数据,每次操作的内存分配次数;越低越好。
图6:从exponential bucket histogram类型指标中收集数据,每次操作的内存分配次数;越低越好。
结论
在记录数据方面,属性值已知的情况下,micrometer和prometheus在counter类型指标上有11ns的优势,通过使用bound instruments 技术直接引用聚合数据对象来避免从map中查找(Opentelemetry不支持bound instruments )。尽管如此,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 exporter工具类,而Opentelemetry可以通过垂直集成优化性能(即exporter和核心指标系统协同工作以实现最佳结果)。Micrometer OTLP支持在序列化之前将内存中的Micrometer格式的数据转换为Java类,这很方便,但从性能的角度来看并不理想。
总的来说,这三个度量体系在记录数据方面都表现出色,这证明了所有这些产品都在性能方面做了优化。在完成了一系列优化后,Opentelemetry在收集方面大放异彩。其较低的内存分配将使每个应用程序受益,这对于具有高基数和严格性能SLA的应用程序尤为重要。