关于高性能高并发服务这个概念大家应该也都比较熟悉了,今天为大家带来如何做一个高性能高并发服务架构的实践和思考。
本次分享主要包括三个部分:
- 服务的瓶颈有哪些
- 如何提升整体服务的性能及并发
- 如何提升单机服务的性能及并发
服务的瓶颈有哪些
通常来说程序的定义是算法+数据结构+数据,算法简单的理解就是一种计算方式,数据结构顾名思义是一种存储组织数据的结构。
这两者体现了程序需要用到的计算机资源,涉及到 CPU 资源、内存资源,而数据部分除了内存资源,往往还可能涉及到硬盘资源,甚至是彼此之间传输数据时会消耗网络(网卡)资源。
当我们搞清楚程序运行起来时涉及哪些资源后,就可以更好地分析我们的服务中哪些可能是临界资源。
所谓临界资源就是多个进程(线程)并发访问某个资源时,该资源同时只能服务某个或者某些进程(线程)。
服务的瓶颈主要就是在这些临界资源上,还有一些资源原本并不是临界资源。
比如内存在一开始是够的,但是因为连接数或者线程数不断地增多,最终导致其成为临界资源,其他的 CPU、磁盘、网卡其实和内存一样,在访问量增大以后一样都可能会成为瓶颈。
所以怎么做到高性能高并发的服务,简单地说就是找到服务的瓶颈,在合理的范围内尽可能的消除瓶颈或者降低瓶颈带来的影响。
再通俗一点的说就是资源总量不够就加资源,什么资源不够就加什么资源,同时尽量降低单次访问的资源消耗,做到在资源总量一定的情况下有能力支撑更多的访问。
如何提升整体服务的性能及并发
数据拆分
最典型的一个临界资源就是数据库,数据库在一个大访问量的系统中往往是最薄弱的一环,因为数据库本身的服务能力是有限的。
以 MySQL 为例,MySQL 可以支持的并发连接数可能也就几千个,假设是 3000 个,一个服务对其数据库的并发访问如果超过了 3000 个,有部分访问可能在建立连接的时候就失败了。
在这种情况下,需要考虑的是如何将数据进行分片,引入多个 MySQL 实例,增加资源,如图 1 所示。
图 1:单数据实例改成数据库集群
数据库这个临界资源通过数据拆分的方式,由原来的一个 MySQL 实例变成了多个 MySQL 实例。
这种情况下数据库资源的整体并发服务能力自然提升了,同时由于服务压力被分散,整个数据库集群表现出来的性能也会比单个数据库实例高很多。
存储类的解决思路基本是类似的,都是将数据拆分,通过引入多个存储服务实例提升整体存储服务的能力,不管对于 SQL 类的还是 NoSQL 类的或文件存储系统等都可以采用这个思路。
服务拆分
应用程序自身的服务需要根据业务情况进行合理的细化,让每个服务只负责某一类功能,这个思想和微服务思想类似。
一句话就是尽量合理地将服务拆分,同时有一个非常重要的原则是让拆分以后的同类服务尽量是无状态或弱关联,这样就可以很容易进行水平扩展。
如果拆分以后的同类服务的不同实例之间本身是有一些状态引起彼此非常强的依赖,比如彼此要共享一些信息这些信息又会彼此影响,那这种拆分可能就未必非常的合理,需要结合业务重新进行审视。
当然生产环节上下游拆分以后不同的服务彼此之间的关联又是另外一种情形,因为同一个生产环节上往往是走完一个服务环节才能进入下一个服务环节。
相当于有多个串行的服务,任何一个环节的服务都有可能出现瓶颈,所以需要拆分以后针对相应的服务进行单独优化,这是拆分以后服务与服务之间的关系。
假设各个同类服务本身是无状态或者弱依赖的情况下,针对应用服务进行分析,不同的应用服务不太一样,但是通常都会涉及到内存资源以及计算资源。
以受内存资源限制为例,一个应用服务能承受的连接数是有限的(连接数受限),另外如果涉及上传下载等大量数据传输的情况,网络资源很快就会成为瓶颈(网卡打满)。
这种情况下最简单的方式就是同样的应用服务实例部署多份,达到水平扩展,如图 2 所示。
图 2:服务拆分
实际在真正拆分的时候需要考虑具体的业务特点,比如像京东主站这种类型的网站,用户在访问的时候除了加载基本信息以外,还有商品图片信息、价格信息、库存信息、购物车信息以及订单信息、发票信息等。
以及下单完成以后对应的分拣配送等配套的物流服务,这些都可以拆成单独的服务,拆分以后各个服务各司其职也能做更好的优化。
服务拆分这件事情,打个不是特别恰当的比方,就好比上学时都是学习,但是分了很多的科目,高考的时候要看总分,有些同学会有偏科的现象,有些科成绩好有些科成绩差一点。
因为分很多科目所以很容易知道自己哪科是比较强的、哪科是比较弱的,为了保证总体分数最优,一般在弱的科目上都需要多花点精力努力提高一下分数,不然总体分数不会太高。
服务拆分也是同样的道理,拆分以后可以很容易知道哪个服务是整体服务的瓶颈,针对瓶颈服务再进行重点优化就可以比较容易的提升整体服务的能力。
增长服务链路
在大型的网站服务方案上,在各种合理拆分以后,数据拆分以及服务拆分支持扩展只是其中的一部分工作,之后还要根据需求看看是否需要引入缓存 CDN 之类的服务。
我把这个叫做增长服务链路,原来直接打到数据库的请求,现在可能变成了先打到缓存再打到数据库,对整个服务链路长度来说是变长的。
增长服务链路的原则主要是将越脆弱或者说越容易成为瓶颈的资源(比如数据库)放置在链路的越末端。
在增长完服务链路之后,还要尽量的缩短访问链路,比如可以在 CDN 层面就返回的就尽量不要继续往下走了。
如果可以在缓存层面返回的就不要去访问数据库了,尽可能地让每次的访问链路变短。
可以一步解决的事情就一步解决,可以两步解决的事情就不要走第三步,本质上是降低每次访问的资源消耗,尤其是越到链路的末端访问资源的消耗会越大。
比如获取一些产品的图片信息可以在访问链路的最前端使用 CDN,将访问尽量挡住。
如果 CDN 上没有命中,就继续往后端访问,利用 Nginx 等反向代理将访问打到相应的图片服务器上,而图片服务器本身又可以针对性的做一些访问优化等。
比如像价格等信息比较敏感,如果有更改可能需要立即生效,需要直接访问最新的数据,但是如果让访问直接打到数据库中,数据库往往直接就打挂了。
所以可以考虑在数据库之前引入 Redis 等缓存服务,将访问打到缓存上,价格服务系统本身保证数据库和缓存的强一致,降低对数据库的访问压力。
在极端情况下,数据量虽然不是特别大,几十台缓存机器就可以抗住,但访问量可能会非常大,可以将所有的数据都放在缓存中,如果缓存有异常甚至都不用去访问数据库直接返回访问失败即可。
因为在访问量非常大的情况下,如果缓存挂了,访问直接打到数据库上,可能瞬间就把数据库打趴下了。
所以在特定场景下可以考虑将缓存和数据库切开,服务只访问缓存,缓存失效重新从数据库中加载数据到缓存中再对外服务也是可以的,所以在实践中是可以灵活变通的。
小结
如何提升整体服务的性能及并发,一句话概括就是:在合理范围内尽可能的拆分,拆分以后同类服务可以通过水平扩展达到整体的高性能高并发。
同时将越脆弱的资源放置在链路的越末端,访问的时候尽量将访问链接缩短,降低每次访问的资源消耗。
如何提升单机服务的性能及并发
前面说的这些情况可以解决大访问量情况下的高并发问题,但是高性能最终还是要依赖单台应用的性能。
如果单台应用性能在低访问量情况下性能已经成渣了,那部署再多机器也解决不了问题,所以接下来聊一下单台服务本身如果支持高性能高并发。
多线程/线程池方式
以 TCP server 为例来展开说明,最简单的一个 TCP server 代码,版本一示例如图 3 所示。
图 3:版本一
这种方式纯粹是一个示例,因为这个 server 启动以后只能接受一条连接,也就是只能跟一个客户端互动,且该连接断开以后,后续就连不上了,也就是这个 server 只能服务一次。
这个当然是不行的,于是就有了版本二,如图 4 所示,版本二可以一次接受一条连接,并进行一些交互处理,当这条连接全部处理完以后才能继续下一条连接。
图 4:版本二
这个 server 相当于是串行的,没有并发可言,所以在版本二的基础上又演化出了版本三,如图 5 所示。
图 5:版本三
这其实是我们经常会接触到的一种模型,这种模型的特点是每连接每线程,MySQL 5.5 以前用的就是这种模型,这种模型的特点是当有大量连接的时候会创建大量的线程。
所以往往需要限制连接总数,如果不做限制可能会出现创建了大量的线程,很快就会将内存等资源耗干。
另一个是当出现了大量的线程的时候,操作系统会有大量的 CPU 资源花费在线程间的上下文切换上,导致真正给业务提供服务的 CPU 资源比例反倒很小。
同时,考虑到大多数时候即使有很多连接也并不代表所有的连接在同一个时刻都是活跃的,所以版本三又演化出了版本四,如图 6 所示。
图 6:版本四
版本四的时候是很多的连接共享一个线程池,这些线程池里的线程数是固定的,这样就可以做到线程池里的一个线程同时服务多条连接了,MySQL 5.6 之后采用的就是这种方式。
在绝大多数的开发中,线程池技术就已经足够了,但是线程池在充分榨干 CPU 计算资源或者说提供有效计算资源方面并不是最完美的。
以一核的计算资源为例,线程池里假设有 x 个线程,这 x 个线程会被操作系统依据具体调度策略进行调度,但是线程上下文切换本身是会消耗一定的 CPU 资源的。
假设这部分消耗代价是 w,而实际有效服务的能力是 c,那么理论上来说 w+c 就是总的 CPU 实际提供的计算资源,同时假设一核 CPU 理论上提供计算资源假设为 t,这个是固定的。
所以就会出现一种情况:当线程池中线程数量较少的时候并发度较低,w 虽然小了,但是 c 也是比较小的,也就是 w+c < t,甚至是远远小于 t,如果线程数很多,又会出现上下文切换代价太大,即 w 变大了。
虽然 c 也随之提升了一些,但因为 t 是固定的,所以 c 的上限值一定是小于 t-w 的,而且随着 w 越大,c 的上限值反倒降低了,因此使用线程池的时候,线程数的设置需要根据实际情况进行调整。
基于事件驱动的模式
多线程(线程池)的方式可以较为方便地进行并发编程,但是多线程的方式对 CPU 的有效利用率并不是最高的,真正能够充分利用 CPU 的编程方式是尽量让 CPU 一直在工作,同时又尽量避免线程的上下文切换等开销。
基于事件驱动的模式(也称 I/O 多路复用)在充分利用 CPU 有效计算能力这件事件上是非常出色的。
比较典型的有 select/poll/epoll/kevent(这些机制本身之间的优劣今天先不展开说明,后续以 epoll 为例说明)。
这种模式的特点是将要监听的 socket fd 注册在 epoll 上,等这个描述符可读事件或者可写事件就绪了,那么就会触发相应的读操作或者写操作。
可以简单地理解为需要 CPU 干活的时候就会告知 CPU 需要做什么事情,实际使用时示例,如图 7 所示。
图 7:epoll 示例
这个事情拿一个经典的例子来说明:假如在餐厅就餐,餐厅里有很多顾客(访问),每连接每线程的方式相当于每个客户一个服务员(线程相当于一个服务员)。
服务的过程中一个服务员一直为一个客户服务,那就会出现这个服务员除了真正提供服务以外有很大一段时间可能是空闲的,且随着客户数越多服务员数量也会越多,可餐厅的容量是有限的。
因为要同时容纳相同数量的服务员和顾客,所以餐厅服务顾客的数量将变成理论容量的 50%。
那这件事件对于老板(老板相当于开发人员,希望可以充分利用 CPU 的计算能力,也就是在 CPU 计算能力<成本>一定的情况下希望尽量的多做一些事情)来说代价就会很大。
线程池的方式是雇佣固定数量的服务员,服务的时候一个服务员服务好几个客户,可以理解为一个服务员在客户 A 面前站 1 分钟,看看 A 客户是否需要服务。
如果不需要就到 B 客户那边站 1 分钟,看看 B 客户是否需要服务,以此类推。这种情况会比之前每个客户一个服务员的情况节省一些成本,但是还是会出现一些成本上的浪费。
还有一种模式也就是 epoll 的方式,相当于服务员就在总台等着,客户有需要的时候就会在桌上的呼叫器上按一下按钮表示自己需要服务,服务员每次看一下总台显示的信息。
比如一共有 100 个客户,一次可能有 10 个客户呼叫,这个服务员就会过去为这 10 个客户服务(假设服务每个客户的时候不会出现停顿且可以在较短的时间内处理完)。
等这个服务员为这 10 个客户服务员完以后再重新回到总台查看哪些客户需要服务,依此类推。在这种情况下,可能只需要一个服务员,而餐厅剩余的空间可以全部给客户使用。
Nginx 服务器性能非常好,也能支撑非常多的连接,其网络模型使用的就是 epoll 的方式,且在实现的时候采用了多个子进程的方式。
相当于同时有多个 epoll 在工作,充分利用了 CPU 多核的特性,所以并发及性能都会比单个 epoll 的方式会有更大的提升。
另外 Redis 缓存服务器大家应该也非常熟悉,用的也是 epoll 的方式,性能也是非常好。
通过这些现成的经典开源项目,大家就可以直观地理解基于事件驱动这一方式在实际生产环境中的性能是非常高的,性能提升以后并发效果一般都会随之提升。
但是这种方式在实现的时候是非常考验编程功底以及逻辑严谨性,换句话编程友好性是非常差的。
因为一个完整的上下文逻辑会被切成很多片段,比如“客户端发送一个命令-服务器端接收命令进行操作-然后返回结果”这个过程。
这个过程至少会包括一个可读事件、一个可写事件。可读事件,简单地理解就是指这条命令已经发送到服务器端的 tcp 缓存区了,服务器去读取命令(假设一次读取完,如果一次读取的命令不完整,可能会触发多次读事件)。
服务器再根据命令进行操作获取到结果,同时注册一个可写事件到 epoll 上,等待下一次可写事件触发以后再将结果发送出去。
想象一下当有很多客户端同时来访问时,服务器就会出现一种情况——一会儿在处理某个客户端的读事件,一会儿在处理另外的客户端的写事件。
总之都是在做一个完整访问的上下文中的一个片段,其中任何一个片段有等待或者卡顿都将引起整个程序的阻塞。
当然这个问题在多线程编程时也是同样是存在的,只不过有时候大家习惯将线程设置成多个,有些线程阻塞了,但可能其他线程并没有在同一时刻阻塞。
所以问题不是特别严重,更严谨的做法是在多线程编程时,将线程池的数量调整到最小进行测试。
如果确实有卡顿,可以确保程序在最快的时间内出现卡顿,从而快速确认逻辑上是否有不足或者缺陷,确认这种卡顿本身是否是正常现象。
语言层提供协程支持
多线程编程的方式明显是支持了高并发,但因为整个程序线程间上下文调度可能造成 CPU 的利用率不是那么高,而基于事件驱动的编程方式效果是非常好的。
但对编程功底要求非常高,而且在实现的时候需要花费的时间也是最多的,所以一种比较折中的方式是考虑采用提供协程支持的语言比如 golang 这种的。
简单说就是语言层面抽象出了一种更轻量级的线程,一般称为协程,在 golang 里又叫 goroutine。
这些底层最终也是需要用操作系统的线程去跑,在 golang 的 runtime 实现时底层用到的操作系统的线程数量相对会少一点。
而上层程序里可以跑很多的 goroutine,这些 goroutine 会在语言层面进行调度,看该由哪个线程来最终执行这个 goroutine。
因为 goroutine 之间的切换代价是远小于操作系统线程之间的切换代价,而底层用到的操作系统数量又较少,线程间的上下文切换代价也会大大降低。
这类语言能比其他语言的多线程方式提供更好的并发,因为它将操作系统的线程间切换的代价在语言层面尽可能挤压到最小,同时编程复杂度大大降低,在这类语言中上下文逻辑可以保持连贯。
因为降低了线程间上下文切换的代价,而 goroutine 之间的切换成本相对来说是远远小于线程间切换成本。
所以 CPU 的有效计算能力相对来说也不会太低,可以比较容易的获得了一个高并发且性能还可以的服务。
小结
如何提升单机服务的性能及并发,如果对性能或者高并发的要求没有达到非常苛刻的要求,选型的时候基于事件驱动的方式可以优先级降低一点,选择普通的多线程编程即可(其实多数场景都可以满足了)。
如果想单机的并发程度更好一点,可以考虑选择有协程支持的语言,如果还嫌不够,那就将逻辑理顺,考虑采用基于事件驱动的模式,这个在 C/C++ 里直接用 select/epoll/kevent 等就可以了。
在 Java 里可以考虑采用 NIO 的方式,而从这点上来说像 golang 这种提供协程支持的语言一般是不支持在程序层面自己实现基于事件驱动的编程方式的。
总结
其实并没有一刀切的万能法则,大体原则是根据实际情况具体问题具体分析,找到服务瓶颈,资源不够加资源,尽可能降低每次访问的资源消耗,整体服务每个环节尽量做到可以水平扩展。
同时尽量提高单机的有效利用率,从而确保在扛住整个服务的同时尽量降低资源消耗成本。