服务端性能优化--最大QPS推算及验证

服务端性能优化–最大QPS推算及验证

影响QPS(即吞吐量)的因素有哪些?每个开发都有自己看法,一直以为众说纷纭,例如:

  • QPS受编程语言的影响。(PHP是最好的语言?)
  • QPS主要受编程模型的影响,比如不是coroutine、是不是NIO、有没有阻塞?
  • QPS主要由业务逻辑决定,业务逻辑越复杂,QPS越低。
  • QPS受数据结构和算法的影响。
  • QPS受线程数的影响。
  • QPS受系统瓶颈的影响。
  • QPS和RT关系非常紧密。
  • more…

嗯,这些说法好像都对,但是好像又有点不对,好像总是不太完整,有没有一个系统点的说法能让人感觉一听就豁然开朗?
今天我们就这个话题来阐述一下,将一些现有的理论作为依据,把上方这些看起来比较零碎的看法总结归纳起来,希望能为服务端的性能提升进行一点优化,这也是一个优化的起点,未来才有可能做更多的优化,例如TCP、DNS、CDN、系统监控、多级缓存、多机房部署等等优化的手段。

好了,废话不多说,直接开聊。

我们经常再做优化的时候,例如电商的促销秒杀等活动页,一开始可能会认为说Gzip并不是影响CPU的最大因子,直到拿出一次又一次的实验数据,研发们才开始慢慢接受(尬不尬),这是为什么?难道说Gzip真的是影响CPU的最大因子吗?那我们就拿出一点数据来验证一下对吧,接下来我们从RT着手开始慢慢了解,看到文章结尾就知道为什么Gzip和CPU的关系,同事也会发现,性能优化的相关知识其实也是体系化的,并不是分散零碎的。

RT

什么是 RT ?是概念还是名词还是理论?

RT其实也没那么玄乎,就是 Response Time (就是响应时间嘛,哈哈哈),只不过看你目前在什么场景下,也许你是c端(app、pc等)的用户,响应时间是你请求服务器到服务器响应你的时间间隔,对于我们后端优化来说,就是接受到请求到响应用户的时间间隔。这听起来怎么感觉这不是在说废话吗?这说的不都是服务端的处理时间吗?不同在哪里?其实这里有个容易被忽略的因素,叫做网络开销。
所以服务端RT ≈ 网络开销 + 客户端RT。也就是说,一个差的网络环境会导致两个RT差距的悬殊(比如,从深圳访问上海的请求RT,远大于上海本地内的请求RT)

客户端的RT则会直接影响客户体验,要降低客户端RT,提升用户的体验,必须考虑两点,第一点是服务端的RT,第二点是网络。对于网络来说常见的有CDN、AND、专线等等,分别适用于不同的场景,有机会写个blog聊一下这个话题。

对于服务端RT来说,主要看服务端的做法。
有个公式:RT = Thread CPU Time + Thread Wait Time
从公式中可以看出,要想降低RT,就要降低 Thread CPU Time 或者 Thread Wait Time。这也是马上要重点深挖的一个知识点。

Thread CPU Time(简称CPU Time)

Thread Wait Time(简称Wait Time)

单线程QPS

我们都知道 RT 是由两部分组成 CPU Time + Wait Time 。那如果系统里只有一个线程或者一个进程并且进程中只有一个线程的时候,那么最大的 QPS 是多少呢?
假设 RT 是 199ms (CPU Time 为 19ms ,Wait Time 是 180ms ),那么 1000s以内系统可以接收的最大请求就是
1000ms/(19ms+180ms)≈5.025。

所以得出单线程的QPS公式:

$$
单线程QPS = 1000ms/RT
$$

最佳线程数

还是上面的那个话题 (CPU Time 为 19ms ,Wait Time 是 180ms ),假设CPU的核数1。假设只有一个线程,这个线程在执行某个请求的时候,CPU真正花在该线程上的时间就是CPU Time,可以看做19ms,那么在整个RT的生命周期中,还有 180ms 的 Wait Time,CPU在做什么呢?抛开系统层面的问题(这里不考虑什么时间片轮循、上下文切换等等),可以认为CPU在这180ms里没做什么,至少对于当前的业务来说,确实没做什么。

  • 一核的情况
    由于每个请求的接收,CPU只需要工作19ms,所以在180ms的时间内,可以认为系统还可以额外接收180ms/19ms≈9个的请求。由于在同步模型中,一个请求需要一个线程来处理,因此,我们需要额外的9个线程来处理这些请求。这样,总的线程数就是:

$$
(180ms + 19ms)/19ms ≈ 10个
$$

        多线程之后,CPU Time从19ms变成了20ms,这1ms的差值代表多线程之后上下文切换、GC带来的额外开销(对于我们java来说是jvm,其他语言另外计算),这里的1ms只是代表一个概述,你也可以把它看做n。

  • 两核的情况
    一核的情况下可以有10个线程,那么两核呢?在理想的情况下,可以认为最佳线程数为:2 x ( 180ms + 20ms )/20ms = 20个
  • CPU利用率
    我们之前说的都是CPU满载下的情况,有时候由于某个瓶颈,导致CPU不得不有效利用,比如两核的CPU,因为某个资源,只能各自使用一半的能效,这样总的CPU利用率就变成了50%,再这样的情况下,最佳线程数应该是:50% x 2 x( 180ms + 20ms )/20ms = 10个
    这个等式转换成公式就是:最佳线程数 = (RT/CPU Time) x CPU 核数 x CPU利用率
    当然,这不是随便推测的,在收集到的很多的一些著作或者论坛的文档里都有这样的一些实验去论述这个公式或者这个说法是正确的。

最大QPS

1.最大QPS公式推导

假设我们知道了最佳线程数,同时我们还知道每个线程的QPS,那么线程数乘以每个线程的QPS既这台机器在最佳线程数下的QPS。所以我们可以得到下图的推算。

image

我们可以把分子和分母去约数,如下图。

image

于是简化后的公式如下图.

image

从公式可以看出,决定QPS的时CPU Time、CPU核数和CPU利用率。CPU核数是由硬件做决定的,很难操纵,但是CPU Time和CPU利用率与我们的代码息息相关。

虽然宏观上是正确的,但是推算的过程中还是有一点小小的不完美,因为多线程下的CPU Time(比如高并发下的GC次数增加消耗更多的CPU Time、线程上下文切换等等)和单线程的CPU Time是不一样的,所以会导致推算出来的结果有误差。

尤其是在同步模型下的相同业务逻辑中,单线程时的CPU Time肯定会比大量多线程的CPU Time小,但是对于异步模型来说,切换的开销会变得小很多,为什么?这里先卖个葫芦吧,看完本篇就知道了。

既然决定QPS的是CPU Time和CPU核数,那么这两个因子又是由谁来决定的呢?(越看越懵哈)

2.CPU Time

终于讲到了 CPU Time,CPU Time不只是业务逻辑所消耗的CPU时间,而是一次请求中所有环节上消耗的CPU时间之和。比如在web应用中,一个请求过来的HTTP的解析所消耗的CPU时间,是CPU Time的一部分。另外,这个请求中请求RPC的encode和decode所消耗的CPU时间也是CPU Time的一部分。

那么CPU Time是由哪些因素决定的呢?两个关键字:数据结构+算法。
举一些例子吧

  • 均摊问题
  • hash问题
  • 排序和查找问题
  • 状态机问题
  • 序列化问题

3.CPU利用率

CPU利用率不高的情况时常发生,一下因素都会影响CPU的利用率,从而影响系统可以支持的最大QPS。

1) IO能力
  • 磁盘IO
  • 网络IO
    ·带宽,比如某大促压力测试时,由于某个应用放在Tair中的数据量大,导致Tair的机器网卡跑满。
    ·网路链路,还是这次大促,借用了其他核心交换机下的机器,导致客户端RT明显增加。
2) 数据库连接池(并发能力=PoolWaitTime/RT(Client) x PoolSize)。
3) 内存不足,GC大量占用CPU,导致给业务逻辑使用的CPU利用率下降,而且GC时还满足Amdahl定律锁定义的场景。
4) 共享资源的竞争,比如各种锁策略(读写锁、锁分离等),各种阻塞队列,等等。
5) 所依赖的其他后端服务QPS低造成的瓶颈。
6) 线程数或者进程数,甚至编程模型(同步模型,异步模型)。

在压力测试过程中,出现最多的就是网络IO层面的问题,GC大量占用CPU Time之类的问题也经常出现。

4.CPU核数,Amdahl定律,Gustafson定律

1)Amdahl定律(安达尔定律,不是达尔文定律!!!)

Amdahl定律是用来描述可伸缩性的,什么是可伸缩性?说白了就是比如增加计算机资源,如CPU、内存、宽带等,QPS能够相应的进行改进。

既然Amdahl定律是描述可伸缩性的,那它是如何描述的呢?

Amdahl在自己的论文中指出,可伸缩性是指在一个系统中,基于可并行化和串行化的组件各自所占的比例,当程序获得额外的计算资源(如CPU或者内存等)时,系统理论上能够获得的加速值(QPS或者其他指标可以翻几倍)。用一个公式来表达,如果F表示必须串行化执行的比例,那么在一个N核处理器的机器中,加速:

$$
Speedup \leq \frac{1}{F+\frac{1-F}{N}}
$$

这个公式代表的意义是比较广泛的,在项目管理中也有一句类似的话:

一个女人生一个孩子要9个月,但是永远不可能让9个女人在一个月内就生一个孩子。
我们根据这个例子套一个公式先,这里设F=100%,9个女人表示N=9,于是就有1/(100%+(1-100%)/9)=1,所以9个女人的加速比为1,等于没有加速。

到这里,其实这个公式还只是描述了在增加资源的情况下系统的加速比,而不是在资源不变的情况下优化数据结构和算法之后带来的提升。优化数据结构和算法带来的提升要看前文中最大的QPS公式。不过这两个公式也不是完全没有联系的,在增加资源的情况下,它们的联系还是比较紧密的。

2) Gustafson定律(古斯塔夫森定律)

这个定律名字有点长,但这不是关键,关键的是,它是Amdahl定律的补充,公式为:

$$
S(P) = P-α·(P-1)
$$

P是处理器的个数,α是串行时间占总执行时间的比例。
生孩子的案例再次套上这个公式,P为女人的个数,等于9,串行比例是100%。Speedup=9-100%x(9-1)=1,也就是9个女人是无法在一个月内把孩子生出来的……

之所以说是Amdahl定律的补充,是因为两个定律的关系是相辅相成的关系。前者从串行和并行执行时间的角度来推导,后者从串行和并行的计算量角度来推导,不管是哪个角度,最终的结果其实是一样的。

3)CPU核数和Amdahl定律的关系

通过最大QPS公式,我们发现,在CPU Time和CPU利用率不变的情况下,核数越多,QPS就越大。比如核数从1到4,在CPU Time和CPU利用率不变的情况下,加速比应该是4,所以QPS应该也是增加4倍。

这是资源增加(CPU核数增加)的情况下的加速比,也可以通过Amdahl定律来衡量,考虑串行和并行的比例在增加资源的情况下是否会改变。也就是要考虑在N增加的情况下,F受哪些因素的影响:

$$
Speedup \leq \frac{1}{F+\frac{1-F}{N}}
$$

只要F大于0,最大QPS就不会翻4倍。

一个公式说要增加4被倍,一个定理说 没有4倍,互相矛盾?

其实事情是这样的,通过最大QPS公式,我们可以发现,如果CPU Time和CPU 利用率不变,核数从1增加到4,QPS会相应的增加4倍。但是在实际情况下,当核数增加时,CPU Time和CPU 利用率大部分时候是变化的,所以前面的假设不成立,即一般场景下QPS不能增加4倍。

而Amdahl定律中的N变化时,F也可能会变化,即一般场景下,最大QPS并不能增加4被,所以这其实并不矛盾。相反它们是相辅相成的。这里一定要注意,这里说的是一般场景,如果你的场景完全没有串行(程序没有串行,系统没有串行,上下文切换没有串行,什么串行都没有),那么理论上是可以增加4倍的。

为什么增加计算机资源时,最大QPS公式中的CPU Time和CPU利用率会变化,F也会变化呢?我们可以从宏观上分析一下,增加计算机资源时,达到满载:

  • QPS会更高,单位时间内产生的对象会更多。在同等条件下,minor GC被触发的次数增加,还有些场景发生过对象多到响应没返回它们就进了“老年代”,从而fullGC被触发。宏观上,这是属于串行的部分,对于Amdahl公式来说F会受到影响,对于最大QPS公式来说,CPU Time和CPU利用率也受到影响.
  • 在同步模型下大量的线程在完成一次请求中,上下文被切换的次数大大增加。
  • 尤其是在有 串行模块的时候,串行的执行和等待时间增加,F会变化,某些场景下CPU
    利用率也达不到理想效果,这取决于你的代码。这也是要做锁分离、为什么要缩小同步
    块的原因。当然还有锁自身的优化,比如偏向、自旋、读写分离等技术,都是为了不断
    地减少Amdahl定律中的F,也是为了减少CPU Time ( 锁本身的优化),提高CPU利
    用率(使用锁的方法的优化)。

。锁本身的优化最为津津乐道的是自旋、偏向、自适应,synchronized分析,还有reetrantLock的代码及AQS等等。

。使用锁的优化方法最常见的是缩小锁区间、锁分离、无锁化、volatile。

所以在增加计算资源时,更高的并发产生,会引起最大QPS公式中两个参数的变化,也会
引起Amdahl定律中F值的变化,同时公式和定律变化的趋势是相同的。Amdahl定律是得到广
泛认可的,也是得到数据验证的。最大QPS公式好像没有人验证过,这里引用一个比较有名的
测试结果,如下图.

image

  • 当计算资源(处理器数量)增加时,在串行部分比例不变的情况下,CPU利用率下降。
  • 当计算资源(处理器数量)增加时,串行占的比例越大,CPU利用率下降得越多。

实验数据验证公式

所以其实到现在我们一直在说理论,带了一点点的公式,听起来好像是那么回事,但是公式到底怎么用?准不准确?可以精准测试还是概要测试即可?

我们接下来实验一下吧。

!!! 接下来会涉及到CPU Time 包含了操作系统对CPU的消耗,比如进程,线程调度等。

1.数据准备

这里就用之前的一个电商活动页面的优化来说吧,在这个过程中,我们做了大量测试,由于测试中使用了localhost方式,所以Java进程在IO上的Wait Time是非常小的。接下来,由于最佳线程数接近CPU核数,
所以在两核的机器上使用了10个Java进程,客户端发起了10个并发请求,这是在最佳线程数下(最佳线程数在一个区间里,在这个区间里QPS总体变化不大,并且也用了5、15个并发测试效果,发现QPS值基本相同)得出的大量结果,接下来就分析一下这些测试结果,见下表。

1)测试QPS结果
原始页面大小 压缩后的大小 优化前QPS 优化后QPS 优化前RT 优化后RT
92kb 17kb 164 2024 60.7ms 4.9ms
138kb 8.7kb 143 1859 69.8ms 3.3ms
182kb 11.4kb 121 2083 82.3ms 4.8ms
248kb 32kb 77 1977 129.6ms 5.0ms
295kb 34.4kb 70 1722 141.1ms 5.8ms

我们其实只要关注各项优化前后的QPS变化即可。

2) CPU利用率

由于Apache Bench和Java部署在同一台机器.上,所以CPU利用率应该减去Apache Bench
的CPU资源消耗。根据观察,优化前Apache Bench 的CPU消耗在1.7%到2%之间,优化后
Apache Bench 的CPU资源消耗在20%左右。为什么优化前后有这么大的差距呢?因为优化后
响应能够及时返回,所以导致Apache Bench使用的CPU资源多了。

在接下来的计算中,我们将优化前的CPU利用率设置为98%,优化后的CPU利用率设置为80%。

3)CPU Time 计算公式

根据QPS的计算方法,把QPS挪到右边的分母中,CPU Time移到等号左边,就会得到下图的公式。

image

4) CPU Time计算示例

根据上方列出的三点(CPU利用率、QPS和CPU核数),接下里我们就详细的描述一下推算方法了。

计算得到的CPU Time

根据上方的表格计算方法,利用QPS计算出各页面理论上的CPU Time,计算后的结果如下表:

原始页面92kb 计算公式
优化前CPU Time计算 1000 / 164 x 2 x 0.98 = 12ms
优化后CPU Time计算 1000 / 2024 x 2 x 0.8 = 0.8ms



原始页面大小 压缩后的大小 优化前QPS 优化后QPS 优化前CPU Time 优化后CPU Time
92kb 17kb 164 2024 12ms 0.8ms
138kb 8.7kb 143 1859 13.7ms 0.86ms
182kb 11.4kb 121 2083 16.2ms 0.77ms
248kb 32kb 77 1977 25.5ms 0.81ms
295kb 34.4kb 70 1722 28ms 0.93ms

这里主要看一下各项的CPU Time优化前后的变化,接下来,我们把两个值做减法,然后和开篇中提到过的实测程序中Gzip的CPU Time进行对比。

实测CPU Time

1) 5个页面的Gzip所消耗的CPU Time

实测5个页面做Gzip所消耗的时间,然后跟公式计算出来的CPU Time做一个对比,如下表:

原始页面大小 CPU Time公式差值(上表的CPU Time差值) Gzip CPU Time测量值(10次平均值) 差值
92kb 11.2ms 8ms 3.2ms
138kb 12.8ms 7ms 5.8ms
182kb 15.4ms 9ms 6.4ms
248kb 24.7ms 21ms 3.0ms
295kb 27.1ms 23ms 4.1ms

可以看到,计算出来的CPU Time值要比测出来的要多一点,多了几毫秒,这是为什么呢?


其实是因为在优化前,有两个消耗CPU Time的阶段,一个是执行Java代码时,另一个是执行Gzip时。而优化后,整个逻辑变成了从缓存获取数据后直接返回,只有非常少的Java代码在消耗CPU Time(10行以内)。

2) Java页面执行消耗的CPU Time

大体上可以认为:

优化前的CPU Time - 优化后的CPU Time = Gzip CPU Time + 全页面Java代码的CPU Time

在实验中,一开始只统计了 Gzip 本身的消耗,而在 Java 文件中 Java代码执行的时间并没有包含在内,所以两者差距比较大。于是,我们单独统计了5个页面的Java代码的执行时间,发现文件中Java码执行的时间为36ms。实际测量的Gzip CPU Time 加上36ms的Java代码执行时间,和使用公式计算出来的CPUTime基本吻合。

根据上面的计算和测量结果,我们发现 Gzip 的 CPU Time消耗加上Java代码的CPU Time消耗,与公式测量出来的总的CPU Time消耗非常接近,误差为1~2ms。考虑到CPU Time测量是单线程测量,而压力测试QPS是并发情况下(会多出进程切换的开销和GC等的开销),我们认为这点误差是合理的,测试结果说明公式在宏观上是正确的。

压力测试最佳线程数和QPS临界点

前面讲到了公式的推导,并在一个固定的条件下验证了公式在该场景下的正确性。

假设在一个thread-per-client的场景,有一一个Ajax请求,这个请求返回一个Json字符串,每个请求的CPU Time为1ms,WaitTime为300ms(比如读写Socket和线程调度的等待开销)。那么最佳线程数是

$$
(300+1/1 )x4x 100%=1204
$$

尤其在广域网上,Wait Time=300ms是正常的数值。在国际环境下,300ms就更加常见了。这意味着如果是4核的机器,需要1204个线程,如果是8核的机器则需要2408个线程。实际上,有些HTTP服务的CPU Time是远小于1ms的,比如上面的场景中将页面压缩并缓存起来之后,CPU Time基本为0.8ms,如果WaitTime还是300ms,那么需要数以千计的线程啊!当线程数不断增加的时候,到达某个临界点之后对系统就开始产生负面影响了。

(1) 大量线程上下文切换的开销,引起CPU Time的增加及QPS的减少。所以,有时候还没有达到最佳线程数,而QPS已经开始略微下降了。因为CPU Time发生变化、线程多了之后,调度引起的CPU Time提升的百分比和QPS下降的百分比成正比(上方的QPS公式),上下文切换带来的开销如下。

  • 上下文切换(微妙级别)
  • JVM本身的开销
  • CPU Cache加载

(2)线程的栈空间会占用大量的内存,假设每个线程的栈空间是1MB,这么多的线程就要占用数GB的内存。

(3)在CPU Time不变的情况下,因为线程上下文切换和操作系统想尽力为线程在宏观上平均分配时间片的行为,导致每个线程的Wait Time都增加了,于是每个请求的RT也增加了,最终就会产生用户体验下降的情况。

可以 用一张图来表示一下临界点的概念。

image

由于线程数增加超过某个临界点会影响CPU Time、QPS和RT,所以很难精确测量高并发下的CPU Time,它随着机器硬件、操作系统、线程数等因素不断变化。我们能做的就是压力测试QPS,并在压力测试的过程中调整线程数,使QPS达到临界点,这个临界点是QPS的一个峰值点。这个峰值点的线程数即当前系统的最佳线程数,当然如果这个时候CPU利用率没有达到100%,那么证明系统中可能存在瓶颈,应该在找到并处理瓶颈之后继续压力测试,并且重新找到这个临界点。

当数据结构发生改变、算法改进或者业务逻辑发生改变时,最佳线程数有可能会跟着变化。

总结:

这篇 blog的例子中,在CPU Time下降到1ms左右而Wait Time需要数百毫秒的场景下,我们需要很多线程。但是当达到这个线程数的时候,有可能早就达到了临界点。所以系统整体已经不是最健康的状态了,但是现有的编程模型已经阻碍了我们前进,那么应该怎么办呢?为使某个系统达到最优状态?

所以下一篇blog我们来说一下编程中的同步模型和异步模型问题,以及为什么异步模型只需要这么少的线程,是不是公式在异步模型下失效了。

my blog

笔记已同步至我的博客园,欢迎访问哈

我的博客园 https://www.cnblogs.com/huangyingsheng/p/13744422.html