连续分析的概念源自Google的论文,它是一种动态分析方法,在应用运行期间收集与应用程序相关的信息。该方法使性能分析贯穿应用的整个生命周期,广泛应用于性能巡检、问题定位等场景。当应用程序运行时,无论是正在进行计算还是执行系统调用(syscall),应用本身都会产生大量有价值的运行时信息。我们可以对这些信息进行实时只读采集,确保采集过程不会给应用带来任何负面影响。通过对这些一手运行时信息进行聚合分析,能够帮助用户深入分析疑难问题的根源(root cause analysis)以及应用性能退化的原因。
1. 背景
目前,陌陌已构建起较为成熟的Trace、Metric、Log可观测平台,能够快速定位服务上下游的调用问题。然而,对于服务自身的内部问题,比如某个私有方法CPU占用过高,或者内存申请过多导致频繁Full GC等情况,往往缺乏有效的手段来确定问题根源。运行时的JVM对用户而言犹如一个黑盒,仅依靠Trace、Metric、Log等数据,不足以反映JVM内部真实的执行状况。排查这类问题通常需要借助一些第三方工具,如Jstack、Arthus。但由于工具使用门槛较高,部分开发经验不足的人员往往难以自行处理,只能交由经验更为丰富的资深开发人员解决,这在无形中降低了问题定位的效率。基于上述背景,我们构建了服务性能持续剖析能力,并将相关剖析工具进行产品化和平台化,以弥补当前陌陌APM监控在自身问题定位方面的不足。
产品目标
- 降低问题定位门槛,提升排查效率,使新手也能够自主进行分析。
- 支持问题现场回溯,助力故障根源分析(root cause analysis)。
- 每日定时生成应用性能分析报告,并提供优化建议。
- 确保采集探针不会对应用的安全性和性能产生影响。
2. 产品全景
我们搭建了性能剖析诊断平台,其产品定位在于对服务自身的疑难问题进行根源分析(root cause analysis),发现并优化应用性能的退化点。该平台覆盖了陌陌所有采用容器化部署的Java类型服务,并提供以下4种基础性能剖析能力:
- CPU持续分析:提供方法维度的性能趋势分析。
- 内存申请(alloc)持续分析:提供方法维度的内存占用分析。
- 线程分析:提供线程和线程组维度的性能分析。
- 内存Dump分析:实现对JVM堆内存使用情况以及内部活跃对象的分析。
此外,我们通过持续、按固定频率收集服务的CPU、内存申请(alloc)性能数据,将方法维度的时序数据存储到Clickhouse中,并生成方法性能趋势图。结合服务运维信息、代码变更记录等,建立了服务性能巡检机制,用于发现服务发布过程中的性能退化事件,防止微小的性能退化随着时间累积,最终导致服务整体性能恶化。
产品架构概览
系统整体大致可划分为Agent、Server、产品控制台(console)、性能巡检这4个模块。
Agent部分
- 借助javaagent探针技术,实现了业务无感知的profile接入。用户只需在发布平台一键勾选profile开关,重新发布后即可生效。
- 基于AsyncProfiler、JMX等技术,实现了低开销的性能诊断能力。剖析期间,对服务性能的影响约为1%;在未开启剖析时,无任何性能开销。
Server端主要具备两大功能
- 向Agent下发剖析任务。除支持常规的单次下发、定时下发任务外,还与报警平台联动,实现了基于报警触发profile采集的功能。这意味着,即便服务未开启定时剖析功能,也不会错失问题现场。
- 接收Agent上传的快照。解析后的数据(如火焰图、分析结果等)将存储到OSS中,以供前端UI展示。对于CPU、alloc类型的快照,还会额外生成方法维度的性能时序数据,并存储到Clickhouse,供后续性能巡检模块分析使用。
3、profile技术原理
在Java领域,主流的profiling功能涵盖CPU、内存分配(memory allocation)、线程(thread)、类(class)等方面,其中CPU profiling最为常用。在此,我们重点介绍CPU profiling的实现方式。基本上,主流的CPU profiling多基于采样(sampling)实现,也有少数方案,如jprofiler,采用基于Instrument字节码增强技术实现CPU采样,但该方法资源消耗巨大,通常不适用于生产环境。CPU采样的基本原理是定期对线程堆栈进行dump,统计堆栈中出现的方法频次,进而估算每个方法占用的CPU时间。
我们知道,方法的调用栈由一个个栈帧(stack frame)构成。当发生函数调用时,会开辟新的栈空间,将函数参数、局部变量、返回地址等压入栈中。栈帧遵循后进先出(LIFO)原则,最近被调用函数的栈帧位于栈顶,先前调用函数的栈帧位于栈中,调用链起始处的函数位于栈底。因此,当在某一时刻对正在运行的线程进行dump时,此时位于栈顶的函数即代表当前正在执行的函数。我们可以根据一个方法在栈顶出现的次数除以总采样次数,来估算它在进程CPU执行时间中所占的比例,即计算出方法自身占用的CPU占比。
业界实现CPU profiling主要有三种技术方案:JMX、JFR和AsyncProfiler。
JMX
JMX全称为Java Management Extensions,是一个为Java应用程序植入管理功能的框架。它提供了一种简单、标准的监控和管理资源的方式,允许用户通过MBeans监控应用程序的性能指标,如内存使用、线程、垃圾回收等。其中,JMX内置的ThredMXBean管理接口中的dumpAllThreads方法可对当前JVM的所有线程进行dump,返回结果包含线程的栈帧(stacktrace)。通常做法是利用javaagent探针技术,在premain方法中启动一个异步线程,定时执行dumpAllThread方法来收集方法堆栈。看似简单,但该方案存在一个致命缺点,即SafePoint bias问题。简单来讲,JMX的固有机制使得在dump某个线程时,只有目标线程运行到“安全点”(SafePoint)时才能执行,这会导致采样到的堆栈都是在安全点附近执行的代码,采样结果缺乏公平性。某些执行时间极短但实际占用大量CPU时间的方法可能得不到采样机会,最终导致结果无法反映真实的CPU热点。若想了解更多Safepoint bias的细节,可参考Why (Most) Sampling Java Profilers Are Fxxking Terrible。
JFR
JFR是Java Flight Record的缩写,是JVM内置的基于事件的JDK监控记录框架,其功能类似于飞机的黑匣子。JFR开启后,会持续记录JVM内部的一系列事件。JFR支持100多种JVM事件,包括类加载(Class Load Event)、垃圾回收(Garbage collect Event)等,甚至开启JFR本身也是一个事件。JFR事件可分为三类:
- Instant Event(瞬时事件):例如Throw Execption Event。
- Duration Event(持续时间事件):例如Garbage collect Event。
- Sample Event(采样事件):通过一定频率采样得到的事件,比如Method sampling Event。方法调用事件的元信息中包含方法堆栈信息,可用于实现CPU采样功能。
JFR的性能开销很低,官方宣称在默认采集配置下,性能影响约为1%,且对方法执行的采样事件完全异步,不存在JMX方案中的Safepoint bias问题。看似是完美方案,但遗憾的是,JFR在JDK 11之前是收费的,而OpenJDK 8需在292版本之后才可使用,且由于是从JDK 11反向移植回去的,未进行专门优化,性能存在较大问题。当前,陌陌仍有不少服务运行在JDK 8上,因此该方案被排除。
AsyncProfiler
AsyncProfiler是一款由C/C++开发的低开销Java性能分析工具,不存在Safepoint bias问题。它利用HotSpot JVM的特殊API收集线程堆栈信息,以实现精确的CPU性能剖析。除CPU外,还支持alloc、lock、wall等类型的剖析,甚至能收集机器硬件事件,如缓存未命中、页面错误等。作为我们最终采用的方案,AsyncProfiler同样具有极低的性能开销。根据我们的压测,在普通负载下,性能影响约为1%;在极端负载下,约为3%。网上也有其他公司分享过相关性能影响测试结果,基本能够相互印证。同时,为降低业务接入复杂度,我们采用了javaagent集成方案。业务服务进程加载profile agent后,会在premain函数中开启一个后台线程,通过System.loadLibrary函数加载AsyncProfiler动态连接库,并在收到Server下发的profile任务后,通过JNI接口实时调用AsyncProfiler执行剖析。
聊聊关于AsyncProfiler实现的一些技术细节
前文提到,AsyncProfiler使用JVM内部接口AsyncGetCallTrace实现CPU堆栈采样。从名称可知,AsyncGetCallTrace是异步的,因此不会像JMX方案那样受安全点影响,采样准确性得以保障。由于AsyncGetCallTrace并非标准JVMTI函数,所以需要采用一些技巧获取方法地址。AsyncProfiler在Agent_OnLoad和Agent_Attach阶段,通过glibc提供的dlsym函数获取AsyncGetCallTrace在libjvm.so中的符号地址,转换后即可像普通函数一样使用。这也意味着AsyncGetCallTrace函数仅能在HotSpot及衍生的JVM中运行。
// AGCT函数签名
void AsyncGetCallTrace(ASGCT_CallTrace *trace,
jint depth,
void* ucontext);
AsyncProfiler实现低开销的关键在于通过注册SIGPROF系统信号实现定时采集。SIGPROF是操作系统信号,可向操作系统注册一个回调函数,并指定触发回调事件的间隔(如10ms)。操作系统每隔10ms会随机从当前进程运行的线程中挑选一个,触发系统中断并执行回调函数,函数参数中包含AsyncGetCallTrace所需的ucontext。由于无需轮询所有线程,采样的整体开销非常低,唯一的开销在于解析栈帧。对于栈帧较深的线程,AsyncProfiler默认最多爬取2000层,多种机制共同确保了低性能开销。
void PerfEvents::signalHandler(int signo, siginfo_t* siginfo, void* ucontext) {
......
ExecutionEvent event;
Profiler::instance()->recordSample(ucontext, counter, PERF_SAMPLE, &event);
......
}
4、产品功能形态
1. 火焰图分析
CPU、内存Alloc聚合火焰图
在CPU和内存申请分析功能方面,我们采用了火焰图(Flame Graph)这一展现形式。从界面功能来看,二者完全一致,其区别仅在于,一个统计的是方法的CPU消耗,另一个统计的是方法的内存申请量。火焰图由Linux性能优化大师Brendan Gregg发明,因其图形形似跳动的火焰而得名。图中每个格子代表一个独立的方法,格子宽度代表该方法消耗的性能多少。这种展现形式能够极为直观地呈现函数之间的调用关系,以及方法的资源占用状况,让我们得以从全局视角洞察所有可能存在潜在性能问题的代码路径。
在下图左侧的排名表格中,每个方法都设有“自身”和“总计”两个统计维度。“自身”列展示的是方法自身消耗的资源(包括CPU、内存),即不涵盖调用其他方法所消耗的资源;“总计”列则展示了方法栈自身以及调用其他方法所消耗的总资源。通常而言,我们需要重点关注“自身”资源消耗排名靠前的方法,因为它们往往是导致服务性能瓶颈的关键因素。为进一步提升问题定位的效率,我们还支持对一段时间内的火焰图进行聚合分析。如此一来,便可排除单次采集可能产生的误差,使火焰图的结果能够更真实地反映服务的实际运行状况。
我们还实现了方法列表与火焰图的联动功能。当在表格中选中单行方法时,火焰图会自动展示仅与该方法相关联的所有执行路径。这一设计的优势在于,即便某个第三方类库的方法消耗了大量性能,我们也能够迅速定位到调用源头的业务代码。
性能差分火焰图
针对一些性能巡检的应用场景,我们可能需要了解某个方法在过去与现在的性能是否发生了变化,以及变化趋势如何等。基于此,我们设计了差分火焰图功能,支持对两个不同时间段的火焰图进行差异对比分析,并通过不同颜色来标记、突出方法的性能退化点。其中,颜色越红,表示退化程度越大;颜色越绿,则表示方法的优化效果越好。这一功能极大地方便了我们评估优化成效。
2. 线程分析
线程分析被设计为一种轻量级的剖析能力,它能够提供线程粒度的CPU使用率和内存申请量统计,真实还原线程的执行过程。我们还设计了多个维度的统计饼图,包括“线程组”“状态”“锁对象”等,借助这些饼图,可以快速定位进程中CPU负载过高、锁争用等问题场景。该页面同样支持查看单个异常线程的方法栈,便于我们快速定位问题代码。
线程状态分组统计
- 按“线程状态”分组统计,可快速分析线程状态的比例是否合理。
- 按“线程组”分组统计,能够迅速找到线程数量较多的线程组。
- 按“锁对象”分组统计,可快速定位当前阻塞在该锁对象上的线程列表。
- 按“线程组CPU占比”分组统计,能够快速锁定CPU占用率较高的线程组。
线程状态、线程组、锁对象排序分析
- 线程所处状态,可判断是否存在死锁情况。
- 等待的锁对象(若线程状态处于等待、阻塞状态)。
- CPU使用率(采集期间的CPU使用率)。
- 申请内存大小(采集期间申请的内存量)。
- 线程进入wait状态的总次数(从进程启动至采集时刻)。
- 线程进入block状态的总次数(从进程启动至采集时刻)。
3. 堆栈内存dump分析
首先,简单介绍一下JVM的内存结构(以JDK11版本以上为例)。JVM进程可用的内存大致可分为以下5类:
堆内存:JVM用于存储对象或动态数据的区域。这是最大的内存区域,也是垃圾收集(GC)发生的地方。堆内存的大小可通过
Xms
(初始值)和Xmx
(最大值)标志进行控制,堆又进一步分为年轻代和老年代空间。- 年轻代:年轻代进一步细分为“Eden”和“Survivor”空间,由“Minor GC”进行管理。
- 老年代:在Minor GC期间,达到最大保留阈值的对象会被移至此处,该空间由“Major GC”管理。
线程堆栈:用于存储线程的静态数据,包括方法/函数帧和对象指针。可使用
Xss
设置堆栈内存限制。元空间:类加载器用于存储类定义的区域。元空间是动态的,可用
-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
来限制其大小。代码缓存:JIT编译器用于存储经常访问的已编译代码块的区域。一般情况下,JVM需将字节码解释为机器码,而JIT编译的代码无需解释,因为它已是机器码并缓存在此。
共享库:用于存储所使用的任何共享库的机器码,操作系统每个进程仅加载一次。
内存dump分析核心功能(不同JDK版本的dump协议内容存在差异):
- 直接输出JVM进程当前的总对象和活跃对象统计信息。
- 输出堆的汇总信息,如年轻代、年老代堆的使用情况等。
- 打印类加载信息。
- 输出堆配置信息。
- 输出finalize队列排队情况。
4. profile持续分析报告
在业务应用持续迭代的进程中,性能恶化的情况时有发生,诸如热循环代码、数据资源的IO瓶颈等场景均可能引发此类问题。
针对核心服务,每日会自动启动性能巡检,支持从CPU、内存申请、线程这三个维度展开分析:
- 支持配置定时采集(每一小时采集一次)
- 支持报警事件触发采集(订阅告警事件以采集profile信息)
正如上文在架构介绍中所提及的, 我们把方法函数维度的性能时序数据存储至Clickhouse中。通过对方法函数堆栈级别的性能时序数据进行差异(diff)分析,能够计算出函数方法级别的性能趋势图。借助统计算法,判断出出现性能退化的函数,并依据专家经验给出合理的优化建议。
CPU性能时序分析
- 方法性能退化分析:与前日数据进行对比,找出CPU占用大幅增加的方法。
- 业务方法(以公司组织命名的包) 退化分析
- 第三方包方法(非公司组织命名的包) 退化分析
- 方法性能时序图:呈现当日的方法性能趋势。
内存申请时序分析
- 方法性能退化分析:与前日数据对比,找出内存申请(Alloc)量大幅增加的方法。
- 业务方法退化分析
- 第三方包方法退化分析
- 方法申请内存时序图:展示报告当日的内存使用趋势。
线程分析
- 线程状态分析:研判各个状态的线程数量是否处于合理范围。
- 线程数量分析:评估线程组的数量是否合理。
- 线程死锁分析:排查是否发生死锁情况。
- 线程性能分析:剖析CPU、内存申请占比最高的线程组。
- 线程组CPU、线程状态数量时序图。
5. 总结
在应用性能监控范畴内,问题根因定位是一项极为关键的特性。将profile、trace和metric相结合,把不同类型的数据相互关联,从而获取更为全面的上下文信息。例如,将profile数据与trace数据关联,便能依据请求的响应时间和错误指标,精准定位到对应的堆栈。其中,profile关联服务内部的调用链,trace关联服务间的调用链,进而得以深入分析具体方法在全链路调用中的真实堆栈情况。如此一来,能够更为精准地定位性能瓶颈与问题所在之处。同时,将分析结果进行可视化和报告化处理,让根因分析的成果更易于理解与分享。运用图表、图形以及各类可视化工具,以直观易懂的形式,将分析结果呈现给开发人员、运维团队以及决策者,助力他们更好地洞悉性能问题的根本缘由。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件举报,一经查实,本站将立刻删除。
文章由技术书栈整理,本文链接:https://study.disign.me/article/202511/6.momo-product-performance.md
发布时间: 2025-03-11