一、从一道面试题开始
面试一家公司,上来就问:项目中是怎么解决高并发的?
看来高并发这个问题在面试中是逃离不了了,现在写一点自己浅薄的理解。我觉得要回答这个问题可以从这几个方面入手:
第一、硬件层面,服务器用更大的内存,更好的硬盘。考虑到 Money,这肯定不是面试官想听的答案,不过可以提上一嘴。
第二、软件层面,这也是重点,先简单介绍一下,后面再详细说明:
应用服务器肯定是最先抗不住的,所以先搞一个 Nginx 做负责均衡,后台多部署几台应用服务器,将请求均匀的分发到每台服务器上;
有了 Nginx 负责均衡,应用服务器的压力暂时抗住了,现在就轮到数据库这块抗不住了。这时候就要考虑分库分表了,还有就是读写分离,将写的压力搞到主库 Master 上,读的压力搞到从库 Slave。就是:分库分表 + 读写分离;
如果访问量继续扩大,此时可以不停的加服务器,但如果就是简单的不停的加机器,其实是不对的,这时就应该加入缓存服务器。
上一步的缓存只是解决了读的问题,其实写的压力还是很大,这个时候就要引入消息中间键了,像各种 MQ 之类的,可以很好的做写请求异步化处理,实现削峰填谷的效果。
二、先考虑一个最简单的系统架构
假设刚刚开始你的系统就部署在一台机器上,背后就连接了一台数据库,数据库部署在一台服务器上。
我们可以再现实点,给个例子,你的系统部署的机器是 4 核 8G,数据库服务器是 16 核 32G。这个架构够简单吧!
此时假设系统用户量总共就 10 万,用户量很少,日活用户按照不同系统的场景有区别,取一个较为客观的比例,10% 吧,每天活跃的用户就 1 万。按照 2-8 法则,每天高峰期算 4 个小时,高峰期活跃的用户占比达到 80%,就是 8000 人活跃在 4 小时内。然后每个人对你的系统发起的请求,我们算每天是 20 次吧。那么高峰期 8000 人发起的请求也才 16 万次,平均每秒也就 10 次请求。
好吧!完全跟高并发搭不上边,对不对?然后系统层面每秒是 10 次请求,对数据库的调用每次请求都会好几次数据库操作的,比如做做 CRUD 之类的。那么我们取一次请求对应 3 次数据库操作吧,那这样的话,数据库层每秒也就 30 次请求,对吧?
按照上述这台数据库服务器的配置,支撑是绝对没问题的。用一张图表示,就是下面这样:
假设此时你的用户数开始快速增长,比如用户量增长了 50 倍,此时日活用户是 50 万,高峰期对系统每秒请求是 500/s。然后对数据库的每秒请求数量是 1500/s,这个时候会怎么样呢?
按照上述的机器配置来说,库层面基本上 1500/s 的请求压力的话,还算可以接受。但是如果系统内处理的是较复杂的业务逻辑,很可能你会发,现应用服务器 CPU 负载较高了。这时,我们就需要对应用服务器做集群部署了。
三、系统集群化部署
集群化部署可以在应用服务器前面挂一个负载均衡层,把请求均匀打到系统层面,让系统可以用多台机器集群化支撑更高的并发压力。
比如说,这里假设给系统增加部署一台机器,那么平均下来,每台机器就只有 250/s 的请求了。这样一来,两台机器的 CPU 负载都会明显降低,这个初步的“高并发”不就先 hold 住了吗?
要是连这个都不做,那单台机器负载越来越高的时候,极端情况下是可能出现机器上部署的系统无法有足够的资源响应请求了,然后出现请求卡死,甚至系统宕机之类的问题。
所以,简单小结,第一步要做的:先搞一个 Nginx 做负责均衡,将请求均匀打到系统层,后台多部署几台应用服务器,将请求均匀的分发到每台服务器上,此时的架构图变成下面的样子:
四、数据库:分库分表 + 读写分离
假设此时用户量继续增长,达到了 1000 万注册用户,那么此时对系统层面的请求量会达到每秒 1000/s,系统层面,你可以继续通过集群化的方式来扩容,反正前面的负载均衡层会均匀分散流量过去的。但是,这时数据库层面接受的请求量会达到 3000/s,这个就有点问题了。
一般来说,普通配置的线上数据库,建议就是读写并发加起来,不要超过 3000/s。因为数据库压力过大,首先一个问题就是高峰期系统性能可能会降低,因为数据库负载过高对性能会有影响。
另外,压力过大把你的数据库给搞挂了怎么办?所以此时你必须得对系统做分库分表 + 读写分离,也就是把一个库拆分为多个库,部署在多个数据库服务上。
再另外,即使数据库抗住了并发,但随着时间的推移,数据量也会越来越大,过多的数据,在对数据库进行 CRUD 操作时也会大大影响性能。一般情况下,数据达到 500W+ 以后查询统计性能严重下降。
1、先说说数据库的分库分表:
数据切分根据其切分类型,可以分为两种方式:垂直拆分和水平拆分。
垂直拆分:基于数据库中的"列"进行,某个表字段较多,可以新建一张扩展表,将不经常用或字段长度较大的字段拆分出去到扩展表中。
例如在上图中,name
、age
为常用字段,可以拆分到一张表中,而 sex
、photo
、grade
为不常用字段,可以拆分到另一张表中,当然这里只是举例子,你可以根据实际情况来拆分。
这样做的好处是:
由于 MySQL 底层是通过数据页存储的,一条记录占用空间过大,可能会导致跨页查询,进而造成额外的性能开销。通过垂直拆分,表中字段长度较短且访问频率较高的优先访问,内存能加载更多的数据,命中率更高,减少了磁盘 IO,从而提升了数据库性能。同时解决业务系统层面的耦合,业务清晰。
当然缺点也很明显:
部分表无法 join,只能通过接口聚合方式解决,提升了开发的复杂度,分布式事务处理复杂,依然存在单表数据量过大的问题(需要水平切分)。
水平拆分:将同一个表按不同的条件分散到多个数据库或多个表中,每个表中只包含一部分数据,从而使得单个表的数据量变小。
还是上图的例子,把 ID
为 0 ~ 99999 的数据放在第一个表,把 ID
为 99999 ~ 199999 的数据放在第二个表,以此类推。
水平切分的优点:
不存在单库数据量过大、高并发的性能瓶颈,提升系统稳定性和负载能力。应用端改造较小,不需要拆分业务模块。
缺点:
跨分片的事务一致性难以保证,跨库的 join 关联查询性能较差,数据多次扩展难度和维护量极大。
2、再说说读写分离
回到并发的例子,此时假设对数据库层面的总并发是 3000/s,其中写并发占到了 1000/s,读并发占到了 2000/s。
那么一旦分库分表之后,采用两台数据库服务器上部署主库来支撑写请求,每台服务器承载的写并发就是 500/s。每台主库挂载一个服务器部署从库,那么 2 个从库每个从库支撑的读并发就是 1000/s。
简单总结,并发量继续增长时,我们就需要 focus 在数据库层面:分库分表 + 读写分离。此时的架构图如下所示:
五、缓存引入
如果你的用户量继续增大呢?
这还不好办?继续加机器就行了呗!
如果你只是简单的不停的加机器,其实是不对的,因为服务器还是挺贵的。这时就应该加入缓存服务器。把所有请求过的结果,保存在缓存服务器,下次如果来了相同的请求,那么直接在缓存中查找,而不用查数据库了,这样一来减少了数据库的请求压力,二来缓存的查询一般都是比数据库快,进而加快了响应速度。
在上面的例子中,假设 90% 的读请求都是重复的,那么现在的架构如下图:
可能未来你的系统读请求每秒都几万次了,但是可能 80%~90% 都是通过缓存集群来读的,而缓存集群里的机器可能单机每秒都可以支撑几万读请求,所耗费机器资源很少,可能就两三台机器就够了。
你要是换成是数据库来试一下,可能就要不停的加从库到10台、20台机器才能抗住每秒几万的读并发,那个成本是极高的。
关于缓存集群的进阶,可以看看白话“一致哈希”这篇文章。
缓存机制的引入避免请求过多时,直接与数据库操作从而造成系统瓶颈,极大的提升了用户体验和系统稳定性。但同时也带来了一些需要注意的问题:
1、缓存穿透
缓存穿透是指查询一个一定不存在的数据,因为缓存中也无该数据的信息,则会直接去数据库层进行查询,从系统层面来看像是穿透了缓存层直接达到 DB,从而称为缓存穿透。
没有了缓存层的保护,查询一定不存在的数据对系统来说可能是一种危险,如果有人恶意用这种查询频繁请求,不,准确的说是攻击系统,大量的请求都会到达数据库层导致 DB 瘫痪从而引起系统故障。
解决办法可以使用空值缓存:一种比较简单的解决办法。
在第一次查询完不存在的数据后,将该 Key 与对应的空值也放入缓存中,只不过设定为较短的失效时间,例如几分钟,这样则可以应对短时间的大量的该 Key 攻击,设置为较短的失效时间是因为该值可能业务无关,存在意义不大,且该次的查询也未必是攻击者发起,无过久存储的必要,故可以早点失效。
2、缓存雪崩
在普通的缓存系统中一般例如 Redis 中,我们会给缓存设置一个失效时间,但是如果所有的缓存的失效时间相同,那么在同一时间失效时,所有系统的请求都会发送到数据库层,db 可能无法承受如此大的压力导致系统崩溃。
3、缓存击穿
缓存击穿实际上是缓存雪崩的一个特例,大家使用过微博的应该都知道,微博有一个热门话题的功能,用户对于热门话题的搜索量往往在一些时刻会大大的高于其他话题,这种我们成为系统的“热点“,由于系统中对这些热点的数据缓存也存在失效时间,在热点的缓存到达失效时间时,此时可能依然会有大量的请求到达系统,没有了缓存层的保护,这些请求同样的会到达 DB 从而可能引起故障。
击穿与雪崩的区别即在于击穿是对于特定的热点数据来说,而雪崩是全部数据。
六、引入消息中间件
你以为到缓存层面就结束了?NO~~~
上一步的缓存集群只是解决了读的问题,其实写的压力还是很大,这个时候就要引入消息中间键了,像各种 MQ 之类的东西。是非常好的做写请求异步化处理,实现削峰填谷的效果,还有就是应用解耦。
假如说,你现在每秒是 1000/s 次写请求,其中比如 500 次请求是必须请求过来立马写入数据库中的,但是另外 500 次写请求是可以允许异步化等待个几十秒,甚至几分钟后才落入数据库内的。
那么此时完全可以引入消息中间件集群,把允许异步化的每秒 500 次请求写入 MQ,然后基于 MQ 做一个削峰填谷。比如就以平稳的 100/s 的速度消费出来然后落入数据库中即可,此时就会大幅度降低数据库的写入压力。
消息中间件系统本身也是为高并发而生,所以通常单机都是支撑几万甚至十万级的并发请求的。所以,他本身也跟缓存系统一样,可以用很少的资源支撑很高的并发请求,用他来支撑部分允许异步化的高并发写入是没问题的,比使用数据库直接支撑那部分高并发请求要减少很多的机器使用量。
既然说到了 MQ,那么面试时很有可能就会问:如何保证消息队列的高可用?
要是你傻乎乎的就用了一个 MQ,各种问题从来没考虑过,那你就杯具了,面试官对你的感觉就是,只会简单使用一些技术,没任何思考,马上对你的印象就不太好了。
解决办法:
1、开启 MQ 的持久化
消息写入之后会持久化到磁盘,哪怕是 MQ 自己挂了,恢复之后会自动读取之前存储的数据,一般数据不会丢。
2、普通集群模式
在多台机器上启动多个 MQ 实例,每个机器启动一个。你创建的 queue,只会放在一个 MQ 实例上。消费的时候,实际上如果连接到了另外一个实例,那么那个实例会从 queue 所在实例上拉取数据过来。
这种方式确实很麻烦,也不怎么好,没做到所谓的分布式,就是个普通集群。因为这导致你要么消费者每次随机连接一个实例然后拉取数据,要么固定连接那个 queue 所在实例消费数据,前者有数据拉取的开销,后者导致单实例性能瓶颈。
而且如果那个放 queue 的实例宕机了,会导致接下来其他实例就无法从那个实例拉取,如果你开启了消息持久化,让 RabbitMQ 落地存储消息的话,消息不一定会丢,得等这个实例恢复了,然后才可以继续从这个 queue 拉取数据。
所以这个事儿就比较尴尬了,这就没有什么所谓的高可用性,这方案主要是提高吞吐量的,就是说让集群中多个节点来服务某个 queue 的读写操作。
3、镜像集群模式
这种模式,才是所谓的 RabbitMQ 的高可用模式。跟普通集群模式不一样的是,在镜像集群模式下,你创建的 queue,无论元数据还是 queue 里的消息都会存在于多个实例上,就是说,每个 RabbitMQ 节点都有这个 queue 的一个完整镜像,包含 queue 的全部数据的意思。然后每次你写消息到 queue 的时候,都会自动把消息同步到多个实例的 queue 上。
这样的话,好处在于,你任何一个机器宕机了,没事儿,其它机器(节点)还包含了这个 queue 的完整数据,别的 consumer 都可以到其它节点上去消费数据。坏处在于,第一,这个性能开销也太大了吧,消息需要同步到所有机器上,导致网络带宽压力和消耗很重!第二,这么玩儿,不是分布式的,就没有扩展性可言了,如果某个 queue 负载很重,你加机器,新增的机器也包含了这个 queue 的所有数据,并没有办法线性扩展你的 queue。
4、消费端弄丢了数据
这个时候得用 RabbitMQ 提供的 ack 机制,简单来说,就是你必须关闭 RabbitMQ 的自动 ack,可以通过一个 api 来调用就行,然后每次你自己代码里确保处理完的时候,再在程序里 ack。
七、总结
到目前为止,通过这些的手段,我们已经可以让系统架构尽可能用最小的机器资源抗住了最大的请求压力,减轻了数据库的负担。总结起来就是下面几点:
- Nginx 系统集群化;
- 数据库层面:分库分表 + 读写分离;
- 针对读多写少的请求,引入缓存集群;
- 针对高写入的压力,引入消息中间件集群。
八、彩蛋
在上述的第一步 Nginx 系统集群化部署中,如果访问量继续增大 Nginx
扛不住了,咋整?
在这种架构中 Nginx 特别重要,是整个系统的入口,一旦 Nginx 挂了,即使后面的整个系统都正常,也无法对外提供服务了。
这里就要用 keepalived 了,用两台 Nginx 组成一个集群,分别部署上keepalived,设置成相同的虚拟 IP,这样一个节点在崩溃的情况下,另一个节点能够自动接替其工作。
另外还可以配置 DNS,以DNS轮询方式进行负载均衡。
参考:
https://www.jianshu.com/p/2f441c373d9e
https://mp.weixin.qq.com/s/YmlcrHH1NeE_vWqDGSayVQ