Uber 最近发了一篇文章,主要讲的是在核心服务上动态调整 GOGC 来降低 GC 的 mark 阶段 CPU 占用。基本可以做到有效果,低风险,可扩展,半自动化。
Uber 当前的服务规模大概是几千微服务,基于云上的调度基础设施来进行部署。大部分服务是 GO 写的,这篇文章的作者是做 Maps Production Engineering,这个组之前帮一些 Java 系统调整过 GC 参数(这应该就是他们帮 Go 做优化想的也是怎么调整参数的出发点吧)。
总之经过一段时间的线上 profile 采集发现 GC 是很多核心服务的一个很大的 CPU 消耗点,比如 runtime.scanobject 方法消耗了很大比例的计算资源:
Service #1
Figure 1: GC CPU cost of Example Service #1
Service #2
Figure 2: GC CPU cost of Example Service #1
有了这样的发现,团队开始想办法能不能想出一些方案来进行优化。下面是一些细节。
GOGC Tuner
Go 的 runtime 会间隙性地调用垃圾收集器来并发进行垃圾回收。这个启动是由内存的压力反馈来决定何时启动 GC 的。所以 Go 的服务可以通过增加内存的用量来降低 GC 的频率以降低 GC 的总 CPU 占用,Uber 内部的服务大多实例配比是 1C5G,而实际的 Go 服务 CPU : 内存占比大约是 1:1 或者 1:2 左右,即占用 1c 的 CPU 时同时占用 1G 或 2G 的内存。所以这里确实存在参数调整的空间。
Go 的 GC 触发算法可以简化成下面这样的公式:
hard_target = live_dataset + live_dataset * (GOGC / 100).
由 pacer 算法来计算每次最合适触发的 heap 内存占用。
Figure 3: Example heap with default configuration.
动态和多样的服务:没有一劳永逸的方案
固定的 GOGC 值没法满足 Uber 内部所有的服务。具体的挑战包括:
- 对于容器内的可用最大内存并没有进行考虑,理论上存在 OOM 的可能性。
- 不同的微服务对内存的使用情况完全不同。比如,比如有些系统只使用 100MB 内存,而内存占用 99 分位的服务则使用 1G 的内存,而 100MB 那个服务,GC 使用的 CPU 非常高。
自动化
Uber 内部搞了一个叫 GOGCTuner 的库。这个库简化了 Go 的 GOGC 参数调整流程,并且能够可靠地自动对其进行调整。
这个工具会根据容器的 memory limit (应用 owner 也可以自己指定)使用 Go 的 runtime API ,动态地调整 GOGC 参数:
-
默认的 GOGC 参数是 100%,这个值对于 GO 的开发者来说并不明确,其本身还是依赖于活跃的堆内存。GOGCTuner 会限制应用使用 70% 的内存。并且能够将内存用量严格限制住。
-
可以保护应用不发生 OOM:该库会读取 cgroup 下的应用内存限制,并且强制限制只能使用 70% 的内存,从我们的经验来看这样还是比较安全的。
- 这种保护当然也是有限制的。这个 tuner 只能对 buffer 的分配进行自适应,所以如果你的堆上活跃对象比 tuner 工具限制的内存量还要高,那这个工具会设置为你的活跃对象容量 * 1.25。
-
在一些特殊情况下,容许 GOGC 的值更高一些,比如:
- 我们提到默认的 GOGC 是不明确的。尽管我们做了自适应,但我们依然依赖于当前活跃的对象的大小。如果当前的活跃对象大小比我们之前最大值的两倍还要大会发生啥?GOGCTuner 会将总内存限制住,使得应用消耗更多的 CPU。如果是手动地调 GOGC 为固定值,这里可能会直接发生 OOM。不过一般应用 owner 会给这种场景提供额外的 buffer 量,参考后文的一些例子。
Normal traffic (live dataset is 150M)
Figure 4: 左边用默认值,右边是手动调整了 GOGC 为固定值
Traffic increased 2X (live dataset is 300M)
Figure 5: load 翻倍。左边的是默认值,右边是手动调整的固定值
Traffic increased 2X with GOGCTuner at 70% (live dataset is 300M)
Figure 6: load 翻倍。左边的是默认值,右边的是用 GOGCTuner 动态调整的值.
- 使用 MADV_FREE 内存策略会导致错误的内存指标。所以使用 Go 1.12-Go 1.15 的同学注意设置 madvdontneed 的环境变量。
Observability
为了提升可观测性,我们还对垃圾回收的一些关键指标进行了监控。
- 垃圾回收触发的时间间隔:可以知道是否还需要进一步的优化。比如 Go 每两分钟强制触发一次垃圾回收。如果你的服务还有 GC 方面的问题,但是你在这张图上看到的值都是120s,那说明你已经没法通过调整 GOGC 进行优化了。这种情况下你该从应用着手去做这些对象分配的优化。
Figure 7: Graph for intervals between GCs.
- GC 的 CPU 使用量: 使我们能知道哪些服务受 GC 影响最大。
Figure 8: Graph for p99 GC CPU cost.
- 活跃的对象大小: 帮我们来诊断内存泄露。在使用 GOGCTuner 之前,应用的 owner 是通过内存总用量来判断是否发生内存泄露的,现在我们需要把活跃的对象内存用量监控起来帮他们进行这个判断。
Figure 9: Graph for estimated p99 live dataset.
- GOGC 的动态值: 能知道 tuner 是不是在干活。
Figure 10: Graph for min, p50, p99 GOGC value assigned to the application by the tuner.
实现
最初的实现方式是每秒运行一个ticker 来监控堆的指标,然后来按指标调整 GOGC 的值。这种方法的缺点显而易见,读取 Memstats 需要 STW 并且这个值也不精确,因为每秒种可能会触发多次 GC。
我们还有更好的办法,Go 有一个 finalizer 机制,在对象被 GC 时可以触发用户的回调方法。 Uber 实现了一个自引用的 finalizer 能够在每次 GC 的时候进行 reset,这样也可以降低这个内存检测的 CPU 消耗。比如:
Figure 11: Example code for GC triggered events.
在 finalizerHandler 里调用 runtime.SetFinalizer(f, finalizerHandler) 能让这个 handler 在每次 GC 期间被执行;这样就不会让引用真的被干掉了,这样使该对象存活也并不需要太高的成本,只是一个指针而已。
影响
在一些服务中部署了 GOGCTuner 之后,我们看到了这个工具对一些服务的性能影响很大,有些服务甚至会有百分之几十的性能提升。我们大约节省了 70k 的 CPU 核心的成本。
下面是两个例子:
Figure 12: Observability service that operates on thousands of compute cores with high standard deviation for live_dataset (max value was 10X of the lowest value), showed ~65% reduction in p99 CPU utilization.
Figure 13: Mission critical Uber eats service that operates on thousands of compute cores, showed ~30% reduction in p99 CPU utilization.
CPU 使用的降低使得 P99 延迟也大幅改进(相应的 SLA,用户体验也同样),也降低了应用需要扩容的成本消耗(因为扩容是按照 SLA 指标来的)。
垃圾回收是最难搞明白的语言特性,其对应用的性能影响也经常被低估。Go 的 GC 策略以及简易的 tuning 方法,我们内部多样的,大规模的 Go 服务特性,以及内部的稳定的 Go 可观测性平台对于我们能够做出这样的改进功不可没。随着 Go 的 GC 的迭代,我们也能继续进行改进,提升公司在技术领域的竞争力。
这里再强调一下引言中的观点:没有银弹,没有一统天下的优化方案。我们认为 GC 优化在云原生场景下依然是个很难的问题。当前 CNCF 中大量的项目使用 Go 编写,希望我们的实践也能够帮助到外部的这些项目。
译注:Uber 的做法不是首创,之前在蚂蚁的同事也有类似的尝试,感兴趣的读者看这里:https://mp.weixin.qq.com/s/sWtt971MjwEJdjDjntC96w
Uber 原文地址:https://eng.uber.com/how-we-saved-70k-cores-across-30-mission-critical-services/