作者 | 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 的服务。其中一些挑战是:
不知道分配给容器的最大内存,可能导致内存溢出问题。
我们的微服务具有显著不同的内存使用量组合。例如,分片系统可以有非常不同的实时数据集。我们在其中一个服务中遇到了这种情况,其中 p99 的使用量是 1GB,而 p1 的使用量是 100MB,因此 100MB 的实例对 GC 有巨大影响。
自动化案例
前面提到的痛点是提出 GOGCTuner 概念的原因。GOGCTuner 库简化了服务所有者优化垃圾回收的过程,并在其上添加了一个可靠性层。
GOGCTuner 根据容器的内存限制(或服务所有者的上限)动态计算正确的 GOGC 值,并使用 Go 的运行时 API 进行设置。以下是 GOGCTuner 库功能的详细信息:
简化配置来便于推理和确定性计算。GOGC 的 100% 对于 GO 初学开发者来说并不明确,也并不确定,因为它仍然依赖于实时数据集。另一方面,70% 的限制可确保服务始终使用 70% 的堆空间。
防止 OOM(内存溢出):这个库从 cgroup 读取内存限制,并使用默认的硬限制 70%(这是我们经验中的安全值)。
值得一提的是,这种保护是有限度的。微调器只能调整缓冲区分配,因此如果您的服务的存活对象高于微调器的限制,微调器会将比较低的存活对象的使用量的 1.25 倍设置成默认的限制值。
对于以下情况,允许更高的 GOGC 值:
如上所述,手动 GOGC 是不确定的。我们仍然依赖实时数据集的大小。如果实时数据集是我们上一个峰值的两倍怎么办?GOGCTuner 将使用更多的 CPU 来强制执行相同的内存限制。相反,手动调整会导致内存溢出。因此,服务所有者过去常常为这些类型的场景提供大量的缓存。请参见下面的示例:
正常流量(实时数据集是 150M)
图 4:正常操作。左边是默认配置,右边是手动调整。
流量翻倍(实时数据集是 300M)
图 5:负载翻倍。左边是默认配置,右边是手动调整。
流量翻倍且 GOGCTuner 设置为 70%(实时数据集是 300M)
图 6:流量翻倍,但使用微调器。左边是默认配置,右边是 GOGCTuner 调整。
使用 MADV_FREE 内存策略的服务会导致错误的内存度量。例如,我们的可观测性指标显示了 50% 的内存使用量(实际上它已经释放了这 50% 中的 20%)。然后,服务所有者只使用这个“不准确的”指标来调整 GOGC。
可观测性
我们发现,我们缺乏一些可以让我们对每个服务的垃圾回收有更多了解的关键指标。
垃圾回收之间的间隔:这可以使我们了解是否还可以调整。如果你的服务仍然有很高的 GC 影响,但你已经看到了这个图 120s,这意味着你不能再使用 GOGC 进行调整。在这种情况下,您需要优化分配。
图 7:GC 之间的间隔图。
GC CPU 影响:让我们知道哪些服务受 GC 影响最大。
图 8:p99 GC CPU 消耗图。
实时数据集大小:帮助我们识别内存泄漏。服务所有者注意到的问题是,他们看到了内存使用量的提高。为了向他们表明没有内存泄漏,我们添加了“实时使用量”指标,展示了稳定的内存使用量。
图 9:p99 实时数据集预估图。
GOGC 值:对于了解调整的效果非常有用。
图 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/
关键词: 我们如何在 项关键服务任务中节省