大家好,我是易安!明天咱们谈一谈架构设计中的高性能架构波及到的底层思维。本文分为缓存架构,单服务器高性能模型,集群下的高性能模型三个局部,内容很干,心愿你仔细阅读。
高性能缓存架构
在某些简单的业务场景下,单纯依附存储系统的性能晋升不够的,典型的场景有:
- 须要通过简单运算后得出的数据,存储系统无能为力
例如,一个论坛须要在首页展现以后有多少用户同时在线,如果应用 MySQL 来存储以后用户状态,则每次获取这个总数都要“count(*)”大量数据,这样的操作无论怎么优化 MySQL,性能都不会太高。如果要实时展现用户同时在线数,则 MySQL 性能无奈撑持。
- 读多写少的数据,存储系统有心无力
绝大部分在线业务都是读多写少。例如,微博、淘宝、微信这类互联网业务,读业务占了整体业务量的 90% 以上。以微博为例:一个明星发一条微博,可能几千万人来浏览。如果应用 MySQL 来存储微博,用户写微博只有一条 insert 语句,但每个用户浏览时都要 select 一次,即便有索引,几千万条 select 语句对 MySQL 数据库的压力也会十分大。
缓存就是为了补救存储系统在这些简单业务场景下的有余,其基本原理是将可能重复使用的数据放到内存中,一次生成、屡次应用,防止每次应用都去拜访存储系统。
缓存可能带来性能的大幅晋升,以 Memcache 为例,单台 Memcache 服务器简略的 key-value 查问可能达到 TPS 50000 以上,其根本的架构是:
缓存尽管可能大大加重存储系统的压力,但同时也给架构引入了更多复杂性。架构设计时如果没有针对缓存的复杂性进行解决,某些场景下甚至会导致整个零碎解体。上面,我来逐个剖析缓存的架构设计要点。
缓存穿透
缓存穿透 是指缓存没有发挥作用,业务零碎尽管去缓存查问数据,但缓存中没有数据,业务零碎须要再次去存储系统查问数据。通常状况下有两种状况:
1. 存储数据不存在
第一种状况是被拜访的数据的确不存在。个别状况下,如果存储系统中没有某个数据,则不会在缓存中存储相应的数据,这样就导致用户查问的时候,在缓存中找不到对应的数据,每次都要去存储系统中再查问一遍,而后返回数据不存在。缓存在这个场景中并没有起到分担存储系统拜访压力的作用。
通常状况下,业务上读取不存在的数据的申请量并不会太大,但如果呈现一些异常情况,例如被黑客攻击,成心大量拜访某些读取不存在数据的业务,有可能会将存储系统拖垮。
这种状况的解决办法比较简单,如果查问存储系统的数据没有找到,则间接设置一个默认值(能够是空值,也能够是具体的值)存到缓存中,这样第二次读取缓存时就会获取到默认值,而不会持续拜访存储系统。
2. 缓存数据生成消耗大量工夫或者资源
第二种状况是存储系统中存在数据,但生成缓存数据须要消耗较长时间或者消耗大量资源。如果刚好在业务拜访的时候缓存生效了,那么也会呈现缓存没有发挥作用,拜访压力全副集中在存储系统上的状况。
典型的就是电商的商品分页,假如咱们在某个电商平台上抉择“手机”这个类别查看,因为数据微小,不能把所有数据都缓存起来,只能依照分页来进行缓存,因为难以预测用户到底会拜访哪些分页,因而业务上最简略的就是每次点击分页的时候按分页计算和生成缓存。通常状况下这样实现是根本满足要求的,然而如果被竞争对手用爬虫来遍历的时候,零碎性能就可能呈现问题。
具体的场景有:
- 分页缓存的有效期设置为 1 天,因为设置太长时间的话,缓存不能反馈实在的数据。
- 通常状况下,用户不会从第 1 页到最初 1 页全副看完,个别用户拜访集中在前 10 页,因而第 10 页当前的缓存过期生效的可能性很大。
- 竞争对手每周来爬取数据,爬虫会将所有分类的所有数据全副遍历,从第 1 页到最初 1 页全副都会读取,此时很多分页缓存可能都生效了。
- 因为很多分页都没有缓存数据,从数据库中生成缓存数据又十分消耗性能(order by limit 操作),因而爬虫会将整个数据库全副拖慢。
这种状况并没有太好的解决方案,因为爬虫会遍历所有的数据,而且什么时候来爬取也是不确定的,可能是每天都来,也可能是每周,也可能是一个月来一次,咱们也不可能为了应答爬虫而将所有数据永恒缓存。通常的应答计划要么就是辨认爬虫而后禁止拜访,但这可能会影响 SEO 和推广;要么就是做好监控,发现问题后及时处理,因为爬虫不是攻打,不会进行暴力毁坏,对系统的影响是逐渐的,监控发现问题后有工夫进行解决。
缓存雪崩
缓存雪崩 是指当缓存生效(过期)后引起零碎性能急剧下降的状况。当缓存过期被革除后,业务零碎须要从新生成缓存,因而须要再次拜访存储系统,再次进行运算,这个解决步骤耗时几十毫秒甚至上百毫秒。而对于一个高并发的业务零碎来说,几百毫秒内可能会接到几百上千个申请。因为旧的缓存曾经被革除,新的缓存还未生成,并且解决这些申请的线程都不晓得另外有一个线程正在生成缓存,因而所有的申请都会去从新生成缓存,都会去拜访存储系统,从而对存储系统造成微小的性能压力。这些压力又会拖慢整个零碎,重大的会造成数据库宕机,从而造成一系列连锁反应,造成整个零碎解体。
缓存雪崩的常见解决办法有两种:更新锁机制 和 后盾更新机制。
1. 更新锁
对缓存更新操作进行加锁爱护,保障只有一个线程可能进行缓存更新,未能获取更新锁的线程要么期待锁开释后从新读取缓存,要么就返回空值或者默认值。
对于采纳分布式集群的业务零碎,因为存在几十上百台服务器,即便单台服务器只有一个线程更新缓存,但几十上百台服务器一起算下来也会有几十上百个线程同时来更新缓存,同样存在雪崩的问题。因而分布式集群的业务零碎要实现更新锁机制,须要用到分布式锁,如 ZooKeeper。
2. 后盾更新
由后盾线程来更新缓存,而不是由业务线程来更新缓存,缓存自身的有效期设置为永恒,后盾线程定时更新缓存。
后盾定时机制须要思考一种非凡的场景,当缓存零碎内存不够时,会“踢掉”一些缓存数据,从缓存被“踢掉”到下一次定时更新缓存的这段时间内,业务线程读取缓存返回空值,而业务线程自身又不会去更新缓存,因而业务上看到的景象就是数据丢了。解决的形式有两种:
- 后盾线程除了定时更新缓存,还要频繁地去读取缓存(例如,1 秒或者 100 毫秒读取一次),如果发现缓存被“踢了”就立即更新缓存,这种形式实现简略,但读取工夫距离不能设置太长,因为如果缓存被踢了,缓存读取间隔时间又太长,这段时间内业务拜访都拿不到真正的数据而是一个空的缓存值,用户体验个别。
- 业务线程发现缓存生效后,通过音讯队列发送一条音讯告诉后盾线程更新缓存。可能会呈现多个业务线程都发送了缓存更新音讯,但其实对后盾线程没有影响,后盾线程收到音讯后更新缓存前能够判断缓存是否存在,存在就不执行更新操作。这种形式实现依赖音讯队列,复杂度会高一些,但缓存更新更及时,用户体验更好。
后盾更新既适应单机多线程的场景,也适宜分布式集群的场景,相比更新锁机制要简略一些。
后盾更新机制还适宜业务刚上线的时候进行缓存预热。缓存预热指零碎上线后,将相干的缓存数据间接加载到缓存零碎,而不是期待用户拜访才来触发缓存加载。
缓存热点
尽管缓存零碎自身的性能比拟高,但对于一些特地热点的数据,如果大部分甚至所有的业务申请都命中同一份缓存数据,则这份数据所在的缓存服务器的压力也很大。例如,某明星微博公布“咱们”来宣告恋爱了,短时间内上千万的用户都会来围观。
缓存热点的解决方案就是复制多份缓存正本,将申请扩散到多个缓存服务器上,加重缓存热点导致的单台缓存服务器压力。以微博为例,对于粉丝数超过 100 万的明星,每条微博都能够生成 100 份缓存,缓存的数据是一样的,通过在缓存的 key 外面加上编号进行辨别,每次读缓存时都随机读取其中某份缓存。
缓存正本设计有一个细节须要留神,就是不同的缓存正本不要设置对立的过期工夫,否则就会呈现所有缓存正本同时生成同时生效的状况,从而引发缓存雪崩效应。正确的做法是设定一个过期工夫范畴,不同的缓存正本的过期工夫是指定范畴内的随机值。
因为缓存的各种拜访策略和存储的拜访策略是相干的,因而下面的各种缓存设计方案通常状况下都是集成在存储拜访计划中,能够采纳“程序代码实现”的中间层形式,也能够采纳独立的中间件来实现。
单服务器下的高性能模型
高性能是每个程序员的谋求,无论咱们是做一个零碎还是写一行代码,都心愿可能达到高性能的成果,而高性能又是最简单的一环,磁盘、操作系统、CPU、内存、缓存、网络、编程语言、架构等,每个都有可能影响零碎达到高性能,一行不失当的 debug 日志,就可能将服务器的性能从 TPS 30000 升高到 8000;一个 tcp\_nodelay 参数,就可能将响应工夫从 2 毫秒缩短到 40 毫秒。因而,要做到高性能计算是一件很简单很有挑战的事件,软件系统开发过程中的不同阶段都关系着高性能最终是否可能实现。
站在架构师的角度,当然须要特地关注高性能架构的设计。高性能架构设计次要集中在两方面:
- 尽量晋升单服务器的性能,将单服务器的性能施展到极致。
- 如果单服务器无奈撑持性能,设计服务器集群计划。
除了以上两点,最终零碎是否实现高性能,还和具体的实现及编码相干。但架构设计是高性能的根底,如果架构设计没有做到高性能,则前面的具体实现和编码能晋升的空间是无限的。形象地说,架构设计决定了零碎性能的下限,实现细节决定了零碎性能的上限。
单服务器高性能的要害之一就是 服务器采取的并发模型,并发模型有如下两个要害设计点:
- 服务器如何治理连贯。
- 服务器如何解决申请。
以上两个设计点最终都和操作系统的 I / O 模型及过程模型相干。
- I/ O 模型:阻塞、非阻塞、同步、异步。
- 过程模型:单过程、多过程、多线程。
在上面具体介绍并发模型时会用到下面这些根底的知识点,所以我倡议你先检测一下对这些基础知识的掌握情况,更多内容你能够参考《UNIX 网络编程》三卷本。
PPC
PPC 是 Process Per Connection 的缩写,其含意是指每次有新的连贯就新建一个过程去专门解决这个连贯的申请,这是传统的 UNIX 网络服务器所采纳的模型。根本的流程图是:
- 父过程承受连贯(图中 accept)。
- 父过程“fork”子过程(图中 fork)。
- 子过程解决连贯的读写申请(图中子过程 read、业务解决、write)。
- 子过程敞开连贯(图中子过程中的 close)。
留神,图中有一个小细节,父过程“fork”子过程后,间接调用了 close,看起来如同是敞开了连贯,其实只是将连贯的文件描述符援用计数减一,真正的敞开连贯是等子过程也调用 close 后,连贯对应的文件描述符援用计数变为 0 后,操作系统才会真正敞开连贯,更多细节请参考《UNIX 网络编程:卷一》。
PPC 模式实现简略,比拟适宜服务器的连接数没那么多的状况,例如数据库服务器。对于一般的业务服务器,在互联网衰亡之前,因为服务器的访问量和并发量并没有那么大,这种模式其实运作得也挺好,世界上第一个 web 服务器 CERN httpd 就采纳了这种模式(具体你能够参考 https://en.wikipedia.org/wiki/CERN\_httpd)。互联网衰亡后,服务器的并发和访问量从几十剧增到成千上万,这种模式的弊病就凸显进去了,次要体现在这几个方面:
- fork 代价高:站在操作系统的角度,创立一个过程的代价是很高的,须要调配很多内核资源,须要将内存映像从父过程复制到子过程。即便当初的操作系统在复制内存映像时用到了 Copy on Write(写时复制)技术,总体来说创立过程的代价还是很大的。
- 父子过程通信简单:父过程“fork”子过程时,文件描述符能够通过内存映像复制从父过程传到子过程,但“fork”实现后,父子过程通信就比拟麻烦了,须要采纳 IPC(Interprocess Communication)之类的过程通信计划。例如,子过程须要在 close 之前通知父过程本人解决了多少个申请以撑持父过程进行全局的统计,那么子过程和父过程必须采纳 IPC 计划来传递信息。
- 反对的并发连贯数量无限:如果每个连贯存活工夫比拟长,而且新的连贯又源源不断的进来,则过程数量会越来越多,操作系统过程调度和切换的频率也越来越高,零碎的压力也会越来越大。因而,个别状况下,PPC 计划能解决的并发连贯数量最大也就几百。
prefork
PPC 模式中,当连贯进来时才 fork 新过程来解决连贯申请,因为 fork 过程代价高,用户拜访时可能感觉比较慢,prefork 模式的呈现就是为了解决这个问题。
顾名思义,prefork 就是提前创立过程(pre-fork)。零碎在启动的时候就事后创立好过程,而后才开始承受用户的申请,当有新的连贯进来的时候,就能够省去 fork 过程的操作,让用户拜访更快、体验更好。prefork 的根本示意图是:
prefork 的实现要害就是多个子过程都 accept 同一个 socket,当有新的连贯进入时,操作系统保障只有一个过程能最初 accept 胜利。但这里也存在一个小小的问题:“惊群”景象,就是指尽管只有一个子过程能 accept 胜利,但所有阻塞在 accept 上的子过程都会被唤醒,这样就导致了不必要的过程调度和上下文切换了。侥幸的是,操作系统能够解决这个问题,例如 Linux 2.6 版本后内核曾经解决了 accept 惊群问题。
prefork 模式和 PPC 一样,还是存在父子过程通信简单、反对的并发连贯数量无限的问题,因而目前理论利用也不多。Apache 服务器提供了 MPM prefork 模式,举荐在须要可靠性或者与旧软件兼容的站点时采纳这种模式,默认状况下最大反对 256 个并发连贯。
TPC
TPC 是 Thread Per Connection 的缩写,其含意是指每次有新的连贯就新建一个线程去专门解决这个连贯的申请。与过程相比,线程更轻量级,创立线程的耗费比过程要少得多;同时多线程是共享过程内存空间的,线程通信相比过程通信更简略。因而,TPC 实际上是解决或者弱化了 PPC fork 代价高的问题和父子过程通信简单的问题。
TPC 的根本流程是:
- 父过程承受连贯(图中 accept)。
- 父过程创立子线程(图中 pthread)。
- 子线程解决连贯的读写申请(图中子线程 read、业务解决、write)。
- 子线程敞开连贯(图中子线程中的 close)。
留神,和 PPC 相比,主过程不必“close”连贯了。起因是在于子线程是共享主过程的过程空间的,连贯的文件描述符并没有被复制,因而只须要一次 close 即可。
TPC 尽管解决了 fork 代价高和过程通信简单的问题,然而也引入了新的问题,具体表现在:
- 创立线程尽管比创立过程代价低,但并不是没有代价,高并发时(例如每秒上万连贯)还是有性能问题。
- 毋庸过程间通信,然而线程间的互斥和共享又引入了复杂度,可能一不小心就导致了死锁问题。
- 多线程会呈现相互影响的状况,某个线程出现异常时,可能导致整个过程退出(例如内存越界)。
除了引入了新的问题,TPC 还是存在 CPU 线程调度和切换代价的问题。因而,TPC 计划实质上和 PPC 计划根本相似,在并发几百连贯的场景下,反而更多地是采纳 PPC 的计划,因为 PPC 计划不会有死锁的危险,也不会多过程相互影响,稳定性更高。
prethread
TPC 模式中,当连贯进来时才创立新的线程来解决连贯申请,尽管创立线程比创立过程要更加轻量级,但还是有肯定的代价,而 prethread 模式就是为了解决这个问题。
和 prefork 相似,prethread 模式会事后创立线程,而后才开始承受用户的申请,当有新的连贯进来的时候,就能够省去创立线程的操作,让用户感觉更快、体验更好。
因为多线程之间数据共享和通信比拟不便,因而实际上 prethread 的实现形式相比 prefork 要灵便一些,常见的实现形式有上面几种:
- 主过程 accept,而后将连贯交给某个线程解决。
- 子线程都尝试去 accept,最终只有一个线程 accept 胜利,计划的根本示意图如下:
Apache 服务器的 MPM worker 模式实质上就是一种 prethread 计划,但略微做了改良。Apache 服务器会首先创立多个过程,每个过程外面再创立多个线程,这样做次要是为了思考稳定性,即:即便某个子过程外面的某个线程异样导致整个子过程退出,还会有其余子过程持续提供服务,不会导致整个服务器全副挂掉。
prethread 实践上能够比 prefork 反对更多的并发连贯,Apache 服务器 MPM worker 模式默认反对 16 × 25 = 400 个并发解决线程。
Reactor
PPC 模式最次要的问题就是每个连贯都要创立过程(为了形容简洁,这里只以 PPC 和过程为例,实际上换成 TPC 和线程,原理是一样的),连贯完结后过程就销毁了,这样做其实是很大的节约。为了解决这个问题,一个自然而然的想法就是资源复用,即不再独自为每个连贯创立过程,而是创立一个过程池,将连贯调配给过程,一个过程能够解决多个连贯的业务。
引入资源池的解决形式后,会引出一个新的问题:过程如何能力高效地解决多个连贯的业务?当一个连贯一个过程时,过程能够采纳“read -> 业务解决 -> write”的解决流程,如果以后连贯没有数据能够读,则过程就阻塞在 read 操作上。这种阻塞的形式在一个连贯一个过程的场景下没有问题,但如果一个过程解决多个连贯,过程阻塞在某个连贯的 read 操作上,此时即便其余连贯有数据可读,过程也无奈去解决,很显然这样是无奈做到高性能的。
解决这个问题的最简略的形式是将 read 操作改为非阻塞,而后过程一直地轮询多个连贯。这种形式可能解决阻塞的问题,但解决的形式并不优雅。首先,轮询是要耗费 CPU 的;其次,如果一个过程解决几千上万的连贯,则轮询的效率是很低的。
为了可能更好地解决上述问题,很容易能够想到,只有当连贯上有数据的时候过程才去解决,这就是 I / O 多路复用技术的起源。
I/ O 多路复用技术归纳起来有两个要害实现点:
- 当多条连贯共用一个阻塞对象后,过程只须要在一个阻塞对象上期待,而无须再轮询所有连贯,常见的实现形式有 select、epoll、kqueue 等。
- 当某条连贯有新的数据能够解决时,操作系统会告诉过程,过程从阻塞状态返回,开始进行业务解决。
I/ O 多路复用联合线程池,完满地解决了 PPC 和 TPC 的问题,而且“大神们”给它取了一个很牛的名字:Reactor,中文是“反应堆”。联想到“核反应堆”,听起来就很吓人,实际上这里的“反馈”不是聚变、裂变反馈的意思,而是“事件反馈 ”的意思,能够艰深地了解为“ 来了一个事件我就有相应的反馈”,这里的“我”就是 Reactor,具体的反馈就是咱们写的代码,Reactor 会依据事件类型来调用相应的代码进行解决。Reactor 模式也叫 Dispatcher 模式(在很多开源的零碎外面会看到这个名称的类,其实就是实现 Reactor 模式的),更加贴近模式自身的含意,即 I / O 多路复用对立监听事件,收到事件后调配(Dispatch)给某个过程。
Reactor 模式的外围组成部分包含 Reactor 和解决资源池(过程池或线程池),其中 Reactor 负责监听和调配事件,解决资源池负责处理事件。初看 Reactor 的实现是比较简单的,但实际上联合不同的业务场景,Reactor 模式的具体实现计划灵便多变,次要体现在:
- Reactor 的数量能够变动:能够是一个 Reactor,也能够是多个 Reactor。
- 资源池的数量能够变动:以过程为例,能够是单个过程,也能够是多个过程(线程相似)。
将下面两个因素排列组合一下,实践上能够有 4 种抉择,但因为“多 Reactor 单过程”实现计划相比“单 Reactor 单过程”计划,既简单又没有性能劣势,因而“多 Reactor 单过程”计划仅仅是一个实践上的计划,理论没有利用。
最终 Reactor 模式有这三种典型的实现计划:
- 单 Reactor 单过程 / 线程。
- 单 Reactor 多线程。
- 多 Reactor 多过程 / 线程。
以上计划具体抉择过程还是线程,更多地是和编程语言及平台相干。例如,Java 语言个别应用线程(例如,Netty),C 语言应用过程和线程都能够。例如,Nginx 应用过程,Memcache 应用线程。
1. 单 Reactor 单过程 / 线程
单 Reactor 单过程 / 线程的计划示意图如下(以过程为例):
留神,select、accept、read、send 是规范的网络编程 API,dispatch 和“业务解决”是须要实现的操作,其余计划示意图相似。
具体阐明一下这个计划:
- Reactor 对象通过 select 监控连贯事件,收到事件后通过 dispatch 进行散发。
- 如果是连贯建设的事件,则由 Acceptor 解决,Acceptor 通过 accept 承受连贯,并创立一个 Handler 来解决连贯后续的各种事件。
- 如果不是连贯建设事件,则 Reactor 会调用连贯对应的 Handler(第 2 步中创立的 Handler)来进行响应。
- Handler 会实现 read-> 业务解决 ->send 的残缺业务流程。
单 Reactor 单过程的模式长处就是很简略,没有过程间通信,没有过程竞争,全副都在同一个过程内实现。但其毛病也是非常明显,具体表现有:
- 只有一个过程,无奈施展多核 CPU 的性能;只能采取部署多个零碎来利用多核 CPU,但这样会带来运维复杂度,原本只有保护一个零碎,用这种形式须要在一台机器上保护多套零碎。
- Handler 在解决某个连贯上的业务时,整个过程无奈解决其余连贯的事件,很容易导致性能瓶颈。
因而,单 Reactor 单过程的计划在实践中利用场景不多,只实用于业务解决十分疾速的场景,目前比拟驰名的开源软件中应用单 Reactor 单过程的是 Redis。
须要留神的是,C 语言编写零碎的个别应用单 Reactor 单过程,因为没有必要在过程中再创立线程;而 Java 语言编写的个别应用单 Reactor 单线程,因为 Java 虚拟机是一个过程,虚拟机中有很多线程,业务线程只是其中的一个线程而已。
2. 单 Reactor 多线程
为了克服单 Reactor 单过程 / 线程计划的毛病,引入多过程 / 多线程是不言而喻的,这就产生了第 2 个计划:单 Reactor 多线程。
单 Reactor 多线程计划示意图是:
我来介绍一下这个计划:
- 主线程中,Reactor 对象通过 select 监控连贯事件,收到事件后通过 dispatch 进行散发。
- 如果是连贯建设的事件,则由 Acceptor 解决,Acceptor 通过 accept 承受连贯,并创立一个 Handler 来解决连贯后续的各种事件。
- 如果不是连贯建设事件,则 Reactor 会调用连贯对应的 Handler(第 2 步中创立的 Handler)来进行响应。
- Handler 只负责响应事件,不进行业务解决;Handler 通过 read 读取到数据后,会发给 Processor 进行业务解决。
- Processor 会在独立的子线程中实现真正的业务解决,而后将响应后果发给主过程的 Handler 解决;Handler 收到响应后通过 send 将响应后果返回给 client。
单 Reator 多线程计划可能充分利用多核多 CPU 的解决能力,但同时也存在上面的问题:
- 多线程数据共享和拜访比较复杂。例如,子线程实现业务解决后,要把后果传递给主线程的 Reactor 进行发送,这里波及共享数据的互斥和爱护机制。以 Java 的 NIO 为例,Selector 是线程平安的,然而通过 Selector.selectKeys()返回的键的汇合是非线程平安的,对 selected keys 的解决必须单线程解决或者采取同步措施进行爱护。
- Reactor 承当所有事件的监听和响应,只在主线程中运行,霎时高并发时会成为性能瓶颈。
你可能会发现,我只列出了“单 Reactor 多线程”计划,没有列出“单 Reactor 多过程”计划,这是什么起因呢?次要起因在于如果采纳多过程,子过程实现业务解决后,将后果返回给父过程,并告诉父过程发送给哪个 client,这是很麻烦的事件。因为父过程只是通过 Reactor 监听各个连贯上的事件而后进行调配,子过程与父过程通信时并不是一个连贯。如果要将父过程和子过程之间的通信模仿为一个连贯,并退出 Reactor 进行监听,则是比较复杂的。而采纳多线程时,因为多线程是共享数据的,因而线程间通信是十分不便的。尽管要额定思考线程间共享数据时的同步问题,但这个复杂度比过程间通信的复杂度要低很多。
3. 多 Reactor 多过程 / 线程
为了解决单 Reactor 多线程的问题,最直观的办法就是将单 Reactor 改为多 Reactor,这就产生了第 3 个计划:多 Reactor 多过程 / 线程。
多 Reactor 多过程 / 线程计划示意图是(以过程为例):
计划具体阐明如下:
- 父过程中 mainReactor 对象通过 select 监控连贯建设事件,收到事件后通过 Acceptor 接管,将新的连贯调配给某个子过程。
- 子过程的 subReactor 将 mainReactor 调配的连贯退出连贯队列进行监听,并创立一个 Handler 用于解决连贯的各种事件。
- 当有新的事件产生时,subReactor 会调用连贯对应的 Handler(即第 2 步中创立的 Handler)来进行响应。
- Handler 实现 read→业务解决→send 的残缺业务流程。
多 Reactor 多过程 / 线程的计划看起来比单 Reactor 多线程要简单,但理论实现时反而更加简略,次要起因是:
- 父过程和子过程的职责十分明确,父过程只负责接管新连贯,子过程负责实现后续的业务解决。
- 父过程和子过程的交互很简略,父过程只须要把新连贯传给子过程,子过程毋庸返回数据。
- 子过程之间是相互独立的,毋庸同步共享之类的解决(这里仅限于网络模型相干的 select、read、send 等毋庸同步共享,“业务解决”还是有可能须要同步共享的)。
目前驰名的开源零碎 Nginx 采纳的是多 Reactor 多过程,采纳多 Reactor 多线程的实现有 Memcache 和 Netty。
我多说一句,Nginx 采纳的是多 Reactor 多过程的模式,但计划与规范的多 Reactor 多过程有差别。具体差别体现为主过程中仅仅创立了监听端口,并没有创立 mainReactor 来“accept”连贯,而是由子过程的 Reactor 来“accept”连贯,通过锁来管制一次只有一个子过程进行“accept”,子过程“accept”新连贯后就放到本人的 Reactor 进行解决,不会再调配给其余子过程,更多细节请查阅相干材料或浏览 Nginx 源码。
Proactor
Reactor 是非阻塞同步网络模型,因为真正的 read 和 send 操作都须要用户进程同步操作。这里的“同步”指用户过程在执行 read 和 send 这类 I / O 操作的时候是同步的,如果把 I / O 操作改为异步就可能进一步晋升性能,这就是异步网络模型 Proactor。
Proactor 中文翻译为“前摄器”比拟难了解,与其相似的单词是 proactive,含意为“被动的”,因而咱们照猫画虎翻译为“被动器”反而更好了解。Reactor 能够了解为“来了事件我告诉你,你来解决”,而 Proactor 能够了解为“来了事件我来解决,解决完了我告诉你”。这里的“我”就是操作系统内核,“事件”就是有新连贯、有数据可读、有数据可写的这些 I / O 事件,“你”就是咱们的程序代码。
Proactor 模型示意图是:
具体介绍一下 Proactor 计划:
- Proactor Initiator 负责创立 Proactor 和 Handler,并将 Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核。
- Asynchronous Operation Processor 负责解决注册申请,并实现 I / O 操作。
- Asynchronous Operation Processor 实现 I / O 操作后告诉 Proactor。
- Proactor 依据不同的事件类型回调不同的 Handler 进行业务解决。
- Handler 实现业务解决,Handler 也能够注册新的 Handler 到内核过程。
实践上 Proactor 比 Reactor 效率要高一些,异步 I / O 可能充分利用 DMA 个性,让 I / O 操作与计算重叠,但要实现真正的异步 I /O,操作系统须要做大量的工作。目前 Windows 下通过 IOCP 实现了真正的异步 I /O,而在 Linux 零碎下的 AIO 并不欠缺,因而在 Linux 下实现高并发网络编程时都是以 Reactor 模式为主。所以即便 Boost.Asio 号称实现了 Proactor 模型,其实它在 Windows 下采纳 IOCP,而在 Linux 下是用 Reactor 模式(采纳 epoll)模仿进去的异步模型。
高性能集群架构
单服务器无论如何优化,无论采纳多好的硬件,总会有一个性能天花板,当单服务器的性能无奈满足业务需要时,就须要设计高性能集群来晋升零碎整体的解决性能。
高性能集群的实质很简略,通过减少更多的服务器来晋升零碎整体的计算能力。因为计算自身存在一个特点:同样的输出数据和逻辑,无论在哪台服务器上执行,都应该失去雷同的输入。因而高性能集群设计的复杂度次要体现在任务分配这部分,须要设计正当的任务分配策略,将计算任务分配到多台服务器上执行。
高性能集群的复杂性次要体现在须要减少一个工作分配器,以及为工作抉择一个适合的任务分配算法 。对于工作分配器,当初更风行的通用叫法是“负载均衡器”。但这个名称有肯定的误导性,会让人潜意识里认为任务分配的目标是要放弃各个计算单元的负载达到平衡状态。而实际上任务分配并不只是思考计算单元的负载平衡,不同的任务分配算法指标是不一样的,有的基于负载思考,有的基于性能(吞吐量、响应工夫)思考,有的基于业务思考。思考到“负载平衡”曾经成为了事实上的规范术语,这里我也用“负载平衡”来代替“任务分配”,但请你时刻记住, 负载平衡不只是为了计算单元的负载达到平衡状态。
负载平衡分类
常见的负载平衡零碎包含 3 种:DNS 负载平衡、硬件负载平衡和软件负载平衡。
DNS 负载平衡
DNS 是最简略也是最常见的负载平衡形式,个别用来实现天文级别的平衡。例如,南方的用户拜访北京的机房,北方的用户拜访深圳的机房。DNS 负载平衡的实质是 DNS 解析同一个域名能够返回不同的 IP 地址。例如,同样是 www.baidu.com,南方用户解析后获取的地址是 61.135.165.224(这是北京机房的 IP),北方用户解析后获取的地址是 14.215.177.38(这是深圳机房的 IP)。
上面是 DNS 负载平衡的简略示意图:
DNS 负载平衡实现简略、成本低,但也存在粒度太粗、负载平衡算法少等毛病。仔细分析一下优缺点,其长处有:
- 简略、成本低:负载平衡工作交给 DNS 服务器解决,毋庸本人开发或者保护负载平衡设施。
- 就近拜访,晋升访问速度:DNS 解析时能够依据申请起源 IP,解析成间隔用户最近的服务器地址,能够放慢访问速度,改善性能。
毛病有:
- 更新不及时:DNS 缓存的工夫比拟长,批改 DNS 配置后,因为缓存的起因,还是有很多用户会持续拜访批改前的 IP,这样的拜访会失败,达不到负载平衡的目标,并且也影响用户失常应用业务。
- 扩展性差:DNS 负载平衡的控制权在域名商那里,无奈依据业务特点针对其做更多的定制化性能和扩大个性。
- 调配策略比较简单:DNS 负载平衡反对的算法少;不能辨别服务器的差别(不能依据零碎与服务的状态来判断负载);也无奈感知后端服务器的状态。
针对 DNS 负载平衡的一些毛病,对于时延和故障敏感的业务,有一些公司本人实现了 HTTP-DNS 的性能,即应用 HTTP 协定实现一个公有的 DNS 零碎。这样的计划和通用的 DNS 优缺点正好相同。
硬件负载平衡
硬件负载平衡是通过独自的硬件设施来实现负载平衡性能,这类设施和路由器、交换机相似,能够了解为一个用于负载平衡的根底网络设备。目前业界典型的硬件负载平衡设施有两款:F5 和 A10。这类设施性能强劲、功能强大,但价格都不便宜,个别只有“土豪”公司才会思考应用此类设施。一般业务量级的公司一是累赘不起,二是业务量没那么大,用这些设施也是节约。
硬件负载平衡的长处是:
- 功能强大:全面反对各层级的负载平衡,反对全面的负载平衡算法,反对全局负载平衡。
- 性能弱小:比照一下,软件负载平衡反对到 10 万级并发曾经很厉害了,硬件负载平衡能够反对 100 万以上的并发。
- 稳定性高:商用硬件负载平衡,通过了良好的严格测试,通过大规模应用,稳定性高。
- 反对平安防护:硬件平衡设施除具备负载平衡性能外,还具备防火墙、防 DDoS 攻打等平安性能。
硬件负载平衡的毛病是:
- 价格昂贵:最一般的一台 F5 就是一台“马 6”,好一点的就是“Q7”了。
- 扩大能力差:硬件设施,能够依据业务进行配置,但无奈进行扩大和定制。
软件负载平衡
软件负载平衡通过负载平衡软件来实现负载平衡性能,常见的有 Nginx 和 LVS,其中 Nginx 是软件的 7 层负载平衡,LVS 是 Linux 内核的 4 层负载平衡。4 层和 7 层的区别就在于 协定 和 灵活性,Nginx 反对 HTTP、E-mail 协定;而 LVS 是 4 层负载平衡,和协定无关,简直所有利用都能够做,例如,聊天、数据库等。
软件和硬件的最次要区别就在于性能,硬件负载平衡性能远远高于软件负载平衡性能。Nginx 的性能是万级,个别的 Linux 服务器上装一个 Nginx 大略能到 5 万 / 秒;LVS 的性能是十万级,据说可达到 80 万 / 秒;而 F5 性能是百万级,从 200 万 / 秒到 800 万 / 秒都有(数据起源网络,仅供参考,如需采纳请依据理论业务场景进行性能测试)。当然,软件负载平衡的最大劣势是便宜,一台一般的 Linux 服务器批发价大略就是 1 万元左右,相比 F5 的价格,那就是自行车和宝马的区别了。
除了应用开源的零碎进行负载平衡,如果业务比拟非凡,也可能基于开源零碎进行定制(例如,Nginx 插件),甚至进行自研。
上面是 Nginx 的负载平衡架构示意图:
软件负载平衡的长处:
- 简略:无论是部署还是保护都比较简单。
- 便宜:只有买个 Linux 服务器,装上软件即可。
- 灵便:4 层和 7 层负载平衡能够依据业务进行抉择;也能够依据业务进行比拟不便的扩大,例如,能够通过 Nginx 的插件来实现业务的定制化性能。
其实上面的毛病都是和硬件负载平衡相比的,并不是说软件负载平衡没法用。
- 性能个别:一个 Nginx 大概能撑持 5 万并发。
- 性能没有硬件负载平衡那么弱小。
- 个别不具备防火墙和防 DDoS 攻打等平安性能。
负载平衡典型架构
后面咱们介绍了 3 种常见的负载平衡机制:DNS 负载平衡、硬件负载平衡、软件负载平衡,每种形式都有一些优缺点,但并不意味着在理论利用中只能基于它们的优缺点进行非此即彼的抉择,反而是基于它们的优缺点进行组合应用。具体来说,组合的 根本准则 为:DNS 负载平衡用于实现天文级别的负载平衡;硬件负载平衡用于实现集群级别的负载平衡;软件负载平衡用于实现机器级别的负载平衡。
我以一个假想的实例来阐明一下这种组合形式,如下图所示。
整个零碎的负载平衡分为三层。
- 天文级别负载平衡:www.xxx.com 部署在北京、广州、上海三个机房,当用户拜访时,DNS 会依据用户的地理位置来决定返回哪个机房的 IP,图中返回了广州机房的 IP 地址,这样用户就拜访到广州机房了。
- 集群级别负载平衡:广州机房的负载平衡用的是 F5 设施,F5 收到用户申请后,进行集群级别的负载平衡,将用户申请发给 3 个本地集群中的一个,咱们假如 F5 将用户申请发给了“广州集群 2”。
- 机器级别的负载平衡:广州集群 2 的负载平衡用的是 Nginx,Nginx 收到用户申请后,将用户申请发送给集群外面的某台服务器,服务器解决用户的业务申请并返回业务响应。
须要留神的是,上图只是一个示例,个别在大型业务场景下才会这样用,如果业务量没这么大,则没有必要严格照搬这套架构。例如,一个大学的论坛,齐全能够不须要 DNS 负载平衡,也不须要 F5 设施,只须要用 Nginx 作为一个简略的负载平衡就足够了。
接下来我介绍一下负载平衡算法以及它们的优缺点:
轮询
负载平衡零碎收到申请后,依照程序轮流调配到服务器上。
轮询是最简略的一个策略,毋庸关注服务器自身的状态,例如:
- 某个服务器以后因为触发了程序 bug 进入了死循环导致 CPU 负载很高,负载平衡零碎是不感知的,还是会持续将申请源源不断地发送给它。
- 集群中有新的机器是 32 核的,老的机器是 16 核的,负载平衡零碎也是不关注的,新老机器调配的工作数是一样的。
须要留神的是负载平衡零碎毋庸关注“服务器自身状态”,这里的关键词是“自身”。也就是说,只有服务器在运行,运行状态是不关注的。但如果服务器间接宕机了,或者服务器和负载平衡零碎断连了,这时负载平衡零碎是可能感知的,也须要做出相应的解决。例如,将服务器从可调配服务器列表中删除,否则就会呈现服务器都宕机了,工作还一直地调配给它,这显著是不合理的。
总而言之,“简略”是轮询算法的长处,也是它的毛病。
加权轮询
负载平衡零碎依据服务器权重进行任务分配,这里的权重个别是依据硬件配置进行动态配置的,采纳动静的形式计算会更加符合业务,但复杂度也会更高。
加权轮询是轮询的一种非凡模式,其次要目标就是为了 解决不同服务器解决能力有差别的问题。例如,集群中有新的机器是 32 核的,老的机器是 16 核的,那么实践上咱们能够假如新机器的解决能力是老机器的 2 倍,负载平衡零碎就能够依照 2:1 的比例调配更多的工作给新机器,从而充分利用新机器的性能。
加权轮询解决了轮询算法中无奈依据服务器的配置差别进行任务分配的问题,但同样存在无奈依据服务器的状态差别进行任务分配的问题。
负载最低优先
负载平衡零碎将任务分配给以后负载最低的服务器,这里的负载依据不同的工作类型和业务场景,能够用不同的指标来掂量。例如:
- LVS 这种 4 层网络负载平衡设施,能够以“连接数”来判断服务器的状态,服务器连接数越大,表明服务器压力越大。
- Nginx 这种 7 层网络负载零碎,能够以“HTTP 申请数”来判断服务器状态(Nginx 内置的负载平衡算法不反对这种形式,须要进行扩大)。
- 如果咱们本人开发负载平衡零碎,能够依据业务特点来抉择指标掂量零碎压力。如果是 CPU 密集型,能够以“CPU 负载”来掂量零碎压力;如果是 I / O 密集型,能够以“I/ O 负载”来掂量零碎压力。
负载最低优先的算法解决了轮询算法中无奈感知服务器状态的问题,由此带来的代价是复杂度要减少很多。例如:
- 起码连接数优先的算法要求负载平衡零碎统计每个服务器以后建设的连贯,其利用场景仅限于负载平衡接管的任何连贯申请都会转发给服务器进行解决,否则如果负载平衡零碎和服务器之间是固定的连接池形式,就不适宜采取这种算法。例如,LVS 能够采取这种算法进行负载平衡,而一个通过连接池的形式连贯 MySQL 集群的负载平衡零碎就不适宜采取这种算法进行负载平衡。
- CPU 负载最低优先的算法要求负载平衡零碎以某种形式收集每个服务器的 CPU 负载,而且要确定是以 1 分钟的负载为规范,还是以 15 分钟的负载为规范,不存在 1 分钟必定比 15 分钟要好或者差。不同业务最优的工夫距离是不一样的,工夫距离太短容易造成频繁稳定,工夫距离太长又可能造成峰值来长期响应迟缓。
负载最低优先算法基本上可能比拟完满地解决轮询算法的毛病,因为采纳这种算法后,负载平衡零碎须要感知服务器以后的运行状态。当然,其代价是复杂度大幅回升。艰深来讲,轮询可能是 5 行代码就能实现的算法,而负载最低优先算法可能要 1000 行能力实现,甚至须要负载平衡零碎和服务器都要开发代码。负载最低优先算法如果自身没有设计好,或者不适宜业务的运行特点,算法自身就可能成为性能的瓶颈,或者引发很多莫名其妙的问题。所以负载最低优先算法尽管成果看起来很美妙,但实际上真正利用的场景反而没有轮询(包含加权轮询)那么多。
性能最优类
负载最低优先类算法是站在服务器的角度来进行调配的,而性能最优优先类算法则是站在客户端的角度来进行调配的,优先将任务分配给处理速度最快的服务器,通过这种形式达到最快响应客户端的目标。
和负载最低优先类算法相似,性能最优优先类算法实质上也是感知了服务器的状态,只是通过响应工夫这个内部规范来掂量服务器状态而已。因而性能最优优先类算法存在的问题和负载最低优先类算法相似,复杂度都很高,次要体现在:
- 负载平衡零碎须要收集和剖析每个服务器每个工作的响应工夫,在大量工作解决的场景下,这种收集和统计自身也会耗费较多的性能。
- 为了缩小这种统计上的耗费,能够采取采样的形式来统计,即不统计所有工作的响应工夫,而是抽样统计局部工作的响应工夫来估算整体工作的响应工夫。采样统计尽管可能缩小性能耗费,但使得复杂度进一步回升,因为要确定适合的 采样率,采样率太低会导致后果不精确,采样率太高会导致性能耗费较大,找到适合的采样率也是一件简单的事件。
- 无论是全副统计还是采样统计,都须要抉择适合的 周期:是 10 秒内性能最优,还是 1 分钟内性能最优,还是 5 分钟内性能最优……没有放之四海而皆准的周期,须要依据理论业务进行判断和抉择,这也是一件比较复杂的事件,甚至呈现零碎上线后须要一直地调优能力达到最优设计。
Hash 类
负载平衡零碎依据工作中的某些要害信息进行 Hash 运算,将雷同 Hash 值的申请调配到同一台服务器上,这样做的目标次要是为了满足特定的业务需要。例如:
- 源地址 Hash
将来源于同一个源 IP 地址的任务分配给同一个服务器进行解决,适宜于存在事务、会话的业务。例如,当咱们通过浏览器登录网上银行时,会生成一个会话信息,这个会话是长期的,敞开浏览器后就生效。网上银行后盾毋庸长久化会话信息,只须要在某台服务器上长期保留这个会话就能够了,但须要保障用户在会话存在期间,每次都能拜访到同一个服务器,这种业务场景就能够用源地址 Hash 来实现。
- ID Hash
将某个 ID 标识的业务调配到同一个服务器中进行解决,这里的 ID 个别是临时性数据的 ID(如 session id)。例如,上述的网上银行登录的例子,用 session id hash 同样能够实现同一个会话期间,用户每次都是拜访到同一台服务器的目标。
总结
本文讲述了高性能架构设计中缓存设计须要留神的几个关键点,常见的缓存问题,并围绕单台服务器的高性能模式,介绍了 PPC,TPC 的模式,之后介绍了实用于互联网海量用户的 Reactor,Proactor 模式,这两种模式是反对高并发的架构模型,最初咱们谈了集群下的负载平衡以及典型架构。如果本文对你有帮忙的话,欢送点赞分享,这对我持续分享 & 创作优质文章十分重要。感激!
本文由 mdnice 多平台公布