性能优化公式与实践

799 阅读20分钟
原文链接: click.aliyun.com

前言

“性能优化“相信是每个程序员都关心的问题,在实际工作中也常会对系统做优化。关于性能优化有两个问题很值得探讨:

  • HOW:影响性能的因素很多,该从何入手?找到需要优化的点后,如何进行优化?
  • WHY:优化后为什么能提升性能?为什么有些优化点更值得做?

至于WHAT,我认为是不言而喻的,性能优化的终极目标只有两点:QPS和RT,可以说所有优化最终都是为了这两个指标。

本文讨论的核心内容是几个与性能相关的公式,比如QPS公式、RT公式、线程数公式等。熟悉这几个公式能让我们抓住系统性能的底层逻辑,有助于在实践过程中对症下药。这些公式在一些性能相关的书籍里都有出现,公式提供了很好的理论支持,但关于如何实践的文章并不多。

本文先对公式进行简单推导和验证,熟悉的同学可以跳过这部分。

  • 针对HOW:本文会通过一个线上应用的优化案例,尝试探讨出一套可参照的实践流程。
  • 针对WHY:在公式验证和实践中,对实际优化结果结合公式进行定量计算,解释类似如下问题——为什么做了一个优化,QPS能提升100或者50%?

PS:个人水平有限,难免出现纰漏,欢迎指正。

公式推导

本小节对几个核心公式进行简单推导,帮助尚不太了解的同学弄清来龙去脉。

QPS和RT

前文提到,QPS和RT是性能优化的终极目标。其中QPS(Query per second)描述了单位时间内系统的吞吐量,而RT长短则反应了接口响应速度。提升QPS能帮助我们利用更少的机器资源扛住更多的流量,而降低RT能提升用户体验。

单线程QPS公式

image.png | left | 195x62

在单线程下,这个公式永远正确。而我们的系统都是多线程的,所以我们需要知道多线程的QPS如何计算。

多线程QPS公式

很简单,就是单线程的QPS * 线程数:
image.png | center | 830x90

可以看到,多线程下的QPS和两个因素相关:RT和线程数,接下里分别讨论下RT和线程数。

RT公式

RT一般可分为客户端RT和服务端RT,客户端RT包含浏览器发出请求—》服务器处理—》请求报文返回三个阶段。
两者关系如下:
客户端RT = 服务端RT + 2*网络耗时

网络耗时可以通过CDN、专线等方式减小,我们重点关注下服务端RT。

一个请求打到服务器上,会由一个线程来承载,同步模型下,而线程从创建到退出的过程,就是一次请求的处理过程。而线程从创建到退出的状态流转,涉及到多线程的调度,这里简单说明下线程的调度。

线程调度

在Linux/Windows上,从JDK1.2开始,JVM线程直接绑定一个内核线程(1:1模型),由系统内核的调度器来调度,在内核看来,内核线程和进程没有区别。对于java这类非实时进程,Linux的调度策略是基于优先级的抢占式调度。

系统将CPU时间切分成多个时间片,从就绪队列中,选取一个就绪的进程,为其分配时间片执行,可能有三种结果:

  • 时间片耗尽前,进程执行完毕,任务退出。
  • 时间片耗尽,进程仍未执行完毕,返回就绪队列,等待调度。
  • 进程执行过中遇到阻塞事件,放弃时间片,进入阻塞队列,等待事件返回,再进入就绪队列,等待调度。

image.png | left | 538x307

根据上述过程,进程有以下几种基本状态:创建、运行、等待(阻塞)、就绪、销毁。转换关系如下:

image.png | left | 379x188

讲了这么多,根据上述状态可知,服务器RT由运行态耗时和非运行态耗时(创建、阻塞、就绪、销毁)组成,使用线程池的情况下,可以忽略创建和销毁的时间,得到公式如下:

image.png | center | 531x46
CPUTime描述了需要CPU时间片的运行时间,WaitTime描述了阻塞的实际,而ReadyTime描述了就绪队列中等待调度的时间。
公式意义:RT和三个变量有关,分别是优化其中任意一项,都能减小RT。

最佳线程数公式

前面讲述了RT公式,接下来推导线程数该设置为多少?

  1. 以单核单线程为例,假设线程(Thread A)执行过程中,有部分时间在做阻塞IO,那么从线程和CPU的视角来看一个时间段的线程状态和CPU使用情况,如下图,可以发现,CPU有相当一段时间,处于闲置状态,未能充分利用。

image.png | center | 830x175

  1. 同样单核的情况下,尝试增加一个线程B,做同样的事,根据前面线程的调度模型与DMA,线程和CPU情况如图。在CPU未达到满负荷情况下,线程数增加一倍,理论上QPS和CPU利用率提升了一倍。

image.png | center | 830x229

  1. 继续增加线程数,可以发现,当达到某个临界值之后,CPU等待阻塞的空闲时间能被完全利用。

image.png | center | 830x341
根据前面的推导,在同步模型下,最佳线程数定义:刚好消耗完线程阻塞时间的线程数临界值

在多核的情况下,在《Programming Concurrency on the JVM》一书中,描述最佳线程数=核心数 / (1-阻塞系数),其中阻塞系数=阻塞时间/(阻塞时间+线程CPU时间),代入可得:

image.png | center | 830x86
其中UseRatio代表CPU使用率,在系统有其他瓶颈(IO、网卡、内存、锁等)时,利用率很难达到90%以上,这需要先解决瓶颈问题。
公式意义:线程不够,无法充分利用CPU的空闲资源,导致实际QPS上不去,线程过多会引起就绪队列变长,CPU频繁调度导致线程等待cpu时间片变长,进而影响RT,线程数存在一个最佳的线程数临界值。

最大QPS公式

根据多线程的QPS公式可知,最佳线程数下,系统具有最大QPS。分别代入RT公式和最佳线程数公式:

image.png | center | 830x130

这里假设最佳线程数下,线程几乎不需要等待调度,即ReadyTime=0。
公式意义:理论上,系统的最大QPS和线程CPU计算时间成反比,和线程阻塞时间无关,提升QPS需要减小CPU计算时间。另外,考虑上下文切换开销、CPU利用率、STW等因素,实际QPS会小于理论值。

公式因子

CPU Time由算法和数据结构决定,会影响RT和最大QPS值,反应线程拿到时间片后,需要执行的时间。
Wait Time在应用中主要表现为线程wait(park,sleep)或者IO耗时,很大程度决定了RT和需要的线程数。
Ready Time主要由线程数与核心数决定,在线程数<最佳线程数时,可以忽略不计。当线程数超过最佳线程数,随线程数的增加而增加,影响线程RT。
ThreadNum由阻塞系数决定。当线程数过大,会导致一些问题,比如大量上下文切换导致CPU资源浪费、虚拟机开销,对于JVM,线程RT变长后也会带来更多的GC,GC伴随的STW会降低线程效率。

公式验证

前面进行了公式的简单推导,停留在理论阶段的知识还不算知识,下面通过一个简单实验来模拟下CPU+Wait(IO)场景进行测试,来验证下公式在实际环境下的适用性。

实验概述

A(模拟目标应用):内部通过循环模拟一段时间CPU计算,再http调用B提供的服务模拟远程IO,然后返回
B(模拟远程服务):服务内部休眠一定时间后返回
C(压测机):通过Apache BenchMarking或者amazon对A进程压测,形成报告
机器配置:8C16G VM(JVM opts -Xms10g -Xmx10g -Xmn4g -XX:SurvivorRatio=5)
image.png | center | 830x337

实验场景

设计了三组实验场景:

  • 参照组:CPU Time=100ms , Wait Time=400ms ,  线程数从1增至150
  • IO优化组:CPU Time=100ms , Wait Time=200ms ,  线程数从1增至150
  • CPU优化组:CPU Time=50ms , Wait Time=400ms ,  线程数从1增至150

其中参照组用于纵向比较线程数、QPS、CPUTime、WaitTime、CPU使用率等因素之间的关系。
另外IO优化组CPU优化组用于模拟分别对IO和CPUTime进行优化,三组横行比较优化后的效果

参照组结果

根据公式计算可得:

  • 最佳线程数=(100ms+400ms)/100ms*8=40
  • 理论最大QPS=1000ms/100ms*8=80

实验结果如下
image.png | center | 830x532
这里主要记录了不同线程数下,QPS、RT、CPU-user使用率、上下文切换(CS)、load几个数值。其中在150线程时,CS最大值接近7000/s,带来的系统开销基本可以忽略。对于其他几个下面我对数据绘制了几个线图,方便查看趋势:

线程数-QPS-RT图(红线部分是理论最佳线程数)
image.png | center | 830x288
上图所示:

  • 当线程数小于40(最佳线程数)时,随线程数增大,QPS呈线性上升,RT缓慢上涨。
  • 当线程数大于40时,随线程数增大,QPS提升不大,RT呈线性上升。

线程数-QPS-CPU使用率图
image.png | center | 830x381
上图所示:

  • 随着线程数增大,CPU(user)使用率线性上升,同时QPS也呈线性上升
  • 当线程数达到40时,CPU使用率接近90%,CPU和QPS缓慢提升。

线程数-RT-load图
image.png | center | 830x301
上图所示:

  • 线程数在接近40前,load和RT缓慢上涨
  • 超过40后,load和RT成线性上涨

实验小结

通过压测的现象,结合公式我们可以得到几个结论:

  • 线程数小于最佳线程数时,CPU尚未充分利用,调度良好,增加线程数可以提升CPU使用率,进而提升QPS,RT基本不变。
  • 线程数达到最佳线程数时,CPU充分利用,此时QPS接近最大值。RT和QPS处于最佳平衡点。
  • 线程数超过最佳线程数时,CPU已经饱和,QPS基本不再提升,因为CPU调度加剧,RT拉长。实际RT=实际线程数/最佳线程数*理论RT
  • QPS实际和CPU使用率成正相关,RT增长和load成正相关。

优化组横向对比

分别对IO和CPU time优化后,对QPS影响如何呢?由公式计算可得:

  • 参照组参数:CPU Time=100ms , Wait Time=400ms , RT=500ms , 推算最佳线程数=40,理论最大QPS=80
  • IO优化组参数:CPU Time=100ms , Wait Time=200ms , RT=250ms , 推算最佳线程数=24,理论最大QPS=80
  • CPU优化组参数:CPU Time=50ms , Wait Time=400ms , RT=450ms , 推算最佳线程数=72,理论最大QPS=160

线程数-QPS三组对比图
image.png | center | 830x332
这里列出了三组场景压测的QPS结果:

  • IO优化对最大QPS没有提升(RT减小),但达到QPS最大值的线程数变少了。
  • CPUTime优化对最大QPS提升很大(RT减小),虽然单线程QPS提升不大,但最佳线程数变大,进而最大QPS值提升。
  • 对比发现,CPUTime减小一倍,QPS提升一倍,符合公式所述。

实验小结

  • 计算得到的理论最佳线程数和实际可能会有偏差,计算结果能指导一个大致区间,通过压测可以得到实际最佳线程数,两者不会相差太大。
  • 理论最大QPS是系统QPS的天花板,实际值会低于该值。原因是其他进程(非java)、串行逻辑、各种锁(比如线程池的队列锁)、GC等原因导致JVM对CPU的实际利用率(注意不是使用率)无法达到100%。
  • 通过实验,公式得到验证。本次实验场景的瓶颈是CPU,如果系统存在其他瓶颈,比如磁盘、网卡、依赖服务等,需要优先解决。

优化实践

上面两节,对公式进行了推导和实验验证,都还是在相对理论的环境下进行的。接下来,我们将探讨如何在实际对线上应用进行优化时该如何入手操作。

下面,我简单总结了对QPS和RT优化的实践流程,并以一个线上应用的优化案例,来具体说明流程如何进行。

QPS优化流程

根据前面推论可知,提升QPS的最佳方式是减小CPU计算时间,下图描述了优化CPU Time的流程。
image.png | center | 587x592

  • 热点发现:通过压测工具将系统压到一个较高水位,然后通过Profiler工具分析系统热点。

    • 压测工具:amazon,csp,ab,jmeter
    • Profiler工具:zprofiler,aia(Live Profiler),bianque,jprofiler,perf
  • 逻辑优化:需要梳理链路和理解代码逻辑,去掉冗余逻辑或减小耗时逻辑。
  • 更优替换:不需要理解整个代码逻辑,对局部进行效率更高的数据结构或者算法替换,比如数组&

链表、排序算法、序列化算法等

RT优化流程

rt根据前面公式可知,有三个因子相关,我们可以利用工具依次排查,找到原因并优化

image.png | center | 495x548

  • 方法耗时工具:arthas的trace方法(方法内堆栈粒度),eagleeye(服务粒度),jprofiler等
  • CPU Time优化:参照上述流程
  • IO Time优化:合并IO、减少IO内容、优化网络链路、减小下游服务RT等
  • Ready Time优化:主要通过设置合理的线程数减小

优化案例

本小节将以一个线上应用的核心接口优化过程作为案例,重点讨论实践过程中,如何利用工具进行热点探查和性能优化,并利用公式对优化结果进行定量计算,搞清楚为什么做了一个优化,QPS能提升一倍。

摸底压测

背景:接口的主要功能是渲染页面,页面由多个模块组成,程序循环调用模块源码(java代码)拿到结果数据,拼装成页面数据。
机器配置:4C8G VM -Xmx4g -Xms4g -Xss512k -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -Xmn2g -XX:MaxDirectMemorySize=1g -XX:SurvivorRatio=10

摸底压测结果
image.png | center | 830x187
摸底压测后拿到结果QPS=41,rt=200ms。整体来说QPS偏低,本次优化的核心目标就是提升QPS。

第一轮优化

既然要做QPS优化,那核心目标就是优化CPU Time。

热点优化

根据优化流程,压测过程中,我通过Zprofiler(相关文档)查找系统热点,Zprofiler通过cpu占用百分比的方式,展示top热点的堆栈。系统热点如下:
image.png | center | 830x217
可见,前面4个热点占据43%的CPU Time,是本轮优化的优化重点。因为不同应用引起热点的原因各不一样,这里不展开讲述,仅简述下大致原因:

  • 1和3都和tair相关,分别是序列化(tair默认使用jdk原生序列化)和zip解压缩带来的开销。
  • 2是police包在打印日志时,每次都会调用native方法查询当前机器的hostName
  • 4则是大量不必要日志带来的本地写盘开销

分析后,可通过“逻辑优化“的方式对几个热点进行优化,方案如下:

  • 1和3的tair读取数据是模块的源码,因为模块本身不多,这里的优化方式是将源码放入内存(local cache)并解决了更新等问题,这样就避免每次从tair读取带来的序列化和解压缩开销。
  • 2的解决方案类似,第一次读取hostName后,缓存到内存,后续直接从内存拿。
  • 4则是将对日志进行了优化。

优化后,再此压测并用Zprofiler对热点进行profile,结果如下:
image.png | center | 830x123
可以看到,几个热点已经不再,CPUTime百分比分布整体均衡了些,后续对top1的热点(优化前第6点)继续优化:方案是将velocity(解释执行)替换为sketch(编译执行),替换后模版渲染开销基本可以忽略。

这样整体的CPU Time优化百分比达到42%左右。

优化结果

image.png | center | 830x179
压测结果:线程=9,load=6,CPU=90%,QPS=78

优化分析

分析为什么QPS能从41提升到78?

根据最大QPS公式可以推导出:
image.png | left | 238x67
代入两次压测的QPS值:41/78=CPUTime2/CPUTime1,得到 CPUTime2=52%CPUTime1
公式计算的到前后优化了48%,和前面Zprofiler优化得到42%差距不大,至于差值部分在后面会解释。

第二轮优化

加入缓存

从业务上来说,页面渲染结果可以做缓存,所以这次引入了tair做缓存,那么结果如何呢?
image.png | center | 830x180
压测结果:线程=5,load=5,CPU=91%,QPS=81

在以往的认知里,加入缓存往往能大幅度提升QPS。但压测结果有些出乎意料,RT有明显下降(115ms->61ms),但QPS没有太明显提升(78->81),这个现象就需要解释了。

优化分析

RT下降比较好解释,缓存挡掉了查询服务等Wait耗时。但QPS问题,我们根据最大QPS公式,可以先大胆推测,QPS没有提升,说明CPUTime没有明显优化。

那么如何拿到CPUTime具体数值呢?这里祭出一个非常好用的工具——Live Profiler(接入文档)来查看方法内部耗时细节。接下来对比缓存前后的方法耗时情况:
缓存前
image.png | center | 830x55
缓存后
image.png | center | 830x53
大致解释下,几个核心字段的含义:

  • Service Name对应HSF服务名称,Count是采集次数
  • Wall Time对应方法的实际耗时RT,Total代表总耗时,Mean代表平均耗时
  • CPU Time就是我们需要的,在CPU上运行的耗时,Total,Mean同上

这里我们关注CPU Time的平均值发现,引入缓存后,方法内的CPU Time确实有明显减少(9.5ms->3.2ms),那为什么没有QPS没有提升?

这里需要知道,Live Profiler(zprofiler同理)只采集了业务方法(比如HSF)以上线程堆栈,看来还存在其他耗时没有找到,接下来通过arthas来找下其他耗时,通过trace方法发现:
缓存前
image.png | center | 830x220
在HSF的ProviderProcessor.handleRequest方法内部,发现两块耗时大头,分别是invoke(44ms),其内部调用业务逻辑,和一个看起来比较奇怪的addListener(41ms)。
首先trace方法拿到的实际耗时RT,也就是WallTime,可以看到invoke的耗时和Live Profiler得到的WallTime相近,其中内部有9.5ms是CPUTime开销。

另外这个让人在意的addListener内部在做什么?
image.png | center | 830x481
原来这个方法内部是做网络相关的操作,包括序列化和网络IO两大块。其中序列化是把返回对象换成JSON再getBytes,而开销大头就是在JSON序列化这块,于此发现了业务方法外的CPU大头40sm。

再看看缓存后的情况:
image.png | center | 830x223
可以看到业务耗时明显降低了,WallTime=5.2ms,但依然存在方法外JSON这块开销40ms。

由此,将方法内外的耗时代入公式计算:

  • 缓存前:其中CPU Time=9+41=50ms,理论最大QPS=1000ms/50ms*4=80
  • 缓存后:其中CPU Time=3+41=43ms,理论最大QPS=1000ms/44ms*4=81

可以发现,两者最大QPS并没有太大差距,也就解释了压测结果为什么出人意料。

第三轮优化

减小报文

方法外的JSON序列化开销,决定因素是对象的大小,而方法内的开销,通过Live Profiler进一步查看下方法内的开销细节。
image.png | center | 830x234
CPU Time主要消耗在两块:渲染结果的tair的序列化、解压缩操作 和 字符串的替换(渲染结果字符串内部时间字段的替换)。

从上面分析发现,方法内外的开销都集中在返回对象的字符串操作上,那么第三轮优化的核心思路:减小对象大小
具体方式是把返回对象里的大头,模块源码通过端上支持的zcache缓存到app,这样返回报文里,只需要返回模块源码的占位符即可,下面看下zcache前后返回报文的大小对比:
image.png | center | 830x286
可以发现,页面的模块数越多,效果越明显。

压测结果

image.png | center | 830x281
结果:线程=7,load=6.6,CPU=89%,QPS=306

优化分析

这下QPS有了明显提升(81->306),老规矩,下面分析计算下为什么能到306。
image.png | center | 830x59
通过Live Profiler发现,zcache前后,CPUTime有明显提升:3.2ms->1.2ms
image.png | center | 830x239
看看方法内部细节耗时,发现序列化等字符串相关CPU Time大幅下降。

而方法外HSF相关的网络开销情况如何呢?
image.png | center | 830x199
这部分开销从40ms降到10ms左右,效果明显。
代入公式计算:RT=14ms,其中CPU Time=10.5+1.5=12ms,理论最大QPS=333 ,实际结果306基本一致。

于此,从amazon压测的结果来看,三轮整体优化效果=(306-41)/41=646%

注意
这里是通过amazon压测得到结果。同学们肯定发现了,HSF的序列化开销后期成了大头,为什么是用JSON序列化呢?HSF默认不是hessian吗?
原因是amazon或者自己用ab压测,都走的HTTP的hsf调用方式,这样hsf的返回结果都是JSON格式的,所以必须用JSON做序列化了。而实际应用间调用都是走的hessian序列化,性能会比JSON好上很多,所以通过amazon等工具直接压测HSF接口,会导致结果有偏差,这个就比较闹心了。

后续通过观察线上调用,原本10ms左右的JSON序列化开销,实际hessian大概开销在3-4ms左右,所以接口的实际最大QPS根据公式计算的可得=1000ms/(1.5+3.5)*4=800,实际QPS推算在700~800之间,优化的整体提升可能更大。

后记

本篇文章讨论的场景主要是CPU瓶颈,对于应用实际还存在很多其他因素,像锁瓶颈,GC和和其他JVM操作引起的STW,编程模型的影响等都未展开讨论。每个都值得深入探究,以后有机会再来探讨其他问题。