明确了性能出现的瓶颈后,我们需要对出现的瓶颈有所针对的优化,包括代码实现,开发框架/组件,基础应用软件,操作系统,硬件基础设施等。
图片本文主要对性能出现瓶颈,软件方面的优化进行梳理。
软件层面的优化涉及的内容较多,本文以Java语言为例,主要讲解几种常见的性能优化手段,并举一些生活化的例子来使大家更容易理解。注意性能优化手段并不一定是独立应用的,在一次优化过程中很可能应用了多种优化技巧。
一、减少请求次数和压缩静态资源文件大小随着业务和应用架构的发展,系统间的调用关系变得越来越复杂。客户端、服务、中间件、数据库之间有着复杂的依赖关系,如果不加以管控,放任它们随意调用,会导致部分模块负载过高,影响系统整体性能,严重的可能导致雪崩效应。
接下来围绕减少请求次数以及压缩静态资源文件大小的目标,列举一些常见的优化手段。
1)减少客户端请求静态资源的次数。通过在浏览器或App端进行本地缓存,减少客户端的请求次数。
2)压缩资源文件大小。3)采用CDN技术。4)减少请求服务的次数。对于一些需要实时刷新的客户端页面,通过降低刷新频率来减少对服务端的请求次数。随着业务的不断更新迭代,系统可能会产生一些冗余的逻辑,造成额外的、不必要的对服务端的请求。对于这些请求,需要结合业务进行优化。
5)减少访问数据库的次数。例如,当查询100个订单的详情时而一次性从数据库中查询出所有订单的详情,对应的性能情况要好于每次只查询1个订单详情而循环查询100次的方案。后者并没有减少数据库的工作量,但会在获取和释放数据库连接上白白损耗很多性能。对于一些固定不变的数据,或者对新鲜度要求不高的数据,没必要每次都从数据库查询,可以考虑将其缓存到Redis或服务的本地内存中,从而减少对数据库的压力。
6)通过Redis批命令或管道减少交互次数。减少调用次数的核心目的主要有两个:一个是减少无效调用,为服务端减压;另一个是将多次调用合并为一次调用,减少连接和网络损耗。不管是应用之间的调用,还是应用与数据库之间的调用,都会涉及建立连接、释放连接之类的操作。即使使用了连接池之后不用每次都新建连接,从池内获取连接和归还连接时也会发生损耗。建立连接后需要通过诸如TCP、HTTP的网络通信协议进行网络传输,每个消息都会有自己的消息头和消息体,合并调用后可以减少消息头所占的带宽和解析消息头带来的损耗。
二、对象轻量化随着公司业务的不断选代以及人员的不断流动,系统和业务会变得极其复杂。如果没有一个良好的设计,就往往会导致一种情况:对于之前设计的实体类,由于接手的开发人员对原先的业务和系统设计并不熟悉,所以他通常不会在原先的字段上去做调整,只是简单粗暴地加上自己的字段。如果该实体类是公用的,那他通常会继承该实体类,然后加上自己的扩展字段,而这些新增的字段很可能不是简单的基本类型,而是复杂的自定义类型。久而久之,使用的实体类越来越臃肿。每创建一个实例都会占用更多的内存,导致更为频繁的GC,网络传输时也会占用更多的带宽。
要解决该问题,就要从设计时就进行严格的把控,使对象轻量化,可以显著改善GC频繁和带宽占用高的情况。
三、对象复用我们可以借助连接池、线程池和单例模式来实现对象复用的优化思路。
连接池的基本思想是在系统初始化的时候将数据库连接作为对象存储在内存中,当用户需要访问数据库的时候,并非建立一个新的连接,而是从连接池中取出一个已建立的空闲连接对象。在使用完毕后,用户也不是将连接关闭,而是将连接放回到连接池中,供下一个请求访问使用。这些连接的建立、断开都由连接池自身来管理。同时,还可以设置连接池的参数来控制连接池中的初始连接数、连接的上下限数,以及每个连接的最大使用次数、最大空闲时间等。当然,也可以通过连接池自身的管理机制来监视连接的数量、使用情况等。
在线程池中可先启动若干数量的线程,这些线程都处于睡眠状态。当客户端有一个新的请求时,就会嗅醒线程池中某一个睡眠的线程,让它来处理客户端的这个请求,当处理完这个请求之后,线程又处于睡眠的状态。线程池能大幅提升程序的性能。比如有一个促销活动,预估高峰期每个服务节点每秒需要承载100个请求以上,如果为每个客户端请求创建一个新的线程的话,那耗费的CPU时间和内存都是十分惊人的,如果采用一个拥有100~200个线程的线程池,那将会节约大量的系统资源,使得更多CPU时间和内存能用来支撑实际的商业应用,而不是浪费在频繁的线程创建、销毁以及上下文切换中。
与线程池相关的参数如下。
ThreadPoolExecutor:核心参数。
corePoolSize:线程池中常驻核心线程数:
maximumPoolSize:线程池能够容纳的同时执行的最大线程数,值必须大于1。
keepAliveTime:多余空闲线程的存活时间。若当前线程池数量超过corePoolSize,keepAliveTime时,多余空闲线程会被销毁,直到剩下corePoolSize为止。
unit:keepAliveTime的单位。
workQueue:存放已被提交但是尚未执行的任务。注意,当核心线程都繁忙时,请求会先进入等待队列,当等待队列也满了,才会继续增加线程,直至达到最大线程数。
threadFactory:表示线程池中工作线程的线程工厂,用于创建线程。
handler:拒绝策略,当队列满了并且工作线程数大于或等于maximumPoolSize时,则通过handler拒绝任务。对于应用服务、请求、连接池和线程池的关系,我们举个生活化的例子帮助读者理解。应用服务相当于饭店,请求相当于顾客,连接池相当于服务员,线程池相当于厨师。顾客想要进店吃饭需要先等待服务员叫号,这就像等待空位的过程;顾客被叫到号就相当于客户端与服务端之间建立了连接;顾客由服务员安排座位,再将自己的点餐需求(请求报文)告知服务员,由服务员转给厨师按照不同客户的需求进行制作(解析报文并进行业务逻辑处理的过程);菜品制作完成后再由服务员上菜(返回报文),这样就完成了“请求一建立连接→将请求转给工作线程进行处理→响应”的过程。了解设计模式的读者,一定听说过单例模式,这其实也是一种对象复用的应用场景。类似的应用场景还有Spring单例Bean。默认情况下,Spring容器只会为每个Bean生成一个实例。如果一个重量级的实例在构造时既耗时又耗资源,那就要考虑它是否适合采用单例模式。我们可以通过单例模式来节省不断构造实例产生的消耗,减少内存占用,降低GC频率,从而提升系统性能。
四、IO优化如前文所述,在CPU、内存、磁盘IO、网络IO这4项最核心的硬件资源指标中最容易成为瓶颈的是磁盘IO,所以提升磁盘IO的速度对提升系统整体性能有很大的好处。NIO替代IO是常见的IO优化手段。
将基于流的IO实现(以字节为单位处理数据)改为基于块的IO实现,增加一个缓冲区,可以大大增加IO性能。例如将InputStream和OutputStream改为使用BufferinputStream和BufferOutputStream,可以大大减少访问硬盘的次数。由于访问硬盘设备对CPU而言会有个用户态切换内核态的过程,这类切换都是有损耗的,因此减少访问硬盘的次数也可以减少用户态和内核态的切换次数,使磁盘IO速度得到大幅提升。
五、异步如果返回结果不是必须的,可以采用异步调用的方式。如果希望拿到异步调用的结果,可以使用轮询和回调两种方式。所谓轮询就是调用方不断去询问被调用方是否执行完成,需要合理设置轮询的频率。如果轮询频率过高会消耗一定CPU资源,轮询频率过低又会导致响应时间过长或数据不够新鲜。所谓回调则是调用方实现一个方法,让被调用方执行完成后主动来调用这个方法。回调的优势是在调用完成后,可以先不受阻塞地处理一些不依赖回调结果的业务逻辑,然后来等待回调结果。
六、并行为了发挥出多核CPU的优势,多线程并行化处理也是一种常见的优化策略,可以在很大程度上提升响应时间和系统吞吐量。
在这里需要提到并行和并发这两个概念的区别:并行(Paralel)是绝对意义上的同时处理,而并发(Concurrent)实质上是交替切换处理的,是一种宏观上并行、微观上串行的模式。
并发和并行的区别如下图所示。
图片CPU会为处于可运行状态的线程分配时间片,分到时间片的线程开始运行,并在时间片结束后通过程序计数器保存当前方法栈信息以及当前正在运行的方法中各个变量的中间结果。然后轮到下一个分到时间片的线程,这个线程如果是之前执行过的线程,那就需要先恢复上次运行的方法栈,将各个变量的值恢复到上一次的状态,继续运行,如此循环往复。这个保存现场和恢复现场的过程叫作线程上下文切换,频繁的切换会造成较大的资源浪费。
对于IO密集型的应用,因为它们很多时候都在等IO,需要让出时间片给需要CPU资源的线程。但如果是计算密集型的应用,频繁的上下文切换反而会导致系统吞吐量降低。
七、缓存缓存也是用来提升性能的常见手段,缓存的本质就是通过存取速度更快的介质(通常是内存)来替代或降低访问存取较慢的介质。例如处理器级别的缓存有CPU寄存器、一级缓存、二级缓存、三级缓存,它们从左往右效率逐级递减,容量逐级增加。处理器级别的缓存不由应用程序控制,本书不对其进行重点讨论。在处理器之外还有内存与磁盘这两种存储介质,内存拥有相对较快的存取速度,当然远远慢于寄存器和一级、二级、三级缓存,但又远快于磁盘。内存相对磁盘而言也有缺点,与处理器级别的缓存一样,它无法持久化,一旦服务进程重启或者服务器重启,内存数据就会丢失。磁盘虽然存取速度很慢,但是可以持久化地保存数据。
在应用程序层面,常见的缓存有本地内存和Redis、Memcache等。
将本地内存作为缓存的优点是不需要经过网络,也没有序列化/反序列化的损耗,可以随取随用。它的缺点是分布式架构下的每个服务节点都需要在内存中缓存一份,这会消耗大量内存,也可能会导致更频繁的GC,而且会涉及不同节点间的缓存同步问题。如果不进行缓存同步操作,则会导致缓存命中率降低。
当然,消耗内存多的问题也可以通过一些手段进行优化。以订单查询系统为例,可以对uid计算hash值并取余,假设订单服务集群有8个节点,先使hash值除以8再取余数,再根据余数的取值将不同用户的请求分发到特定的服务节点进行处理。这样每个节点只需要缓存一部分用户的数据即可。但是这么做也有风险,有可能余数为1的用户订单量普遍比余数为2的用户要多,那就会导致服务节点负载不均。这就需要通过更复杂的算法对用户进行更合理的划分,使得集群中各节点的负载能更均匀。
使用Redis可以规避本地内存作为缓存的上述缺点,并且通常来说更节省内存资源,可以提升缓存的命中率,但缺点是多了网络传输和序列化/反序列化的消耗。此外,Redis适合缓存小对象,做一些短平快的操作,如果用来缓存大对象则会导致性能急剧下降。
阅读后如有收获,不吝关注,分享,留言评论等操作,我喜欢!!!