作者 | Cristian Velazquez
译者 | 张健欣
策划 | Tina
作为 Uber 工程实现盈利的众多努力的一部分,最近我们的团队致力于通过提高效率来降低算力成本。其中最有影响力的一些工作是围绕 GOGC 优化展开的。在这篇博客,我们想分享我们在高效、低风险、大规模、半自动化 Go 垃圾回收调优机制方面的经验。
Uber 的技术栈由数千个微服务组成,由云原生的基于调度的基础设施支持。这些服务中的大部分都是用 Go 编写的。我们的团队——地图制作工程组,以前曾在通过调优 GC 来显著提高多个 Java 服务的效率方面发挥过重要作用。在 2021 年初,我们探讨了对基于 Go 的服务进行性能调优的可能性。我们运行了几个 CPU 配置文件来评估当前的状态,发现 GC 是大多数关键任务服务的最大 CPU 消费者。下面是一些 CPU 配置文件的代表,其中 GC(由 runtime.scanobject 方法标识)消耗了分配的计算资源的很大一部分。
Service #1
图 1:示例服务 #1 的 GC CPU 消耗
Service #2
图 2:示例服务 #2 的 GC CPU 消耗
由于这一发现,我们开始为相关服务进行 GC 调优。令我们高兴的是,Go 的 GC 实现和简单的调优使得我们能够自动化大部分检测和调优机制。我们将在后续部分详细介绍我们的方法及其影响。
GOGC 调优器
Go 运行时环境以周期性的间隔调用并发垃圾回收器,除非之前有一个触发事件。触发事件基于内存背压。因此,受 GC 影响的 Go 服务受益于更多的内存,因为这减少了 GC 必须运行的次数。另外,我们意识到我们的主机级 CPU 与内存的比率是 1:5(1 core:5 GB 内存),而大多数 Golang 服务的配置比率是 1:1 到 1:2。因此,我们相信我们可以利用更多的内存来减少 GC CPU 的影响。这是一种与服务无关的机制,如果应用得当,会产生很大的影响。
深入研究 Go 的垃圾回收超出了本文的讨论范围,但以下是这项工作的相关内容:Go 中的垃圾回收是并发的,需要分析所有对象来确定哪些对象仍然是可访问的。我们将可访问的对象称为“实时数据集”。Go 只提供了一个工具——GOGC,用实时数据集的百分比表示,用来控制垃圾回收。GOGC 值充当数据集的乘数。GOGC 的默认值是 100%,这意味着 Go 运行时环境将为新的分配保留与实时数据集相同的内存量。例如:硬目标 = 实时数据集 + 实时数据集 * (GOGC / 100)。
然后,pacer 负责预测触发垃圾回收的最佳时间,从而避免击中硬目标(和软目标)。
图 3:使用默认配置的示例堆内存
动态而多样:没有万能的方法
我们发现,基于固定的 GOGC 值的调整不适合 Uber 的服务。其中一些挑战是:
自动化案例
前面提到的痛点是提出 GOGCTuner 概念的原因。GOGCTuner 库简化了服务所有者优化垃圾回收的过程,并在其上添加了一个可靠性层。
正常流量(实时数据集是 150M)
图 4:正常操作。左边是默认配置,右边是手动调整。
流量翻倍(实时数据集是 300M)
图 5:负载翻倍。左边是默认配置,右边是手动调整。
流量翻倍且 GOGCTuner 设置为 70%(实时数据集是 300M)
图 6:流量翻倍,但使用微调器。左边是默认配置,右边是 GOGCTuner 调整。
可观测性
我们发现,我们缺乏一些可以让我们对每个服务的垃圾回收有更多了解的关键指标。
图 7:GC 之间的间隔图。
图 8:p99 GC CPU 消耗图。
图 9:p99 实时数据集预估图。
图 10:微调器给应用程序分配 min、p50、p99 GOGC 值的图。
实 现
我们最初的方法是,让一个计时器每秒运行一次来监控堆指标,然后相应地调整 GOGC 值。这种方法的缺点是,开销开始变得相当大,因为为了读取堆指标,Go 需要执行一次 STW(ReadMemStats),这还不怎么准确,因为我们每秒可能会多次进行垃圾回收。
幸运的是,我们找到了一种替代方案。Go 有 finalizers(SetFinalizer),它们是在垃圾回收对象时运行的函数。它们主要用于清理 C 代码或其它资源中的内存。我们可以使用一个自引用的 finalizer,在每次 GC 调用时重置自己。这能够使我们减少任何 CPU 开销。例如:
图 11:GC 触发事件的示例代码。
调用运行时。在 finalizerHandler 中的 SetFinalizer(f, finalizerHandler) 允许应用程序在每个 GC 上运行;它基本上不会让引用消亡,因为它不是一个代价高昂的资源(它只是一个指针)。
影 响
在我们的几十个服务中部署了 GOGCTuner 之后,我们深入研究了其中一些在 CPU 使用量上有显著的两位数提升的服务。仅这些服务就累积节省了约 70K 内核。下面是 2 个这样的例子:
图 12:在数千个计算内核上运行,实时数据集的标准差很高(最大值是最小值的 10 倍)的可观测性服务,显示 p99 CPU 的使用降低了约 65%。
图 13:运行在数千个计算核心上的关键任务 Uber eats 服务,显示 p99 CPU 的使用降低了约 30%。
由此导致的 CPU 使用的减少在战术上优化了 p99 的延迟(以及相关的 SLA、用户体验),并在战略上优化了性能成本(因为服务是根据他们的使用量进行扩展的)。
结 语
垃圾回收是影响应用程序性能的最难以捉摸且被低估的因素之一。Go 强大的 GC 机制和简化的调优,我们多样化的大规模的 Go 服务足迹,以及强大的内部平台(Go、计算、可观测性),共同让我们能够产生如此大规模的影响。由于技术和我们能力的变化,问题本身正在演变,我们希望继续改进 GC 调优的方式。
重申我们在引言中提到的:没有万能的解决方案。我们认为,由于公共云和运行在其中的容器化负载的性能高度可变,在云原生设置中 GC 性能也是变化的。再加上我们使用的绝大多数 CNCF 落地项目(Kubernetes、Prometheus、Jaeger 等等)都是用 Golang 编写的,这意味着任何外部的大规模部署也可以受益于这些工作。
作者介绍:
Cristian Velazquez 是 Uber 的地图制作工程团队的高级二级工程师。他负责多个效率倡议,这些倡议跨多个组织,其中最相关的是 Java 和 Go 的垃圾回收调优。
原文链接:
https://eng.uber.com/how-we-saved-70k-cores-across-30-mission-critical-services/