当前位置: 首页 > 科技观察

高并发Web服务的演进——节省系统内存和CPU

时间:2023-03-21 15:16:02 科技观察

1.并发连接越来越多常态化给Web系统带来了很多挑战。最简单粗暴的解决方法就是增加web系统的机器,升级硬件配置。虽然现在的硬件越来越便宜,但是一味的增加机器数量来解决并发量的增加是非常昂贵的。结合技术优化方案是更有效的解决方案。为什么并发连接数呈指数增长?其实从近几年的用户群来看,这个数字并没有出现指数级增长,所以并不是主要原因。主要原因是网络变得更加复杂,交互更加丰富。1、页面元素增多,交互复杂。网页元素越来越多,也越来越丰富。更多的资源元素意味着更多的下载请求。Web系统的交互越来越复杂,交互场景和次数也显着增加。以“www.qq.com”首页为例,刷新一次大概有244个请求。而且,在页面打开后,还会有一些定时查询或报告请求会继续运行。现在的Http请求,为了减少重复创建和破坏连接的行为,通常会建立一个长连接(Connectionkeep-alive)。一旦建立,连接将保持一段时间,并被后续请求重用。但是,这也带来了另一个新问题。连接的维护会占用Web系统服务器的资源。如果连接没有被充分利用,会导致资源浪费。长连接建立后,第一批资源转移,之后几乎没有数据交互。长连接占用的系统资源在超时前不会自动释放。另外还有一些web需求需要长时间连接,比如Websocket。2、主流浏览器的连接数在增加。面对越来越丰富的网络资源,主流浏览器的并发连接数也在不断增加。在同一个域下,早期的浏览器一般只有1-2个下载连接。目前主流的浏览器一般都是2-6。在需要下载大量资源的场景下,增加浏览器并发连接数可以加快页面加载速度。更多的连接有利于浏览器加载页面元素,其他正常的下载连接可以继续工作,而部分连接遇到“网络拥堵”。这自然无形中增加了Web系统后端的压力,下载连接数越多,意味着占用Web服务器的资源越多。但在用户访问高峰期,由于自身发热,自然形成“高并发”场景。这些连接和请求占用了服务器大量的CPU、内存等资源。尤其是在资源超过100+的网站页面,使用更多的下载链接是非常有必要的。#p#2.Web前端优化,减轻服务器端压力要缓解“高并发”的压力,需要前后端的协同优化才能达到最佳效果。在用户登录行的Web前端,可以减少或减轻Http请求的影响。1、减少web请求的常见实现方式是通过Http协议头中的expire或max-age进行控制,将静态内容放入浏览器本地缓存,直接使用,而不是向web服务器请求一段时间时间之后的本地资源。还有HTML5中的本地存储技术(LocalStorage),也是作为强大的本地数据缓存。该方案缓存后,根本不会向web服务器发送任何请求,大大减轻了服务器的压力,带来了良好的用户体验。但是这种方案对于上网的用户来说是无效的,同时也影响了一些网页资源的实时性。2.缓解Web请求浏览器的本地缓存有一个过期时间,一旦过期,就必须重新请求服务器。这时候会出现两种情况:(1)服务器的资源内容没有更新,浏览器请求web资源,服务器回复“可以继续使用本地缓存”。(通信发生,但Web服务器只需要做一个简单的“回复”)(2)服务器的文件或内容已经更新,浏览器请求Web资源,Web服务器通过网络。(当通信发生时,Web服务器需要完成复杂的传输工作。)这里的协商方式是由Http协议的Last-Modified或Etag控制的。此时如果向请求服务器请求,如果内容没有改变,服务器会返回304NotModified。这样就不需要每次请求web服务器都做传输完整数据文件的复杂工作,只要一个简单的http响应就可以达到同样的效果。上面的请求虽然“减轻”了web服务器的压力,但是连接还是建立了,请求发生了。3.合并页面请求如果你是一个比较老的web开发者,你应该印象比较深刻,在ajax盛行之前。大部分页面都是直接输出的,没有那么多ajax请求。web后台将页面内容完整组装返回给前端。当时,页面静态是一种非常广泛的优化方法。后来逐渐被ajax取代,交互性更强,一个页面的请求也越来越多。由于手机网络(2G/3G)比PC宽带差很多,而且部分手机配置比较低,面对100多个请求的网页,加载速度会慢很多。因此,优化的方向又回到了合并页面元素,以减少请求次数:(1)合并HTML显示内容。无需链接,直接将CSS和JS嵌入到HTML页面中。(2)Ajax动态内容合并请求。对于动态内容,将10个Ajax请求合并为1个批量信息查询。(3)小图片合并,通过CSS偏移技术Sprites,将很多小图片合并为一张。这种优化方式在PC端的网页优化中也很常见。合并请求减少了数据传输的次数,这相当于将它们从一个请求更改为一个“批量”请求。上述优化方法达到了“减轻”Web服务器压力的目的,减少了需要建立的连接数。#p#三、节省Web服务器的内存前端优化完成后,我们需要关注的是Web服务器本身。内存对于web服务器来说是非常重要的资源,内存越大通常意味着可以同时放置更多的任务。就Web服务占用的内存而言,大致可以分为:(1)用于维持连接的基本内存。进程初始化时,会加载一些基本模块到内存中。(2)将传输数据的内容加载到各个缓冲区中,占用内存。(3)程序执行过程中申请和使用的内存。如果保持一个连接能够占用尽可能少的内存,那么我们就可以保持更多的并发连接,这样web服务器就可以支持更多的并发连接。Apache(httpd)是一个成熟而古老的Web服务,Apache的发展和演化一直在追求这一点。它试图不断减少服务占用的内存以支持更大的并发。我们从Apache工作模式的演进来看,看看他们是如何优化内存的。1.preforkMPM,多进程工作模式prefork是Apache最成熟稳定的工作模式,至今仍在广泛使用。主进程生成后,首先完成基本的初始化工作,然后通过fork预生成一批子进程(子进程会复制父进程的内存空间,不需要做基本的初始化工作).然后等待服务。之所以预先生成,是为了减少频繁创建和销毁进程的开销。多进程的好处是进程间的内存数据不会互相干扰,同时一个进程的异常终止也不会影响其他进程。但是在内存方面,每个httpd子进程占用的内存都很大,因为子进程的内存数据是从父进程复制过来的。我们可以粗略地认为内存中放置了大量的“重复数据”。最后,我们可以产生的子进程数量非常有限。面对高并发,因为有很多Keep-alive长连接,这些子进程被“占用”,可能导致可用子进程耗尽。所以prefork不太适合高并发场景。优点:成熟稳定,兼容所有新旧模块。同时也无需担心线程安全问题。(比如我们常用的mod_php,将PHP编译成Apache的子模块,不需要支持线程安全)缺点:一个服务进程占用内存大。2.workerMPM,多进程多线程的混合模式。worker模式与prefork相比,采用了多进程多线程的混合模式。它还会预先fork几个子进程(数量很少),然后每个子进程创建一些线程(包括一个监听线程)。每个请求都会分配给一个线程来服务。线程比进程轻,因为线程通常共享父进程的内存空间,所以内存使用会减少。在高并发场景下,因为比prefork更节省内存,可用线程会更多。但是并没有解决Keep-alive的长连接“占用”线程的问题,而是对象变成了一个相对轻量级的线程。有的人会觉得奇怪,那这里为什么不完全使用多线程,还要引入多进程呢?因为还要考虑稳定性,如果一个线程挂了,会导致同进程下其他正常的子线程挂掉。如果全部采用多线程,如果一个线程挂掉,整个Apache服务就会“全军覆没”。但在目前的工作模式下,受影响的只是Apache的部分服务,而不是整个服务。线程共享父进程的内存空间,减少了内存占用,但又引发了新的问题。它是“线程安全”。多线程修改共享资源造成的“竞争行为”也迫使我们使用的模块支持“线程安全”。因此,在一定程度上增加了Web服务的不稳定性。比如mod_php使用的PHP扩展也需要支持“线程安全”,否则无法在该模式下使用。优点:占用内存少,高并发下性能更好。缺点:必须考虑线程安全问题,锁的引入增加了CPU开销。3.eventMPM,多进程多线程混合模式,引入Epoll,是Apache中比较新的模式,在当前版本(Apache2.4.10)中已经是稳定可用的模式。和worker模式很像,最大的区别是解决了keep-alive场景下线程长期占用的资源浪费问题。在eventMPM中,会有专门的线程来管理这些保活线程。当真正的请求到来时,该请求会被传递给服务线程,执行完毕后释放。减少了“占用”连接而不使用连接的资源浪费,增强了高并发场景下的请求处理能力。因为减少了“空闲等待”的线程数,减少了线程数,同样的场景下内存占用也会减少。eventMPM在遇到一些不兼容的模块时会失败,并会退回到工作模式,一个工作线程处理一个请求。新版Apache的官方模块都支持eventMPM。注意eventMPM需要Linux系统(Linux2.6+)支持EPoll才可以启用。在Apache的三种模式中,eventMPM在实际应用场景中是内存效率最高的。4.使用相对轻量级的Nginx作为web服务器。Apache的不断优化虽然降低了内存占用,但是增加了处理高并发的能力。然而,如前所述,Apache是一个古老而成熟的Web服务。同时集成了很多稳定的模块,是一个比较重的web服务。Nginx是一个相对轻量级的web服务,自然比Apache占用更少的内存。此外,Nginx通过一个进程为N个连接提供服务。采用的方式是不增加Apache的进程/线程来支持更多的连接数。对于Nginx,它创建的进程/线程更少,减少了大量的内存开销。根据静态文件的QPS性能压测结果,Nginx处理静态文件的性能大约是Apache的3倍。对于PHP等动态文件的QPS,Nginx通常使用FastCGI与PHP-FPM通信,PHP作为一个无关的对外服务存在。而Apache通常会将PHP编译成自己的word模块(新版Apache也支持FastCGI)。对于PHP动态文件,Nginx的性能略逊于Apache。5.sendfile节省内存Apache、Nginx和许多其他Web服务都有sendfile支持。sendfile可以减少向“用户态内存空间”(userbuffer)复制数据,从而减少内存占用。当然,很多同学的第一反应当然是问Why?为了尽可能清楚地解释这个原理,我们先回到Linux内核态的存储空间与用户态的交互上来。一般情况下,用户态(也就是我们程序所在的内存空间)不会直接读写或操作各种设备(磁盘、网络、终端等),内核通常作为“中间人”来完成对设备的操作或读写。以最简单的磁盘读写为例,从磁盘读取A文件,写入B文件。一个文件数据从磁盘开始,然后加载到“内核缓冲区”,再复制到“用户缓冲区”,我们就可以对数据进行处理了。写的时候也是一样,从“用户态缓冲区”加载到“内核缓冲区”,最后写到B盘文件。这样写文件很累,所以有人认为这里可以跳过“用户缓冲区”的拷贝。其实这就是MMP(Memory-Mapping,内存映射)的实现。建立了磁盘空间和内存之间的直接映射。数据不再复制到“用户模式缓冲区”,而是返回一个指向内存空间的指针。因此,我们之前读写文件的例子就会变成,A文件数据从磁盘加载到“内核缓冲区”,然后从“内核缓冲区”复制到B文件的“内核缓冲区”,而B文件然后从“内核缓冲区”写回磁盘。在这个过程中减少了一次内存拷贝,内存占用也减少了。好了,回到sendfile的话题,简单来说,sendfile的方法和MMP类似,就是减少数据从“内核态缓冲区”到“用户态缓冲区”的内存拷贝。从读取磁盘文件到传输到套接字(不使用sendfile)的默认过程是:使用sendfile后:这种方法不仅节省内存,而且有CPU开销。#p#四、节省Web服务器的CPU对于Web服务器来说,CPU是另一个非常核心的系统资源。虽然一般来说,我们认为业务程序的执行会消耗我们的主CPU。但是,对于Web服务程序来说,多线程/多进程的上下文切换也比较消耗CPU资源。一个进程/线程通常不能长时间占用CPU。当发生阻塞或时间片用完时,不能继续占用CPU。这时会发生上下文切换,CPU时间片从旧的进程/线程切换到新的进程/线程。另外,在并发连接数较高的场景下,轮询和检测这些用户建立的连接(socket文件描述符)状态也会消耗CPU。Apache和Nginx的发展和演化也在努力降低CPU开销。1、Select/Poll(Apache早期版本中的I/O多路复用)通常,Web服务要维护很多与用户通信的socket文件描述符,而I/O多路复用其实就是为了方便这些文件描述符的管理和检测.Apache的早期版本使用的是选择模式。简单的说,就是把我们关心的socket文件描述符交给内核,让内核告诉我们那些描述符是可操作的。Poll和select的原理基本相同,所以放在一起就不详细介绍它们的区别了。select/poll返回的是我们之前提交的文件描述符集合(内核修改socket文件描述符的可读、可写、异常的标志位),我们需要通过轮询检查才能得到我们可以操作的文件描述符文件描述符。在这个过程中,不断重复。在实际的应用场景中,我们监测到的socket文件描述符大多是“空闲”的,即无法操作。我们轮询整个集合,以便找到我们可以操作的少量套接字文件描述符。因此,当我们监控的socket文件描述符越多(并发用户连接数越来越多)时,轮询的工作就会越来越重,从而增加CPU的开销。如果我们监控的套接字文件描述符几乎都是“活跃的”,那么使用这种模式是比较合适的。2、epoll(新版ApacheeventMPM、Nginx等支持)Epoll是Linux2.6正式支持的I/O多路复用。我们可以理解为对select/poll的改进。首先,我们也告诉内核我们关心的套接字文件描述符集合,同时为它们注册“回调函数”。如果套接字文件准备就绪,我们将通过回调函数得到通知。因此,我们不需要轮询整套套接字文件描述符,可以直接获取已经可操作的套接字文件描述符。然后,我们不遍历大部分“空闲”描述符。即使我们监控的套接字文件描述越来越多,轮询的也只是“活跃的、可操作的”套接字文件描述符。其实有一种极端的场景,就是我们的文件描述符几乎都是“活跃的”,这就导致大量回调函数的执行,增加了CPU的开销。然而,在Web服务的真实场景中,大多数时候,连接集合中存在着很多“空闲”的连接。3.线程/进程的创建、销毁和上下文切换通常,Apache在一定时间内有一个进程/线程为一个连接服务。因此,Apache有很多进程/线程,服务于很多连接。在web服务的高峰期,会建立很多进程/线程,会带来大量的上下文切换开销。在Nginx中,通常只有一个master主进程和若干个worker子进程。那么,一个工作进程服务于多个连接,从而节省了CPU上下文切换的开销。两种模式虽然不同,但不能直接判断其好坏。总的来说,各有各的优势,就不争了。4、多线程下锁的CPU开销Apache中的worker和event模式都使用了多线程。因为多线程共享父进程的内存空间,在访问共享数据时,会存在竞争,这是一个线程安全问题。因此,通常会引入锁(Linux下常用的线程相关锁有mutexmetux、读写锁rwlock等),成功获取到锁的线程可以继续执行,获取不到的线程通常会选择block和等待。锁机制的引入往往会使程序的复杂度增加很多,同时也存在线程“死锁”或“饥饿”的风险(多个进程在进程间访问共享资源时也存在同样的问题)。死锁现象(两个线程锁住对方想要获取的资源,互相阻塞等待,永远满足不了满足条件):饥饿现象(一个线程一直无法获取到自己想要锁住的资源,andcanneverExecutethenextstep):为了避免这些锁带来的问题,不得不增加程序的复杂度。解决方案一般包括:(1)锁定资源。按照约定的顺序,大家先给共享资源X加锁,加锁成功后共享资源Y才能加锁。(2)如果线程占用了资源X,但未能锁定资源Y,则放弃锁,同时释放之前占用的资源X。在使用PHP时,还必须兼容Apache的worker和event模式中的线程安全。通常新版PHP官方库没有线程安全问题,需要注意的是第三方扩展。PHP实现线程安全,不是通过锁。而是每个线程独立申请一个全局变量的副本,相当于线程的私有内存空间,但是比较消耗内存。不过这样做的好处是不需要引入复杂的锁机制实现,同时也避免了锁机制对CPU的开销。顺便提一下,这里经常和Nginx配合使用的PHP-FPM(FastCGI),使用的是多进程,所以不会有线程安全问题。5.总结可能有的同学看完之后会得出这样的结论,Nginx+PHP-FPM的工作方式貌似是最节省资源的Web系统工作方式。从某种程度上来说确实如此,但是Web系统的建设需要从实际业务应用的角度出发,需要具体问题具体分析,找到最适合的技术方案。Web服务的不断演进和发展,力求用尽可能少的系统资源来支持更多的用户请求,是一条宏伟的前进道路。这些技术方案汇集了很多解决问题的思路,值得学习和借鉴。