摘要:在软件工程实践中,性能优化一直被认为是具有一定技术难度的工作,随着性能测试工具的丰富和完善,利用工具执行性能测试变得越来越方便了,但是判断性能测试结果是否能满足业务要求,以及如何完成性能调优工作,仍然是一个值得不断深入研究和实践探索的课题。本文结合作者多年实践经验,从什么是性能调优、何时需要性能调优、以及如何做性能调优三个部分来谈谈性能调优的相关工作。
一、什么是性能调优
本文谈及的性能调优,主要是针对服务端Java应用的性能调优。从应用使用者的角度来看,在使用过程中出现服务响应速度太慢,意味着应用性能可能存在问题。从资源使用率的角度来说,长时间出现CPU、内存使用率、网络带宽等处于高负荷状态,甚至出现应用服务异常,就说明应用存在性能不足的问题,需要进行性能调优。
在性能调优实践中,通常需要结合性能测试结果,通过现象分析、设计分析、代码分析以及环境和配置分析,发现应用系统的性能短板,并采用合适的优化手段,使得应用系统的性能得到提升,从而满足业务的要求。
二、何时需要性能调优
理想情况下,只要我们完成了代码开发,就应该追求更好的性能效果。但是实际上,只要是在国内互联网行业工作过几年的人,都知道时刻追求性能最优这件事在实际工作环境中太难实现了。因为有时候业务的快速落地、产品快速迭代,才会给公司或团队带来更大的效益。
那么,在实际工作环境中,哪些时候做性能调优才是必要的呢?
1.应用系统首次上线前的调优
首先,在新应用系统上线前,如果将要面对的用户群体比较庞大或者业务情况比较复杂,那么性能测试基本是必须的。若未经性能测试,我们就无法了解系统是否存在性能问题,也不能获知应用系统的能力上限。当用户访问量超出系统承载能力时,就会出现服务器CPU、内存等资源使用率过高的问题,轻则影响用户体验,重则会出现服务器宕机、应用系统崩溃的情况,从而造成用户数据丢失、资产受损等严重后果。
因此,我们需要先完成性能测试,了解以系统目前的能力是否满足业务要求。一般来说,我们会采用吞吐量、并发数、响应时间来衡量应用系统的性能情况,并且会结合业务需求制定具体的性能指标。如果性能测试的结果未达到性能指标的要求,就需要先进行系统的性能调优工作,性能测试结果达标后再执行发布上线计划。
2.性能回归测试发现问题
其次,系统初期满足性能要求,上线后运行一段时间,由于用户数量增长,业务数据不断积累,又或者是因为应用功能不断丰富完善,在经过多次版本迭代之后,我们通过自动化性能测试或者线上监控发现性能指标出现严重下滑,已不能满足持续增长的业务要求,此时也需要开展性能调优工作。
3.支持特殊活动
还有一种情况,遇到业务方策划大型的运营活动,比如每年电商的双十一大促,业务量会在短时间内出现爆发式增长。为了支持这种特殊的业务活动,一般需要在活动前,对全部参与活动的系统做全链路压测,根据压测结果进行性能调优,并且制定相应的动态扩容策略、限流配置策略以及服务降级预案等保护措施。
上述三种情况是比较常见的需要安排性能测试和性能优化的情况,若在性能测试中发现了问题,就需要马上进行性能优化,以支持互通场景下的业务要求。当然,在不同工作场景下,也会有其他情况需要及时进行性能优化,面对不同的场景也会有不同的性能优化的策略,这就需要具体问题具体分析了。
三、如何做性能调优
我们可以把性能调优工作分为两种类型:整体调优和局部调优。整体调优包括设计调优、系统调优,局部调优一般包括代码调优和配置调优。
在大多数性能测试实践中,我们经常需要根据业务情况制定一系列性能指标,比如并发用户量、预期的响应时间、系统吞吐量等,制定性能测试计划并在性能测试实施过程中发现实际测试情况和预期值之间的差距,然后再具体分析性能瓶颈,从而思考和制定优化策略,再经过测试验证,以确定优化手段是否起到作用。在实践过程中,采用整体调优和局部调优相结合的方式完成对被测系统的性能调优工作,性能调优的分类如图所示。
1.整体调优
随着互联网应用的不断发展,每个产品背后都可能有着庞大的应用集群,对大型应用或者分布式系统进行全链路级别的性能测试也变得越来越重要。
在全链路压测过程中,往往是先进行整体调优再配合局部调优的方式进行的。比如在一次应用系统迭代升级的性能测试过程中,我们发现在用户访问应用首页时调用的一个接口,随着并发数的增加,接口的响应时间逐渐变长,在预设的并发压力下,接口的平均响应时间超过了预期值。由于过长的响应时间会导致用户的体验变差,我们需要在正式发布上线前,对这个接口进行性能优化。此接口的调用链路如下图所示:
首次进行此接口的压测时,我们发现响应时间超出预期,结合服务调用链路以及压测期间监控系统的数据,可以看到在高并发情况下,被测系统中负责处理用户数据的UserService服务器的CPU利用率接近100%,内存使用率也显著增加,短时间内出现了大量线程堆积,同时该应用使用的数据库服务、缓存服务以及下游应用服务的各项资源利用率均在合理范围内。根据这些监控数据,可以初步推断响应时间过长的问题应该出在UserService应用内部。
然后,结合此应用的代码进行分析,发现在一次用户请求中,该应用提供的接口,串行查询了4类用户信息,这4类信息在数据库中存储在4张不同的表中,除了4次查询之外,还执行了一次用户的自动签到处理。也就是说一次用户请求在此应用内依次串行执行了4次数据库查询和1次数据库写入操作,又由于该应用配置了固定的数据库连接池大小,当用户并发请求增多时,多于数据库连接池中连接数的并发请求就会在队列中等待,因此随着并发请求的增多,响应变慢,时间变长。
针对这个具体的问题,我们设计了三种优化方案:
方案一,避免出现长请求阻塞情况。
为了避免长请求阻塞,就需要对请求进行水平分割。也就是说原本放在一个接口请求中返回的4类信息,水平分割为多次请求返回。但是这种水平分割方案也需要考虑不会产生过多的请求放大效应。经过多组试验对比,我们最终选择了将4类信息分在2次请求中返回给上游系统,对上游系统Service A来说,原接口中只返回了2类用户信息,而另外2类用户信息不需要在第一时间返回到客户端展示,可以通过增加另外一个接口,异步查询UserService服务获取。
方案二,异步化分离操作,减少高并发下的资源抢占。
这个方案就是要把UserService应用在用户访问首页时候的自动签到功能进行异步化处理。由于用户在首次打开应用首页时,并不会及时关注到自动签到的结果,完全可以将此操作异步化,以缓解高并发情况下写数据的资源争夺问题。
方案三,客户端懒加载处理,并缓存部分用户信息。
此方案是将应用首页的用户信息延迟加载,而不是每次打开应用首页就会触发后端的查询服务,并且将低频变更的用户信息缓存在客户端本地,实时性要求高的用户信息可以每次请求服务端。
形成这三个方案后,我们进行了初步的方案对比分析,方案二仅需要UserService应用修改即可实现,改动影响范围最小。方案一需要上游服务配合修改,客户端在信息展示上做微调处理。方案三涉及到的修改内容较多,涉及客户端交互方式的调整,也需要其他应用进行配合调整,改动点较多,有可能引入其他问题。因此在这次调优实践中,我们决定先采用方案一加方案二的方式进行优化,然后进行性能测试,如果测试结果仍不能达到预期效果,再去推动方案三的执行。
经过测试,在执行方案一调整后,此接口的响应时间缩短了30%,QPS提升了40%,UserService应用的CPU利用率峰值下降了15%左右,再经过方案二的优化,此接口的响应时间缩短了60%,QPS提升了130%,UserService应用的CPU利用率峰值下降了约40%。调整后的响应时间已经达到了预期值,此次性能优化任务圆满完成。
总结来看,此次性能调优工作是为了让服务整体性能满足业务指标,即接口响应时间恢复到合理范围内。本次调优过程是一次从整体到局部的分析和调整,偏重于后端代码的设计调优。本次优化分析的过程如下图所示。实际上,无论是为了缩短响应时间还是提高系统吞吐量,又或者为了扩大系统数据容量,当需要对整体系统进行性能优化时,优化分析的思路是基本一致的,即:执行测试、分析数据、定位瓶颈、分析原因、提出方案、执行优化、验证方案。
2.局部调优
局部调优是指精细化调优,通常包含代码调优和配置调优。
代码调优是针对某个方法或者某个模块进行的精准优化,通过对不同的实现方式进行性能测试,对比结果,采用更好的实现方式。比如说电商系统里的一个智能推荐功能,我们通过对推荐算法本身进行优化,从而在保证推荐效果不变的情况下,提高推荐结果生成的速度,这就是一种典型的局部调优场景。
除了算法调优之外,还有比较多的代码调优工作,比如访问集合元素的提速,字符串拼接方式优化,读写文件的提速,SQL语句优化等,这些工作是比较精细化的代码处理,当开发过程中只考虑功能实现时,往往会忽略这些细节问题,然而一旦待处理的数据膨胀或者某个方法被多次调用时,未经优化的代码就会出现比较明显的劣势,甚至会导致应用服务不能正常工作。
举一个局部调优的例子,某应用系统中有一个功能是读取磁盘文件中的内容到内存中并进行后续处理。在最初实现时,这段代码的开发者没有考虑性能问题,以Java的字符流形式读取文件,示意代码如下:
public static void read1(String file){
int result = 0;
try (Reader reader = new FileReader(file)) {
int value;
while ((value = reader.read()) != -1) {
result += value;
}
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
这种实现方式完全可以实现读取文件的功能,问题只是效率比较低下。只要将这个读取方式改成采用“缓冲区”的方式,就可以实现读文件的速度提升,示意代码如下所示:
public static void read2(String file){
int result = 0;
try (Reader reader = new BufferedReader(new FileReader(FILE_PATH))) {
int value;
while ((value = reader.read()) != -1) {
result += value;
}
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
优化方案中利用“缓冲区”正是提效的关键所在,它是Java.io.BufferedReader类的read方法,此方法会将字符数据读取到一个缓冲区数组中,然后再从这个缓存区中一个一个的读取字符数据,通过增加缓冲区的方式减少IO操作的频次,从而提升了处理效率。
//BufferedReader类中的read()方法
public int read() throws IOException {
synchronized (lock) {
ensureOpen();
for (;;) {
if (nextChar >= nChars) {
fill();
if (nextChar >= nChars)
return -1;
}
if (skipLF) {
skipLF = false;
if (cb[nextChar] == '\n') {
nextChar++;
continue;
}
}
return cb[nextChar++];
}
}
}
经过测试验证,在同一台机器上运行的情况下,当读取文件大小为1.5M时,用方法1(read1)耗时118ms,用方法2(read2)耗时30ms。方法2的处理效率约是方法1的四倍。由于此方法被多次调用来进行文件预处理,一处细节的修改带来了非常明显的处理效率提升。处理相同批次的业务文件,原本耗时一小时才能完成文件预处理的工作,优化后在20分钟内即可完成。
一般来说局部调优侧重于代码的优化,对代码的优化大致可以分为如下几类:
-
数据结构类型优化:比如选用高效的方式进行字符串拼接,或者选用合适的集合操作数据等。
-
磁盘IO优化:比如前文中谈及的读取磁盘文件的优化案例。
-
网络传输优化:选用合适的通信协议、合适的序列化方式传输数据等。
-
算法优化:比如典型的算法问题,找出时间最优、空间最优的排序算法。
-
SQL优化:比如控制事务的大小、降低事务隔离级别,或是通过增加索引减少慢查询等。
总结
服务端的性能优化问题涵盖了非常大的范围,并且随着互联网应用的日渐完善,用户量、应用复杂度、数据量的不断膨胀,服务端在性能提升方面也面临着越来越多的挑战。新的技术层出不穷,但是性能调优的思路方法还是值得从过往经验中学习和借鉴的。
本文从什么是性能调优、何时需要性能调优、如何做性能调优三个部分进行了介绍。其中在如何做性能调优这一部分,结合实际工作实践经验,从整体调优和局部调优两个维度进行阐述。整体调优需要我们对架构熟悉,形成一套可以快速发现并定位性能瓶颈的方法,也要求我们熟悉经典的设计模式,只有这样才能针对具体问题找到合理的解决方案。局部调优更多依赖于扎实的技术功底,对编程语言、数据结构、网络和存储各方面的基础知识能够娴熟地运用。
实际上,做好性能优化这件事绝不是一朝一夕可以练成的,只有巩固好基础知识,在实践中不断学习和总结经验,同时积极学习新的技术和方法,才能将性能调优工作做得越来越好。