软件性能问题排查方法与思路

2024/11/23

软件性能问题排查方法与思路

对于一个拥有较长生命周期的软件,纵使硬件性能仍在不断发展,但软件的性能优化总是绕不开的问题,在我的职业生涯中,接触过的软件性能优化可以分为两类主要场景:

  1. 故障场景:这类场景偏向于问题的排查分析,软件的性能问题已经造成了用户不可接受的影响,如崩溃、高延迟。或者是监控系统发现了异常的硬件资源占用等。
  2. 压力测试场景:这类场景偏向于测试,是为了评估软件在理想状况下的表现,一般是在给定硬件资源的情况下,评估软件的表现,必要时执行优化。

本篇文章简单说说异常场景下性能问题排查优化的一些通用思路、方法。异常场景下的性能优化工作流程如下:

  1. 发现问题
  2. 查看大方向的性能指标,发现存在异常的指标
  3. 根据异常的大方向性能指标,进一步深入查看更细化的指标
  4. 根据这些指标,提出可能的假设,验证假设,找出原因

发现问题

性能问题的发现通常是通过用户反馈或者测试过程中出现的异常警报。对于已有监控系统的软件,监控工具能帮助提前发现性能瓶颈。然而,监控系统并非万能,它可能无法直接指出具体问题所在,而是需要我们通过一些基础性能指标进一步排查。

在故障场景下,性能问题的表现通常是两种:崩溃 或 缓慢卡顿。

大方向排查

我们需要知道,在软件性能优化中,不管是前端后端,还是数据库,最终它们都是要落到操作系统跟硬件上运行的,而这些异常的操作系统或硬件的大方向性能指标就是初步需要关注的指标,大方向的性能指标可以抽象总结为以下几种类型:

  1. 内存
  2. CPU
  3. IO
    1. 磁盘
    2. 网络

通过观测这些主要指标可以初步识别问题的大方向,但其实这一步并非每次都能直接找出存在异常的具体指标,主要存在两方面原因:

  • 一是指标之间可能会相互影响。如数据库在某些场景下,高磁盘 IO 会伴随着高 CPU,这个时候就很难判断磁盘 IO 跟 CPU 哪个才是异常指标。
  • 二是有些指标看似是因,其实是果。像 JVM 堆内存不足了,由于程序仍不断需要着内存,会频繁触发 FGC,导致 CPU 过高,这个时候简单看好像是 CPU 出问题了,但其实根因是在内存上。

即使难以确定具体的指标,但至少已经筛选出一些异常指标了。在初步筛选后,进一步观察和分析各项性能指标,锁定可能存在问题的系统资源,帮助我们为后续排查指明方向。

深入排查

在明确大方向异常指标所在后,接下来的任务是通过大方向的异常指标来进一步缩小问题范围。例如 CPU 使用率过高了,可以深入一步,查看高 CPU 进程,再进一步,查看高 CPU 线程,直到追查到异常方法栈。

如果通过常规的指标排查无法直接定位问题,那么假设的验证就成为下一步的关键了。如 CPU 高了,实际上就是有进程线程在做高负载的计算,但是不同的场景、不同的应用发生高负载计算的原因不尽相同。此时就需要结合自己的业务、经验,又或者是最近做的变更来对这个异常场景做出假设,当做出假设后,就可以利用一些工具来验证假设。

验证假设

不同端的应用验证方式不尽相同,像 Java 程序有 JDK 的各种工具,如 jstat, jstack, jmap, visualvm 等等。而像前端,浏览器有自带的 DevTools。这些工具是学不完的,我一般是碰到了问题再现学现用。

性能工具的使用本身并不难,难点在于面对繁多的工具时,如何找到合适的切入点。

其实如果了解应用的运行原理,就会明白指标其实就是应用运行的过程性度量,发现了异常指标,其实就能大概定位到有问题的地方了。

但如果不了解原理,排查起来就很难了。像 Java 应用拥有自动垃圾回收机制,如果不了解 JVM 的内存结构以及 GC 机制,面对内存升高的问题,就比较难下手了。而如果不知道前端应用是如何跑在浏览器上的,和网络协议等知识,面对前端应用的问题,也不知道如何下手。为了高效进行性能排查,积累相关的基础原理知识至关重要。

在验证假设的过程中,最重要的是构建一条逻辑链,从异常指标开始,进一步深挖原因,可以使用 5W 思考框架,不断地问为什么,如果运气好的话,这样挖下去就能找到问题的根因。但更多时候,我们只能找到一个大概的原因。所以当挖到无法挖的时候,就需要使用假设来验证,以下是我挖过的一个例子:

  1. 为什么 CPU 高,因为 JVM 堆内存快被用完了,JVM 一直在频繁 FGC。
  2. 为什么频繁 FGC ,因为每次 FGC 回收的内存都不多。
  3. 为什么每次 FGC 回收的内存都不多,因为还有大量存活对象。
  4. 为什么还有大量存活对象?

其实到了第四步,已经知道问题的大概原因了,但是面对茫茫的堆内存快照中的对象,我们并不知道具体哪些存活对象是异常的。所以之前做的假设就派上用场了,不断提出假设,看这个假设是否会是造成异常的原因。

上面的那个例子最终我是一个个查看堆内存占比前几的对象,一个个静态审查这些类相关的代码,最后发现是一个实体类没有重写 equals 跟 hashCode 方法,导致对象在 hashset 中被重复添加,最终导致 FGC 频繁,从而导致 CPU 内存双高。

但有时候最终会发现,我们做的假设是错的,做的假设无论如何也不能跟排查到的现象做出联系,这个时候一般就是假设做错了。这很正常,如果某个假设验证失败,可以回到先前的排查步骤,重新审视可能影响性能的其他因素,或调整假设,进行新的验证。

案例

这是一个前端的数据大屏应用内存泄露的问题,有动态的数据切换展示效果。最初发现问题是因为测试的过程中,发现挂了一晚上浏览器就崩溃了,提示 Out of Memory。

接手排查第一步就是先查看大方向的异常指标。通过 chrome 的任务管理器,能发现页签的总内存、Javascript 堆内存都在不断上升,手动执行垃圾回收,内存也不会下降。

于是进一步查看细分方向的异常指标,发现 Javascript 堆内存上升的过程中,DOM 节点的数量也在不断上升:

虽然性能监视器的 DOM 节点在不断上升,但界面的 UI 元素并没有增加,此时基本可以推断是出现了游离 DOM 节点,即 DOM 节点没有被任何元素引用,但 DOM 节点的引用链却仍存在着。

再进一步探寻为什么会出现这么多游离 DOM。发现大部分游离 DOM 都是一个数据大屏的组件实例对应的 html 元素。再进一步,发现这些数据大屏组件的引用链都是相似的,都是通过一个函数被一个叫做 bindCallbackMap 的对象所引用:

据此可以做出假设:大量数据大屏的组件实例没被回收是因为频繁创建组件的同时, bindCallbackMap 在不断添加这些组件的引用,导致即使组件被销毁,从界面上移除了,组件的引用仍被持有着。

按照这个假设,静态审查了这个组件相关的代码,发现了这么一段代码:

if (this.stomp && this.connected && callback) {
   sendHeaders.mid = Date.now() + '' + this.increaseNum++
   sendHeaders.bind = url
   sendHeaders.uid = userId
   this.bindCallbackMap[sendHeaders.mid] = {
     func: callback,
     timeoutFunc: timeoutCallback
   }
}

根据代码的逻辑,能发现数据大屏组件是通过函数闭包被引用的。也能跟上面对象引用链的分析对的上,问题的症结就是在这了。

总结

在性能问题排查中,理解基础原理与清晰的排查思路至关重要。遇到性能问题时,可以从大方向到细节层层推进,最终通过验证假设来定位问题根源。但是随着技术的不断发展,我们仍需要不断更新自己的知识体系,结合实际问题采取合适的工具。

Post Directory