大型互联网架构高并发设计
高并发一直是大型互联网架构的重点和难点,尤其是2C类系统,在一些活动大促、节日活动、秒杀和团购场景中,经常面临突然的流量爆发,造成系统的高并发访问。用户访问量可能超出系统的承载能力,从而造成服务器性能下降,导致应用服务器和数据库服务器宕机。
解决方案:这并不是一个单点技术问题,不可能只通过增加数据库和后端服务器的处理能力就能达到对高并发业务的完美支撑,而是需要在整个交易链路上,采用多级策略进行精准和严格的控制,在前端、网络传输、负载、网关、后端、数据库等多个层面进行严格控制。
高并发的主要策略有多级缓存策略、异步化策略和读写分离策略。
2.3.1 多级缓存策略
多级缓存的主要目的是减少客户端与服务端的网络交互,减少用户请求穿透到服务端和数据库。尽量将资源放置在离用户更近的地方,让用户能够更快地得到应答。缓存可分为客户端缓存和服务端缓存两大类。用户访问可以分为静态资源的访问和动态接口的请求两
种。其中H5、安卓、iOS、微信等终端的本地存储都属于客户端缓存的范畴,而CDN、负载、应用内存、缓存中间件都属于服务端缓存。
1.缓存的使用流程
无论是哪种缓存,其使用方式基本都是相同的,缓存的使用流程如图2-80所示。
(1) 第1~5步:客户端发起请求给服务端后,服务端先去缓存中查找是否有符合要求的数据,如果缓存没有命中(没有找到匹配的缓存数据),则再去数据库中查找,数据库中找到数据后,将此数据存入缓存中,然后给客户端应答。
(2) 第6~8步:当用户再次请求相同数据时,服务端可以直接在缓存中找到匹配的数据(缓存命中),然后返回给客户端,这样就避免了数据库查询操作。
因此,缓存位置越靠近用户,应答速度越快。要最大程度地避免对数据库的直接操作,因为数据库数据毕竟存储在磁盘上,磁盘的I/O 性能与内存相差巨大,不但速度慢还会形成性能瓶颈,在高并发场景下很容易出现宕机问题。
图2-80 缓存的使用流程
2.客户端缓存
服务架构整体上可以分为C/S和B/S两大类。
C/S(Client/Server)结构,即客户端/服务器结构,如手机上的
App、计算机上的游戏客户端都属于C/S结构。它的优点是性能更好、更流畅,缺点是升级维护比较麻烦。例如,如果某一款游戏升级,那么所有安装了游戏客户端的用户都必须升级。
B/S(Browser/Server)结构,即浏览器/服务器结构,浏览器实际上也是一种软件客户端,所以B/S结构属于C/S结构的一种。它的优点是升级十分方便,一次升级所有客户端都不需要做任何改变;缺点是性能差一些,容易出现卡顿。
基于B/S结构的客户端缓存要充分利用Local Storage和Session
Storage进行数据存储,如用户基本信息、静态参数信息、字典信息等,并通过设置其失效时间来进行更新,减少与服务端的交互。
使用浏览器的Storage存储键值对比Cookie方式更加友好,容量更大。其中Session Storage属于临时存储,只在浏览器的会话期间有效,浏览器关闭则清空;Local Storage属于长期存储,有效期是永久的,浏览器关闭依然保留,一般可存储5MB左右的数据。Local Storage作用域是协议、主机名、端口。 Session Storage作用域是浏览器窗口、协议、主机名、端口。
对于C/S结构的桌面软件和App应用,可以利用设备的内存、磁盘、客户端数据库来缓存更多信息,如页面、图片、视频等静态资源,系统参数、业务流程数据、字典数据等,从而减少与服务端的交互。
充分利用HTTP缓存来进行静态页面、图片、CSS样式文件、
JavaScript脚本文件的缓存,让静态资源加载得更加迅速。非必要情况下不要禁用HTTP缓存,否则会对性能影响很大。
3. CDN缓存
对于系统中的静态资源访问,主要存在两大问题:网络带宽问题和响应速度问题。
(1) 网络带宽问题。在高并发的情况下会出现页面无法打开、资源加载缓慢的情况,机房的主干网络由于大量的资源请求造成拥堵。带宽与网络供应商、网络设备相关,很难实现动态扩容,而且网络带宽升级成本极高,因此要避免用户对于静态资源的大量请求占用机房带宽。
(2) 响应速度问题。
如图2-81所示,北京、上海、广东的用户都访问相同的资源文件(如某个页面、音频、视频)。服务器部署在北京机房,因此距离北京的用户最近,网络传输最短,网络冲突损耗最低,所以访问速度也最快,而上海、广东的用户访问就会很慢。
图2-81 静态资源加载
解决这个问题,就需要使用到CDN(Content Delivery Network,内容分发网络)技术。CDN可以让用户的访问模式变为图2-82所示的模式。在北京、上海、广东各自建设资源存储服务器,让各个地区的用户都可以访问距离自己最近的节点去获取静态资源,以此来加快不同地区用户的访问速度。
图2-82 CDN资源加载
由于CDN具有加快资源访问速度的能力,因此它也被形象地称为网络加速器。由于资源都存储在用户的边缘,因此这些CDN节点也被称为边缘存储、边缘缓存、边缘服务器。
CDN主要用于缓存静态文件,如网页文件、CSS样式文件、
JavaScript脚本文件、图片文件、视频文件、音频文件等。对于电影、音乐、图片等网站,以及大量应用H5技术的互联网系统都会采用CDN 技术进行静态资源加速。
CDN属于一种基础设置,需要在全国设立存储节点,节点越多则效果越好。因此,一般只有资金雄厚的云服务厂商才能够搭建,企业应用一般是购买这些云服务厂商的CDN服务使用。
由于存储节点遍布全国各地,静态文件的内容如何进行同步就成为一个问题,因此CDN必须具有一个管理节点,负责将资源分发到其他节点,如图2-83所示。
图2-83 CDN资源管理架构
当静态资源发生了更改,就需要将其同步到所有边缘存储节点中,这时就可以在管理节点发起推送动作或刷新动作,更新各个节点的缓存。各种静态资源都可以设置其失效时间,当资源失效后,用户首次访问时,会重新加载该资源。
用户访问静态资源的方式也与使用其他缓存相同,当用户访问某个文件时,会先到距离自己最近的网络设备中查找,如果找到了,则直接返回;如果没有找到,则CDN边缘存储服务器会去管理中心拉取最新文件进行存储,然后再返回给用户。
CDN的节点同步、资源分发是比较耗时的,各个地区的延迟也不相同,有时需要10到20分钟才能保证全国节点同步完毕。
4.负载缓存
服务端一般都会采用负载均衡做集群部署,在减轻单一服务节点压力的同时,增强系统的可靠性,如图2-84所示。负载设备可以是
F5(硬件设备),也可以是LVS、Nginx、HAProxy等软件。为了减少请求穿透到上游服务节点,减轻高并发的压力,就可以在负载节点上做缓存,如果缓存命中了,则直接返回给客户端;如果缓存没有命中,则再去访问上游服务节点,这样就能够最大程度地减少服务请求穿透到上游服务器。需要注意的是,负载缓存一般只针对静态资源做缓存,而不对服务端接口做缓存,因为服务端接口的业务处理和应答消息都是动态变化的。
图2-84 负载均衡
(1)负载缓存性能对比示例。
如图2-85所示,加载某系统页面,需要加载14项资源,总计1.4MB 的传输,在没有开启Nginx缓存设置,也没有开启gzip压缩的情况下,页面加载总计耗时1.41秒。
图2-85 Nginx未开启缓存和gzip时资源加载情况
如图2-86 所示,开启Nginx的gzip压缩,1.4MB资源压缩后变为
564kB,页面加载耗时提升到1.07秒。如图2-87所示,开启Nginx缓存后,只产生208B的传输,同样是加载1.4MB资源,仅用了218毫秒。
图2-86 Nginx开启gzip时资源加载情况
图2-87 Nginx开启缓存和gzip时资源加载情况
通过上面3种情况的对比,可以看出在负载软件上开启压缩和缓存设置,对性能有巨大的提升。
(2) Nginx开启gzip压缩的配置说明。
开启gzip后可以对数据传输进行压缩,提高传输效率。
(3) Nginx开启缓存的配置说明。
将静态文件存储在Nginx上,使用Nginx作为静态服务器,以此来实现动静分离架构模式。上游服务器提供给动态接口(动态服务节
点),因此无须开启缓存。静态资源可以直接利用Nginx的缓存功能,加快访问速度,如图2-88所示。
图2-88 Nginx动静分离架构(1)
动静分离架构不仅有利于增加系统的连接数和提高系统的并发能力,还便于前后端团队分工协作,发挥各自特长。
(4) Nginx反向代理缓存。
如果使用反向代理模式搭建动静分离架构(架构方式如图2-89所示),则需要进行额外配置才能够对上游服务进行缓存。首先要开辟出对应的内存、磁盘空间用于缓存文件存储。
图2-89 Nginx动静分离架构(2)
① 设置HTTP代理缓存。
Nginx代理缓存配置方式如下,参数说明如表2-5所示。
表2-5 Nginx代理缓存参数说明
② 使用代理缓存配置。
其中参数$upstream_cache_status代表缓存的命中状态,主要包括MISS(未命中,请求被转发到上游服务)、HIT (缓存命中)、 EXPIRED(缓存已经过期,请求被转发到上游服务)、UPDATING (正在更新缓存,使用旧的应答)和STALE(后端将得到过期的应答)。
如果某次访问使用了Nginx缓存,则应答头返回Nginx-
Cache=HIT,代表缓存命中,此时Nginx会直接以304状态码应答,如图
2-90所示。
图2-90 缓存命中应答注意
Nginx也可以缓存服务端动态接口的应答信息,但是不推荐使用。如果后端数据已经发生变化,而用户始终得到的还是旧的缓存信息,例如,查询某个订单的状态,即使订单状态已经变化,但是返回的依然是变化之前的状态,这就会引起很多问题。
5.应用内存级缓存
应用内存级缓存是将信息直接放在应用服务器的内存中,一般采用Map结构进行存储。其优点是存储和读取速度快,主要目的是提高程序的执行效率。这样,在高并发的场景下能够减少对数据库的访问,提高程序响应速度。其缺点是稳定性差,重启则丢失;存储数据量有限,受内存限制;无法在集群和分布式环境下共用,造成重复缓存。
内存级缓存主要用于对象类和配置类缓存。例如,线程池、对象池、连接池本质上也属于一种内存级缓存技术。把一些大对象、创建比较耗时的对象先存起来,在使用时可以节省创建时间。
在集群架构下,每个节点都有自己的内存缓存,它们相互之间无法共用,并且缓存的内容都是重复的,但是存取速度极快,如图2-91所示。
图2-91 集群架构下的内存缓存
在分布式架构下,每个节点也都有自己的内存缓存,缓存的内容各不相同,只缓存与系统本身相关的内容。如图2-92所示,订单服务缓存订单配置信息,用户服务缓存用户配置信息,产品服务缓存产品配置信息,各个服务的缓存也同样无法共用。
图2-92 分布式架构下的内存缓存
内存级缓存的应用极其广泛。例如,线程池、对象池、连接池就是最常用的一种。内存级缓存比较适合小型项目的开发、单体架构的内部系统开发,具有性能高、依赖少、开发快的特点。
6.中间件缓存
当下主流的缓存中间件有Memcached、Redis等,它们以独立服务的模式存在,主要目的是对数据库进行保护,防止大量的并发请求到达数据库,有效应对高并发。
在集群架构下,客户端的请求经由负载设备被分发到不同的集群节点,所有的节点使用同一个Redis缓存服务,如图2-93所示。
图2-93 集群架构下的中间件缓存
在分布式架构下,每个服务都可以使用自己独立的缓存服务,也可以共享缓存服务。至于选择哪种模式,还要根据具体的业务和数据量进行分析。两种模式的架构方式如图2-94所示。如果是小型分布式系统,需要缓存的数据量不大,并且对于缓存的隔离性没有要求,则可以采用共享模式。
图2-94 分布式架构下的中间件缓存
如果是大型分布式系统,缓存数据量大,并且具有隔离性要求,则应该采用独立模式。其中隔离性要求是考虑的重点,在共享模式
下,所有子系统的数据都缓存在一个Redis服务中,所以数据对所有人可见,存在被修改和删除的风险。
使用中间件缓存需要注意3种问题:缓存穿透、缓存击穿和缓存雪崩。
(1) 缓存穿透。
缓存穿透属于一种攻击行为,或者严重的程序bug,是指缓存和数据库中都没有指定的数据,而客户端不断发起请求进行查询,导致大量请求到达数据库,使数据库压力过大甚至宕机。例如,经过猜测和轮询验证,发现用户ID为0的数据是不存在的,因此就进行疯狂的攻击调用。
解决方案:① 对于缓存和数据库中都不存在的数据,依然存入缓存中,存储的值为NULL、空字符串或空JSON串;② 缓存有效时间设置得短一些,如10~30秒,能够有效应对缓存穿透攻击行为;③ 可以在
应用层增加过滤器或切面,或者在单独的Controller层增加校验,对于一些明显不合理的请求参数予以屏蔽。
(2) 缓存击穿。
缓存击穿是指某个单一热点缓存到期,同时并发请求量巨大,引起数据库压力瞬间剧增,造成过大压力甚至宕机。这就像一面墙上被打了一个洞,因此称为击穿现象。
解决方案:系统启动时就要对热点数据进行提前加载,并且设置热点数据永远不过期,需要清除热点缓存时选择在低风险时段清除。
(3) 缓存雪崩。
缓存雪崩是指大批量的缓存集中过期,而此时并发量较大,从而引起数据库压力过大甚至宕机。由于一个服务的故障,还可能会引起其他服务相继出现问题,就像雪崩一样。
解决方案:尽量将缓存设置不同的过期时间,也可以进行随机设置,尽量将过期时间设置在业务低谷时间段。对于热点配置数据,应该设置永不过期。
2.3.2 异步化策略
异步化策略是提高系统并发能力的重要方法,它可以有效地提高系统的吞吐量,让系统可以承载更大的业务请求,然后进行消化处理。
异步化策略主要从技术和业务两个层面进行设计。技术层面主要利用线程池、消息队列等异步化技术达到削峰填谷的目的。业务层面主要对复杂的业务流程进行拆分,将大事务拆分为小事务,将大流程拆分为小流程。
1.异步化技术方案
异步化是有效的流量削峰方式,在程序内部可以采用异步线程池、异步回调技术实现。在程序之外可以借助消息中间件实现。
异步方式可以承载大量的并发请求,服务端接收请求后,交由异步线程处理,或者直接丢入消息队列中,然后立即给客户端应答,具有响应速度快、吞吐量高的特点。异步化流程如图2-95所示。
图2-95 异步化流程
在程序开发层面主要利用线程池、响应式编程、事件驱动等技术来达到异步化的目的。
在消息队列模式下,可以通过增加生产者的数量来增加客户端连接数,在高并发的情况下让更多的用户请求接入进来(图2-96)。当消费者处理能力不足时,例如,业务复杂而处理缓慢,可以通过增加消费者的数量来提高业务处理能力。就算无法快速增加消费者节点,也可利用MQ强大的消息缓存能力慢慢处理,而不至于将服务器压垮。
图2-96 消息队列模式
(1)削峰填谷。
使用Kafka、RabbitMQ、ActiveMQ、RocketMQ等消息队列中间件,能达到削峰填谷的效果。客户端的请求并不是稳定而持续的,而是有时流量很大,有时流量很小。流量大时可能超出服务的处理能力,流量小时又无法发挥服务的最佳性能。
如图2-97所示,实线代表使用消息队列之前的请求处理情况,有高有低,有波峰,有波谷。波峰会超出系统处理能力,波谷会浪费服务器性能。使用MQ之后,就可以变为虚线的处理曲线,波峰被削掉,填充到波谷中,形成比较平缓的曲线,从而能够有效地发挥服务性能。
使用MQ时一定要保证消费的幂等性,不能造成错序消费、重复消费问题。
图2-97 消息队列削峰填谷
(2)同步、异步、阻塞和非阻塞。
在异步化中有同步、异步、阻塞和非阻塞4个概念经常容易混淆,下面用一个生活场景来进行说明。例如,我们要使用洗衣机洗衣服,将所有的衣服、水、洗衣液都已经准备完毕。
第一种方法:启动开关后,洗衣机开始洗衣服,我们就站在洗衣机旁边守着,不断地去看是否洗完了,这就是同步阻塞。
第二种方法:启动开关后,我们就去做别的事情了,但是隔一段时间来看一下是否洗完了,这就是同步非阻塞。
第三种方法:启动开关后,我们就站在旁边守着,但是我们不再去看它是否洗完了,而是洗完之后洗衣机会自动播放音乐,通知我们洗完了,这就是异步阻塞。
第四种方法:启动开关后,我们就去做别的事情了,等衣服洗完后,洗衣机自动播放音乐通知我们,这就是异步非阻塞。
可以看出,异步非阻塞是效率最高的处理方式,客户端响应最快,同时服务器资源利用充分。同步非阻塞对于客户端来说感觉上没有什么变化,依然要主动询问结果,但是对于服务端而言效率得到明显提升。同步阻塞是用得最多的一种方式,大多数的实时接口都采用这种方式。
技术方案和理论知晓了,但是如何在具体的业务场景中使用呢?这就是业务流程异步化架构设计。
2.业务流程异步化
线程池、响应式编程、消息队列等异步化技术只是解决问题的工具,核心是如何将业务流程异步化,这也是架构设计的重点和难点。
(1)非核心业务流程异步化。
例如,银行转账的场景,用户A给用户B转账100元,转账成功后,分别给A、B两个用户发送短信、App消息、公众号消息。如果按照传统的同步实现方式(图2-98),则每一步都需要等待各个子系统的应答,效率极低,用户的等待时间将被大大拉长,占用的连接资源迟迟无法释放,吞吐量较低。如果在高并发场景下,则这种实现方式的弊端会被进一步放大。
图2-98 非核心业务同步调用流程
发送短信、发送App消息、发送公众号消息都属于非核心业务,是否发送成功、是否及时其实不是至关重要的,对于转账成功与否没有任何影响。
可以在转账成功之后立即给客户端应答,对于发送短信、发送App 消息、发送公众号消息这些操作都采用异步化技术实现(图2-99,图中虚线代表异步调用,实线代表同步调用),提升用户体验的同时,系统的吞吐量也可以呈几何倍增长。在企业架构中,对于一些通知、打印、文件处理等非核心业务应该尽量采用异步方式处理。
图2-99 非核心业务异步调用流程
(2)核心业务流程异步化。
除了对非核心业务的异步化,还可以对核心业务进行异步化处理,相应的复杂度也会比较高。
例如,一个分布式电商系统的商品购买流程,会涉及订单系统、库存系统、支付系统、积分系统、物流系统等多个服务,完整调用流程如图2-100所示。
① 用户在商品详情页面点击“立即购买”按钮。
② 订单系统接收到用户请求,校验用户信息合法后,生成订单数据,状态为待支付。
③ 订单创建成功后,订单系统调用库存系统预减库存。
④ 库存预减成功后,订单系统调用支付系统生成支付订单。
⑤ 跳转进入收款页面(支付宝、微信、收银台等),等待用户支付。
⑥ 用户点击“立即支付”按钮,支付系统完成资金扣除等一系列动作。
⑦ 跳转进入购买成功页面。
⑧ 支付系统异步回调通知订单系统支付完成。
⑨ 订单系统接收到支付成功的通知后,更新订单状态为已支付。
⑩ 订单状态更新成功后,调用库存系统真实扣减库存。
库存扣减成功后,订单系统调用积分系统计算积分。
积分计算成功后,订单系统调用物流系统,创建物流订单。
图2-100 核心业务同步调用流程
在同步调用的设计中,这些步骤必须按照严格的顺序执行,1~5步是一个事务,8~12步也是一个事务。它们的共同点是流程长,事务较大。在这个过程中会产生数据库锁,从而导致数据库资源被长时间占用,锁竞争加剧,从而导致吞吐量下降,在高并发场景下,情况会进一步恶化,用户需要更长的时间等待。
第一种优化方案就是分库,要保证每个子系统都使用独立的数据库,即订单库、产品库、资金库、积分库,数据库拆分后,每个事务的粒度减小,本地事务锁的粒度更小,从而提高处理速度,但是注意会引起分布式事务问题。即使进行了分库,降低了锁的粒度,但是整个流程依然是同步的,吞吐量并没有太大的提升。第二种优化方案就是使用Future模式,结合消息队列和回调通知机制,达到业务流程的异步化。
异步化之后的业务流程可分为下单、查询和支付3个阶段。完整流程如图2-101所示(图中实线代表同步操作,虚线代表异步操作)。
图2-101 核心业务异步调用流程第一阶段:下单。
① 客户端发起下单请求,订单系统生成订单数据并存入数据库中,订单状态设置为订单创建中。订单系统异步通知库存系统进行预减库存,通知完毕后,订单系统会立即给客户端应答,此时订单状态依然为创建中,用户页面的表现形式为正在加载(Loading)效果或倒计时效果。
② 库存系统接收到消息后进行预减库存操作,然后再异步通知支付系统创建支付订单。通知完毕后,立即回调订单系统,通知其库存已经预减成功。订单系统接收到通知后,更新订单中的库存状态为预减库存成功。
③ 支付系统接收到来自库存系统的消息后进行创建订单操作,然后立即异步回调订单系统,通知其支付订单已创建,并告知支付订单号等信息。
④ 订单系统接收到通知后,核对库存已经扣减成功,支付订单也已经创建,因此会更新订单中的订单状态为待支付,并记录支付订单号。
至此,整个下单操作完成,订单状态从创建中变为待支付。
第二阶段:查询。
客户端轮询订单状态,如果预减库存、创建支付订单的异步操作全部完成,则订单状态就会变为待支付状态,页面自动跳转到支付页面。
第三阶段:支付。
① 用户进入支付页面后,点击“立即支付”按钮,支付系统完成支付,页面会同步跳转进入购买成功页面。
② 支付成功后,支付系统异步通知订单系统支付已完成。订单系统收到通知后,更新支付状态为支付成功。同时,订单系统异步通知库存系统真实扣减库存。库存系统扣减库存后,异步通知积分系统计算积分。积分系统计算积分完毕后,再异步通知物流系统创建物流订单。
③ 库存系统扣减库存完毕后,立即异步通知订单系统库存已经扣减完毕。订单系统收到通知后,更新库存状态为扣减成功。
④ 积分系统计算积分完毕后,立即异步通知订单系统积分已计算完毕。订单系统收到通知后,更新积分状态为已计算积分,并记录对应的积分数值。
⑤ 物流系统创建物流信息完毕后,立即异步通知订单系统物流订单已经创建。订单系统收到通知后,更新物流状态、物流订单号等。
至此,整个购买流程结束,整个业务流程异步化,订单库中记录着订单状态、库存状态、支付状态、积分状态、物流状态。只要有一个状态不正常则可以知晓数据不一致,就可以自动进行事务补偿、业务修正,从而保证所有数据的最终一致性。
2.3.3 读写分离策略
大多数的互联网业务都是读多写少,充分利用这个特性可以极大地减轻数据库的压力。对于MySQL、Oracle等关系型数据库,
MongoDB等文档型数据库,Redis、Memcached等NoSQL数据库,都可以采用这种策略。读写分离的原理就是充分利用主从模式同步数据,利用代理模式对客户端请求进行分发。
如图2-102所示,将新增、修改、删除等写请求分发到主库中,将读请求分发到从库中即可完成读写分离。
第一种方式是由应用程序本身实现,如果是写入,则使用主库数据源;如果是查询,则使用从库数据源。这涉及语句的判断及数据源的动态切换,实现比较烦琐,但是可控性更好,开发人员可以随意控制规则。
第二种方式是借助代理软件,如图2-103所示,客户端只需要与代理软件进行通信,而不需要知道到底哪个数据库是主库,哪个数据库是从库,数据库的具体部署方式也完全不用关心。代理软件负责数据库请求的连接、解析、转发等。这种方式的好处是分层解耦,服务透明化,客户端无须进行任何代码改动。
图2-102 读写分离架构
图2-103 读写分离代理模式
Redis的主从模式、哨兵模式、集群模式,MongoDB的主从模式、副本集模式、分片模式对读写分离均有良好的支持。
对于MySQL这种关系型数据库可以使用Atlas、MyCat、Cobra、 MySQL Proxy等,成熟的开源产品较多。对于关系型数据库的高可用架构和数据同步策略可参见2.1.10小节。