一、概述
高性能是每个程序员的追求,无论做一个系统、还是写一组代码,都希望能够达到高性能的效果。而高性能又是最复杂的一环,磁盘、操作系统、CPU、内存、缓存、网络、编程语言、数据库、架构等,每个都可能影响系统的高性能,一行不恰当的 debug 日志,一个不合适的索引,都可能将服务器的性能从 3 万 TPS 降低到 8 千 TPS,一个 tcp_nodelay 参数,可能将相应时间从 2ms 延长到 40ms。因此,要做到高性能计算是一件很复杂很有挑战的事情,软件系统开发过程中的不同阶段都关系着高性能最终是否能够实现。
高性能架构设计的设计点在哪里?
(1)尽量提升单服务器的性能,将单服务器的性能发挥到极致。
(2)如果单服务器无法支撑性能,设计服务器集群方案。
(3)具体的实现及编码,架构设计决定了系统性能的上限,实现细节决定了系统性能的下限。
高性能架构设计导图:
二、单服务器高性能
单服务器高性能的关键点是什么?
(1)服务器如何管理连接(I/O 模型:阻塞、非阻塞,同步、异步)。
(2)服务器如何处理请求(进程、多进程、多线程)。
单服务器高性能模式有哪些?
模式 | 简要说明 |
PPC | 每次有新的连接就新建一个进程去专门处理这个连接的请求。改进版是prefork,预先创建进程。 |
TPC | 每次有新的连接就新建一个线程去专门处理这个连接的请求。改进版是prethread,预先创建线程。 |
Reactor | 即Dispatcher模式,非阻塞同步网络模型, I/O 多路复用统一监听事件,收到事件后分配(Dispatch)给某个进程或线程。 |
Proactor | 是异步网络模型,在异步事件完成的时候能够支持多事件处理程序的复用和分发。 |
2.1 PPC
PPC 是 Process Per Connection 的缩写,每次有新的连接就新建一个进程去专门处理这个连接的请求,这是传统的 UNIX 网络服务器所采用的模型。基本的流程图是:
说明:父进程接受连接(图中 accept),父进程“fork”子进程(图中 fork),子进程处理连接的读写请求(图中子进程 read、业务处理、write),子进程关闭连接(图中子进程中的 close)。
注意:父进程“fork”子进程后,直接调用了 close,看起来好像是关闭了连接,其实只是将连接的文件描述符引用计数减一,真正的关闭连接是等子进程也调用 close 后,连接对应的文件描述符引用计数变为 0 后,操作系统才会真正关闭连接。
PPC模式实现简单,比较适合服务器的连接数没那么多的情况,例如数据库服务器。对于普通的业务服务器,在互联网兴起之前,由于服务器的访问量和并发量并没有那么大,这种模式其实运作得也挺好,世界上第一个web服务器CERN httpd就采用了这种模式(具体你可以参考https://en.wikipedia.org/wiki/CERN_httpd)。互联网兴起后,服务器的并发和访问量从几十剧增到成千上万,这种模式的弊端就凸显出来了,主要体现在这几个方面:
fork代价高:站在操作系统的角度,创建一个进程的代价是很高的,需要分配很多内核资源,需要将内存映像从父进程复制到子进程。即使现在的操作系统在复制内存映像时用到了Copy on Write(写时复制)技术,总体来说创建进程的代价还是很大的。
父子进程通信复杂:父进程“fork”子进程时,文件描述符可以通过内存映像复制从父进程传到子进程,但“fork”完成后,父子进程通信就比较麻烦了,需要采用IPC(Interprocess Communication)之类的进程通信方案。例如,子进程需要在close之前告诉父进程自己处理了多少个请求以支撑父进程进行全局的统计,那么子进程和父进程必须采用IPC方案来传递信息。
支持的并发连接数量有限:如果每个连接存活时间比较长,而且新的连接又源源不断的进来,则进程数量会越来越多,操作系统进程调度和切换的频率也越来越高,系统的压力也会越来越大。因此,一般情况下,PPC方案能处理的并发连接数量最大也就几百。
基于fork代价高的弊端,出现了改进版prefork,系统在启动的时候就预先创建好进程,然后才开始接受用户的请求,当有新的连接进来的时候,就可以省去 fork 进程的操作,让用户访问更快、体验更好。prefork 的基本示意图是:
prefork 的实现关键就是多个子进程都 accept 同一个 socket,当有新的连接进入时,操作系统保证只有一个进程能最后 accept 成功。但存在一个“惊群”现象。
惊群:指虽然只有一个子进程能 accept 成功,但所有阻塞在 accept 上的子进程都会被唤醒,这样就导致了不必要的进程调度和上下文切换(Linux 2.6 版本后内核已经解决了 accept 惊群问题)
prefork 模式和 PPC 一样,还是存在父子进程通信复杂、支持的并发连接数量有限的问题,因此目前实际应用也不多。
Apache 服务器提供了 MPM prefork 模式,推荐在需要可靠性或者与旧软件兼容的站点时采用这种模式,默认情况下最大支持 256 个并发连接。
2.2 TPC
TPC 是 Thread Per Connection 的缩写,其含义是指每次有新的连接就新建一个线程去专门处理这个连接的请求。
与进程相比,线程更轻量级,创建线程的消耗比进程要少得多;同时多线程是共享进程内存空间的,线程通信相比进程通信更简单。
TPC 实际上是解决或者弱化了 PPC fork 代价高的问题和父子进程通信复杂的问题。
说明:父进程接受连接(图中 accept),父进程创建子线程(图中 pthread),子线程处理连接的读写请求(图中子线程 read、业务处理、write),子线程关闭连接(图中子线程中的 close)。
注意:和 PPC 相比,主进程不用close连接了。原因是在于子线程是共享主进程的进程空间的,连接的文件描述符并没有被复制,因此只需要一次 close 即可。
TPC 虽然解决了 fork 代价高和进程通信复杂的问题,但是也引入了新的问题,具体表现在:
(1)创建线程虽然比创建进程代价低,但并不是没有代价,高并发时还是有性能问题;
(2)无需进程间通信,但是线程间的互斥和共享又引入了新的复杂度,可能一不小心就导致了死锁问题;
(3)多线程会出现互相影响的问题,某个线程出现异常时,可能导致整个进程退出;
除了引入了新的问题,TPC 还是存在 CPU 线程调度和切换代价的问题。
TPC 方案本质上和 PPC 方案基本类似,在并发几百连接的场景下,反而更多地是采用 PPC 的方案,因为 PPC 方案不会有死锁的风险,也不会多进程互相影响,稳定性更高。
针对 TPC 创建线程需要代价的弊端,仿照 prefork 方式,衍生出了 prethread 方式,prethread 模式会预先创建线程,然后才开始接受用户的请求,当有新的连接进来的时候,就可以省去创建线程的操作,让用户感觉更快、体验更好。MySql 采用的 prethread 方式。
由于多线程之间数据共享和通信比较方便,因此实际上 prethread 的实现方式相比 prefork 要灵活一些,常见的实现方式有下面几种:
(1)主进程 accept,然后将连接交给某个线程处理。
(2)子线程都尝试去 accept,最终只有一个线程 accept 成功,方案的基本示意图如下:
Apache 服务器的 MPM worker 模式本质上就是一种 prethread 方案,但稍微做了改进。Apache 服务器会首先创建多个进程,每个进程里面再创建多个线程,这样做主要是为了考虑稳定性,即:即使某个子进程里面的某个线程异常导致整个子进程退出,还会有其他子进程继续提供服务,不会导致整个服务器全部挂掉。
高并发需要根据两个条件划分:连接数量,请求数量。
-
海量连接(成千上万)海量请求:例如抢购,双十一,12306等
-
常量连接(几十上百)海量请求:例如中间件
-
海量连接常量请求(QPS过千):例如门户网站
-
常量连接常量请求(QPS几十几百):例如内部运营系统,管理系统
PPC和TPC对那些吞吐量比较大,长连接且连接数不多的系统应该比较适用。
bio:阻塞io,PPC和TPC属于这种 nio:多路复用io,reactor基于这种技术 aio:异步io,Proactor基于这种技术
I/O多路复用就是通过一种机制,一个进程/线程可以监视多个连接,一旦某个连接就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。
IO复用就是同时等待多个文件描述符就绪,以系统调用的形式提供。如果所有文件描述符都没有就绪的话,该系统调用阻塞,否则调用返回,允许用户进行后续的操作。
当多条连接共用一个阻塞对象后,进程只需要在一个阻塞对象上等待,而无须再轮询所有连接,常见的实现方式有 select、epoll、kqueue 等。
epoll是Linux下的一种IO多路复用技术,可以非常高效的处理数以百万计的socket句柄。epoll事先通过epoll_ctl()来注册一 个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知(相比select去掉了遍历文件描述符,而是通过监听回调的的机制)
select/poll是收到通知后轮询socket列表看看哪个socket可以读,普通的socket轮询是指重复调用read操作。
epoll、select 两者区别:
(1)select的句柄数目受限,在linux/posix_types.h头文件有这样的声明:#define __FD_SETSIZE 1024 ,表示select最多同时监听1024个fd,而epoll没有,它的限制是最大的打开文件句柄数目。
(2)epoll的最大好处是不会随着FD的数目增长而降低效率,在selec中采用轮询处理,其中的数据结构类似一个数组的数据结构,而epoll是维护一个队列,直接看队列是不是空就可以了。epoll只会对"活跃"的socket进行操作(在内核实现中epoll是根据每个fd上面的callback函数实现的),只有"活跃"的socket才会主动的去调用 callback函数(把这个句柄加入队列),其他idle状态句柄则不会。
如果没有大量的idle -connection或者dead-connection,epoll的效率并不会比select/poll高很多
(3)使用mmap加速内核与用户空间的消息传递。无论是select/poll还是epoll都需要内核把fd消息通知给用户空间,如何避免不必要的内存拷贝就很重要,在这点上,epoll是通过内核与用户空间mmap同一块内存实现的。
2.3 Reactor
I/O 多路复用结合线程池,完美地解决了 PPC 和 TPC 的问题,而且给它取了一个很牛的名字:Reactor。Reactor 模式也叫 Dispatcher 模式(在很多开源的系统里面会看到这个名称的类,其实就是实现 Reactor 模式的),更加贴近模式本身的含义,即 I/O 多路复用统一监听事件,收到事件后分配(Dispatch)给某个进程。
Reactor 模式的核心组成部分包括 Reactor 和处理资源池(进程池或线程池),其中 Reactor 负责监听和分配事件,处理资源池负责处理事件。
结合不同业务场景,模式具体实现方案可以灵活多变,如:
-
单 Reactor 单进程(线程);
-
单 Reactor 多线程;
-
多 Reactor 多进程(线程);
-
多Reactor 单进程/线程(无意义);
以上方案具体选择进程还是线程,更多地是和编程语言及平台相关。例如,Java 语言一般使用线程(例如,Netty),C 语言使用进程和线程都可以。例如,Nginx 使用进程,Memcache 使用线程。
2.3.1单Reactor单进程(线程)
可以看到进程里有 Reactor、Acceptor、Handler 这三个对象:
-
Reactor 对象的作用是监听和分发事件;
-
Acceptor 对象的作用是获取连接;
-
Handler 对象的作用是处理业务;
select、accept、read、send 是标准的网络编程 API,dispatch 和业务处理是需要完成的操作。
过程说明:
Reactor 对象通过 select 监控连接事件,收到事件后通过 dispatch 进行分发;
如果是连接建立的事件,则由 Acceptor 处理,Acceptor 通过 accept 接受连接,并创建一个 Handler 来处理连接后续的各种事件;
如果不是连接建立事件,则 Reactor 会调用连接对应的 Handler(第 2 步中创建的 Handler)来进行响应,Handler 会完成 read-> 业务处理 ->send 的完整业务流程;
优点:模式很简单,没有进程间通信,没有进程竞争;
缺点:只有一个进程,无法发挥多核 CPU 的性能;Handler 在处理某个连接上的业务时,整个进程无法处理其他连接的事件,很容易导致性能瓶颈。
适用场景:只适用于业务处理非常快速的场景,目前比较著名的开源软件中使用单 Reactor 单进程的是 Redis。
Redis6.0 中也使用了多线程,但是基本设计思路还是按单进程、单线程的方式处理,只是把 read、解析处理、write 做了多线程处理,命令的执行还是单线程方式。
2.3.2单Reactor多线程
过程说明:
主线程中Reactor对象通过select监控连接事件,收到事件后通过dispatch进行分发;
如果是连接建立的事件,则由Acceptor处理,Acceptor 通过 accept 接受连接,并创建一个 Handler 来处理连接后续的各种事件;
如果不是连接建立事件,则 Reactor 会调用连接对应的 Handler(第 2 步中创建的 Handler)来进行响应;
Handler 只负责响应事件,不进行业务处理;Handler 通过 read 读取到数据后,会发给 Processor 进行业务处理;
Processor 会在独立的子线程中完成真正的业务处理,然后将响应结果发给主进程的 Handler 处理;Handler 收到响应后通过 send 将响应结果返回给 client;
虽然克服了克服单 Reactor 单进程 / 线程方案的缺点,能够充分利用多核 CPU 的处理能力,但同时也存在下面的问题:
(1)多线程数据共享和访问比较复杂,涉及共享数据的互斥和保护机制;
(2)Reactor 承担所有事件的监听和响应,只在主线程中运行,瞬间高并发时会成为性能瓶颈;
为什么没有单 Reactor 多进程方案:
如果采用多进程,子进程完成业务处理后,将结果返回给父进程,并通知父进程发送给哪个 client,这是很麻烦的事情。因为父进程只是通过 Reactor 监听各个连接上的事件然后进行分配,子进程与父进程通信时并不是一个连接。如果要将父进程和子进程之间的通信模拟为一个连接,并加入 Reactor 进行监听,则是比较复杂的。而采用多线程时,因为多线程是共享数据的,因此线程间通信是非常方便的。虽然要额外考虑线程间共享数据时的同步问题,但这个复杂度比进程间通信的复杂度要低很多。
2.3.3多 Reactor 多进程 / 线程
为了解决单 Reactor 多线程的问题,最直观的方法就是将单 Reactor 改为多 Reactor:
过程说明:
(1)父进程中 mainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 接收,将新的连接分配给某个子进程;
(2)子进程的 subReactor 将 mainReactor 分配的连接加入连接队列进行监听,并创建一个 Handler 用于处理连接的各种事件;
(3)当有新的事件发生时,subReactor 会调用连接对应的 Handler(即第 2 步中创建的 Handler)来进行响应,Handler 完成 read→业务处理→send 的完整业务流程;
多 Reactor 多进程 / 线程的方案看起来比单 Reactor 多线程要复杂,但实际实现时反而更加简单,主要原因是:
(1)父进程和子进程的职责非常明确,父进程只负责接收新连接,子进程负责完成后续的业务处理。
(2)父进程和子进程的交互很简单,父进程只需要把新连接传给子进程,子进程无须返回数据。
(3)子进程之间是互相独立的,无须同步共享之类的处理(这里仅限于网络模型相关的 select、read、send 等无须同步共享,“业务处理”还是有可能需要同步共享的)
目前著名的开源系统 Nginx 采用的是多 Reactor 多进程,采用多 Reactor 多线程的实现有 Memcache 和 Netty。
Nginx 采用的是多 Reactor 多进程的模式,但方案与标准的多 Reactor 多进程有差异。具体差异表现为主进程中仅仅创建了监听端口,并没有创建 mainReactor 来“accept”连接,而是由子进程的 Reactor 来“accept”连接,通过锁来控制一次只有一个子进程进行“accept”,子进程“accept”新连接后就放到自己的 Reactor 进行处理,不会再分配给其他子进程
2.4 Proactor
Reactor 是非阻塞同步网络模型,因为真正的 read 和 send 操作都需要用户进程同步操作。如果把 I/O 操作改为异步就能够进一步提升性能,这就是异步网络模型 Proactor。
过程说明:
(1)Proactor Initiator 负责创建 Proactor 和 Handler,并将 Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核;
(2)Asynchronous Operation Processor 负责处理注册请求,并完成 I/O 操作,Asynchronous Operation Processor 完成 I/O 操作后通知 Proactor;
(3)Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理。
(4)Handler 完成业务处理,Handler 也可以注册新的 Handler 到内核进程;
理论上 Proactor 比 Reactor 效率要高一些,异步 I/O 能够充分利用 DMA 特性,让 I/O 操作与计算重叠,但要实现真正的异步 I/O,操作系统需要做大量的工作。
DMA:Direct Memory Access(直接存储器访问) 是所有现代电脑的重要特色,它允许不同速度的硬件装置来沟通,而不需要依赖于 CPU 的大量中断负载; 目前 Windows 下通过 IOCP 实现了真正的异步 I/O,而在 Linux 系统下的 AIO 并不完善。
缺点:Proactor实现逻辑复杂;依赖操作系统对异步的支持,目前实现了纯异步操作的操作系统少。Windows 里实现了一套完整的支持 socket 的异步编程接口,这套接口就是 IOCP,是由操作系统级别实现的异步 I/O,真正意义上异步 I/O,因此在 Windows 里实现高性能网络程序可以使用效率更高的 Proactor 方案。Linux系统对异步IO支持不是很好,不是很完善。
适用场景 : 异步接收和同时处理多个服务请求的事件驱动程序。
2.5Reactor 和 Proactor 的区别
-
Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据。
-
Proactor 是异步网络模式, 感知的是已完成的读写事件。在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据。
因此,Reactor 可以理解为“来了事件操作系统通知应用进程,让应用进程来处理”,而 Proactor 可以理解为“来了事件操作系统来处理,处理完再通知应用进程”。这里的“事件”就是有新连接、有数据可读、有数据可写的这些 I/O 事件这里的“处理”包含从驱动读取到内核以及从内核读取到用户空间。
举个实际生活中的例子,Reactor 模式就是快递员在楼下,给你打电话告诉你快递到你家小区了,你需要自己下楼来拿快递。而在 Proactor 模式下,快递员直接将快递送到你家门口,然后通知你。
无论是 Reactor,还是 Proactor,都是一种基于“事件分发”的网络编程模式,区别在于 Reactor 模式是基于“待完成”的 I/O 事件,而 Proactor 模式则是基于“已完成”的 I/O 事件。
三、集群高性能
虽然计算机硬件的性能快速发展,但和业务的发展速度相比,还是小巫见大巫了,尤其是进入互联网时代后,业务的发展速度远远超过了硬件的发展速度。这些业务的开发,单机的性能无论如何是无法支撑的,必须采用机器集群的方式来达到高性能。
一个人干不完就多找几个人干。
通过大量机器来提升性能,并不仅仅是增加机器这么简单,让多台机器配合起来达到高性能的目的,是一个复杂的任务。
集群高性能架构设计主要涉及两方面:任务分配和任务分解。
3.1任务分配
任务分配的意思是指每台机器都可以处理完整的业务任务,不同的任务分配到不同的机器上执行。如下一台服务器变两台服务器:
(1)需要增加一个任务分配器,这个分配器可能是硬件网络设备(例如,F5、交换机等),可能是软件网络设备(例如,LVS),也可能是负载均衡软件(例如,Nginx、HAProxy),还可能是自己开发的系统(gateway)。选择合适的任务分配器也是一件复杂的事情,需要综合考虑性能、成本、可维护性、可用性等各方面的因素。
(2)任务分配器和真正的业务服务器之间有连接和交互,需要选择合适的连接方式,并且对连接进行管理。例如,连接建立、连接检测、连接中断后如何处理等。
(3)任务分配器需要增加分配算法。例如,是采用轮询算法,还是按权重分配,又或者按照负载进行分配。如果按照服务器的负载进行分配,则业务服务器还要能够上报自己的状态给任务分配器。
任务分配其实就是常说的负载均衡。通过负载技术将工作任务平衡、分摊到多个操作单元运行。负载均衡建立在网络结构之上,是提供系统高可用性、处理能力以及缓解网络压力的重要手段。
不同的任务分配算法目标是不一样的,有的基于负载考虑,有的基于性能(吞吐量、响应时间)考虑,有的基于业务考虑。
3.1.1负载均衡分类
根据实现位置不同可分为两类:
(1)服务端负载:硬件负载均衡和软件负载均衡;
(2)客户端负载。(spring-cloud 中的 Ribbon,Dubbo,Thrift)
根据实现方式不同可分为三类:
(1)DNS 负载均衡;
(2)硬件负载均衡;
(3)软件负载均衡。
3.1.2服务端负载均衡和客户端负载均衡区别
基于服务端实现的负责均衡(无论软硬件),都会在负载均衡设备(软硬件)下挂服务端清单并通过心跳检测来剔除故障节点以保证清单中都是可以正常访问的服务端节点。 无论是软件负载还是硬件负载都能够基于类似下述架构方式进行构建。
客户端负载均衡中,客户端节点都维护者自己所需要访问的服务清单,而这些清单来自服务注册中心。相比较服务器负载均衡而言,客户端负载均衡是一个非常小众的概念,客户端负载均衡是在spring-cloud分布式框架组件Ribbon中定义的。在使用spring-cloud分布式框架时,同一个service大概率同时启动多个,当一个请求奔过来时,那么这多个service,Ribbon通过策略决定本次请求使用哪个service的方式就是客户端负载均衡。在spring-cloud分布式框架中客户端负载均衡对开发者是透明的,添加@LoadBalanced注解就可以了。客户端负载均衡和服务器负载均衡的核心差异在服务列表本身,客户端负载均衡服务列表在通过客户端维护,服务器负载均衡服务列表由中间服务单独维护。
3.1.3DNS 负载均衡
DNS 是最简单也是最常见的负载均衡方式,一般用来实现地理级别的均衡。
优点:
1.简单、成本低:负载均衡工作交给 DNS 服务器处理,无须自己开发或者维护负载均衡设备。
2.就近访问,提升访问速度:DNS 解析时可以根据请求来源 IP,解析成距离用户最近的服务器地址,可以加快访问速度,改善性能。
缺点:
1.更新不及时:DNS 缓存的时间比较长,修改 DNS 配置后,由于缓存的原因,还是有很多用户会继续访问修改前的 IP,这样的访问会失败,达不到负载均衡的目的,并且也影响用户正常使用业务。
2.扩展性差:DNS 负载均衡的控制权在域名商那里,无法根据业务特点针对其做更多的定制化功能和扩展特性。
3.分配策略比较简单:DNS 负载均衡支持的算法少;不能区分服务器的差异(不能根据系统与服务的状态来判断负载);也无法感知后端服务器的状态。
3.1.4硬件负载均衡
硬件负载均衡是通过单独的硬件设备来实现负载均衡功能,这类设备和路由器、交换机类似,可以理解为一个用于负载均衡的基础网络设备。主要有 F5 & A10 等。
优点:
1.功能强大:全面支持各层级的负载均衡,支持全面的负载均衡算法,支持全局负载均衡。
2.性能强大:对比一下,软件负载均衡支持到 10 万级并发已经很厉害了,硬件负载均衡可以支持 100 万以上的并发。
3.稳定性高:商用硬件负载均衡,经过了良好的严格测试,经过大规模使用,稳定性高。
4.支持安全防护:硬件均衡设备除具备负载均衡功能外,还具备防火墙、防 DDoS 攻击等安全功能。
缺点:
1. 价格昂贵。
2.扩展能力差。
3.1.5软件负载均衡
软件负载均衡通过负载均衡软件来实现负载均衡功能,常见的有 Nginx 和 LVS,其中 Nginx 是软件的 7 层负载均衡,LVS 是 Linux 内核的 4 层负载均衡。4 层和 7 层的区别就在于协议和灵活性。
优点:
1.简单:无论是部署还是维护都比较简单。
2.便宜:只要买个 Linux 服务器,装上软件即可。
3.灵活:4 层和 7 层负载均衡可以根据业务进行选择;也可以根据业务进行比较方便的扩展,例如,可以通过 Nginx 的插件来实现业务的定制化功能。
缺点:
1.性能一般:一个 Nginx 大约能支撑 5 万并发
2.功能没有硬件负载均衡那么强大。
3.一般不具备防火墙和防 DDoS 攻击等安全功能。
3.1.6使用原则
-
DNS 负载均衡用于实现地理级别的负载均衡;
-
硬件负载均衡用于实现集群级别的负载均衡;
-
软件负载均衡用于实现机器级别的负载均衡
3.1.7常见的负载均衡算法
-
静态:以固定概率分配任务,不考虑服务器状态信息,比如轮询、加权轮询算法等。
-
动态:以服务器实时负载状态信息来决定任务分配,如最小连接法、加权最小连接法。
-
随机,通过随机选择服务进行执行,一般这种方式使用较少;
-
轮训,负载均衡默认实现方式,请求来之后排队处理;
-
加权轮训,通过对服务器性能的分型,给高配置,低负载的服务器分配更高的权重,均衡各个服务器的压力;
-
地址Hash,通过客户端请求的地址的HASH值取模映射进行服务器调度。
-
最小链接数;即使请求均衡了,压力不一定会均衡,最小连接数法就是根据服务器的情况,比如请求积压数等参数,将请求分配到当前压力最小的服务器上。
3.1.8负载均衡技术
-
基于DNS的负责均衡技术
-
反向代理:
-
基于NAT(NetWork Adress Transaction)
-
Ribbion 客户端负载均衡实现 SpringCloud Ribbion 客户端负载均衡 是Spring Cloud Netflix 子项目核心项目,是基于Http和TCP的客户端负载工具,主要给服务端调用及API网关转发提供负载均衡功能。Spring Cloud Ribbon是一种工具栏框架无需独立部署运行,而是集成于其他项目配套使用。 通过Spring Cloud Ribbon的封装,在Spring Cloud 微服务架构中实现客户端负载均衡非常简单。
Spring Cloud Alibaba 默认集成了Ribbon,当Spring Cloud 应用与Spring Cloud Alibaba Nacos Discovery 集成时,会自动触发Nacos中实现的对Ribbon的自动化配置(通过开关ribbon.nacos.enabled控制是否自动触发,默认true)。ServerList的维护机制将被NacosServerList覆盖,服务实例清单列表交给Nacos的服务治理机制维护。Nacos默认仍使用的是Spring Cloud Ribbon默认的负载均衡实现,只是扩展了服务实例清单维护机制。
3.1.9负载最低优先类
负载均衡系统根据服务器的负载来进行分配,这里的负载并不一定是通常意义上说的“CPU 负载”,而是系统当前的压力,可以用 CPU 负载来衡量,也可以用连接数、I/O 使用率、网卡吞吐量等来衡量系统的压力。负载最低优先:
-
LVS 这种 4 层网络负载均衡设备,可以以“连接数”来判断服务器的状态,服务器连接数越大,表明服务器压力越大。
-
Nginx 这种 7 层网络负载系统,可以以“HTTP 请求数”来判断服务器状态
-
如果自己开发负载均衡系统,可以根据业务特点来选择指标衡量系统压力。如果是 CPU 密集型,可以以“CPU 负载”来衡量系统压力;如果是 I/O 密集型,可以以“I/O 负载”来衡量系统压力。
优点:
-
负载最低优先的算法解决了轮询算法中无法感知服务器状态的问题
缺点:
-
最少连接数优先的算法要求负载均衡系统统计每个服务器当前建立的连接,其应用场景仅限于负载均衡接收的任何连接请求都会转发给服务器进行处理,否则如果负载均衡系统和服务器之间是固定的连接池方式,就不适合采取这种算法
-
CPU 负载最低优先的算法要求负载均衡系统以某种方式收集每个服务器的 CPU 负载,而且要确定是以 1 分钟的负载为标准,还是以 15 分钟的负载为标准,不存在 1 分钟肯定比 15 分钟要好或者差。不同业务最优的时间间隔是不一样的,时间间隔太短容易造成频繁波动,时间间隔太长又可能造成峰值来临时响应缓慢。
3.1.10性能最优类
负载最低优先类算法是站在服务器的角度来进行分配的,而性能最优优先类算法则是站在客户端的角度来进行分配的,优先将任务分配给处理速度最快的服务器,通过这种方式达到最快响应客户端的目的。
缺点:
-
负载均衡系统需要收集和分析每个服务器每个任务的响应时间,在大量任务处理的场景下,这种收集和统计本身也会消耗较多的性能
-
为了减少这种统计上的消耗,可以采取采样的方式来统计,需要合适的采样率
-
无论是全部统计还是采样统计,都需要选择合适的周期
3.1.11Hash 类
在某些场景中,希望特定的请求最好始终保持在一台服务器上执行,此时需要使用一致性hash算法来实现。
例子:服务中缓存了用户数据,因此用户每次访问的时候最好能保持同一台服务器。
缓存Redis,Memcache,Nginx,Dubbo 负载均衡算法都是用的一致性Hash。
Hash的方式有两种:
-
源地址 Hash
-
ID Hash
具体算法介绍: https://blog.csdn.net/u011436427/article/details/123344374
3.1.12任务分配器集群
随着系统规模不断扩大,任务分配器也会面临负载压力,因此任务分配器也需要通过集群来扩充容量,提高可用性。
这个架构比 2 台业务服务器的架构要复杂,主要体现在:
-
任务分配器从 1 台变成了多台(对应图中的任务分配器 1 到任务分配器 M),这个变化带来的复杂度就是需要将不同的用户分配到不同的任务分配器上(即图中的虚线“用户分配”部分),常见的方法包括 DNS 轮询、智能 DNS、CDN(Content Delivery Network,内容分发网络)、GSLB 设备(Global Server Load Balance,全局负载均衡)等。
-
任务分配器和业务服务器的连接从简单的“1 对多”(1 台任务分配器连接多台业务服务器)变成了“多对多”(多台任务分配器连接多台业务服务器)的网状结构。
-
机器数量从 3 台扩展到 30 台(一般任务分配器数量比业务服务器要少,这里假设业务服务器为 25 台,任务分配器为 5 台),状态管理、故障处理复杂度也大大增加。
上面这两个例子都是以业务处理为例,实际上“任务”涵盖的范围很广,可以指完整的业务处理,也可以单指某个具体的任务。例如,“存储”“运算”“缓存”等都可以作为一项任务,因此存储系统、运算系统、缓存系统都可以按照任务分配的方式来搭建架构。此外,“任务分配器”也并不一定只能是物理上存在的机器或者一个独立运行的程序,也可以是嵌入在其他程序中的算法,例如 Memcache 的集群架构。
3.2任务分解
通过任务分配的方式,能够突破单台机器处理性能的瓶颈,通过增加更多的机器来满足业务的性能需求,但如果业务本身也越来越复杂,单纯只通过任务分配的方式来扩展性能,收益会越来越低。
例如,业务简单的时候 1 台机器扩展到 10 台机器,性能能够提升 8 倍(需要扣除机器群带来的部分性能损耗,因此无法达到理论上的 10 倍那么高),但如果业务越来越复杂,1 台机器扩展到 10 台,性能可能只能提升 5 倍。造成这种现象的主要原因是业务越来越复杂,单台机器处理的性能会越来越低。为了能够继续提升性能,需要采取第二种方式:任务分解。
继续以上面“任务分配”中的架构为例,“业务服务器”如果越来越复杂,可以将其拆分为更多的组成部分,以微信的后台架构为例。
通过上面的架构示意图可以看出,微信后台架构从逻辑上将各个子业务进行了拆分,包括:接入、注册登录、消息、LBS、摇一摇、漂流瓶、其他业务(聊天、视频、朋友圈等)。
通过这种任务分解的方式,能够把原来大一统但复杂的业务系统,拆分成小而简单但需要多个系统配合的业务系统。从业务的角度来看,任务分解既不会减少功能,也不会减少代码量(事实上代码量可能还会增加,因为从代码内部调用改为通过服务器之间的接口调用),那为何通过任务分解就能够提升性能呢?
主要有几方面的因素:
(1)简单的系统更加容易做到高性能
系统的功能越简单,影响性能的点就越少,就更加容易进行有针对性的优化。而系统很复杂的情况下,首先是比较难以找到关键性能点,因为需要考虑和验证的点太多;其次是即使花费很大力气找到了,修改起来也不容易,因为可能将 A 关键性能点提升了,但却无意中将 B 点的性能降低了,整个系统的性能不但没有提升,还有可能会下降。
(2)可以针对单个任务进行扩展
当各个逻辑任务分解到独立的子系统后,整个系统的性能瓶颈更加容易发现,而且发现后只需要针对有瓶颈的子系统进行性能优化或者提升,不需要改动整个系统,风险会小很多。以微信的后台架构为例,如果用户数增长太快,注册登录子系统性能出现瓶颈的时候,只需要优化登录注册子系统的性能(可以是代码优化,也可以简单粗暴地加机器),消息逻辑、LBS 逻辑等其他子系统完全不需要改动。
既然将一个大一统的系统分解为多个子系统能够提升性能,那是不是划分得越细越好呢?例如,上面的微信后台目前是 7 个逻辑子系统,如果把这 7 个逻辑子系统再细分,划分为 100 个逻辑子系统,性能是不是会更高呢?
其实不然,这样做性能不仅不会提升,反而还会下降,最主要的原因是如果系统拆分得太细,为了完成某个业务,系统间的调用次数会呈指数级别上升,而系统间的调用通道目前都是通过网络传输的方式,性能远比系统内的函数调用要低得多。以一个简单的图示来说明。
从图中可以看到,当系统拆分 2 个子系统的时候,用户访问需要 1 次系统间的请求和 1 次响应;当系统拆分为 4 个子系统的时候,系统间的请求次数从 1 次增长到 3 次;假如继续拆分下去为 100 个子系统,为了完成某次用户访问,系统间的请求次数变成了 99 次。
为了描述简单,抽象出来一个最简单的模型:假设这些系统采用 IP 网络连接,理想情况下一次请求和响应在网络上耗费为 1ms,业务处理本身耗时为 50ms。也假设系统拆分对单个业务请求性能没有影响,那么系统拆分为 2 个子系统的时候,处理一次用户访问耗时为 51ms;而系统拆分为 100 个子系统的时候,处理一次用户访问耗时竟然达到了 149ms。
虽然系统拆分可能在某种程度上能提升业务处理性能,但提升性能也是有限的,不可能系统不拆分的时候业务处理耗时为 50ms,系统拆分后业务处理耗时只要 1ms,因为最终决定业务处理性能的还是业务逻辑本身,业务逻辑本身没有发生大的变化下,理论上的性能是有一个上限的,系统拆分能够让性能逼近这个极限,但无法突破这个极限。因此,任务分解带来的性能收益是有一个度的,并不是任务分解越细越好,而对于架构设计来说,如何把握这个粒度就非常关键了。