海量用户同时进行高频访问对任何平台都是难题,也是行业乐此不疲的研究方向。但值得庆幸的是,虽然业务场景不同,设计和优化的思想却是万变不离宗。本文将结合业务与高并发系统设计的核心技术点,对系统架构调优方案进行深度剖析。
文章根据 Authing 身份云高级工程师罗杰林,在又拍云 Open Talk 技术沙龙北京站所作主题演讲《亿级流量系统架构演进之路》整理而成,现场视频及 PPT 可点击阅读原文查看。
相信大家都同意,互联网发展势头的逐渐凶猛改变了我们很多的生活方式。比如网购、银行转账等业务,不再需要我们必须线下办理,这极大方便了我们的生活。这背后当然也对身为互联网从业人员的我们来说,面临的考验也越来越大,在系统架构升级上也会倾注更大的心血。
认识高并发系统
高并发系统拥有高并发、高性能、高可用,分布式、集群化,安全性等特性。
我们首先来看一下高并发、高性能、高可用,也就是我们经常提到的三高系统。当我们流量非常大的情况下,我们一定要保证这三高。这其中高并发是指要支持很多并发用户,高性能是在高并发的前提下保证优秀的性能,高可用则是保证系统在某一节点出现问题时不会整体宕机且继续持续提供服务。由此可见三高的主要特性则是分布式和集群化,而我们主要要解决的问题则是安全性。
上图是一些常见的与我们生活息息相关的高并发场景。左上电商秒杀是最常见的场景了,去年疫情期间口罩紧缺抢口罩就是这个场景,很多人在一个统一的时间去点击同一个页面,这个的并发数是特别高的。右上则是抢票,这个大家也很熟悉了,特别是春节需要回家的在外地工作的朋友们,肯定都是开个抢票软件一直刷给自己抢票的,这种的并发流量特别大。左下则是银行交易系统,我们所有的线上、线下扫码其实都需要通过银行系统,这就让它的日交易量极大。最后是 Authing 身份证,我们主要是给用户做整套的身份认证和用户管理体系,这个体系让开发者避免了重复构建身份的操作,减少了开发者编写的代码,提高他们的效率。以下图作为例子:
图中展示的是我们的核心组件,表面上看是一个简单的登录框,也就是用户认证界面,但是其背后有一个庞大的由用户体系、管理体系、认证体系等一系列服务组成的后台支撑。尽管用户只是进行了用户名和密码的输入,但是我们要考虑到的不仅仅是用户的安全认证、多种登录方式,还有很多用户同时认证时要如何处理等等多种事项。除此之外,我们还需要考虑到如何让包括私有化用户在内的多种类型的客户实现高可用和快速部署,完成快速集成。
如果有做高并发的朋友,对于 CAP 理论一定不陌生。它的主要观点是分布式系统无法同时满足三个,只能够满足其中两个。即分布式系统要么满足 CA,要么满足 CP,但无法同时满足CAP。其中的意思是说如果满足了可用性和分区的容错性,那可能意味着要牺牲一致性,进而达到最终的数据一致性。它是告诉我们要作出取舍。
从单体应用架构说起
上图中示意的单体应用构架是早期常用的模式。早期因为人手紧缺通常会将 Web 和 Server 一起开发再一起部署,之后和数据库连在一起就可以正常提供服务。这么做的优点是维护简单,但是迭代比较麻烦。
现在前后端分离后,我们通常把 Web 和 Server 分开为两个服务部署,为快速迭代提供了便利。如果我们有一个 Server 需要修复,我们可以单独对这个服务进行代码修改和部署,然后快速上线服务。但是它的缺点是随着业务的增多,Server 包含的内容也越来越多,这会让它耦合很深进而导致服务变慢。这一点我深有体会,多年前我有个朋友架构出了问题,有段时间每到周末他会买一袋瓜子来我家一起琢磨。为什么要买一袋瓜子呢?因为耦合的太深了,服务启动要 5 分钟,改一个东西又要等 5 分钟重启,所以我们嗑着瓜子聊天等待。
类似上面提到的依赖复杂、臃肿繁杂是单体应用会遇到的一个问题,除此之外单体应用还有以下问题:
- 单点瓶颈
- 稳定差
- 扩展性差
- 业务模型缺失
- 新业务扩展差
- 业务流程基础能力缺乏
- 前后端耦合严重
- API 杂乱难维护
既然痛点如此明显,那么如何去优化就很重要。不过在谈这个问题之前需要思考一个新问题——CPU 越多性能就会越好吗?
大多数情况是这样的,因为 CPU 可以提高运算速度。但这不是绝对的,假如我们的程序里有很多锁的概念,那就无法体现出多线程的多核性。那可能 CPU 的多少就不会有显著效果。一般遇到这种情况,许多公司会考虑把服务拆开。这就涉及到成本问题,也就是说增加 CPU 并不是最优解,我们还是需要考虑如何去优化锁。不过思考具体优化前我们可以先了解下池化技术。
上图是池化技术的抽象概念,一般获取连接以及线程用完后都会放入资源池资源池。同时我们还需要有以下四个概念:连接池、线程池、常量池、内存池。
一般用连接池较多,因为系统之间的调用、请求外部服务时都会通过请求连接来进行。曾经我们使用的是短连接,但是由于 HTTP 的每次连接都需要重复建立和关闭连接的过程,非常耗时,所以现在开始使用连接池。它每次请求完后创建的连接都是重复可用的,非常有助于节省开销。同时我们的任务最后都是需要拆出来的,而那些拆出来的异步任务则都放置在线程池内进行。常量池和内存池的概念是想通的,我们会申请一块大的内存复用。
了解池化技术后,我们回到具体优化。
应用架构优化
Web Server 优化
首先来看一下 Web Server 的优化,它主要通过代码优化、热点缓存、算法优化等等步骤实现。
第一步是代码优化,将不合理的代码进行优化。比如查询接口通常都会查询很多内容,使得运算缓慢,这就需要优先进行优化。
第二步是热点缓存,将全部的热点数据进行缓存从而尽可能减少数据库的操作。比如 Authing 身份认证在拿到 token 后不可能每次进行数据库运算,这样 QPS 会非常慢,我们可以通过将热点数据全部缓存来提高 QPS。
第三步是算法优化,因为我们的业务通常都非常复杂,所以这个概念非常广泛。比如查询一个列表,是需要一次性列出全部列表还是在内存中计算完毕后将结果返回给前端呢?这就需要针对不同的业务场景进行优化,从而提高性能。
单独部署
完成单体应用优化后,如果这些服务都部署在同一台服务器上,那可能会出现 CPU 和内存被占用的情况。这时候我们可以把 Web、以及加载完缓存的应用程序拎出来分别部署到一个单独服务器上。同时将静态资源全部存储在 CDN 上,通过就近访问加快页面加载速度。通过这些方式,让我们的 Auting 达到了 50 毫秒内响应的需求。单独部署的方式也非常适合系统之间的需求,无论你是什么业务场景,如果需要提升响应速度,那大家可以考虑这个方式。
垂直拆分
之后我们需要对业务进行拆分。业务拆分有以下三种方式:
- 按照业务场景拆分,比如将用户、订单、账务进拆分。
- 按照业务是同步还是异步进拆分,这样做的好处是可以很好控制异步流量,不让它影响我们的核心服务运行。
- 按照模型拆分,因为业务拆分主要是为了解决系统之间耦合严重依懒性问题,为了后期尽量减少系统间的以来,所以前期的模型一定要尽可能的建设好。
在完成系统拆分后,我们需要评判优化后的系统能承载多少业务量,优化了多少。那么我就需要对它进行一次压测。压测会涉及到大家都有所了解的木桶理论,我们将系统比作一个木桶,那么木桶能够承载多少水量取决于最低的那块木板。所以压测时我们不需要关注那些占用资源少的部分,我们要关心那些高的已经达到了系统瓶颈的部分。通过这部分来查找我们系统的潜在问题点。
横向拆分
在我们将服务进行垂直拆分后,随着请求量逐渐增多可能还是无法满足需求。这时候我们可以将系统进行水平拆分,然后进行水平扩容,一个不够就增加两个甚至更多。同时通过负载均衡的服务器将请求量均匀分给这些水平节点。通常我们会选择使用 NG 来作负载均衡服务器。
上图是我们的负载均衡服务器。负载均衡下面会有很多网关系统,我们看到中间有一个 Nginx 集群。我们都知道 Nginx 能够承受的并发量非常大,所以流量小的时候不需要这个集群,需要它的时候一定是并发量非常大的情况。当你的并发量极大,到 Nginx 集群都无法承受的时候,我们最好不要在它的集群前面再放一层 Nginx,因为效果并不明显。同时我个人也不太建议大家选择 F5,因为 F5 是一个硬件,它的成本比较大。我个人建议大家选择 LVS,它是 Linux 下面的一个虚拟服务,如果配置的好,它的性能完全比得上 F5。
说完了负载均衡,我们回到水平拆分。
在进行水平拆分时我们不能忽略缓存问题。在单机模式下缓存都是本地缓存,而当我们成为分布式后,如果有一个服务器拿到 token 并存到本地,另一个服务器就会因为没有拿到而无法通信。因此我们引入分布式缓存,比如将缓存放到 Redis 这种分布式缓存里,让所有应用都请求 Redis 拿缓存。
当我们水平拆分后,还需要关注分布式 ID。因为单体时候生成 ID 的方法可能不适用于分布式服务。以时间戳举例,以前在单体时有,请求我们就生成一个 ID,这是有唯一性的。在分布式情况下多个服务器收到请求可能会生成重复 ID,做不到唯一性。所以我们需要单独做一个 ID 服务来生成 ID。
配置中心
在我们把服务进行了水平和垂直的拆分后,如何让配置统一同步的配置到每一个服务就成了问题。最好的办法就是当我们修改配置后,让所有服务都同时感知到这个更改,然后自己应用并配置。因此我们引入了配置中心。
上图是配置中心的大体流程,目前比较流行的配置中心方案有两个是,一个是阿里开源的 Nacos,另一个是 Spring Cloud 组建的 Spring Cloud config,感兴趣的朋友们可以了解一下。
接下来我们具体看一下上图。这其中 Server 是存放我们配置的控制台。一般开发者会在控制台通过 API 修改配置,修改后的配置可以持久放置在 Mysql 或其他数据库内。Client 包含了我们所有的应用,在它里面会有一个监听 Server 内是否有配置更改的监听,当有配置更改时则去获取这个配置,这样所有的应用就可以在前端更新后及时更新了。同时为了防止 App 在获取更新时因为网络问题而获取失败的情况,我们会在本地做一个快照,当网络出现问题时,App 可以降级到本地获取文件。
数据库拆分
我们完成了系统的拆分,做好了负载均衡,并完成了配置中心。在请求量不太大的情况下,我们其实已经完成了系统的优化。等到后期业务继续扩张时,我们遇到的瓶颈就不再是系统,而是数据库了。那么要如何解决这个问题呢?
第一种方式是主从复制与读写分离。读写分离可以解决数据读写全都在一个库上的问题,通过将主从库拆分为 master 和 slave,让写这一环节全部由 master 来处理,将写的压力分摊从而提高数据库性能。之后随着业务量的继续增大,单独的主从复制已经无法满足我们的需求时,我们通过第二种方式来处理。
第二种方式是进行垂直拆分。垂直拆分的概念和业务的拆分相似,我们根据服务将数据库拆分为 Users、Orders、Apps 等等,让每一个服务都拥有自己的数据库,避免统一请求从而提升并发性。伴随业务量的继续增长,即便是单独的库也会到达瓶颈,这时我们就需要用到第三种方式。
第三种方式是水平拆分。比如我们将 Users 这个数据库内的表进一步拆分为 Users1,Users2,Users3 等等多个表。要完成这个拆分我们需要考虑,面对多个表我们在查询时要如何去做的问题。这时我们需要按照我们的具体业务来判断。比如查询用户,我们可以根据用户 ID,将 ID 拆分分片,然后使用哈希算法让他们统一在一定范围内。之后我们每次拿到 Users 就通过哈希来计算具体在哪一片并快速抵达相应位置。Auting 多租户的设计就用到了拆分的概念,如下图所示。
服务限流
等到业务量多到一定程度后我们肯定会涉及到服务限流,这是一个变相的降级策略。虽然我们的理想都是系统能够承受越来越多的用户越来越多的量,但是因为资源总是有限的,所以你必须要进行限制。
请求拒绝
服务限流有两种主要算法,漏桶算法与令牌桶算法。我们可以看一下上图,它画的比较形象。漏桶算法中我们可以将流量想象成一杯水,在水流流出的地方进行限制,无论水流流入的速度有多快,但是流出速度是一样的。令牌桶则是建立一个发放令牌的任务,让每一个请求进入前都需要先拿到令牌,如果请求速度过快令牌不够用时就采取对应的限流策略。除去这两种算法,一般还会用到大家都很熟悉的计数器算法,感兴趣的朋友也可以去自行了解一下,这里我们就不细谈了。
这几种算法其实本质上都是在流量过量的时候,拒绝过量的部分的请求。而除去这种拒绝式的策略,我们还有一种排队的策略。
消息队列
当我们的业务有无法限流、拒绝的情况存在时,我们就需要用到队列消息。
如图所示,消息队列的主要概念是生产者会将消息放入队列中,由消费者从队列中获取消息并解决。我们通常使用 MQ、Redis、Kafka 来做消息队列。队列负责解决发布/订阅和客户端推拉两个问题,生产者负责解决以下问题:
- 缓冲:为入口处过大的流量设置缓冲
- 削峰:与缓冲的效果类似
- 系统解耦:如果两个服务没有依赖调用关系,可以通过消息队列进行解耦
- 异步通信
- 扩展:基于消息队列可以做很多监听者进行监听
服务熔断
在业务正常提供服务时,我们可能会遇到下图这种情况:
服务 A、B 分别调用服务 C、D,而这两者则都会调用服务 E,一旦服务 E 挂掉就会因为请求堆积而拖垮前面的全部服务。这个现象我们一般称之为服务雪崩。
而为了避免这个情况的发生,我们引入了服务熔断的概念,让它起到一个保险丝的作用。当服务 E 的失败量到达一定程度后,下一个请求就不会让服务 E 继续处理,而是直接返回失败信息,避免继续调用服务 E 的请求堆积。
简单来讲这是一种服务降级,通常的服务降级还有以下几种:
- 页面降级:可视化界面禁用点击按钮、调整静态页面
- 延迟服务:如定时任务延迟处理、消息入 MQ 后延迟处理
- 写降级:直接禁止相关写操作的服务请求
- 读降级:直接禁止相关读的服务请求
- 缓存降级:使用缓存方式来降级部分读频繁的服务接口
- 停服务:关闭不重要的功能,为核心服务让出资源
压测
上图就是我们具体压测要关注的东西。首先我们要知道压测其实是一个闭环,因为我们可能会需要重复这个流程很多次,不断地重复发现问题、解决问题、验证是否生效、发现新问题这个过程,直到最终达到我们的压测目标。
在压测开始前我们会制定压测目标,然后依据目标来准备环境。压测模型可以是线上的,也可以是线下。一般线下考虑到成本问题,因此会选择单机或小集群来进行,这可能让结果不太精准,所以通常大家都选择在线上或者机房来进行压测,数据更精准。在压测过程中我们会发现新的问题,然后解决它,并验证结果直到达到压测目标。
在压测的过程中我们需要关注以下几点。首先是 QPS,即每秒查询量。它和 TPS 的区别在于,TPS 有事务的概念,需要完成事务才算一次请求。而 QPS 没有这个概念,它只要查询到结果就算做一次请求。其次是 RT(响应时间),这个需要我们重点关注,而且越是高并发的系统,RT 越重要。之后在压测中我们需要关注系统到底能承载多大的并发数和吞吐量。成功率则是指在压测过程中,当压力越来越大的时候我们的业务是否能按照原计划执行并得到既定结果。GC 则是指垃圾回收,这也是个很大的问题,因为如果我们代码写的不好,那么随着压力的增大 GC 逐渐频繁最终会导致系统停顿。
之后则是硬件方面,需要我们关注 CPU、内存、网络、I/O 的占有率,有一种任意一项卡主就有可能导致一个系统瓶颈。最后是数据库,这里暂不展开细讲。
日志
在压测过程中发生的问题我们要如何才能知道呢?那就要依靠日志了,它让系统变得可视化,方便我们发现问题的根源。
那日志要如何做呢?这里主要是依靠埋点来完成,比如通过埋点请求进入每一个系统、每一层的时间和响应时间,然后通过这两个时间差看出系统的耗时。由此可以看出只有埋点清晰,才能精准发现问题的所在。
上图是一个比较通用的日志处理方案,每一个服务产生的日志都是通过 Filbeat 收集到 Kafka,然后到 Logstach,最后到 ElasticSearch。其中 Kibana 是一个可视化界面,方便我们分析日志。
上图是 Auting 的日志和监控系统。中间是 K8S 集群,左边是业务上的消息队列,右边则是我们的监控系统。监控系统我们只要是使用 Grafana 根据业务报警,比如我们会配置当成功率低于多少时就报警的情况。主要的日志系统则是使用 logstash 抽取 log 文件到 ES 内使用 Kibana 查看。
最后,我想说的是所有的高可用系统一定不能忘记一个核心概念,那就是异地多活。举例来讲就是我们需要在多地备署多个机房,拥有多地备份和多地容灾。上图是我对上述全部的应用架构优化进行的总结,希望能够为大家提供参考,谢谢。