掘金 后端 ( ) • 2024-04-25 10:32

本文由 简悦 SimpRead 转码, 原文地址 blog.csdn.net

作者:来自 Elastic Luca Wintergerst, Tim Rühsen

在当今云服务和 SaaS 平台的时代,持续改进不仅是一个目标,而是一个必要条件。在 Elastic,我们始终在寻找方法来优化我们的系统,无论是我们的内部工具还是 Elastic Cloud 服务。我们最近在 Elastic Cloud QA 环境中进行的性能优化调查,由 Elastic Universal Profiling 指导,是如何将数据转化为可操作见解的一个很好的例子。

在本博客中,我们将介绍我们的一位工程师发现的一项内容,该发现导致我们在 QA 环境中节省了数千美元,并且一旦我们将此更改部署到生产环境中,节省的金额将更加巨大。

Elastic Universal Profiling:我们的首选优化工具

在解决性能挑战的解决方案套件中,Elastic Universal Profiling 是一个至关重要的组成部分。作为一个利用 eBPF 的 “始终运行” 分析器,它能够与我们的基础架构完美集成,并系统地收集我们系统的全面分析数据。由于它无需任何代码仪器化或重新配置,因此可以轻松部署到我们云中的任何主机(包括 Kubernetes 主机)—— 我们已将其部署到 Elastic Cloud 的整个环境中。

我们所有的主机都运行分析代理程序以收集这些数据,这为我们提供了对我们正在运行的任何服务的详细洞察。

发现机会

一切都始于对我们 QA 环境的常规检查。我们的一位工程师正在查看分析数据。在通用分析的支持下,这个最初的发现相对迅速。我们发现了一个未经优化且计算成本高昂的函数。

让我们一步一步来。

为了发现成本高昂的函数,我们只需查看一个 TopN 函数列表。TopN 函数列表显示了我们运行的所有服务中使用 CPU 最多的所有函数。

为了根据它们的影响排序,我们按 “total CPU” 降序排序:

  • Self CPU” 衡量的是一个函数直接使用的 CPU 时间,不包括它调用的函数所花费的时间。这个指标有助于识别那些自身消耗大量 CPU 资源的函数。通过优化这些函数,我们可以使它们运行得更快,使用更少的 CPU。
  • Total CPU” 则是将函数及其调用的任何函数使用的 CPU 时间加总。这提供了一个函数及其相关操作使用 CPU 的完整画面。如果一个函数的 “total CPU” 使用量很高,可能是因为它调用了其他使用大量CPU的函数。

当我们的工程师审查 TopN 函数列表时,一个名为 “...inflateCompressedFrame…” 的函数引起了他们的注意。这是一个常见的场景,某些类型的函数经常成为优化的目标。这里是一个简化的指南,说明了应该寻找什么和可能的改进措施:

  • 压缩/解压缩:是否有更高效的算法?例如,从 zlib 切换到 zlib-ng 可能会提供更好的性能。
  • 加密哈希算法:确保使用最快的算法。有时,根据安全要求,一个更快的非加密算法可能是合适的。
  • 非加密哈希算法:检查是否使用了最快的选项。例如,xxh3 通常比其他哈希算法更快。
  • 垃圾收集:尽量减少堆分配,特别是在频繁使用的路径中。选择不依赖垃圾收集的数据结构。
  • 堆内存分配:这通常是资源密集型的。考虑使用 jemalloc 或 mimalloc 替代标准的 libc malloc() 来减少它们的影响。
  • 页错误:留意 TopN 函数或火焰图中的 "exc_page_fault"。它们指示内存访问模式可以被优化的区域。
  • 内核函数的过度 CPU 使用:这可能表明系统调用过多。使用更大的缓冲区进行读/写操作可以减少系统调用的数量。
  • 序列化/反序列化:像 JSON 编码或解码这样的过程,通常可以通过切换到更快的 JSON 库来加速。

识别这些区域可以帮助确定哪里的性能可以显著提高。

从 TopN 视图中点击该函数会在火焰图中显示它。请注意,火焰图显示的是完整的云 QA 基础设施的样本。在这个视图中,我们可以看到这个函数单独就在我们 QA 环境的这部分造成了每年超过 6000 美元的成本。

在对线程进行筛选后,该函数的功能变得更加清晰。下图显示了 QA 环境中所有主机上该线程的火焰图。

相比于在所有主机上查看该线程,我们也可以仅查看某个特定主机的火焰图。

如果我们一次只查看一个主机,我们会发现影响甚至更为严重。请记住,之前提到的 17% 是针对整个基础设施的。有些主机可能甚至没有运行这项服务,因此会拉低平均值。

将筛选范围缩小到运行该服务的单个主机,我们可以发现这台主机实际上将近 70% 的 CPU 周期用于运行这个函数。

仅这一台主机的费用,就将这个函数的年成本定在大约 600 美元左右。

理解性能问题

在确定了一个潜在的资源密集型函数后,我们的下一步是与我们的工程团队合作,理解这个函数并着手可能的修复工作。以下是我们方法的简要分解:

  • 理解函数:我们首先分析了函数应该执行的任务。它使用 gzip 进行解压缩。这个洞查让我们简要考虑了之前提到的减少 CPU 使用的策略,比如使用更高效的压缩库,如 zlib,或者切换到 zstd 压缩。

  • 评估当前实现:该函数目前依赖于 JDK 的 gzip 解压缩,预计在底层使用本机库。我们通常的偏好是使用 Java 或 Ruby 库(如果有的话),因为它们简化了部署过程。直接选择本机库将要求我们管理每个支持的操作系统和 CPU 的不同本机版本,这会使我们的部署过程变得复杂。

  • 使用火焰图进行详细分析:对火焰图的进一步审查显示系统遇到了页面错误,并花费了大量的 CPU 周期来处理这些错误。

让我们从理解火焰图开始

  • 最后几个非 jdk.* JVM 指令(绿色)显示了由 Netty 的 DirectArena.newUnpooledChunk 启动的直接内存 Byte Buffer 的分配。直接内存分配是昂贵的操作,通常应该避免在应用程序的关键路径上进行。
  • Elastic AI Observability 助手对于理解和优化火焰图的部分也非常有用。特别是对于对通用分析不熟悉的用户,它可以为收集到的数据增加大量的上下文,并让用户更好地理解它们并提供潜在的解决方案。

Netty 的内存分配

Netty是一个流行的异步事件驱动的网络应用程序框架,使用 maxOrder 设置来确定为其应用程序中的对象管理分配的内存块大小。计算块大小的公式是 chunkSize = pageSize << maxOrder。默认的 maxOrder 值为 9 或 11,这使得默认内存块大小分别为 4MB 或 16MB,假设页面大小为 8KB。

内存分配的影响

Netty 采用 PooledAllocator 进行高效的内存管理,该分配器在启动时在直接内存池中分配内存块。此分配器通过重用定义块大小以下的对象的内存块来优化内存使用。任何超过此阈值的对象都必须在 PooledAllocator 之外进行分配。

在这种池化环境之外分配和释放内存会因几个原因而导致更高的性能成本:

  • 增加的分配开销:大于块大小的对象需要单独的内存分配请求。这些分配比较耗时且资源密集,与为较小对象的快速池化分配机制相比。

  • 碎片化和垃圾回收(GC)压力:在池外分配较大的对象可能导致内存碎片化增加。此外,如果这些对象在堆上分配,它们可以增加 GC 压力,导致可能的暂停和应用程序性能降低。

Netty 和 Beats/Agent 输入:Logstash 的 Beats 和 Elastic Agent 输入使用 Netty 来接收和发送数据。在处理接收到的数据批次时,解压数据帧需要创建足够大的缓冲区来存储未压缩的事件。如果这个批次大于块大小,就需要一个非池化块,导致直接内存分配,从而减慢性能。通用分析器让我们能够从火焰图中的 DirectArena.newUnpooledChunk 调用中确认这一点。

在我们的环境中修复性能问题

我们决定实施一个快速的解决方案来测试我们的假设。除了需要调整一次 jvm 选项外,这种方法没有任何主要的缺点。

立即的解决方案包括手动将 maxOrder 设置调整回其先前的值。这可以通过向 Logstash 的 config/jvm.options 文件添加一个特定的标志来实现:

-Dio.netty.allocator.maxOrder=11

这个调整将默认的块大小恢复为16MB(chunkSize = pageSize << maxOrder,或者16MB = 8KB << 11),这与 Netty 之前的行为保持一致,从而减少了在 PooledAllocator 之外分配和释放较大对象所带来的开销。

在 QA 环境的一些主机上部署了这个变更后,性能分析数据立即显示出了影响。

单个主机

多个主机

我们还可以使用差异火焰图视图来查看影响。

对于这个特定的线程,我们比较了一部分主机从一月初到二月初的一天数据。整体性能的提升以及二氧化碳和成本节省都是显著的。

同样的比较也可以针对单个主机进行。在这个视图中,我们将一月初的一个主机与二月初的同一主机进行比较。该主机的实际 CPU 使用量减少了50%,每台主机每年为我们节省了约 900 美元。

为了解决 Logstash 中的问题

除了临时的解决方案之外,我们正在努力为 Logstash 这种行为提供一个正式的修复方案。你可以在这个问题中找到更多详细信息,但是潜在的候选方案包括:

  • 全局默认调整:一种方法是通过在 jvm.options 文件中包含这个更改,将 maxOrder 永久地设置回 11,适用于所有实例。这个全局性的更改将确保所有 Logstash 实例使用更大的默认块大小,减少了在池化分配器之外进行分配的需求。

  • 自定义分配器配置:对于更有针对性的干预,我们可以在 Logstash 的 TCP、Beats 和 HTTP 输入中专门自定义分配器设置。这将涉及在这些输入的初始化时配置 maxOrder 值,提供一个针对性的解决方案,解决数据摄入受影响最严重的区域的性能问题。

  • 优化主要的分配站点:另一个解决方案集中在 Logstash 中重要的分配站点的行为。例如,修改 Beats 输入中的帧解压缩过程,避免使用直接内存,而是默认使用堆内存,可以显著减少性能影响。这种方法将绕过减少的默认块大小所带来的限制,最小化对大型直接内存分配的依赖。

节省成本并增强性能

在 1 月 23 日对 Logstash 实例进行新配置更改后,平台的每日功能成本从最初的大约 6000 美元急剧降至 350 美元,标志着显著的 20 倍降低。这一变化显示了通过技术优化实现大幅成本节省的潜力。然而,重要的是要注意,这些数字代表的是潜在的节省,而不是直接的成本降低。

即使主机使用的 CPU 资源减少了,也不一定意味着我们也在节省金钱。要从中受益,现在的最后一步是要么减少我们正在运行的虚拟机数量,要么将每台虚拟机的 CPU 资源缩减到与新的资源需求相匹配。

弹性通用分析的经验突显了通过详细的实时数据分析来识别优化领域的重要性,这些优化领域可以实现显著的性能提升和成本节约。通过根据分析洞见实施有针对性的变更,我们在 QA 环境中显著降低了 CPU 使用量和运营成本,并且对更广泛的生产部署有着有希望的影响。

我们的研究结果显示,在云环境中,始终运行、基于分析驱动的方法的好处,在未来的优化中提供了良好的基础。随着这些改进的推广,进一步节省成本和提高效率的潜力也在不断增加。

在你的环境中也可以实现这一切。立即了解如何开始。

本文所述的任何功能或功能的发布和时间仍由 Elastic 公司全权决定。目前不可用的任何功能或功能可能不会按时或完全交付。

原文:Elastic Universal Profiling: Delivering performance improvements and reduced costs | Elastic Blog