既然是亿级用户应用,那么高并发必然是其架构设计的核心要素。本文我们将介绍高并发架构设计的一些通用设计方案。
高并发架构设计的要点
高并发意味着系统要应对海量请求。从笔者多年的面试经验来看,很多面试者在面对“什么是高并发架构”的问题时,往往会粗略地认为一个系统的设计是否满足高并发架构,就是看这个系统是否可以应对海量请求。再细问具体的细节时,回答往往显得模棱两可,比如每秒多少个请求才是高并发请求、系统的性能表现如何、系统的可用性表现如何,等等。
为了可以清晰地评判一个系统的设计是否满足高并发架构,在正式给出通用的高并发架构设计方案前,我们先要厘清形成高并发系统的必要条件、高并发系统的衡量指标和高并发场景分类。
形成高并发系统的必要条件
◎高性能:性能代表一个系统的并行处理能力,在同样的硬件设备条件下,性能越高,越能节约硬件资源;同时性能关乎用户体验,如果系统响应时间过长,用户就会产生抱怨。
◎高可用性:系统可以长期稳定、正常地对外提供服务,而不是经常出故障、宕机、崩溃。
◎可扩展性:系统可以通过水平扩容的方式,从容应对请求量的日渐递增乃至突发的请求量激增。
我们可以将形成高并发系统的必要条件类比为一个篮球运动员的各项属性:“高性能”相当于这个球员在赛场上的表现力强,“高可用性”相当于这个球员在赛场上总可以稳定发挥,“可扩展性”相当于这个球员的未来成长性好。
高并发系统的衡量指标
1. 高性能指标
一个很容易想到的可以体现系统性能的指标是,在一段时间内系统的平均响应时间。例如,在一段时间内有10000个请求被成功响应,那么在这段时间内系统的平均响应时间是这10000个请求响应时间的平均值。
然而,平均值有明显的硬伤并在很多数据统计场景中为大家所调侃。假设你和传奇篮球巨星姚明被分到同一组,你的身高是4350px,姚明的身高是5650px,那么这组的平均身高是2m!这看起来非常不合理。假设在10000个请求中有9900个请求的响应时间分别是1ms,另外100个请求的响应时间分别是100ms,那么平均响应时间仅为1.99ms,完全掩盖了那100个请求的100ms响应时间的问题。平均值的主要缺点是易受极端值的影响,这里的极端值是指偏大值或偏小值——当出现偏大值时,平均值将会增大;当出现偏小值时,平均值将会减小。
笔者推荐的系统性能的衡量指标是响应时间PCTn统计方式,PCTn表示请求响 应时间按从小到大排序后第n分位的响应时间。假设在一段时间内100个请求的响应时间从小到大排序如图所示,则第99分位的响应时间是100ms,即PCT99= 100ms。
分位值越大,对响应时间长的请求越敏感。比如统计10000个请求的响应时间:
◎PCT50=1ms,表示在10000个请求中50%的请求响应时间都在1ms以内。
◎PCT99=800ms,表示在10000个请求中99%的请求响应时间都在800ms以内。
◎PCT999=1.2s,表示在10000个请求中99.9%的请求响应时间都在1.2s以内。
从笔者总结的经验数据来看,请求的平均响应时间=200ms,且PCT99=1s的高并发系统基本能够满足高性能要求。如果请求的响应时间在200ms以内,那么用户不会感受到延迟;而如果请求的响应时间超过1s,那么用户会明显感受到延迟。
2. 高可用性指标
可用性=系统正常运行时间/系统总运行时间,表示一个系统正常运行的时间占比,也可以将其理解为一个系统对外可用的概率。我们一般使用N个9来描述系统的可用性如何,如表所示。
高可用性要求系统至少保证3个9或4个9的可用性。在实际的系统指标监控中,很多公司会取3个9和4个9的中位数:99.95%(3个9、1个5),作为系统可用性监控的阈值。当监控到系统可用性低于99.95%时及时发出告警信息,以便系统维护者可以及时做出优化,如系统可用性补救、扩容、分析故障原因、系统改造等。
3. 可扩展性指标
面对到来的突发流量,我们明显来不及对系统做架构改造,而更快捷、有效的做法是增加系统集群中的节点来水平扩展系统的服务能力。可扩展性=吞吐量提升比例/集群节点增加比例。在最理想的情况下,集群节点增加几倍,系统吞吐量就能增加几倍。一般来说,拥有70%~80%可扩展性的系统基本能够满足可扩展性要求。
高并发场景分类
我们使用计算机实现各种业务功能,最终将体现在对数据的两种操作上,即读和写,于是高并发请求可以被归类为高并发读和高并发写。比如有的业务场景读多写少,需要重点解决高并发读的问题;有的业务场景写多读少,需要重点解决高并发写的问题;而有的业务场景读多写多,则需要同时解决高并发读和高并发写的问题。将高并发场景划分为高并发读场景和高并发写场景,是因为在这两种场景中往往有不同的高并发解决方案。
数据库读/写分离
大部分互联网应用都是读多写少的,比如刷帖的请求永远比发帖的请求多,浏览商品的请求永远比下单购买商品的请求多。数据库承受的高并发请求压力,主要来自读请求。我们可以把数据库按照读/写请求分成专门负责处理写请求的数据库(写库)和专门负责处理读请求的数据库(读库),让所有的写请求都落到写库,写库将写请求处理后的最新数据同步到读库,所有的读请求都从读库中读取数据。这就是数据库读/写分离的思路。
数据库读/写分离使大量的读请求从数据库中分离出来,减少了数据库访问压力,缩短了请求响应时间。
读/写分离架构
我们通常使用数据库主从复制技术实现读/写分离架构,将数据库主节点Master作为“写库”,将数据库从节点Slave作为“读库”,一个Master可以与多个Slave连接,如图所示。
市面上各主流数据库都实现了主从复制技术。
读/写请求路由方式
在数据库读/写分离架构下,把写请求交给Master处理,而把读请求交给Slave处理,那么由什么角色来执行这样的读/写请求路由呢?一般可以采用如下两种方式。
1. 基于数据库Proxy代理的方式
在业务服务和数据库服务器之间增加数据库Proxy代理节点(下文简称Proxy),业务服务对数据库的一切操作都需要经过Proxy转发。Proxy收到业务服务的数据库操作请求后,根据请求中的SQL语句进行归类,将属于写操作的请求(如insert/delete/update语句)转发到数据库Master,将属于读操作的请求(如select语句)转发到数据库任意一个Slave,完成读/写分离的路由。开源项目如中心化代理形式的MySQL-Proxy和MyCat,以及本地代理形式的MySQL-Router等都实现了读/写分离功能。
2. 基于应用内嵌的方式
基于应用内嵌的方式与基于数据库Proxy代理的方式的主要区别是,它在业务服务进程内进行请求读/写分离,数据库连接框架开源项目如gorm、shardingjdbc等都实现了此形式的读/写分离功能。
主从延迟与解决方案
数据库读/写分离架构依赖数据库主从复制技术,而数据库主从复制存在数据复制延迟(主从延迟),因此会导致在数据复制延迟期间主从数据的不一致,Slave获取不到最新数据。针对主从延迟问题有如下三种解决方案。
1. 同步数据复制
数据库主从复制默认是异步模式,Master在写完数据后就返回成功了,而不管Slave是否收到此数据。我们可以将主从复制配置为同步模式,Master在写完数据后,要等到全部Slave都收到此数据后才返回成功。
这种方案可以保证数据库每次写操作成功后,Master和Slave都能读取到最新数据。这种方案相对简单,将数据库主从复制修改为同步模式即可,无须改造业务服务。
但是由于在处理业务写请求时,Master要等到全部Slave都收到数据后才能返回成功,写请求的延迟将大大增加,数据库的吞吐量也会有明显的下滑。这种方案的实用价值较低,仅适合在低并发请求的业务场景中使用。
2. 强制读主
不同的业务场景对主从延迟的容忍性不一样。例如,用户a刚刚发布了一条状态,他浏览个人主页时应该展示这条状态,这个场景不太能容忍主从延迟;而好友用户b此时浏览用户a的个人主页时,可以暂时看不到用户a最新发布的状态,这个场景可以容忍主从延迟。我们可以对业务场景按照主从延迟容忍性的高低进行划分,对于主从延迟容忍性高的场景,执行正常的读/写分离逻辑;而对于主从延迟容忍性低的场景,强制将读请求路由到数据库Master,即强制读主。
3. 会话分离
比如某会话在数据库中执行了写操作,那么在接下来极短的一段时间内,此会话的读请求暂时被强制路由到数据库Master,与“强制读主”方案中的例子很像,保证每个用户的写操作立刻对自己可见。暂时强制读主的时间可以被设定为略高于数据库完成主从数据复制的延迟时间,尽量使强制读主的时间段覆盖主从数据复制的实际延迟时间。
本地缓存
在计算机世界中,缓存(Cache)无处不在,如CPU缓存、DNS缓存、浏览器缓存等。值得一提的是,Cache在我国台湾地区被译为“快取”,更直接地体现了它的用途:快速读取。缓存的本质是通过空间换时间的思路来保证数据的快速读取。
业务服务一般需要通过网络调用向其他服务或数据库发送读数据请求。为了提高数据的读取效率,业务服务进程可以将已经获取到的数据缓存到本地内存中,之后业务服务进程收到相同的数据请求时就可以直接从本地内存中获取数据返回,将网络请求转化为高效的内存存取逻辑。这就是本地缓存的主要用途。在本书后面的核心服务设计篇中会大量应用本地缓存,本节先重点介绍本地缓存的技术原理。
基本的缓存淘汰策略
虽然缓存使用空间换时间可以提高数据的读取效率,但是内存资源的珍贵决定了本地缓存不可无限扩张,需要在占用空间和节约时间之间进行权衡。这就要求本地缓存能自动淘汰一些缓存的数据,淘汰策略应该尽量保证淘汰不再被使用的数据,保证有较高的缓存命中率。基本的缓存淘汰策略如下。
◎FIFO(First In First Out)策略:优先淘汰最早进入缓存的数据。这是最简单的淘汰策略,可以基于队列实现。但是此策略的缓存命中率较低,越是被频繁访问的数据是越早进入队列的,于是会被越早地淘汰。此策略在实践中很少使用。
◎LFU(Least Frequently Used)策略:优先淘汰最不常用的数据。LFU策略会为每条缓存数据维护一个访问计数,数据每被访问一次,其访问计数就加1,访问计数最小的数据是被淘汰的目标。此策略很适合缓存在短时间内会被频繁访问的热点数据,但是最近最新缓存的数据总会被淘汰,而早期访问频率高但最近一直未被访问的数据会长期占用缓存。
◎LRU(Least Recent Used)策略:优先淘汰缓存中最近最少使用的数据。此策略一般基于双向链表和哈希表配合实现。双向链表负责存储缓存数据,并总是将最近被访问的数据放置在尾部,使缓存数据在双向链表中按照最近访问时间由远及近排序,每次被淘汰的都是位于双向链表头部的数据。哈希表负责定位数据在双向链表中的位置,以便实现快速数据访问。此策略可以有效提高短期内热点数据的缓存命中率,但如果是偶发性地访问冷数据,或者批量访问数据,则会导致热点数据被淘汰,进而降低缓存命中率。
LRU策略和LFU策略的缺点是都会导致缓存命中率大幅下降。近年来,业界出现了一些更复杂、效果更好的缓存淘汰策略,比如W-TinyLFU策略。
分布式缓存
由于本地缓存把数据缓存在服务进程的内存中,不需要网络开销,故而性能非常高。但是把数据缓存到内存中也有较多限制,举例如下。
◎无法共享:多个服务进程之间无法共享本地缓存。
◎编程语言限制:本地缓存与程序绑定,用Golang语言开发的本地缓存组件不可以直接为用Java语言开发的服务器所使用。
◎可扩展性差:由于服务进程携带了数据,因此服务是有状态的。有状态的服务不具备较好的可扩展性。
◎内存易失性:服务进程重启,缓存数据全部丢失。
我们需要一种支持多进程共享、与编程语言无关、可扩展、数据可持久化的缓存,这种缓存就是分布式缓存。
分布式缓存选型
主流的分布式缓存开源项目有Memcached和Redis,两者都是优秀的缓存产品,并且都具有缓存数据共享、与编程语言无关的能力。不过,相对于Memcached而言,Redis更为流行,主要体现如下。
◎数据类型丰富:Memcached仅支持字符串数据类型缓存,而Redis支持字符串、列表、集合、哈希、有序集合等数据类型缓存。
◎数据可持久化:Redis通过RDB机制和AOF机制支持数据持久化,而Memcached没有数据持久化能力。
◎高可用性:Redis支持主从复制模式,在服务器遇到故障后,它可以通过主从切换操作保证缓存服务不间断。Redis具有较高的可用性。
◎分布式能力:Memcached本身并不支持分布式,因此只能通过客户端,以一致性哈希这样的负载均衡算法来实现基于Memcached的分布式缓存系统。而Redis有官方出品的无中心分布式方案Redis Cluster,业界也有豆瓣Codis和推特Twemproxy的中心化分布式方案。
由于Redis支持丰富的数据类型和数据持久化,同时拥有高可用性和高可扩展性,因此它成为大部分互联网应用分布式缓存的首选。
如何使用Redis缓存
使用Redis缓存的逻辑如下。
(1)尝试在Redis缓存中查找数据,如果命中缓存,则返回数据。
(2)如果在Redis缓存中找不到数据,则从数据库中读取数据。
(3)将从数据库中读取到的数据保存到Redis缓存中,并为此数据设置一个过期时间。
(4)下次在Redis缓存中查找同样的数据,就会命中缓存。
将数据保存到Redis缓存时,需要为数据设置一个合适的过期时间,这样做有以下两个好处。
◎如果没有为缓存数据设置过期时间,那么数据会一直堆积在Redis内存中,尤其是那些不再被访问或者命中率极低的缓存数据,它们一直占据Redis内存会造成大量的资源浪费。设置过期时间可以使Redis自动删除那些不再被访问的缓存数据,而对于经常被访问的缓存数据,每次被访问时都重置过期时间,可以保证缓存命中率高。
◎当数据库与Redis缓存由于各种故障出现了数据不一致的情况时,过期时间是一个很好的兜底手段。例如,设置缓存数据的过期时间为10s,那么数据库和Redis缓存即使出现数据不一致的情况,最多也就持续10s。过期时间可以保证数据库和Redis缓存仅在此时间段内有数据不一致的情况,因此可以保证数据的最终一致性。
在上述逻辑中,有一个极有可能带来风险的操作:某请求访问的数据在Redis缓存中不存在,此请求会访问数据库读取数据;而如果有大量的请求访问数据库,则可能导致数据库崩溃。Redis缓存中不存在某数据,只可能有两种原因:一是在Redis缓存中从未存储过此数据,二是此数据已经过期。下面我们就这两种原因来做有针对性的优化。
缓存穿透
当用户试图请求一条连数据库中都不存在的非法数据时,Redis缓存会显得形同虚设。
(1)尝试在Redis缓存中查找此数据,如果命中,则返回数据。
(2)如果在Redis缓存中找不到此数据,则从数据库中读取数据。
(3)如果在数据库中也找不到此数据,则最终向用户返回空数据
可以看到,Redis缓存完全无法阻挡此类请求直接访问数据库。如果黑客恶意持续发起请求来访问某条不存在的非法数据,那么这些非法请求会全部穿透Redis缓存而直接访问数据库,最终导致数据库崩溃。这种情况被称为“缓存穿透”。
为了防止出现缓存穿透的情况,当在数据库中也找不到某数据时,可以在Redis缓存中为此数据保存一个空值,用于表示此数据为空。这样一来,之后对此数据的请求均会被Redis缓存拦截,从而阻断非法请求对数据库的骚扰。
不过,如果黑客访问的不是一条非法数据,而是大量不同的非法数据,那么此方案会使得Redis缓存中存储大量无用的空数据,甚至会逐出较多的合法数据,大大降低了Redis缓存命中率,数据库再次面临风险。我们可以使用布隆过滤器来解决缓存穿透问题。
布隆过滤器由一个固定长度为m的二进制向量和k个哈希函数组成。当某数据被加入布隆过滤器中后,k个哈希函数为此数据计算出k个哈希值并与m取模,并且在二进制向量对应的N个位置上设置值为1;如果想要查询某数据是否在布隆过滤器中,则可以通过相同的哈希计算后在二进制向量中查看这k个位置值:
◎如果有任意一个位置值为0,则说明被查询的数据一定不存在;
◎如果所有的位置值都为1,则说明被查询的数据可能存在。之所以说可能存在,是因为哈希函数免不了会有数据碰撞的可能,在这种情况下会造成对某数据的误判,不过可以通过调整m和k的值来降低误判率。
虽然布隆过滤器对于“数据存在”有一定的误判,但是对于“数据不存在”的判定是准确的。布隆过滤器很适合用来防止缓存穿透:将数据库中的全部数据加入布隆过滤器中,当用户请求访问某数据但是在Redis缓存中找不到时,检查布隆过滤器中是否记录了此数据。如果布隆过滤器认为数据不存在,则用户请求不再访问数据库;如果布隆过滤器认为数据可能存在,则用户请求继续访问数据库;如果在数据库中找不到此数据,则在Redis缓存中设置空值。虽然布隆过滤器对“数据存在”有一定的误判,但是误判率较低。最后在Redis缓存中设置的空值也很少,不会影响Redis缓存命中率。
缓存雪崩
如果在同一时间Redis缓存中的数据大面积过期,则会导致请求全部涌向数据库。这种情况被称为“缓存雪崩”。缓存雪崩与缓存穿透的区别是,前者是很多缓存数据不存在造成的,后者是一条缓存数据不存在导致的。
缓存雪崩一般有两种诱因:大量数据有相同的过期时间,或者Redis服务宕机。第一种诱因的解决方案比较简单,可以在为缓存数据设置过期时间时,让过期时间的值在预设的小范围内随机分布,避免大部分缓存数据有相同的过期时间。第二种诱因取决于Redis的可用性,选取高可用的Redis集群架构可以极大地降低Redis服务宕机的概率。
高并发读场景总结:CQRS
无论是数据库读/写分离、本地缓存还是分布式缓存,其本质上都是读/写分离,这也是在微服务架构中经常被提及的CQRS模式。CQRS(Command Query Responsibility Segregation,命令查询职责分离)是一种将数据的读取操作与更新操作分离的模式。query指的是读取操作,而command是对会引起数据变化的操作的总称,新增、删除、修改这些操作都是命令。
CQRS的简要架构与实现
为了避免引入微服务领域驱动设计的相关概念,下图给出了CQRS的简要架构。
(1)当业务服务收到客户端发起的command请求(即写请求)时,会将此请求交给写数据存储来处理。
(2)写数据存储完成数据变更后,将数据变更消息发送到消息队列。
(3)读数据存储负责监听消息队列,当它收到数据变更消息后,将数据写入自身。
(4)当业务服务收到客户端发起的query请求(即读请求)时,将此请求交给读数据存储来处理。
(5)读数据存储将此请求希望访问的数据返回。
写数据存储、读数据存储、数据传输通道均是较为宽泛的代称,其中写数据存储和读数据存储在不同的高并发场景下有不同的具体指代,数据传输通道在不同的高并发场景下有不同的形式体现,可能是消息队列、定时任务等。
◎对于数据库读/写分离来说,写数据存储是 Master,读数据存储是 Slave,消息队列的实现形式是数据库主从复制。
◎对于分布式缓存场景来说,写数据存储是数据库,读数据存储是 Redis 缓存,消息队列的实现形式是使用消息中间件监听数据库的binlog数据变更日志。
无论是何种场景,都应该为写数据存储选择适合高并发写入的存储系统,为读数据存储选择适合高并发读取的存储系统,消息队列作为数据传输通道要足够健壮,保证数据不丢失。