在前面的文章中,说了很多JVM和数据库方面的东西,我所描述的内容大多偏重于技术本身,和实际的业务系统结合的比较少,本文开始进入实际的系统设计中应当注意的方方面面(文章偏重于访问量高,但是每次访问量并不是很大的系统),而偏重点在于性能和效率本身,由于这个知识涉及的基础和面很广,所以建议是先看下以前写的内容或自己有一定的基础来才开始接触比较好,另外本文也不能诠释性能的关键,从一个应用系统前端到后端涉及的部分非常多,本文也只会说明其中一部分,后续的部分我们再继续说;下面我们想一下一个web应用绝大部分请求的整个过程:client发出请求->server开始响应并创建请求对象及反馈对象->如果没有用户对象就创建session信息->调用业务代码->业务代码分层组织数据->调用数据(从某个远程或数据库或文件等)->开始组织输出数据->反馈数据开始通过模板引擎进行渲染->渲染完成未静态文件向客户端进行输出->待客户端接收完成结束掉请求对象(这种请求针对短连接,长连接有所区别)。
就从前端说起吧,说下一下几个内容:
1、线程数量
2、内容输出
3、线程上下文切换
4、内存
1.首先说下线程数量,线程数量很多人认为在配置服务器的线程数量时认为越多越好,各大网站上很多人也给出了自己的测试数据,也有人说了每个CPU配置多少线程为合适(比如有人说过每个CPU给25个线程较为合适),但是没有一个明确的为什么,其实这个要和CPU本身的运行效率和上来说明,并非一概而论的,也需要考虑每个请求所持有的CPU开销大小以及其处于非Running状态的时间来说明,线程配置得过多,其实往往会形成CPU的征用调度问题,要比较恰当将CPU用满才是性能的最佳状态(说到线程就不得不说下CPU,因为线程就是消耗CPU的,其本身持有的内存片段非常小,前面文章已经说明了它的内存使用情况,所以我们主要是讨论它与CPU之间的关系)。
首先内存到CPU的延迟在几十纳秒,虽然CPU内部的三级缓存比这个更加小,但是几乎对于我们所能识别的时间来讲可以被忽略;另外内存与CPU之间的带宽也是以最少几百M每秒的速度通信,所以对于内存与CPU交互数据的时间开销对于常规的高并发小请求的应用客户忽略掉,我们只计算本身的计算延迟开销以及非计算的等待开销,这些都一般会用毫秒来计算,相互之间是用10e6的级别来衡量,所以前者可以忽略,我们可以认为处于running的时间就是CPU实际执行的时间,因为这种短暂的时间也很难监控出来到底用了多久。
那么首先可以将线程的运行状态划分为两大类,就是:运行与等待,我们不考虑被释放的情况,因为线程池一般不会释放线程,至于等待有很多种,我们都认为它是等待就可以了;为什么是这两种呢,这两种正好对应了CPU是否在被使用,running状态的线程就在持有CPU的占用,等待的就处于没有使用CPU。
再明确一个概念,一个常规的web请求,后台对应一个线程对它的请求进行处理,同一个线程在同一个时间片上只能请求一个CPU为他进行处理,也就是说我们可以认为它不论请求过多少次CPU、不论请求了多少个CPU,只要这些CPU的型号是一样的,我们就可以认为它是请求的一个CPU(注意这里的CPU不包含多个core的情况,因为多个core的CPU只能说明这个CPU的处理速度可以接近于多个CPU的速度,而真正对线程的请求来讲,它认为这是一个CPU,在主板上也是一个插槽,所以计算CPU的时候不考虑多核心)。
最后明确线程在什么情况下会发生等待,比如读取数据库时,数据库尚未反馈内容之前,该线程是不会占用CPU的,只会处理等待;类似的是向客户端输出、线程为了去持有锁的等待一系列的情况。
此时一个线程过来如果一个线程毫无等待(这种情况不存在,只是一种假设),不论它处理多久,处理时间长度多长,此时如果只有一个CPU,那么这个应用服务器只需要一个1个线程就足以支撑,因为线程没有等待,那么CPU就没有停止运行,1个线程处理完这个请求后,接着就处理下一个请求,CPU一直是满的,也几乎没有太大的征用,此时1个线程就是最佳的,如果是多个同型号的CPU,那么就是CPU数量的线程是最佳的;不过这个例子比较极端,在很多类似的情况下,大家喜欢用CPU+1或CPU-1来完成对类似情况的线程设置,为了保证一些特殊情况的发生。
那么考虑下实际的情况,如果有等待,这个等待不是锁等待的(因为锁等待有瓶颈,瓶颈在于CPU的个数对于他们无效),应该如何考虑呢?我们此时来考虑下这个等待的时间长度应该如何去考虑,假如等待的时间长度为100ms,而运行的时间长度为10ms,那么在等待的这100ms中,就可以有另外10个线程进来,对CPU进行占用,也就是说对于单个CPU来说,11个线程就可以占满整个CPU的使用,如果是多个CPU当然在理论上可以乘以CPU的个数,这里再次强调,这里的CPU个数是物理的,而不算多核,多核在这里的意义比如以前一个CPU处理一个线程需要30ms,现在采用4个core,只需要处理10ms了,在这里体现了速度,所以计算是不要用它来计算。
那么对于锁等待呢?这个有点麻烦了,因为这个和模块有关系,这里也只能说明某个有锁等待的模块要达到最佳状态的访问效率可以配置的线程数,首先要明确锁等待已经没有CPU个数的概念,不论多少个CPU,只要运行到这段代码,他们就是一个CPU,不然锁就没有存在的意义了;另外,假如访问是非常密集的,那么当某个线程持有锁并访问的时候,其他没有得到的运行到这个位置都会处于等待,我们将一个模块的所有有锁等待的时间集中在一起,只有当前一个线程将具有锁的这段代码运行完成后,下一个线程才可以继续运行,所以它其他地方都没有瓶颈,或者说其他地方理论配置的线程数都会很高,唯独遇到这个地方就会很慢,假如一个线程从运行代码时长为20ms,等待事件为100ms,锁等待为20ms,此时假如该线程没有受到任何等待就是140ms即可运行完成,而当多个线程同时并发到这里的时候,后续每个线程将会等待20*N的时间长度,当有7个线程的时候,恰好排满运行的队列,也就是当又7个线程访问这个模块的时候,理论上刚好达到每个线程顺序执行而且成流水线状态,但是这里不能乘以CPU的个数了,为什么,你懂的。
2.内容输出,其实内容输出有很多种方法,在java方面,你可以自己编写OutputStream或者PrintWriter去输出,也可以用渲染模板去渲染输出,渲染的模板也有很多,最常见的就是JSP模板来渲染,也有velocity等各种各样的渲染模板,当然对于页面来讲只能用渲染模板去做,不过异步请求你可以选择,在选择时要对应选择才能将效果做得比较好。
说到这里不得不说下velocity这个东西,也就是经常看到的vm的文件,这种文件和JSP一样都是渲染模板的方法,只是语法格式有所区别,velocity是新出来的东西,很多人认为新的东西肯定很好,其实velocity是渲染效率很低的,在内容较小的输出上对性能进行压力测试,其单位时间内所能承受的访问量,比JSP渲染模板要低好几倍,不过对较大的数据输出和JSP差不多,也就是页面输出使用velocity无所谓的,而且效果比JSP要好,但是类似ajax交互中的小数据输出建议不要使用vm模板引擎,使用JSP模板引擎甚至于直接输出是最佳的方式。
说到这里JSP模板引擎在输出时是会被预先编译为java的class文件,VM是解释执行的,所以小文件两者性能差距很大,当遇到大数据输出时,其实大部分时间在输出文件的过程中,解释时间几乎就可以被忽略掉了。
那么JSP输出小文件是不是最快的呢?未必,JSP的输出其实是将JSP页面的内容组成字符串,最终使用PrintWriter流取完成,中间跳转交互其实还是蛮多的,而且有部分容器在组装字符串的时候竟然用+,这个让我很是郁闷啊,所以很多时候小数据的输出,我还是喜欢自己写,经过测试得到的结果是使用OutputStream的性能将会比PrintWriter高一些,(至于高多少,大家可以自己用工具或写代码测试下就知道了,这里可能单个处理速度几乎看不出区别,要并发访问看下平均每秒能处理的请求数就会有区别了),字符集方面,在获取要输出内容的时候,指定byte的字符集,如:String.getByte(“字符集”),一般这类输出也不会有表头,只需要和接收方或者叫浏览器一致就可以了(有些接收方可能是请求方);其实OutputStream比PrintWriter快速的原因很简单,在底层运行和传输的过程中,始终采用二进制流来完成,即使是字符也需要转换成byte格式,在转换前,它需要去寻找很多的字符集关系,最终定位到应该如何去转换,内部代码看过一下就明白,内部的方法调用非常多,一层套一层,相应占用的CPU开销也会升高。
总结起来说,如果你有vm模板引擎,在页面请求时建议使用vm模板引擎来做,因为代码要规范一些,而且也很好用;另外如果在简单的ajax请求,返回数据较小的情况下,建议使用OutputStream直接输出,这个输出可以放在你的BaseAction的中,对实现类中是透明的,实现类只需要将处理的反馈结果数据放在一个地方,由父类完成统一的输出即可,此处将Ajax类的调用可以独立一个父亲类出来,这样继承后就不用关心细节了。
输出中文件和大数据将是一个问题,对于文件来说,尤其是大文件,在前面文章已经说明,输出时压缩只能节省服务器输出时和客户端的流量,从而提高下载速度,但是绝对不会提高服务器端的性能,因为服务器端是通过消耗CPU去做动作,而且压缩的这个过程是需要时间的,这种只会降低速度,而绝对不会提高;那么大文件的方法就是一种是将大文件提前压缩好存放,如果实在太大,需要考虑采用断点传送,并将文件分解。
对大数据来讲,和文件类似,不过数据可能对我们要好处理一点,需要控制访问频率甚至于直接在超过访问频率下拒绝访问请求,每次请求的量也需要控制,如果对特殊大的数据量,建议采用异步方式输出到文件并压缩后,再由客户端下载,这样不论是客户端还是服务器端都是有好处的。
3、线程上下文切换,对于线程的上下文切换,在一般的系统中基本遇不到,不过一些特殊应用会遇到,比如刚才的异步导出的功能,请求的线程只是将事情提交上去,但是不是由它去下载,而是由其他线程再去处理这个问题,处理完成后再回写某个状态即可;在javaNIO中是非常的多,NIO是一种高性能服务器的解决方案,在有限的线程资源情况下,对极高并发的小请求,并存在很多推拉数据的情况下是很有效的,最大的要求就是服务器要有较好的连接支撑能力,NIO细节不用多说,理解上就是异步IO,把事情交给异步的一个线程去做,但是它也未必马上做,它做完再反馈,这段时间交给你的这个线程不是等待而是去做其他的事情,充分利用线程的资源,处理完反馈结果的线程也未必是开始请求的线程,几个来来回回是有很多的开销的,总体其实效率上未必有单个请求好,但是对服务器的性能发挥是非常有效的。
线程之间的开销大小也要看具体应用情况以及配置情况决定,此时将任务和线程没有做一个一对一的绑定,而是放一堆事情在队列中,处理线程也有很多,谁有时间处理谁就处理它,每个线程都做自己这一类的事情,甚至于将一些内容交给远程去做,交互后就不管了,结果反馈的时候,这边再由一个线程去处理结果请求即可。
在整个过程中会涉及到一次或多次的线程切换,这个过程中的开销在某些时候也是不小的,关键还是要看应用场景,不能一概而论。
4、内存,最后还是内存,其实这里我就不想多说了,因为前面几篇文章说得太多了,不论是理论上还是实现上,以及经验上都说了非常多,不过可以说明的一点就是内存的问题绝大部分来源于代码,而代码有很大一部分可能性来源于工程的程序员编写或者框架,第三方包的内存问题相对较少,一般被开源出来的包内存溢出的可能性不大,但是不排除有写得比较烂的代码;二方包呢,一般指代公司内部人员封装的包,如果在经过很多项目的验证可以比较放心使用,要绝对放心的话还是需要看看源码才行,至于JVM本身的BUG一般不要找到这个上面来,虽然也有这种可能性,不过这种问题除了升级JVM外也没有太多的办法,修改它的源码的可能性不大,除非你真的太厉害了(这里在内存上一般是指C或C++语言的源码,java部分的基础类包这些代码如果真的有问题,还是比较容易修改的,但还是不建议自己刻意去修改,除非你能肯定有你更好的解决方案而且是稳定有效的);在编写代码的时候将那些可以提前做的事情做了(比如这个事情以后会反复做,重复做,而且都是一样的,那么可以提前做一次,以后就不用做了),那些逻辑是可以省掉的,最后是如果你的应用很特殊是不是更好的解决方案和算法来完成。
总结下,从今天提到的系统设计的角度来说,影响QPS的最关键的东西就是模板渲染,它会占据请求的很大一部分时间,而且这个东西可以做非常大的改进,比如:压缩空白字符、重复对象的简化和模板化、大数据和重复信息的CSS化、尽量将输出转化为网络可以直接接受的内容;而其次就是如何配置线程,配置得太少,CPU的开销一直处于一种比较闲的状态,而配置得太多,CPU的征用情况比较严重,没有建议值,只要最适合应用场景的值,不过你的代码如果没有太多的同步,线程最少应该设置为CPU的格式+1或-1个;上下文切换对常规应用一般不要使用,对特殊的应用要注意中间的切换开销应该如何降低;文件输出上讲提前做的压缩提前做掉,注意控制访问频率和单次输出量;最后内存上多多注意代码,配置上只需要控制好常规的几个参数,其余的在没有特殊情况不要修改默认的配置。
扩展,那么关于一个系统的架构中是不是就这么一点就完了呢,当然不是,这应该说说出了一个常见的OLTP系统的一些常见的性能指标,但是还有很内容,比如:缓存、宕机类异常处理、session切换、IO、数据库、分布式、集群等都是这方面的关键内容,尤其是IO也是当今系统中性能瓶颈的最主要原因之一;在后续的文章中会逐步说明一些相关的解决方案。