本文最初发布于 Salesforce 工程博客,经原作者授权由 InfoQ 中文站翻译并分享。
Salesforce CRM 应用程序是一个运行在 JVM 上的多租户单体应用,整个生产环境运行在数万台服务器上。这些主机每天处理数十亿个请求,为不同的租户处理报告查询和各种同步或异步任务。每天都有成千上万的工程师对单库源代码进行修改。此外,对底层数据以及 Salesforce CRM 之上的用户自定义扩展进行的大量更改,可能会导致我们在生产环境中观察不同服务器/集群时看到不一致的行为。
挑战和解决方案
当功能中断或性能退化时,准确确定在那天的关键点上发生了什么,对于调试非最佳行为至关重要。单个单体应用程序负责处理这么多不同租户的多样化要求,每个工作线程都会有丰富的上下文数据(例如,租户 ID、用户、URL),为了能够迅速查明生产环境的问题,必须维护好这些数据。
我们的团队在 Salesforce 主要职责是,确保在任何时候都可以从每个生产服务器获得正确的诊断数据。我们开发了一个完全自主的应用程序性能管理系统,不断地从所有生产服务器捕获性能分析和诊断数据。
我们遇到了一些 Salesforce 应用程序特有的问题,但是,我们相信这些问题的解决方案可能对构建类似系统的工程师有用。这篇文章简要地描述了一些主要的挑战和我们采用的解决方案。
扩展性
挑战
代理在数万个 JVM 上运行。每个数据中心都有很多主机,从每个主机都会捕获大量的数据,再加上跨数据中心的网络延迟,这意味着我们无法有效地将所有数据持久存储到一个集中化的存储解决方案。在所有服务器上,每秒写次数达 300 多万次,每一次有几 KB 的数据,总计超过每秒 1GB。这种速率对于单个网络或存储解决方案来说太高了(在合理的成本下)。
解决方案
将负载分布到多个数据中心,然后从一个可以访问所有数据中心并知道如何将请求路由到特定数据中心的中心站点协调检索。用户请求指定需要哪些主机集群的性能分析数据,中心站点将请求路由到正确数据中心内的相应服务器。这为调查工程师提供了良好的体验,依靠一个中心站点查看整个站点的所有性能分析数据。我们在存储中维护一个路由查找表(可以由系统管理员在运行时修改),将集群映射到相应的数据中心。
容错
挑战
对于 JVM 性能分析来说,许多最重要或最有趣的时间段都是在极端状况下,要么是故障原因,要么是故障征兆。当慢速请求堆积、客户工作负载模式改变或贪婪作业分配太多大对象时,内存和 CPU 会急剧增加。在紧急关头,JVM 可能会宕机或终止,或丢失网络连接;因此,如果只是将性能分析数据缓冲到内存中,而不是立即将其持久化到某种形式的永久存储中,那么性能分析数据可能会丢失。
解决方案
为了避免丢失最重要的数据,同时保持批量保存数据的能力,需要以弹性的方式缓存数据。当应用程序受到威胁时,将缓冲区保存在内存中可能容易丢失数据,因此,我们实现了一个磁盘上的循环缓冲区。从 JVM 捕获样本(线程转储和相关上下文)后,立即将它们保存在本地磁盘上。这样,在服务器或网络故障时,可以防止在将数据转发到集中存储之前丢失缓存数据。为了防止在长时间停机时对磁盘空间产生负面影响,缓冲区的循环特性是必须的,因此,缓冲区会根据配置的时间间隔覆盖。
多语言运行时支持
挑战
Salesforce 为 Apex 编程语言提供了一个定制的解释器,客户可以使用该解释器来添加特定于其组织的自定义业务逻辑。在处理生产环境的问题时,能够分析用户定义的扩展很有价值,这可以减少服务成本或响应时间。我们的解决方案必须能够捕获和表示性能分析和可观测数据,而不用管底层的语言是什么。此外,Salesforce 运行着各种基于 JVM 的服务,其中许多都是很好的性能分析候选对象。因此,我们的解决方案必须能够适应各种各样的 JVM,而不能仅仅适用于 CRM 的单体应用。
解决方案
我们的系统设计没有对所使用的编程语言做任何假设。底层实现语言作为另一个元数据附加到所有性能分析数据点,允许用户基于该语言进行查询。此外,为了能够支持不同的语言和环境,实现多语言支持,用于抽象堆栈跟踪和元数据的数据结构是以一种通用且足够灵活的方式表示的。
上下文元数据
挑战
通常情况下,领域专属上下文会触发调查和调试:报告花费的时间比它应该花费的时间长、请求失败或花费的时间太长、特定于租户的性能问题或加载了畸形数据的页面。允许调试工程师使用这个领域专属上下文来驱动他们的调查,可以极大地加快得出结论的过程。
如果工程师已经知道花费很长时间才能加载的 URL,那么搜索与该 URL 相关的堆栈跟踪信息可以让他们快速地将视野缩小到相关数据。这样就省去了许多常见的起始步骤,比如根据线程 ID 搜索日志和交叉引用 URL。
解决方案
我们的实现会针对每个线程收集领域专属上下文和它收集的堆栈跟踪信息。我们将流程设计得足够通用,以便每个经过性能分析的服务都可以确定将哪些相关信息映射到每个线程抽样:一个 JVM 处理请求,这些请求可能附加了 URL、HTTP 方法和请求参数;一个 JVM 运行批处理作业,这些作业可能附加了作业名称、ID 和作业类型。领域专属上下文与标准性能分析元数据一起存储,完全索引,并允许在查询时添加适当的过滤器,以避免额外的干扰。
此外,我们还允许对堆栈跟踪信息进行深度搜索,用户可以在其中通过正则表达式查找包含特定栈帧的抽样数据。这是最有用和最受欢迎的查询参数之一,因为在许多情况下,开发人员只对某些代码路径感兴趣,而不是对应用程序中执行的所有代码的抽样数据感兴趣。特性团队知道其模块的入口点(接口/API),因此,他们可以使用这些知识验证其特性在生产环境中的行为,从而提供一个反馈循环来识别潜在的进一步优化机会。
高线程数
挑战
为了及时处理请求或活动的激增,Salesforce 应用程序维护着大量的活动线程池。这将导致一定百分比的活动线程始终处于空闲状态,不做任何重要的工作。各个服务器上各种类似的情况会使这种影响成倍地增加,因此,任何给定的线程转储都至少包含 1500 个单独的线程。来自这些线程的数据将很快淹没我们的存储基础设施。
解决方案
抛弃捕获的那些空闲线程的数据!我们的目标不是在给定时刻完美地表示每个线程,而是表示在给定时刻正在进行的工作。过滤掉那些无所事事地等待工作的线程,或者是在检查一个值时休眠的线程,可以让我们更频繁地分析数据并更长时间地保存数据。在给定的 JVM 中,我们能够过滤掉 99%的初始线程转储。供参考:在整个生产环境中,我们平均每分钟丢弃数亿的堆栈跟踪信息,每分钟仅保留 500 万跟踪信息供以后使用。
压缩和去重
挑战
代码库经常变化,但是整体来看,它们的变化并不大。此外,在特定的时期内,最常执行的代码基本上没有变化。因此,线程转储包含大量的重复数据。这就提出了一个挑战,将性能分析数据直接捕获并存储会导致巨大但合理的存储需求。
解决方案
将数据分割成离散的部分,并跟踪数据的关系,使我们能够尽可能减少存储线程转储时的重复。选择正确的存储解决方案和正确的存储模式至关重要,这样构建的系统可以减少重复,从而使存储这些数据成为可能。我们在HBase上使用Apache Phoenix构建了相关的表。每个数据中心只存储每个堆栈跟踪信息帧一次,并且为了实现快速查找建立了索引。堆栈跟踪信息也只存储一次。堆栈跟踪信息以栈帧 ID 列表的形式存储,并根据它们的散列 ID 值建立索引。在给定的线程转储中,各个线程抽样被存储为时间序列事件,堆栈跟踪信息记录为对散列 ID 值的引用。所有这些结合起来,使我们减少了堆栈跟踪信息(任何给定线程转储的最大部分)存储的空间占用。
为了减少 HBase 集群上的写入负载,我们保留了可配置的堆栈跟踪信息和栈帧缓存。我们可以检查这些缓存,看看堆栈跟踪信息是否已经写入,而不是写入每条堆栈跟踪信息或栈帧。这些缓存消除了 99%的栈帧写入和 75%的堆栈跟踪信息写入。
回归识别
挑战
对应用程序(例如新版本)进行更改后的回归检测和识别是我们希望解决的主要问题之一。回归可能是由于对特定代码路径的调用频率增加,或者是由于子系统的运行时性能下降。
解决方案
通过将性能分析数据保存较长的时间(我们目前的 TTL 为 90 天),我们的系统支持在不同的版本、库和平台升级以及对软件的其他(较长时间有效)更改之间进行比较分析。
可以在运行时进行短时间(少于一小时)的比较分析。这些数据被表示为火焰图和树形差异图。这种可视化使得工程师可以很容易地识别代码路径的差异,在他们调查的时间段内找出罪魁祸首。
此外,对于时间跨度较大的查询,因为通过如此大量的数据在运行时确定回归是不可行的,所以我们开发了一种通用的作业和报表框架,用户可以定义和调度在所有数据集上执行的作业,作业结果会被持久化,并且可以从 UI 上查看。作业框架内置支持与 Salesforce 其他内部工具的交互,使我们能够将性能分析数据与日志、系统级和应用级监控数据、站点可靠性工具等关联起来。
未来工作
我们正在寻求集成Java Flight Recorder,以添加 CPU 性能分析以及提供更高的采样率支持。这将使我们可以准确地度量在不同代码路径上花费的 CPU 周期的数量,帮助我们关联成本与系统中单个组件的服务价值。此外,我们正在考虑与Async Profiler集成,以便为非 JVM 应用程序提供更好的支持。
我们的下一篇博文将介绍使用诊断数据收集代理捕获可观测数据的方法。该文将详细介绍我们如何设计一个低开销、可配置的代理来从生产服务器收集系统和应用程序诊断数据。
原文链接:
How to Continuously Profile Tens of Thousands of Production Servers
评论