Uber 使用开源 Presto 查询几乎所有数据源,包括动态和静态数据源。Presto 的多功能性使我们能够做出明智的数据驱动的业务决策。我们在两个地区运营着大约 20 个 Presto 集群,跨越 10,000 多个节点。我们每周有大约 12,000 名活跃用户,每天运行大约 500,000 个查询,从 HDFS 读取大约 100 PB。如今,Presto 通过其可扩展的数据源连接器用于查询各种数据源,如Apache Hive、Apache Pinot、AresDb、MySQL、Elasticsearch 和 Apache Kafka。
图 1
我们选择的集群类型可以满足任何请求,无论是用于交互式还是批处理目的。交互式工作负载适合等待结果的仪表板/桌面用户,而批处理工作负载是按预定义时间表运行的预定作业。我们的每个集群都根据其机器类型进行分类。我们的大多数集群都由较大的机器组成,这些机器配备了超过 300 GB 的堆内存,而其他集群则由较小的机器组成,这些机器配备了不到 200 GB 的堆内存,并且我们根据每个集群的大小和组成它的机器的类型修改了它的并发性。
所有生产集群都会每周进行一次内存碎片优化活动。尽管我们一直在改善碎片化问题,但我们仍然遭受着持续的完全垃圾收集(非常长的暂停)和偶尔出现的一些内存不足错误。为了让大家了解一下这个问题,让我向大家展示一下 Presto 完整 GC 的累计计数:
图 2:Presto 每天的完整 GC 计数。
概述 - G1GC 垃圾收集器G1GC是一种试图平衡吞吐量和延迟的垃圾收集器。G1 是一种分代垃圾收集器,与较新的并发垃圾收集器(Shenandoah、ZGC 等)不同。分代意味着将内存分为短寿命对象和长寿命对象。
这里要区分的第一件重要的事情是,内存有两种类型:堆栈和堆。堆栈分配成本低廉,因为我们只需要增加一个指针,因此每当我们调用一个函数时,我们都会减少堆栈指针(堆栈向下增长),一旦我们完成该函数,我们只需增加指针,然后,分配和释放都只需要一个语句。另一方面,堆分配/释放成本稍高一些。对于 G1GC,分配类似于堆栈,我们只需要增加一个指针,但释放需要运行 GC。
在 Java 中,由于所有对象都分配在堆上,那么我们在堆栈上分配什么呢?就是指向堆上对象的“指针”。然后对于堆空间,G1 将其划分为称为“区域”的小部分。
G1 尝试在堆上实现至少 2,048 个区域。
图 3:堆被划分为多个区域。
每个区域的大小是多少?这取决于您的堆大小,但范围是 1-32 MB。JVM 决定哪个大小可确保我们有 2,048 个区域(或更多)。
每个区域可以是年轻代、老一代或者空闲代。
图 4:堆区域分为年轻一代、老一代和空闲一代。
最后,年轻代也分为伊甸园和幸存者。伊甸园是任何新分配发生的地方。对于幸存者,它将创建两个不同的舞台。为什么?年轻代清除内存的方法是在区域之间复制对象,因此它需要一个空的幸存者来复制内存。
因此,完整的过程是,每当我们执行 new Object() 时,它都会被分配到 Eden。GC 运行并且对象尚未死亡,因此它会被复制到 Survivor0。下次 GC 再次运行时,如果对象仍未死亡,它会被提升到 Survivor1。因此它会继续在幸存者之间来回复制,直到最终被提升到老一代。
图 5:堆完全分为所有提到的类型。
回顾一下,年轻代使用复制机制来释放内存。那么我们什么时候分配给老年代呢?有两种情况:
G1 有一个年龄阈值。每次复制年轻一代对象时,我们都会增加年龄。一旦达到阈值,它就会被复制到老一代。每个区域大小为 1-32 MB。任何占区域大小 50% 或以上的对象都会直接分配给老生代。G1 将其称为巨型对象。G1 如何清除旧代?它使用一种称为“并发标记和清除”的算法。它是从根对象(线程堆栈、全局变量等)开始的图形遍历,并遍历每个仍被引用的对象。必须提到的是,G1 使用 STAB(开始时的快照),因此启动后的任何新对象都将被视为活跃的,无论其实际活跃程度如何。一旦完成,G1 就会知道哪些对象仍然活跃,而那些不活跃的对象可以在接下来混合收集中清除。
什么?混合收集?确实如此。混合收集是一种年轻代收集,其中将老一代区域包含在该过程中。因此,它会复制仍存在于另一个老一代区域中的对象。此过程对于减少内存碎片至关重要。
谁来决定每个组件(伊甸园、幸存者、老生代等)的大小?堆总是在收缩和扩张以完成其工作,尽管有一定的限制。例如,年轻代只能占整个堆的 5-60%。
今天的讨论不需要深入探讨更高级的 G1GC 主题,所以让我们从我们在 Uber 所做的事情开始。
Uber 的 G1GC当 Java 在 Uber 得到更广泛的使用时,我们使用的是 OpenJDK 8。大多数时候,我们唯一需要调整的选项是-XX:InitiatingHeapOccupancyPercent=X。这个阈值控制着 G1 是否应该启动并发标记和清除循环。
它的默认值为 45%,这通常会导致 CPU 增加,因为任何使用某些缓存的服务最终都会超过该阈值,并且会不断触发该阈值。例如,服务 A 将所有用户存储在内存中,这会导致老生代占总堆的 ~60%。那么 45% 的阈值将始终满足。
那我们该如何调整呢?
启用 GC 日志和 GC 指标寻找混合收集后老生代利用率的峰值选择略高于峰值的值 - 通常高 5-10%但是,请记住,Presto 服务器现在运行在 JDK 11 上。我们如何调整它们?这是我们第一次尝试调整此版本。为什么不同?Java 引入了动态 IHOP(InitiatingHeapOccupancyPercent)。然后我们不再有 45% 的默认值,而是有一个可以随时更改的值,并且它仅在 GC 日志中可用。
调优 JDK 11动态 IHOP 如何计算?它使用年轻代的当前大小加上空闲阈值(基本思想,它使用稍微复杂一些的公式)。此空闲阈值默认值为总堆的 10%,用作允许 GC 完成的缓冲区(记住并发标记和清除会随应用程序一起运行)。
我们遵循的流程如下(我们在每个步骤之间等待 1-2 周,以获得足够的数据来验证我们的实验)。我们首先在一个集群上尝试了以下操作,以避免影响所有用户。
添加更多 GC 指标我们缺少年轻一代和老一代的利用率,因此我们无法轻松了解有关利用率的历史数据。
将最大年轻代规模从 60% 减少到 20%我们看到年轻代扩大了几倍(占总堆的 50%)。这导致 GC 暂停时间过长,并发标记需要更长时间才能再次运行。如果我们仍在进行混合收集,则并发标记无法运行。
结果如何?
更好的 GC 暂停。并发标记仍然不好。发生这种情况的原因是,通过将最大大小减少 40%,我们将其分配给了老一代,因此并发标记仍然开始得晚。将可用空间从 10% 增加到 35%,并将堆浪费从 5% 减少到 1%我们先来谈谈堆浪费百分比。默认情况下,此调整选项为 5%,这告诉 G1,只有当垃圾超过总堆的 5% 时,它才必须释放垃圾。为什么?为了避免在混合收集期间长时间 GC 暂停。当我们进行并发标记时,G1 根据老一代区域的利用率对其进行排序,并首先选择具有更多可用空间的区域,因为它们复制到新区域的速度更快。
对于我们的 300G 集群,这意味着 15G 永远不会被清理。根据过去的经验,我们决定将其减少到 3G( -XX:G1HeapWastePercent=1 )。
对于可用空间,我们分析了几个 GC 日志,发现混合收集后利用率保持在 20-35%。然后 20% 的最大年轻代加上 35% 的可用空间将给我们一个 45% (100-(35+20)%) 的阈值。使用此配置,我们至少提供 10% 的缓冲区 (35 到 45%) 来清理一些垃圾。
结果如何?
1% 似乎太多了,我们开始看到超过 1 秒的长时间停顿。这一变化很有帮助,因为通过 GC 日志,我们可以确定,一旦混合收集尝试从 2% 变为 1% 垃圾,就会开始出现长时间停顿。35% 表现良好。完整 GC 减少(此集群减少约 80%)。将可用空间从 35% 增加到 40%,并将堆浪费从 1% 增加到 2%结果是:
2% 的堆浪费给我们带来了额外的 9G,而对延迟的影响却很小(~50-100ms 对比 1% 时为 1-1.5s)。40% 的表现略好于 35%,但我们并没有获得太多收益(85-90% 对 80%)。我们决定不再进一步提高,以避免遭受重创。在不同的集群上尝试相同的调整选项我们在新集群中测试了相同的配置,并在尝试所有配置之前验证了行为,以查看影响。我们决定选择过去几周内 Full GC 次数最多的集群。部署 24 小时后,我们已经可以看到影响:
图 6:单个集群的 Full GC 累计次数
以前,仅仅几个小时之后,我们就会开始看到完整的 GC,但是经过这些更改后,我们就没有再看到任何完整的 GC。
结论在使用上述调整标志进行了数周的测试后,我们决定对所有集群使用相同的标志。添加/更新标志后,所有集群均表现最佳,内部 OOM 错误极少甚至没有。由于这一变化,Presto 集群的可靠性得到提高,从而减少了之前因 OOM 错误而失败的查询的重新运行,从而提高了 Presto 集群的整体性能。我们在最终调整中使用的标志是:
-XX:+解锁实验性虚拟机选项
-XX:G1MaxNewSizePercent=20
-XX:G1ReservePercent=40
-XX:G1HeapWastePercent=2
这些标志特定于 Uber 中的 Presto 用例,经过多次调整迭代后最终确定。我们预计每个组织的标志会根据各个工作负载而有所不同,并且必须根据具体情况进行调整。启用这些标志后,我们将看到更频繁的垃圾收集,但它们使我们拥有更可靠的 Presto 集群并减轻了所有者的随叫随到负担。
对于我们所有的集群,我们观察到以下影响:
图 7:所有集群每天的累计老生代数量。
图 8:所有集群上每天的内部错误。
下一步是什么?我们所做的大部分垃圾收集调优都是针对面向产品的应用程序,我们还没有密切关注我们的存储应用程序。因此,我们计划继续针对 Uber 提供的其他解决方案进行调优。这将是一次有趣的学习体验,因为存储应用程序使用大型堆,这与我们通常调优的方式不同。一旦我们获得更多数据,我们就会与社区分享。
Presto 上的 GC 调优就是一个例子,说明如何通过改进垃圾收集来提高系统的整体性能和可靠性。我们的下一个重点是进一步微调 Presto 集群的 GC,尤其是那些性能较弱的机器,因为这些机器仍然会遇到一些完整的 GC,并提高系统的整体可靠性。
列出的所有优化都特定于 Uber 中的 Presto 部署,不能直接移植到其他服务。列出的标志仅用于演示目的,以了解我们最终在调整中使用了哪些标志。此外,我们将提出一些最佳实践和指南,这些实践和指南可供所有 Uber 的存储应用程序使用,具体取决于它们的一般用途,这将是一个很好的起点。这将使我们能够改进所有存储应用程序,提高整体可靠性和性能。
作者:
Cristian Velazquez
Cristian Velazquez is a Staff Software Engineer on the Maps Production Engineering team at Uber. He works on multiple efficiency initiatives across multiple organizations. He has done several tuning across multiple services and multiple Java versions.
Vineeth Karayil Sekharan
Vineeth Karayil Sekharan is a Senior Software Engineer on Uber’s Data Analytics team. He works on managing Presto and is involved in initiatives like JDK version upgrades and Platform Modernization for Presto.
出处:https://www.uber.com/en-JP/blog/uber-gc-tuning-for-improved-presto-reliability/