运行生产服务需要了解其内部发生的情况,但添加指标并不是免费的。在 InfoQ 发布的一场演讲中,IOP Systems 联合创始人 Brian Martin 说明,一次实现与另一次实现之间的差异,可能会将一次成本约为 5 纳秒的计数器更新,变成一次超过 1 微秒的操作。对于直方图,当线程之间发生竞争时,差异可能从约 7 纳秒扩大到几十微秒。
演讲的核心并不是选择某个特定的 Rust 库,而是将测量视为性能设计的一部分。放置在每秒调用数百万次的路径中的指标,会放大任何微小成本;而缺少测量则会使诊断延迟、生产事故和性能优化变得更加困难。
首先了解数据类型及其更新成本
Martin 将指标分为三种主要类型:通常不会减少的计数器,例如请求数;表示当前值的瞬时度量,例如队列深度;以及描述数值分布的直方图,例如响应时间。这一区分很重要,因为每种类型都需要不同的操作,而且直方图能够提供单个总计数器无法提供的信息。
在最简单的情况下,atomic fetch_add 适用于整数计数器。而比较与交换循环(即 CAS)通常需要在多个线程竞争同一位置时反复重试。相当一部分成本来自缓存行在各核心之间的同步。演讲给出了在一台包含 32 个虚拟处理器的 AWS Graviton 机器上的测量结果:使用低成本原子更新时,理论处理上限约为每秒 1.19 亿个请求;而使用成本更高、基于 Prometheus 的实现时,约为每秒 2300 万个请求。
通过按处理器分片降低竞争
当所有线程共享同一个计数器时,缓存行会持续在各核心之间转移。Martin 建议改为为每个处理器创建独立计数器,使写操作几乎不发生竞争,然后在读取时汇总这些值。这种方法会略微增加读取成本,但能够保护热写入路径,而该路径会在每个请求中重复执行。
这里需要注意伪共享现象。逻辑上拥有独立计数器并不够,如果它们被放在同一个缓存行中,仍然会产生问题;因为缓存行大小为 64 字节,这意味着 8 个 64-bit 计数器可能彼此相邻。因此,演讲建议对计数器进行分组和填充,使其占据独立的缓存行。根据演讲展示的数据,使用合适的分片后,理论性能可以从采用原子计数器时的每秒约 1.19 亿个请求,提高到每秒约 64 亿个请求。
为更新路径设计直方图
直方图的成本首先来自确定某个值所属的桶。在线性桶列表中进行线性搜索是最简单、也是最慢的选择;二分搜索可以减少比较次数,但仍然取决于桶的数量。更快的替代方案是直接索引,根据数值计算桶编号,而不是搜索桶。
这种索引方式存在权衡。划分为线性范围的方式速度很快,但在较小数值处可能产生相对较大的误差。对数索引能够更好地控制相对误差,但计算对数本身成本较高。Martin 介绍了使用基于 Log2 的外部范围,并配合用于调整精度的子桶,例如 HDR Histogram 和 H2Histogram。在非原子测试中,HDR Histogram 确定桶并完成更新约需 2.65 纳秒,H2Histogram 约需 2.15 纳秒。
何时可以接受近似一致性?
直方图的成本并不只取决于索引方式。有些应用会在每次操作中更新多个原子变量,或使用 CAS 更新数值总和,或者为了获得一致快照而加锁。演讲指出,一些实现的成本超过 2 微秒,在 32 个核心下甚至达到几十微秒;而采用直接索引和单次原子更新的实现则更接近计数器的成本。
另一种选择是近似一致性:读取直方图时,某些桶可能正在发生变化,但当指标本身就是近似值时,两次连续读取之间的差异仍然具有参考价值。这不是一条普遍适用的规则;需要完全一致快照的系统,可能会尽管成本较高,仍然选择同步。
certi.news 的编辑解读
实际发生变化的是,添加指标的决策必须包括更新结构,而不仅仅是指标名称。原子计数器、按处理器分片和直接索引,可能使测量能够在敏感路径中使用;而同步直方图或可动态扩展的直方图,则可能在高负载下带来很高的成本。演讲没有为所有库或服务提供一套通用方案;它说明,灵活性、库在其他项目中的可用性、一致性和性能之间存在部分冲突。因此,应在目标竞争程度和处理规模下测试实际实现,而不是依赖库的名称或无竞争状态下的结果。
Martin 还介绍了通过 Rezolus 项目使用 eBPF,从 Linux 内核获取精确指标,包括调度器、系统调用路径和 TCP 协议栈,而无需修改内核代码。仍未解决的问题是,在每种情况下究竟需要多高的精度和一致性,以及在扩展系统时,读取或汇总各部分的成本是否仍然可以接受。