在2010年,Google发表了一篇非常重要的论文:Google-Wide Profiling: A Continuous Profiling Infrastructure for Data Centers[1],提出了一种持续性能剖析的基础设施, 能够针对大规模的服务、集群进行海量的数据采集、分析。这种新的性能剖析手段能够提供精确可靠的性能分析结果,同时又能保持较低的性能开销。
自此以后就涌现出大量商业或者开源的持续性能剖析实现,比较典型的有Google Cloud profiler[2], Pyroscope[3]等。目前,性能剖析已然成为可观测性的又一大支柱。
传统的可观测性三大支柱,即链路追踪、指标监控和日志,具有共同的局限性:受到性能因素和实现方式(无论是基于编译增强或者是SDK接入)的制约,数据往往由预先提供的规则、埋点生成。 例如,OpenTelemetry、SkyWalking等开源的可观测性解决方案提供的插件大多都专注于那些广泛使用并处于积极维护状态的SDK,同时插件本身往往只为关键的代码路径提供埋点。 那么,如果性能的瓶颈发生在没有被埋点的代码片段中,就很难定位问题。举几个简单的例子,我们无法洞察以下场景中的性能瓶颈,
对于如果解决链路追踪的局限性,业界有多种不同的尝试,
以上方式都能够解决一些特定的问题。然而,持续性能剖析的"野心"不止于此,它不仅仅能够分析应用的调用方法栈、CPU使用,同时也能够监测虚拟机中对象的分配甚至是锁的竞争。 这些信息对于诊断应用的性能瓶颈都至关重要。
在开源社区也有一些类似的想法,比如SkyWalking Rover能够提供基于eBPF的、通用的性能剖析能力,使得开发者能够在Linux环境中对一个普通的二进制程序进行性能诊断,如网络性能、on-CPU/off-CPU分析等。
本文不会涉及eBPF相关的性能剖析,而是专注于与语言相关的(Java虚拟机)持续性能剖析工作。
在介绍我们的设计之前,简单回顾一些概念
JFR
文件,可以简单地理解为事件Event
的集合,一个JFR
文件会包含多个事件Event
,一个事件里大致包含这些信息(下图使用JDK原生的方式读取)。
async-profiler[7]是一个低开销的Java采样分析器,它利用虚拟机(HotSpot)中特殊的API来收集堆栈信息以及内存分配信息。与大家熟悉的Arthas
一样,它可以被集成到Java-Agent
中,用来采集JFR
事件。它支持直接生成JFR
事件文件或转换成SVG格式的火焰图进行输出。
火焰图(Flame Graph)是一种可视化程序性能分析工具,由Brendan Gregg提出。它将堆栈信息转换为可视化图表,以便开发人员更好地查看并理解这些信息。
火焰图以一个全局的视野来看待时间分布,它从底部到顶部,列出了所有可能导致性能瓶颈的调用栈。 一般来说,我们的关注点应该在方形的宽度上,方形的宽度大小代表了该调用栈在整个抽样历史中出现的次数。通常情况下,性能瓶颈会出现在那些宽度较大的栈顶方块中。知道这一点后,就可以帮助开发人员快速定位性能瓶颈。
这部分主要是介绍从用户创建火焰图任务到火焰图生成的整体流程:
ElasticSearch
。与async-profiler
的能力相对应,系统支持创建两种类型的任务,我们称为
HTML
):适用于简单的分析场景JFR
):适用于较为复杂的分析场景,如包含多种事件类型。用户可以下载JFR
在本地进行深入分析,这种实现对于客户端的性能损耗非常小,比如在客户端内存压力较大的情况下分析堆内存分配,在客户端分析就容易造成OOM。客户端程序中的Java-Agent程序会每隔一段时间通过gRPC
接口查询需要执行的任务,获取任务后便会开始执行,即根据下发的任务用不同的指令启动async-profiler
。任务执行完成后,将生成的文件上传到服务端。
服务端收到文件后,会根据不同的文件类型执行不同的逻辑,
ElasticSearch
中搜索出预先分析得到的一系列Tree,再把这些数据渲染返回前端。系统的前端则是通过pyroscope-flamegraph
组件,把数据转换成火焰图进行展示。前端展示的组件,可以从之前阿里云的相关文章中[8]获取更多的细节。我们通过一个案例来说明,如果利用JFR事件和由此生成的火焰图来优化JFR文件的读取和处理。我们使用IDEA Profiler进行性能分析,
在系统设计之初,我们直接使用JDK
内置的JFR模块,一次性把文件里的事件全部读取出来,依次处理。
这种方式在事件量不大的情况下性能表现正常,所以我们在单测和E2E测试中都没有发现问题。但是,在对接业务团队进行压测的时候,生成了大量对象分配事件(一个多60M的JFR文件可以包含200多万个事件),这时候处理起来会有两个问题:
我们用火焰图对程序进行了分析,
CPU耗时分析:
内存分配分析:
从图中可以看出,CPU耗时和内存分配都主要集中在insertStackFrames
方法中。该方法是把JFR
事件中的StackTrace序列化成List<String> stackString
,然后通过二分搜索的方式插入到Tree中,以保证顺序性。
由于上述一次读取分析的事件量非常大,我们首先想到的是利用迭代器的思想,使用Iterable
,每次分批读取固定数量的事件进行处理,例如1000个。按照我们的预计,内存的峰值使用应该能够下降。
通过后续的测试,我们的猜想得到证实。然而,由于我们进行了分批处理,在处理的过程中始终打开着一个很大的文件。如此一来,分析期间的峰值内存占用虽然有所下降,但总体仍然保持在较高的水位,同时分析速度也没有显著提升。
在确定JDK
原生的JFR
读取器无法满足性能需求以后,我们开始寻找其他的处理方式。
JFR
解析方式:经过压测,pyroscope-io/jfr-parser
的内存占用和分析速度也不太令人满意[9]。但是其中多chunk的处理方式为我们提供了新的思路;JFR
读取方式(例如:async-profiler
)通过研究async-profiler
的源码我们发现,它并不是每个事件都包含一个完整的StackTrace,而是会把所有StackTrace存放在一个字典中,每个事件通过stackTraceId来关联StackTrace。
这里的StackTrace也并不会被完整地序列化成Java对象,而是包含对其他对象ID的引用,
public class StackTrace {
// 方法ID
public final long[] methods;
// 每个byte表示对应的方法类型,有INTERPRETED, JIT_COMPILED等
public final byte[] types;
// 每个int表示方法所在的行号和bci
public final int[] locations;
// ...
}
从图中可以看出,240万个事件只包含3万个StackTrace,这种low-level的读取方式能够显著降低内存占用。
Pyroscope的处理方式是遍历每个事件时,都把对应的StackTrace序列化成字符串列表List<String> stackStrings
的形式,插入到树中,这样转换和插入的操作就过多。
例如,现在有5个事件,每个事件包含的StackTrace有10层,如果遍历每个事件时都需要往Tree里插入StackTrace字符串,这样转换和插入操作都要重复50次。
通过之前的优化我们已经知道,多个事件可能包含相同的StackTrace(通常表征一个热点方法),每次插入的都是相同的StackTrace,可以约化成一次全部插入,再把相应的value累加。 那么我们可以在遍历事件的先缓存stackTraceId及其累计观测数值,最后再遍历这个哈希表,根据stackTraceId找到对应的StackTrace进行序列化,然后再把这个StackTrace和累计值插入一次即可。
对于之前的240万个事件,对于同一类型的性能剖析指标(CPU, TLAB Object Alloc等),经过优化以后只需要插入3万次左右。
上述的优化效果已经比较显著,但如果单个JFR
文件过大,一次性分析所有的事件仍然有可能导致OOM。所以我们考虑使用分chunk处理,即通过调整JFR
生成的策路,将JFR
文件切分成多个chunk,每个chunk都是独立地中包含了一部分事件,都可以被单独分析。
async-profiler
提供的JfrReader并不支持多chunk处理,我们提交了Pull Request[10],并与作者沟通了相关的实现。
熊哲源、陆家靖,来自技术平台部
Google-Wide Profiling: A Continuous Profiling Infrastructure for Data Centers: https://research.google/pubs/pub36575/
[2]Cloud Profiler: https://cloud.google.com/profiler/docs
[3]Pyroscope: https://pyroscope.io/
[4]Apache SkyWalking: Use Profiling to Fix the Blind Spot of Distributed Tracing: https://skywalking.apache.org/blog/2020-04-13-apache-skywalking-profiling/
[5]Support keep trace profiling when cross-thread #479: https://github.com/apache/skywalking-java/pull/479
[6]Sampling-based profiler: https://www.elastic.co/guide/en/apm/agent/java/current/method-sampling-based.html
[7]async profiler: https://github.com/async-profiler/async-profiler
[8]可观测可回溯 | Continuous Profiling 实践解析: https://mp.weixin.qq.com/s/yiwq81ZHB0nSTcYSjOeyZg
[9]fix string and bytes convert use large memory #19: https://github.com/grafana/jfr-parser/pull/19
[10]Support read JFR file chunk by chunk #718: https://github.com/async-profiler/async-profiler/pull/718