Metric 技术指标聚合处理与存储
基础知识
概述
在现代软件开发中,监控和性能追踪变得越来越重要。OpenTelemetry 是一个开源项目,旨在提供统一的工具和标准,用于采集、处理和导出应用程序的性能数据。其中,Metrics(指标)是 OpenTelemetry 中一个重要的概念。
Metrics 是指系统或应用程序运行状态的量化数据。这些数据通常以数值的形式表示,并且能够提供有关系统性能、健康状况和行为的关键信息。
一些典型场景有:
- 记录某个后端接口的延时
- 记录某个后端接口的请求次数
- 记录某个“发送短信”API调用的成功率
- 记录内存的平均占用量
- 记录CPU的使用率
- 记录风扇的转速
从上述列举的场景中,其实不难发现,虽然都叫“指标”,但其背后却有着不尽相同的性质。
- 指标的数值具有不同类型,例如,调用的次数,应该是一个天然的整数,而“使用率”,“成功率”,则可能是一个浮点数。
- 指标的值具有不同的处理方式,例如对于“请求次数”,合理的做法是将它们相加,而对于“延时”,将延时相加得到总延时往往不是很有意义,我们更关心延时的平均值、分位数、中位数等统计数据。
- 指标根据“产生时机”,可以分为同步指标和异步指标。例如,想要记录后端接口的延时,一般我们会在接口逻辑中,同步地更新指标。而代表“CPU的使用率”,“风扇的转速”的指标,我们往往会使用定期进行回调观测的方式来更新。
SDK使用
在OpenTelemetry中,指标的维护分为“声明”和“更新”两步。声明一个指标时,我们需要给出诸如指标的类型、指标名称、指标的描述等。
在OpenTelemetry中,同步地更新一个指标的接口可以表示为:
其中,metricName为当前要更新的指标,value为要更新的值(根据指标类型不同,value的语义既有可能是一个增量delta,也可能是一个绝对值),attributes代表用户希望记录的关于这个数值的“来源”的一些属性,由一系列键值对组成。
例如,指标的名称叫做http_request_count,表示http请求的次数,每次在后端接到一个http请求,就调用:
对于这个指标而言,其属性可能会包括:
- http.path,数据来自哪个接口?
- pod.name,数据来自哪个k8s pod?
- cluster.name,数据来自哪个cluster?
- region,数据来自哪个地域的服务器?
上述所有的属性字段,共同组成所谓“属性集合”。也就是说,在更新指标时,需要给出值和对应的属性集合。属性集合可以为空。
OpenTelemetry SDK中的Exporter模块负责了指标向各类后端的上报逻辑。OpenTelemetry支持了各种各样的后端(例如Prometheus、Jaeger、Zipkin),也允许你自己实现数据的导出逻辑。但是在Exporter模块之前,SDK会负责在内存中收集和记录指标。
SDK会在内部维护的一套通用逻辑:属性集合完全相同(即所有键值对都相同)的两个value,在上报前,会根据指标在定义时选择的类型进行合并,即本文题目中的“指标聚合处理”。例如对于http.request.count指标,假设只有前文中我们提到的这四个属性,那来自相同http.path、相同pod、相同cluster、相同region的请求次数计数,不管从语义上,还是从不需要重复存储和上报完全相同的属性集合的角度考量,都可以将它们加在一起。
OpenTelemetry SDK中的指标聚合技术
OpenTelemetry SDK有不同编程语言版本的实现,在主流的SDK实现中,采用了如图所示的聚合逻辑。
具体来说,对于每个指标,OpenTelemetry SDK维护了一个巨大的Map,Map的键为属性集合,Map的值为经过聚合处理后的值。不难看出,这是对上文描述的聚合逻辑的一种简单直接的实现:当用户代码每次调用Add接口时,SDK会查找当前的Map的Key中,是否已经存在完全相同的属性集合,如果有,则将二者的value进行聚合,作为map entry的新的值。如果这是一个从未出现过的属性集合,就在Map中新建一项。
空间复杂度
根据基本的数学知识可以得出,对于某个指标:
Map的表项的数目=不同属性集合的数量。
如果属性集合的各个属性之间相互独立,那么不同属性集合数目=其包含的每个属性字段(构成的集合)的基数的笛卡尔积。如果其中某个或某几个属性字段的基数很大(我们称这个属性“发散”),最终总的Map Entry数将以它们的基数呈乘积倍增长。
例如:仅仅有三个发散程度为1000的字段,理论上就需要1000,000,000个Map表项。
一些语言版本以及较老版本的OpenTelemetry SDK,并没有对Map Entry的数量上限进行控制,将给程序带来OOM的风险。一种简单的工业实践是,当Map Entry的数量(即总的发散程度)到达了上限时,将新来的且从未出现过的属性集合统一归为一种特殊的“OverFlow”类型,避免继续新增表项,然后对它们的Value进行聚合。
实现细节剖析
为了更好地理解上述指标聚合方法的开销,我们需要深入源码,了解上述聚合逻辑的实现细节。各语言版本的SDK的实现本质上大同小异,下面将以C++ OpenTelemetry SDK的实现为例进行解析。
在C++ SDK中,前文中提到的Map,就是使用STL中的std::unordered_map来实现的:
对于c++的std::unordered_map(以及其他绝大多数编程语言下的“哈希表”容器),想要使用一个自定义类型作为Map的Key,往往需要自己实现两个方法:
- 该自定义类型的哈希函数。
- 该自定义类型的==(或equals)方法,即如何判断两个Key是相等的。
在我们的场景中,我们需要将属性集合(可以理解为一个unordered_map)当作大聚合Map的Key,因此,摆在我们面前的问题是,如何对属性集合进行哈希?
答案是:进行combine hash,对属性集合中的每个键值对(键和值都是一个字符串),先调用原生的对字符串哈希的算法,得到所有键和值各自的哈希,然后按照某个固定的相同的顺序,将这些哈希值再“结合”在一起,生成一个最终的Hash值。
OpenTelemetry C++ SDK做了一个进一步的优化:直接将计算出的combine hash结果(size_t类型)作为Map的key,避免了在Key中存储整个属性集合的成本。
OpenTelemetry C++ SDK采用的combine hash算法是:
最终得到的combine hash的结果(即seed的值)与各个键值对参与更新seed(即将其作为第二个参数调用GetHash()方法)的顺序是有关的。但是属性集合内部也是一个哈希表,其内部存储的键值对是没有顺序的,而我们需要保证具有相同键值对的属性集合,经过我们的combine hash,能算出相同的哈希值。因此,用户每次调用Add时,SDK将对用户传入的属性集合根据Key进行一次排序。如果SDK支持传入一个列表(数组)作为属性集合,这次排序还有助于处理用户添加的重复的属性,以最后一次对该属性指定的值为准。
总结一下,聚合模块的成本主要可以抽象为以下几部分:
- 调用Add接口后,对用户传入的属性集合进行排序的成本。
- 对所有键值对(既包括键也包括值)计算字符串哈希的成本。
- 维护一个unordered_map的成本。
程序可能在不同的线程中调用Add()方法,但是全局的指标实例只有一个,因此指标内部的unordered_map存在并发问题。c++ SDK目前的实现是对其进行加锁。profiling结果认为,这是目前在多线程下指标聚合模块理论上的性能瓶颈,除非未来将unordered_map改为使用一种更高效的无锁哈希表的实现。