【转】高并发,我把握不住啊!

什么是高并发

高并发指的是系统同时处理很多请求。

高并发是一个结果导向的东西,例如,常见的高并发场景有:淘宝的双11、春运时的抢票、微博大V的热点新闻等,这些典型场景并不是陡然出世,而是随着业务发展的发展而逐渐出现。像2020年淘宝双11全球狂欢季,订单创建峰值达到了惊人的58.3万笔/秒,4年前的2016年,这个数字大概是四分之一,再往前四年,这个数据不可考,但是肯定就没这么夸张了。

高并发的业务场景出现了,随之而来的就是要支持这个高并发业务场景的架构——技术要为业务服务,业务倒逼技术发展。高并发的架构也不是某个天才冥思苦想或者灵机一动,这个过程是随着业务的发展而演进。用一个比喻,先有了秋名山,才到了老司机。

 

那到底多大并发才算高并发呢?

这个本身是没有具体标准的事情,只看数据是不行的,要结合具体的场景。不能说10W QPS的秒杀是高并发,而1W QPS的信息流就不是高并发。信息流场景涉及复杂的推荐模型和各种人工策略,它的业务逻辑可能比秒杀场景复杂10倍不止。业务场景不一样,执行复杂度不一样,单看并发量也没有意义。

总结就是,高并发无定势,是要和具体的业务场景相结合的。无高并发场景,无高并发架构。

高并发目标

宏观目标

高并发绝不意味着只追求高性能。从宏观角度看,高并发系统设计的目标有三个:高性能、高可用,以及高可扩展。就是所谓的“三高”,三高不是孤立的,而是相互支撑的。

1、高性能:性能体现了系统的并行处理能力,在有限的硬件投入下,提高性能意味着节省成本。同时,性能也反映了用户体验,响应时间分别是100毫秒和1秒,给用户的感受是完全不同的。

2、高可用:表示系统可以正常服务的时间。一个全年不停机、无故障;另一个隔三差五出线上事故、宕机,用户肯定选择前者。另外,如果系统只能做到90%可用,也会大大拖累业务。

3、高扩展:表示系统的扩展能力,流量高峰时能否在短时间内完成扩容,更平稳地承接峰值流量,比如双11活动、明星离婚等热点事件。

三高

这3个目标是需要通盘考虑的,因为它们互相关联、甚至也会相互影响。

比如说:考虑系统的扩展能力,你需要将服务设计成无状态的,这种集群设计保证了高扩展性,其实也间接提升了系统的性能和可用性。

再比如说:为了保证可用性,通常会对服务接口进行超时设置,以防大量线程阻塞在慢请求上造成系统雪崩,那超时时间设置成多少合理呢?一般,我们会参考依赖服务的性能表现进行设置。

具体目标

性能指标

性能指标通过性能指标可以度量目前存在的性能问题,也是高并发主要关注的指标,性能和流量方面常用的一些指标有

  1. QPS/TPS/HPS:QPS是每秒查询数,TPS是每秒事务数,HPS是每秒HTTP请求数。最常用的指标是QPS。

需要注意的是,并发数和QPS是不同的概念,并发数是指系统同时能处理的请求数量,反应了系统的负载能力。

并发数 = QPS✖平均响应时间
  1. 响应时间:从请求发出到收到响应花费的时间,例如一个系统处理一个HTTP请求需要100ms,这个100ms就是系统的响应时间。

  2. 平均响应时间:最常用,但是缺陷很明显,对于慢请求不敏感。比如 1 万次请求,其中 9900 次是 1ms,100 次是 100ms,则平均响应时间为 1.99ms,虽然平均耗时仅增加了 0.99ms,但是 1%请求的响应时间已经增加了 100 倍。

  3. TP90、TP99 等分位值:将响应时间按照从小到大排序,TP90 表示排在第 90 分位的响应时间, 分位值越大,对慢请求越敏感。

img

  1. RPS(吞吐量):单位时间内处理的请求量,通常由QPS和并发数决定。
    通常,设定性能目标时会兼顾吞吐量和响应时间,比如这样表述:在每秒 1 万次请求下,AVG 控制在 50ms 以下,TP99 控制在 100ms 以下。对于高并发系统,AVG 和 TP 分位值必须同时要考虑。

    另外,从用户体验角度来看,200 毫秒被认为是第一个分界点,用户感觉不到延迟,1 秒是第二个分界点,用户能感受到延迟,但是可以接受。
    因此,对于一个健康的高并发系统,TP99 应该控制在 200 毫秒以内,TP999 或者 TP9999 应该控制在 1 秒以内。

  2. PV:综合浏览量,即页面浏览量或者点击量,一个访客在24小时内访问的页面数量。

  3. UV:独立访客 ,即一定时间范围内相同访客多次访问网站,只计算为一个独立的访客。

  4. 带宽: 计算带宽大小需要关注两个指标,峰值流量和页面的平均大小。

    日网站带宽可以使用下面的公式来粗略计算:

    日网站带宽=pv/统计时间(换算到秒)*平均页面大小(单位kB)*8

峰值一般是平均值的倍数;

QPS不等于并发连接数,QPS是每秒HTTP请求数量,并发连接数是系统同时处理的请求数量:

峰值每秒请求数(QPS) = (总PV数 * 80%) /(6小时秒数 * 20%)

可用性指标

高可用性是指系统具有较高的无故障运行能力,可用性 = 平均故障时间 / 系统总运行时间,一般使用几个 9 来描述系统的可用性。

可用性指标

对于大多数系统。2个9是基本可用(如果达不到开发和运维可能就要被祭天了),3个9是较高可用,4个9是具有自动恢复能力的高可用。要想达到3个9和4个9很困难,可用性影响因素非常多,很难控制,需要过硬的技术、大量的设备资金投入,工程师要具备责任心,甚至还要点运气。

可扩展性指标

面对突发流量,不可能临时改造架构,最快的方式就是增加机器来线性提高系统的处理能力。

对于业务集群或者基础组件来说,扩展性 = 性能提升比例 / 机器增加比例,理想的扩展能力是:资源增加几倍,性能提升几倍。通常来说,扩展能力要维持在 70%以上。

但是从高并发系统的整体架构角度来看,扩展的目标不仅仅是把服务设计成无状态就行了,因为当流量增加 10 倍,业务服务可以快速扩容 10 倍,但是数据库可能就成为了新的瓶颈。

像 MySQL 这种有状态的存储服务通常是扩展的技术难点,如果架构上没提前做好规划(垂直和水平拆分),就会涉及到大量数据的迁移。

我们需要站在整体架构的角度,而不仅仅是业务服务器的角度来考虑系统的扩展性 。所以说,数据库、缓存、依赖的第三方、负载均衡、交换机带宽等等都是系统扩展时需要考虑的因素。我们要知 道系统并发到了某一个量级之后,哪一个因素会成为我们的瓶颈点,从而针对性地进行扩展。

高并发架构演进

谁不是生下来就是老司机,架构也不是架起来就支持高并发。我们来看一个经典的架构演进的例子——淘宝,真实诠释了“好的架构是进化来的,不是设计来的”。

以下是来自《淘宝技术这十年》描述的淘宝2003—2012年的架构演进。

个人网站

初代淘宝的团队人员只有十来个,而且面临千载难逢的商业机会,所以要求上线的时间越快越好(实际用了不到一个月),那么淘宝的这些牛人是怎么做到的呢?

——买一个。

初代淘宝买了这样一个架构的网站: LAMP(Linux+Apache+MySQL+PHP)。整个系统的架构如下:

初代架构

最后开发的网站是这样的:

初代淘宝网站

由于商品搜索比较占用数据库资源,后来还引入了阿里巴巴的搜索引擎iSearch。

Oracle/支付宝/旺旺

淘宝飞速发展,流量和交易量迅速提升,给技术带来了新的问题——MySQL抗不住了。怎么办?要搞点事情吗?没有,淘宝买了Oracle数据库,当然这个也考虑到团队里有Oracle大牛的原因。

替换了数据库之后的架构:

引入Oracle之后的淘宝架构

比较有意思的,当时由于买不起商用的连接池,所以用了一个开源的连接池代理服务SQLRelay,这个代理服务经常会死锁,怎么解决呢?人肉运维,工程师24小时待命,出现问题赶紧重启SQL Relay服务。😂😂

后来为了优化存储,又买了NAS(Network Attached Storage,网络附属存储),NetApp 的 NAS 存储作为了数据库的存储设备,加上 Oracle RAC(Real Application Clusters,实时应用集群)来实现负载均衡。

Java 时代 1.0

2004年,淘宝已经运行了一年的时间,上面提到的SQLRelay的问题解决不了,数据库必须要用Oracle,所以决定更换开发语言。

在不拖慢现有业务发展的情况下,平滑更换整体的架构,对当时的淘宝仍然是个有挑战性的事情。所以怎么办?淘宝的解决方案是请了Sun公司的大佬。

当时,由于struts1.x存在很多问题,所以淘宝自研了一套MVC框架。Sun当时在推EJB,所以这套架构里也引入了EJB。

Java 时代 1.0

Java 时代 2.0

在之前,淘宝的架构的架构主要思路还是“买”,随着业务的发展,到了2005 年,“买”已经很难解决问题了,需要对整个架构进行调整和优化,需要综合考虑容量、性能、成本的问题。

在Java时代2.0,主要做了对数据分库、放弃EJB、引入Spring、加入缓存、加入CDN等。

Java时代2.0

Java时代3.0

Java时代3.0的最大特点就是淘宝开始从商用转为“自研”,开始真正创造自己的核心技术,例如缓存存储引擎Tair,分布式存储系统TFS。搜索引擎iSearch也进行了升级。引入了自研技术的淘宝架构:

Java时代3.0

分布式时代1.0

到了2008年的时候,淘宝的业务进一步发展。

整个主站系统的容量已经到了瓶颈,商品数在1亿个以上,PV在2.5亿个以上,会员数超过了 5000万个。这时Oracle的连接池数量都不够用了,数据库的容量到了极限,即使上层系统加机器也无法继续扩容,我们只有把底层的基础服务继续拆分,从底层开始扩容,上层才能扩展,这才能容纳以后三五年的增长。

淘宝开始对业务模块逐步拆分和服务化改造。例如拆分出了商品中心、商品中心等等。同时引入了一些自研的中间件,如分布式数据库中间件,分布式消息中间件等等。

面向服务架构

《淘宝技术这十年》这本书只描述到了2012年,也就是分布式时代。上图是根据参考【8】画的一张图。

转眼理2012又快过了十年,这十年,阿里巴巴逐渐进入极盛时代,技术上也是风起云涌,才人辈出。粒度更细的微服务、隔离差距的容器化技术、快速伸缩的云平台技术…… 如果《淘宝技术这十年》的作者能再写一个十年,一定也是非常精彩。

按照参考【10】,接下来的淘宝服务化开始逐渐演进到云平台架构,由于资料实在难找,而且这时候以淘宝的体量,内部的架构复杂度足以写一本书了。所以接下来的架构演进参考服务端高并发分布式架构演进之路,是一个牛人以淘宝为模拟对象进行的架构演进,虽然不是淘宝真正的架构技术演进,但也很值得借鉴。

在这里我们略过了微服务架构——分布式时代2.0,微服务本身是更细粒度、更轻量级的服务化,这里插入一个关于微服务很有意思的说法——马丁老哥老被人说设计的东西不符合面向服务的概念,于是他就自己发明创造了一个灵活的微服务理论,以后再有人说:马老师,你又不遵循微服务架构设计的原则了。嗯,你说哪一点不符合,我立马去改微服务的理论。

容器化时代

前最流行的容器化技术是Docker,最流行的容器管理服务是Kubernetes(K8S),应用/服务可以打包为Docker镜像,通过K8S来动态分发和部署镜像。Docker镜像可理解为一个能运行你的应用/服务的最小的操作系统,里面放着应用/服务的运行代码,运行环境根据实际的需要设置好。把整个“操作系统”打包为一个镜像后,就可以分发到需要部署相关服务的机器上,直接启动Docker镜像就可以把服务起起来,使服务的部署和运维变得简单。

在大促的之前,可以在现有的机器集群上划分出服务器来启动Docker镜像,增强服务的性能,大促过后就可以关闭镜像,对机器上的其他服务不造成影响。

容器化时代

云平台时代

在服务化的时候,淘宝已经演进到了云平台架构。

所谓的云平台,就是把海量机器资源,通过统一的资源管理,抽象为一个资源整体,在之上可按需动态申请硬件资源(如CPU、内存、网络等),并且之上提供通用的操作系统,提供常用的技术组件(如Hadoop技术栈,MPP数据库等)供用户使用,甚至提供开发好的应用,用户不需要关系应用内部使用了什么技术,就能够解决需求(如音视频转码服务、邮件服务、个人博客等)。

clipboard.png

简单总结一下:高并发的架构某种程度上是逼出来的,一般人谁能想到淘宝当年抛弃php是因为解决不了数据库连接池的问题。架构演进就像是西湖的水——西湖的水,工程师的泪,说起来容易,里面究竟灭了多少火,填了多少坑。我们外人看到的平湖秋波,里面水很深🐶。

高并发架构实现

想让系统抗住更多的并发,主要就是两个方向:

  • 纵向扩展

    1、提升单机的硬件性能:通过增加内存、 CPU核数、存储容量、或者将磁盘 升级成SSD等堆硬件的方式来提升

    2、提升单机的软件性能:使用缓存减少IO次数,使用并发或者异步的方式增加吞吐量。

  • 横向扩展:单机性能总会存在极限,所以最终还需要引入横向扩展,通过集群部署以进一步提高并发处理能力。

    1、做好分层架构:这是横向扩展的前提,因为高并发系统往往业务复杂,通过分层处理可以简化复杂问题,更容易做到横向扩展。

    2、各层进行水平扩展:无状态水平扩容,有状态做分片路由。业务集群通常能设计成无状态的,而数据库和缓存往往是有状态的,因此需要设计分区键做好存储分片,当然也可以通过主从同步、读写分离的方案提升读性能。

用一个比喻,你要去打十个大汉,你大概是打不过的,最好的结果就是他们打不倒你——吊起来打。所以这时候就得想办法了。第一个办法就是努力锻炼,然后全副武装,也许还有点希望,这就是纵向扩展;第二个办法,不行,你一看对面人多,你就叫了十九个兄弟,然后你们二十个打他们十个,唉,这下看上去能打的过了,这就是横向扩展;还有第三个不常用的办法,你找个门把住,每次就放一个大汉进来,打倒一个再放下一个,这个就是削峰限流的做法。

我要打十个

我们看一下一个大概的支持三高的典型架构:

高并发典型架构

接下来,我们从上往下,看一下,各层的一些关键技术。

网络层

多机器

堆机器不是万能的,不堆机器是万万不能的。

我们努力地升级改造架构,最后让我们提供的服务能够快速横向扩展。横向扩展的基础同样是要有一定数量的、一定性能的机器。

还是上面哪个比喻,你要打十个大汉,等你努力练成了叶师傅,你突然发现对面的孩子都长大了,人数×2,这时候你还是得叫兄弟。

一般狗大户大厂在全国各地都有机房,可能光北京就有两个,把不同地方的请求分到不同的机房,再分到不同的集群,再分到不同的机器,这么一匀,就在服务能扛的范畴之内了。我们大概来看一下,怎么估算所需机器的数量。

  • 通过QPS和PV计算部署服务器的台数

单台服务器每天PV计算:

公式1:每天总PV = QPS * 3600 * 6
公式2:每天总PV = QPS * 3600 * 8

服务器计算:

服务器数量 = ceil( 每天总PV / 单台服务器每天总PV )
  • 峰值QPS和机器计算公式

原理:每天80%的访问集中在20%的时间里,这20%时间叫做峰值时间

公式:( 总PV数 * 80% ) / ( 每天秒数 * 20% ) = 峰值时间每秒请求数(QPS)

机器:峰值时间每秒QPS / 单台机器的QPS = 需要的机器。

一般有大流量业务的公司都实现了多机房,包括同城多机房、跨城多机房、跨国多机房等。为了保证可用性,财大气粗的公司会预备大量的冗余,一般会保证机器数是计算峰值所需机器数的两倍。需要节约成本的,也可以考虑当前流行的云平台,之前热点事件的时候,微博就从阿里云租了不少云服务器。

DNS

DNS简单示意图

DNS是请求分发的第一个关口,实现的是地理级别的均衡。dns-server对一个域名配置了多个解析ip,每次DNS解析请求来访问dns-server。通常会返回离用户距离比较近的ip,用户再去访问ip。例如,北京的用户访问北京的机房,南京的用户访问南京的资源。

一般不会使用DNS来做机器级别的负载均衡,因为造不起,IP资源实在太宝贵了,例如百度搜索可能需要数万台机器,不可能给每个机器都配置公网IP。一般只会有有限的公网IP的节点,然后再在这些节点上做机器级别的负载均衡,这样各个机房的机器只需要配置局域网IP就行了。

DNS负载均衡的优点是通用(全球通用)、成本低(申请域名,注册DNS即可)。

缺点也比较明显,主要体现在:

  • DNS 缓存的时间比较长,即使将某台业务机器从 DNS 服务器上删除,由于缓存的原因,还是有很多用户会继续访问已经被删除的机器。

  • DNS 不够灵活。DNS 不能感知后端服务器的状态,只能根据配置策略进行负载均衡,无法做到更加灵活的负载均衡策略。比如说某台机器的配置比其他机器要好很多,理论上来说应该多分配一些请求给它,但 DNS 无法做到这一点。

所以对于时延和故障敏感的业务,有实力的公司可能会尝试实现HTTP-DNS的功能,即使用HTTP 协议实现一个私有的 DNS 系统。HTTP-DNS 主要应用在通过 App 提供服务的业务上,因为在 App 端可以实现灵活的服务器访问策略,如果是 Web 业务,实现起来就比较麻烦一些,因为 URL 的解析是由浏览器来完成的,只有 Javascript 的访问可以像 App 那样实现比较灵活的控制。

CDN

CDN是为了解决用户网络访问时的“最后一公里”效应,本质是一种“以空间换时间”的加速策略,即将内容缓存在离用户最近的地方,用户访问的是缓存的内容,而不是站点实时访问的内容。

由于CDN部署在网络运营商的机房,这些运营商又是终端用户的网络提供商,因此用户请求路由的第一跳就到达了CDN服务器,当CDN中存在浏览器请求的资源时,从CDN直接返回给浏览器,最短路径返回响应,加快用户访问速度。

下面是简单的CDN请求流程示意图:

CDN请求流程图

CDN能够缓存的一般是静态资源,如图片、文件、CSS、Script脚本、静态网页等,但是这些文件访问频度很高,将其缓存在CDN可极大改善网页的打开速度。

反向代理层

我们把这一层叫反向代理层,也可以叫接入层、或者负载层。这一层是流量的入口,是系统抗并发很关键的一层。

还是那个比喻,还是你打十个大汉,这次你叫了十九个兄弟,理想的情况是你们两个打对面一个,但是你由于太激动,冲在了最前面,结果瞬间被十个大汉暴打……

反向代理会对流量进行分发,保证最终落到每个服务上的流量是服务能扛的范围之内。

Nginx、LVS、F5

DNS 用于实现地理级别的负载均衡,而 Nginx、 LVS、 F5 用于同一地点内机器级别的负载均衡。其中 Nginx 是软件的 7 层负载均衡,LVS 是内核的 4 层负载均衡,F5 是硬件的 4 层负载均衡。

软件和硬件的区别就在于性能,硬件远远高于软件,Ngxin 的性能是万级,一般的 Linux 服务器上装个 Nginx 大概能到 5 万 / 秒;LVS 的性能是十万级,据说可达到 80万 / 秒;F5 性能是百万级,从 200 万 / 秒到 800 万 / 秒都有。

硬件虽然性能高,但是单台硬件的成本也很高,一台最便宜的 F5 都是几十万,但是如果按照同等请求量级来计算成本的话,实际上硬件负载均衡设备可能会更便宜,例如假设每秒处理 100 万请求,用一台 F5 就够了,但用 Nginx, 可能要 20 台,这样折算下来用 F5 的成本反而低。因此通常情况下,如果性能要求不高,可以用软件负载均衡;如果性能要求很髙,推荐用硬件负载均衡。

4 层和 7 层的区别就在于协议和灵活性。Nginx 支持 HTTP、 E-mail 协议,而 LVS 和 F5 是 4层负载均衡,和协议无关,几乎所有应用都可以做,例如聊天、数据库等。目前很多云服务商都已经提供了负载均衡的产品,例如阿里云的 SLB、UCIoud 的 ULB 等,中小公司直接购买即可。

对于开发而言,一般只需要关注到Nginx这一层面就行了。

Nginx负载均衡架构示意图

负载均衡典型架构

像上面提到的负载均衡机制,在使用中,可以组合使用。

DNS负载均衡用于实现地理级别的负载均衡,硬件件负载均衡用于实现集群级别的负载均衡;软件负载均衡用于实现机器级别的负载均衡。

三层负载均衡

整个系统的负载均衡分为三层。

  • 地理级别负载均衡:www.xxx.com 部署在北京、广州、上海三个机房,当用户访问时,DNS 会根据用户的地理位置来决定返回哪个机房的 IP,图中返回了广州机房的 IP 地址,这样用户就访问到广州机房了。
  • 集群级别负载均衡:广州机房的负载均衡用的是 F5 设备,F5 收到用户请求后,进行集群级别的负载均衡,将用户请求发给 3 个本地集群中的一个,我们假设 F5 将用户请求发给了 “广州集群 2” 。
  • 机器级别的负载均衡:广州集群 2 的负载均衡用的是 Nginx, Nginx 收到用户请求后,将用户请求发送给集群里面的某台服务器,服务器处理用户的业务请求并返回业务响应。

Nginx负载均衡

我们主要关心是Nginx这一层的负载,通常LVS 和 F5这两层都是由网络运维工程师管控。

Nginx负载均衡/反向代理示意图

对于负载均衡我们主要关心的几个方面如下:

  • 上游服务器配置:使用 upstream server配置上游服务器

  • 负载均衡算法:配置多个上游服务器时的负载均衡机制。

  • 失败重试机制:配置当超时或上游服务器不存活时,是否需要重试其他上游服务器。

  • 服务器心跳检查:上游服务器的健康检查/心跳检查。

upstream server中文直接翻译是上游服务器,意思就是负载均衡服务器设置,就是被nginx代理最后真实访问的服务器。

负载均衡算法

负载均衡算法数量较多,Nginx主要支持以下几种负载均衡算法:

1、轮询(默认)

每个请求按时间顺序逐一分配到不同的后端服务,如果后端某台服务器死机,自动剔除故障系统,使用户访问不受影响。

2、weight(轮询权值)

weight的值越大分配到的访问概率越高,主要用于后端每台服务器性能不均衡的情况下。或者仅仅为在主从的情况下设置不同的权值,达到合理有效的地利用主机资源。

3、ip_hash

每个请求按访问IP的哈希结果分配,使来自同一个IP的访客固定访问一台后端服务器,并且可以有效解决动态网页存在的session共享问题。

4、fair

比 weight、ip_hash更加智能的负载均衡算法,fair算法可以根据页面大小和加载时间长短智能地进行负载均衡,也就是根据后端服务器的响应时间 来分配请求,响应时间短的优先分配。Nginx本身不支持fair,如果需要这种调度算法,则必须安装upstream_fair模块。

5、url_hash

按访问的URL的哈希结果来分配请求,使每个URL定向到一台后端服务器,可以进一步提高后端缓存服务器的效率。Nginx本身不支持url_hash,如果需要这种调度算法,则必须安装Nginx的hash软件包。

失败重试

Nginx关于失败重试主要有两部分配置,upstream server 和 proxy_pass。

通过配置上游服务器的 max_fails和 fail_timeout,来指定每个上游服务器,当fail_timeout时间内失败了max_fail次请求,则认为该上游服务器不可用/不存活,然后将会摘掉该上游服务器,fail_timeout时间后会再次将该服务器加入到存活上游服务器列表进行重试。

健康检查

Nginx 对上游服务器的健康检查默认采用的是惰性策略,Nginx 商业版提供了healthcheck 进 行 主 动 健 康 检 查 。当 然 也 可 以 集 成 nginx_upstream_check_module( https://github.com/yaoweibin/nginx_upstream_check module ) 模块来进行主动健康检查。

nginx_upstream_check_module 支持 TCP 心跳和 HTTP 心跳来实现健康检查。

流量控制

流量分发

流量分发就不多说了,上面已经讲了,是接入层的基本功能。

流量切换

我听朋友说过一个有意思的事情,他们公司将流量从一个机房切到另一个机房,结果翻车,所有工程师运维平台一片飘红,全公司集体围观,运维团队就很丢面子。

幸灾乐祸

流量切换就是在某些情况下,比如机房故障、光纤被挖断、服务器故障故障情况,或者灰度发布、A/B等运维测试场景,需要将流量切到不同的机房、服务器等等。

就像我们上面提到的负载均衡典型架构,不同层级的负载负责切换不同层级的流量。

  1. DNS:切换机房入口。
  2. HttpDNS:主要 APP 场景下,在客户端分配好流量入口,绕过运营商 LocalDNS并实现更精准流量调度。
  3. LVS/HaProxy:切换故障的 Nginx 接入层。
  4. Nginx:切换故障的应用层。

另外,有些应用为了更方便切换,还可以在 Nginx 接入层做切换,通过 Nginx 进行一些流量切换,而没有通过如 LVS/HaProxy 做切换。

限流

限流是保证系统可用的一个重要手段,防止超负荷的流量直接打在服务上,限流算法主要有令牌桶、漏桶。

漏桶和令牌桶

可以在很多层面做限流,例如服务层网关限流、消息队列限流、Redis限流,这些主要是业务上的限流。

这里我们主要讨论的是接入层的限流,直接在流量入口上限流。

对于 Nginx接入层限流可以使用 Nginx自带的两个模块:连接数限流模块 ngx_http_limit_conn_module和漏桶算法实现的请求限流模块 ngx_http_limit_req_moduleo

还可以使用 OpenResty提供的 Lua限流模块 ua-resty-limit-traffic应对更复杂的限流场景。

limmit_conn用来对某个 key 对应的总的网络连接数进行限流,可以按照如 IP、域名维度进行限流。limit_req用来对某个 key对应的请求的平均速率进行限流,有两种用法:平滑模式(delay ) 和允许突发模式(nodelay )。

流量过滤

很多时候,一个网站有很多流量是爬虫流量,或者直接是恶意的流量。

可以在接入层,对请求的参数进行校验,如果参数校验不合法,则直接拒绝请求,或者把请求打到专门用来处理非法请求的服务。

最简单的是使用Nginx,实际场景可能会使用OpenResty,对爬虫 user-agent 过滤和一些恶意IP (通过统计 IP 访问量来配置阈值),将它们分流到固定分组,这种情况会存在一定程度的误杀,因为公司的公网 IP —般情况下是同一个,大家使用同一个公网出口 IP 访问网站,因此,可以考虑 IP+Cookie 的方式,在用户浏览器种植标识用户身份的唯一 Cookie。访问服务前先种植 Cookie, 访问服务时验证该 Cookie, 如果没有或者不正确,则可以考虑分流到固定分组,或者提示输入验证码后访问。

流量过滤

降级

降级也是保证高可用的一把利剑,降级的思路是“弃车保帅”,在眼看着不能保证全局可用的情况下,抛弃或者限制一些不重要的服务。

降级一般分为多个层级,例如在应用层进行降级,通过配置中心设置降级的阈值,一旦达到阈值,根据不同的降级策略进行降级。

也可以把降级开关前置到接入层,在接入层配置功能降级开发,然后根据情况行自动/人工降级。后端应用服务出问题时,通过接入层降级,可以避免无谓的流量再打到后端服务,从而给应用服务有足够的时间恢复服务。

Web层

经过一系列的负载均衡,用户终于请求到了web层的服务。web服务开发完成,经过部署,运行在web服务器中给用户提供服务。

集群

一般会根据业务模块,来划分不同的服务,一个服务部署多个实例组成集群。

集群

为了隔离故障,可以再将集群进行分组,这样一个分组出现问题,也不会影响其它分组。像比较常问的秒杀,通常会将秒杀的服务集群和普通的服务集群进行隔离。

能做到集群化部署的三个要点是无状态、拆分、服务化。

  • 无状态:设计的应用是无状态的,那么应用比较容易进行水平扩展。
  • 拆分:设计初期可以不用拆分,但是后期访问量大的时候,就可以考虑按功能拆分系统。拆分的维度也比较灵活,根据实际情况来选择,例如根据系统维度、功能维度、读写维度、AOP 维度、模块维度等等。
  • 服务化:拆分更多的是设计,服务化是落地,服务化一般都得服务治理的问题。除了最基本的远程调用,还得考虑负载均衡、服务发现、服务隔离、服务限流、服务访问黑白名单等。甚至还有细节需要考虑,如超时时间、重试机制、服务路由、故障补偿等。

Web服务器

独立开发一个成熟的 Web 服务器,成本非常高,况且业界又有那么多成熟的开源 Web 服务器,所以互联网行业基本上都是 "拿来主义" ,挑选一个流行的开源服务器即可。大一点的公司,可能会在开源服务器的基础上,结合自己的业务特点做二次开发,例如淘宝的 Tengine,但一般公司基本上只需要将开源服务器摸透,优化一下参数,调整一下配置就差不多了。

服务器的选择主要和开发语言相关,例如,Java 的有 Tomcat、JBoss、Resin 等,PHP/Python 的用 Nginx。

Web服务器的性能之类的一般不会成为瓶颈,例如Java最流行的Web服务器Tomcat默认配置的最大请求数是 150,但是没有关系,集群部署就行了。

容器

容器是最近几年才开始火起来的,其中以 Docker 为代表,在 BAT 级别的公司已经有较多的应用。

容器化可以说给运维带来了革命性的变化。Docker 启动快,几乎不占资源,随时启动和停止,基于Docker 打造自动化运维、智能化运维逐渐成为主流方式。

容器化技术也天生适合当前流行的微服务,容器将微服务进程和应用程序隔离到更小的实例里,使用更少的资源,更快捷地部署。结合容器编排技术,可以更方便快速地搭建服务高可用集群。

k8s集群

服务层

开发框架

一般,互联网公司都会指定一个大的技术方向,然后使用统一的开发框架。例如,Java 相关的开发框架 SSH、SpringBoot, Ruby 的 Ruby on Rails, PHP 的 ThinkPHP, Python 的Django 等。

框架的选择,有一个总的原则:优选成熟的框架,避免盲目追逐新技术!

对于一般的螺丝工而言,所做的主要工作都是在这个开发框架之下。对于开发语言和框架的使用,一定要充分了解和利用语言和框架的特性。

以Java为例,在作者的开发中,涉及到一个加密解密的服务调用,服务提供方利用了JNI的技术——简单说就是C语言编写代码,提供api供Java调用,弥补了Java相对没那么底层的劣势,大大提高了运算的速度。

在服务开发这个日常工作的层面,可以做到这些事情来提高性能:

  • 并发处理,通过多线程将串行逻辑并行化。
  • 减少IO次数,比如数据库和缓存的批量读写、RPC的批量接口支持、或者通过冗余数据的方式干掉RPC调用。
  • 减少IO时的数据包大小,包括采用轻量级的通信协议、合适的数据结构、去掉接口中的多余字段、减少缓存key的大小、压缩缓存value等。
  • 程序逻辑优化,比如将大概率阻断执行流程的判断逻辑前置、For循环的计算逻辑优化,或者采用更高效的算法
  • 各种池化技术的使用和池大小的设置,包括HTTP请求池、线程池(考虑CPU密集型还是IO密集型设置核心参数)、数据库和Redis连接池等。
  • JVM优化,包括新生代和老年代的大小、GC算法的选择等,尽可能减少GC频率和耗时。
  • 锁选择,读多写少的场景用乐观锁,或者考虑通过分段锁的方式减少锁冲突。

可以通过这些事情来提高可用性:

  • 设置合适的超时时间、重试次数及机制,必要时要及时降级,返回兜底数据等,防止把服务提方供打崩
  • 防重设计:通过防重key、防重表等方式实现防重
  • 幂等设计:在接口层面实现幂等设计

服务中心

当系统数量不多的时候,系统间的调用一般都是直接通过配置文件记录在各系统内部的,但当系统数量多了以后,这种方式就存在问题了。

比如说总共有 10 个系统依赖 A 系统的 X 接口,A 系统实现了一个新接口 Y, 能够更好地提供原有 X 接口的功能,如果要让已有的 10 个系统都切换到 Y 接口,则这 10 个系统的几十上百台器的配置都要修改,然后重启,可想而知这个效率是很低的。

服务中心的实现主要采用服务名字系统。

  • 服务务名字系统 (Service Name System)

看到这个翻译,相信你会立刻联想到 DNS, 即 Domain Name System。没错,两者的性质是基本类似的。

DNS 的作用将域名解析为 IP 地址,主要原因是我们记不住太多的数字 IP, 域名就容易记住。服务名字系统是为了将 Service 名称解析为 "host + port + 接口名称" ,但是和 DNS一样,真正发起请求的还是请求方。

image-20210501154424095

在微服务的架构下,实现这个功能的称之为注册中心,例如在Java语言体系下,开源的注册中心有Nacos、Ecuraka等。

配置中心

配置中心就是集中管理各个服务的配置。

在服务不多的时候,各个服务各自管理自己的配置,没有问题,但是当服务成百上千,再各行其政,就是一个比较头疼的事。

所以将配置中心抽象成公共的组件,集中配置多个系统,操作效率高。

在微服务架构体系下,配置中心的开源方案有SpringCloud的SpringCloud Config、阿里的Nacos等。

服务框架

服务拆分最直接的影响就是本地调用的服务变成了远程调用,服务消费者A需要通过注册中心去查询服务提供者B的地址,然后发起调用,这个看似简单的过程就可能会遇到下面几种情况,比如:

  • 注册中心宕机;
  • 服务提供者B有节点宕机;
  • 服务消费者A和注册中心之间的网络不通;
  • 服务提供者B和注册中心之间的网络不通;
  • 服务消费者A和服务提供者B之间的网络不通;
  • 服务提供者B有些节点性能变慢;
  • 服务提供者B短时间内出现问题。

怎么去保证服务消费者成功调用服务生产者?这就是服务治理框架要解决的问题。

在Java语言体系下,目前流行的服务治理框架有SpringCloud和Dubbo。

以SpringCloud为例:

SpringCloud体系

  • Feign封装RestTemplate实现http请求方式的远程调用
  • Feign封装Ribbon实现客户端负载均衡
  • Euraka集群部署实现注册中心高可用
  • 注册中心心跳监测,更新服务可用状态
  • 集成Hystrix实现熔断机制
  • Zuul作为API 网关 ,提供路由转发、请求过滤等功能
  • Config实现分布式配置管理
  • Sluth实现调用链路跟踪
  • 集成ELK,通过Kafka队列将日志异步写入Elasticsearch,通过Kibana可视化查看

SpringCloud是一整套完整微服务解决方案,被称为“SpringCloud 全家桶”。这里只是简单地介绍一下。

Dubbo主要提供了最基础的RPC功能。

不过SpringCloud的RPC采用了HTTP协议,可能性能会差一些。

利好的是,“SpringCloud2.0”——SpringCloud Alibaba流行了起来,Dubbo也可以完美地融入SpringCloud的生态。

消息队列

消息队列在高性能、高扩展、高可用的架构中扮演着很重要的角色。

消息队列是用来解耦一些不需要同步调用的服务或者订阅一些自己系统关心的变化。使用消息队列可以实现服务解耦(一对多消费)、异步处理、流量削峰/缓冲等。

服务解耦

服务解耦可以降低服务间耦合,提高系统系统的扩展性。

例如一个订单服务,有多个下游,如果不用消息队列,那么订单服务就要调用多个下游。如果需求要再加下游,那么订单服务就得添加调用新下流的功能,这就比较烦。

引入消息队列之后,订单服务就可以直接把订单相关消息塞到消息队列中,下游系统只管订阅就行了。

服务结构

异步处理

异步处理可以降低响应时间,提高系统性能。

随着业务的发展项目的请求链路越来越长,这样一来导致的后果就是响应时间变长,有些操作其实不用同步处理,这时候就可以考虑采用异步的方式了。

图片

流量削峰/缓冲

流量削峰/缓冲可以提高系统的可用性。

我们前面提到了接入层的限流,在服务层的限流可以通过消息队列来实现。网关的请求先放入消息队列中,后端服务尽可能去消息队列中消费请求。超时的请求可以直接返回错误,也可以在消息队列中等待。

流量削峰/缓冲

消息队列系统基本功能的实现比较简单,但要做到高性能、高可用、消息时序性、消息事务性则比较难。业界已经有很多成熟的开源实现方案,如果要求不高,基本上拿来用即可,例如,RocketMQ、Kafka、ActiveMQ 等。

但如果业务对消息的可靠性、时序、事务性要求较高时,则要深入研究这些开源方案,提前考虑可能会遇到的问题,例如消息重复消费、消息丢失、消息堆积等等。

平台层

当业务规模比较小、系统复杂度不高时,运维、测试、数据分析、管理等支撑功能主要由各系统或者团队独立完成。随着业务规模越来越大,系统复杂度越来越高,子系统数量越来越多,如果继续采取各自为政的方式来实现这些支撑功能,会发现重复工作非常多。所以就会自然地把相关功能抽离出来,作为公共的服务,避免重复造轮子,减少不规范带来的沟通和协作成本。

平台层是服务化思维下的产物。将公共的一些功能拆分出来,让相关的业务服务只专注于自己的业务,这样有利于明确服务的职责,方便服务扩展。

同时一些公共的平台,也有利于各个服务之间的统筹,例如数据平台,可以对数据进行聚合,某个服务以前需要一些整合一些数据可能要调用多个上游服务,但是引入数据平台以后,只需要从数据平台取数据就可以了,可以降低服务的响应时间。

运维平台

运维平台核心的职责分为四大块:配置、部署、监控、应急,每个职责对应系统生命周期的一个阶段,如下图所示:

运维平台

  • 部署:主要负责将系统发布到线上。例如,包管理、灰度发布管理、回滚等。
  • 监控:主要负责收集系统上线运行后的相关数据并进行监控,以便及时发现问题。
  • 应急:主要负责系统出故障后的处理。例如,停止程序、下线故障机器、切换 IP 等。

运维平台的核心设计要素是“四化"——标准化、平台化、自动化、可视化。

  • 标准化:要制定运维标准,规范配置管理、部署流程、监控指标、应急能力等,各系统按照运维标准来

    实现,避免不同的系统不同的处理方式。

  • 平台化:传统的手工运维方式需要投入大量人力,效率低,容易出错,因此需要在运维标准化的基础上,

    将运维的相关操作都集成到运维平台中,通过运维平台来完成运维工作。

  • 自动化:传统手工运维方式效率低下的一个主要原因就是要执行大量重复的操作,运维平台可以将这些重

    复操作固化下来,由系统自动完成。

  • 可视化:运维平台有非常多的数据,如果全部通过人工去查询数据再来判断,则效率很低,可视化的主要目的就是为了提升数据查看效率。

测试平台

测试平台核心的职责当然就是测试了,包括单元测试、集成测试、接口测试、性能测试等,都可以在测试平台来完成。

测试平台的核心目的是提升测试效率,从而提升产品质量,其设计关键就是自动化。

测试平台架构

数据平台

数据平台的核心职责主要包括三部分:数据管理、数据分析和数据应用。每一部分又包含更多的细分领域,详细的数据平台架构如下图所示:

数据平台

  1. 数据管理

数据管理包含数据采集、数据存储、数据访问和数据安全四个核心职责,是数据平台的基础功能。

  • 数据采集:从业务系统搜集各类数据。例如,日志、用户行为、业务数据等,将这些数据传送到数据平台。
  • 数据存储:将从业务系统采集的数据存储到数据平台,用于后续数据分析。
  • 数据访问:负责对外提供各种协议用于读写数据。例如,SQL、 Hive、 Key-Value 等读写协议。
  • 数据安全:通常情况下数据平台都是多个业务共享的,部分业务敏感数据需要加以保护,防止被其他业务读取甚至修改,因此需要设计数据安全策略来保护数据。
  1. 数据分析

数据分析包括数据统计、数据挖掘、机器学习、深度学习等几个细分领域。

  • 数据挖掘:数据挖掘这个概念本身含义可以很广,为了与机器学习和深度学习区分开,这里的数据挖掘主要是指传统的数据挖掘方式。例如,有经验的数据分析人员基于数据仓库构建一系列规则来对数据进行分析从而发现一些隐含的规律、现象、问题等,经典的数据挖掘案例就是沃尔玛的啤酒与尿布的关联关系的发现。

  • 机器学习、深度学习:机器学习和深度学习属于数据挖掘的一种具体实现方式,由于其实现方式与传统的数据挖掘方式差异较大,因此数据平台在实现机器学习和深度学习时,需要针对机器学习和深度学习独立进行设计。

  1. 数据应用

数据应用很广泛,既包括在线业务,也包括离线业务。例如,推荐、广告等属于在线应用,报表、欺诈检测、异常检测等属于离线应用。数据应用能够发挥价值的前提是需要有 "大数据" ,只有当数据的规模达到一定程度,基于数据的分析、挖掘才能发现有价值的规律、现象、问题等。如果数据没有达到一定规模,通常情况下做好数据统计就足够了,尤其是很多初创企业,无须一开始就参考 BAT 来构建自己的数据平台。

管理平台

管理平台的核心职责就是权限管理,无论是业务系统(例如,淘宝网) 、中间件系统(例如,消息队列 Kafka) , 还是平台系统(例如,运维平台) ,都需要进行管理。如果每个系统都自己来实现权限管理,效率太低,重复工作很多,因此需要统一的管理平台来管理所有的系统的权限。

说到“平台”,不由地想起这几年一会儿被人猛吹,一会儿被人唱衰的“中台”。在平台里的数据平台,其实已经和所谓的“数据中台”类似了。“中台”是个概念性的东西,具体怎么实现,没有统一的标准方案。作者所在的公司,也跟风建了中台,以“数据中台”为例,我们数据中台的建设主要为了数据共享和数据可视化,简单说就是把各个业务模块的一些数据汇聚起来。说起来简单,落地很难,数据汇聚的及时性、数据共享的快速响应……最终的解决方案是采购了阿里的一些商业化组件,花了老鼻子钱,但是效果,不能说一地鸡毛,也差不多吧。

缓存层

虽然我们可以通过各种手段来提升存储系统的性能,但在某些复杂的业务场景下,单纯依靠存储系统的性能提升不够的。

绝大部分在线业务都是读多写少。例如,微博、淘宝、微信这类互联网业务,读业务占了整体业务量的 90%以上。以微博为例:一个明星发一条微博,可能几千万人来浏览。

如果直接从DB中取数据,有两个问题,一个是DB查询的速度有瓶颈,会增加系统的响应时间,一个是数据库本身的并发瓶颈。缓存就是为了弥补读多写少场景下存储系统的不足。

在前面我们提到的CDN可以说是缓存的一种,它缓存的是静态资源。

从整个架构来看,一般采用多级缓存的架构,在不同层级对数据进行缓存,来提升访问效率。

多级缓存

简单说一下整体架构和流程,缓存一级一级地去读取,没有命中再去读取下一级,先读取本地缓存,再去读取分布式缓存,分布式缓存也没有命中,最后就得去读取DB。

分布式缓存

为了提高缓存的可用性,一般采用分布式缓存。分布式缓存一般采用分片实现,即将数据分散到多个实例或多台服务器。算法一般釆用取模和一致性哈希。

要采用不过期缓存机制,可以考虑取模机制,扩容时一般是新建一个集群。

取模算法

而对于可以丢失的缓存数据,可以考虑一致性哈希,即使其中一个实例出问题只是丢一小部分。

一致性hash算法

对于分片实现可以考虑客户端实现,或者使用如Twemproxy 中间件进行代理(分片对客户端是透明的)。

如果使用 Redis, 则 可 以考虑使用 redis-cluster 分布式集群方案。

Redis Cluster

热点本地缓存

对于那些访问非常频繁的热点缓存,如果每次都去远程缓存系统中获取,可能会因为访问量太大导致远程缓存系统请求过多、负载过高或者带宽过高等问题,最终可能导致缓存响应慢,使客户端请求超时。

一种解决方案是通过挂更多的从缓存,客户端通过负载均衡机制读取从缓存系统数据。不过也可以在客户端所在的应用/代理层本地存储一份,从而避免访问远程缓存,即使像库存这种数据,在有些应用系统中也可以进行几秒钟的本地缓存,从而降低远程系统的压力。

缓存的引入虽然提高了系统的性能,但同时也增加了系统的复杂度,带来了一些运维的成本。

缓存穿透

缓存穿透是指缓存没有发挥作用,业务系统虽然去缓存查询数据,但缓存中没有数据,业务系统需要再次去存储系统查询数据,结果存储系统也没有数据。

缓存穿透的示意图:

缓存穿透

一般情况下,如果存储系统中没有某个数据,则不会在缓存中存储相应的数据,这样就导致用户查询的时候,在缓存中找不到对应的数据,每次都要去存储系统中再查询一遍,然后返回数据不存在。缓存在这个场景中并没有起到分担存储系统访问压力的作用。

通常情况下,业务上读取不存在的数据的请求量并不会太大,但如果出现一些异常情况,例如被黑客攻击,故意大量访问某些读取不存在数据的业务,有可能会将存储系统拖垮。

这种情况的解决办法有两种:

一种比较简单,如果查询存储系统的数据没有找到,则直接设置一个默认值(可以是空值,也可以是具体的值) 存到缓存中,这样第二次读取缓存时就会获取到默认值,而不会继续访问存储系统。

一种需要引入布隆过滤器,它的原理也很简单就是利用高效的数据结构和算法,快速判断出查询的Key是否在数据库中存在,不存在直接返回空,存在就去查了DB,刷新KV再返回值。

缓存击穿

缓存击穿和缓存穿透也有点难以区分,缓存穿透表示的是缓存和数据库中都没有数据,缓存击穿表示缓存中没有数据而数据库中有数据。缓存击穿是某个热点的key失效,大并发集中对其进行请求,就会造成大量请求读缓存没读到数据,从而导致高并发访问数据库,引起数据库压力剧增。这种现象就叫做缓存击穿。

缓存击穿示意图:

缓存击穿

关键在于某个热点的key失效了,导致大并发集中打在数据库上。所以要从两个方面解决,第一是否可以考虑热点key不设置过期时间,第二是否可以考虑降低打在数据库上的请求数量。

主要有两个解决办法:

  • 利用互斥锁保证同一时刻只有一个客户端可以查询底层数据库的这个数据,一旦查到数据就缓存至Redis内,避免其他大量请求同时穿过Redis访问底层数据库。这种方式会阻塞其他的线程,此时系统的吞吐量会下降

  • 热点数据缓存永远不过期。

永不过期有两种方式:

  • 物理不过期,针对热点key不设置过期时间
  • 逻辑过期,把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建

缓存雪崩

缓存雪崩,指的是是缓存不可用,或者同一时刻是大量热点key失效。

两种情况导致的同样的后果就是大量的请求直接落在数据库上,对于一个高并发的业务系统来说,几百毫秒内可能会接到几百上千个请求,最严重的后果就是直接导致数据库宕机,可能会引起连锁反应,导致系统崩溃。

缓存雪崩

缓存雪崩的解决方案可以分为三个维度:

  • 事前:

① 均匀过期:设置不同的过期时间,让缓存失效的时间尽量均匀,避免相同的过期时间导致缓存雪崩,造成大量数据库的访问。

② 分级缓存:第一级缓存失效的基础上,访问二级缓存,每一级缓存的失效时间都不同。

③ 热点数据缓存永远不过期。

④ 保证Redis缓存的高可用,防止Redis宕机导致缓存雪崩的问题。可以使用 Redis集群等方式来避免 Redis 全盘崩溃的情况。

  • 事中:

① 互斥锁:在缓存失效后,通过互斥锁或者队列来控制读数据写缓存的线程数量,比如某个key只允许一个线程查询数据和写缓存,其他线程等待。这种方式会阻塞其他的线程,此时系统的吞吐量会下降

② 使用熔断机制,限流降级。当流量达到一定的阈值,直接返回“系统拥挤”之类的提示,防止过多的请求打在数据库上将数据库击垮,至少能保证一部分用户是可以正常使用,其他用户多刷新几次也能得到结果。

  • 事后:

① 开启Redis持久化机制,尽快恢复缓存数据,一旦重启,就能从磁盘上自动加载数据恢复内存中的数据。

存储层

不管是为了满足业务发展的需要,还是为了提升自己的竞争力,关系数据库厂商(Oracle、DB2、MySQL 等)在优化和提升单个数据库服务器的性能方面也做了非常多的技术优化和改进。但业务发展速度和数据增长速度,远远超出数据库厂商的优化速度,尤其是互联网业务兴起之后,海量用户加上海量数据的特点,单个数据库服务器已经难以满足业务需要,必须考虑数据库集群的方式来提升性能。

读写分离

读写分离的基本原理是将数据库读写操作分散到不同的节点上,下面是其基本架构图:

读写分离

读写分离的基本实现是:

  • 数据库服务器搭建主从集群,一主一从、一主多从都可以。

  • 数据库主机负责读写操作,从机只负责读操作。

  • 数据库主机通过复制将数据同步到从机,每台数据库服务器都存储了所有的业务数据。

  • 业务服务器将写操作发给数据库主机,将读操作发给数据库从机。

主从异步复制原理

读写分离的实现逻辑并不复杂,但有两个细节点将引入设计复杂度:主从复制延迟和分配机制。

复制延迟

以 MySQL 为例,主从复制延迟可能达到 1 秒,如果有大量数据同步,延迟 1 分钟也是有可能的。

主从复制延迟会带来一个问题:如果业务服务器将数据写入到数据库主服务器后立刻 (1 秒 内)进行读取,此时读操作访问的是从机,主机还没有将数据复制过来,到从机读取数据是读不到最新数据的,业务上就可能出现问题。

比如说将微博的信息同步给审核系统,所以我们在更新完主库之后,会将微博的 ID 写入消息队列,再由队列处理机依据 ID 在从库中 获取微博信息再发送给审核系统。此时如果主从数据库存在延迟,会导致在从库中获取不到微博信息,整个流程会出现异常。

复制延迟

解决主从复制延迟的常见方法:

  1. 数据的冗余

我们可以在发送消息队列时不仅仅发送微博 ID,而是发送队列处理机需要的所有微博信息,借此避免从数据库中重新查询数据。

  1. 使用缓存

我们可以在同步写数据库的同时,也把微博的数据写入到缓存里面,队列处理机在获取微博信息的时候会优先查询缓存,这样也可以保证数据的一致性。

  1. 二次读取

我们可以对底层数据库访问的API进行封装,一次读取从库发现不实时之后再读取一次,例如我们通过微博ID没有在从库里读到微博,那么第二次就直接去主库读取。

  1. 查询主库

我们可以把关键业务,或者对实时性有要求的业务读写操作全部指向主机,非关键业务或者实时性要求不高的业务采用读写分离。

分配机制

将读写操作区分开来,然后访问不同的数据库服务器,一般有两种方式:程序代码封装和中间件封装。

  1. 程序代码封装

程序代码封装指在代码中抽象一个数据访问层(所以有的文章也称这种方式为 "中间层封装" ) ,实现读写操作分离和数据库服务器连接的管理。例如,基于 Hibernate 进行简单封装,就可以实现读写分离,基本架构是:

代码封装读写分离分配

程序代码封装的方式具备几个特点:

  • 实现简单,而且可以根据业务做较多定制化的功能。

  • 每个编程语言都需要自己实现一次,无法通用,如果一个业务包含多个编程语言写的多个子系统,则重复开发的工作量比较大。

  • 故障情况下,如果主从发生切换,则可能需要所有系统都修改配置并重启。

如果不想自己造轮子,也可以用开源的方案,淘宝的TDDL是比较出名的一个。

TDDL

  1. 中间件封装

中间件封装指的是独立一套系统出来,实现读写操作分离和数据库服务器连接的管理。中间件对业务服务器提供 SQL 兼容的协议,业务服务器无须自己进行读写分离。对于业务服务器来说,访问中间件和访问数据库没有区别,事实上在业务服务器看来,中间件就是一个数据库服务器。

其基本架构是:

数据库中间件

数据库中间件的方式具备的特点是:

  • 能够支持多种编程语言,因为数据库中间件对业务服务器提供的是标准 SQL 接口。
  • 数据库中间件要支持完整的 SQL 语法和数据库服务器的协议(例如,MySQL 客户端和服务器的连接协议) ,实现比较复杂,细节特别多,很容易出现 bug, 需要较长的时间才能稳定。
  • 数据库中间件自己不执行真正的读写操作,但所有的数据库操作请求都要经过中间件,中间件的性能要求也很高。
  • 数据库主从切换对业务服务器无感知,数据库中间件可以探测数据库服务器的主从状态。例如,向某个测试表写入一条数据,成功的就是主机,失败的就是从机。

目前开源的数据库中间件有基于 MySQL Proxy 开发的奇虎 360 的 Atlas 、阿 里 的Cobar、基于 Cobar 开发的 Mycat 等。

分库分表

读写分离分散了数据库读写操作的压力,但没有分散存储压力,当数据量达到干万甚至上亿条的时候,单台数据库服务器的存储能力会成为系统的瓶颈,主要体现在这几个方面:

  • 数据量太大,读写的性能会下降,即使有索引,索引也会变得很大,性能同样会下降。

  • 数据文件会变得很大,数据库备份和恢复需要耗费很长时间。

  • 数据文件越大,极端情况下丟失数据的风险越高(例如,机房火灾导致数据库主备机都发生故障)。

基于上述原因,单个数据库服务器存储的数据量不能太大,需要控制在一定的范围内。为了满足业务数据存储的需求,就需要将存储分散到多台数据库服务器上。

业务分库

业务分库指的是按照业务模块将数据分散到不同的数据库服务器。例如,一个简单的电商网站,包括用户、商品、订单三个业务模块,我们可以将用户数据、商品数据、订单数据分开放到三台不同的数据库服务器上,而不是将所有数据都放在一台数据库服务器上。

业务分库

虽然业务分库能够分散存储和访问压力,但同时也带来了新的问题,接下来我们详细分析一下。

  1. join 操作问题

业务分库后,原本在同一个数据库中的表分散到不同数据库中,导致无法使用 SQL 的 join 查 询。

例如: "查询购买了化妆品的用户中女性用户的列表〃 这个功能,虽然订单数据中有用户的 ID信息,但是用户的性别数据在用户数据库中,如果在同一个库中,简单的 join 查询就能完成;但现在数据分散在两个不同的数据库中,无法做 join 查询,只能采取先从订单数据库中查询购买了化妆品的用户 ID 列表,然后再到用户数据库中查询这批用户 ID 中的女性用户列表,这样实现就比简单的 join 查询要复杂一些。

  1. 事务问题

原本在同一个数据库中不同的表可以在同一个事务中修改,业务分库后,表分散到不同的数据库中,无法通过事务统一修改。虽然数据库厂商提供了一些分布式事务的解决方案(例如,MySQL 的 XA) , 但性能实在太低,与高性能存储的目标是相违背的。

例如,用户下订单的时候需要扣商品库存,如果订单数据和商品数据在同一个数据库中,我们可订单,如果因为订单数据库异常导致生成订单失败,业务程序又需要将商品库存加上;而如果因为业务程序自己异常导致生成订单失败,则商品库存就无法恢复了,需要人工通过曰志等方式来手工修复库存异常。

  1. 成本问题

业务分库同时也带来了成本的代价,本来 1 台服务器搞定的事情,现在要 3 台,如果考虑备份,那就是 2 台变成了 6 台。

基于上述原因,对于小公司初创业务,并不建议一开始就这样拆分,主要有几个原因:初创业务存在很大的不确定性,业务不一定能发展起来,业务开始的时候并没有真正的存储和访问压力,业务分库并不能为业务带来价值。业务分库后,表之间的 join 查询、数据库事务无法简单实现了。

业务分库后,因为不同的数据要读写不同的数据库,代码中需要增加根据数据类型映射到不同数据库的逻辑,增加了工作量。而业务初创期间最重要的是快速实现、快速验证,业务分库会拖慢业务节奏。

单表拆分

将不同业务数据分散存储到不同的数据库服务器,能够支撑百万甚至千万用户规模的业务,但如果业务继续发展,同一业务的单表数据也会达到单台数据库服务器的处理瓶颈。例如,淘宝的几亿用户数据,如果全部存放在一台数据库服务器的一张表中,肯定是无法满足性能要求的,此时就需要对单表数据进行拆分。

单表数据拆分有两种方式:垂直分表和水平分表。示意图如下:

image-20210509213156925

分表能够有效地分散存储压力和带来性能提升,但和分库一样,也会引入各种复杂性。

两种分表方式可以用一个例子比喻,我们很多人可能都看过这么一篇文章,怎么把苹果切出星星来,答案是横着切。

苹果切分

  1. 垂直分表

垂直分表适合将表中某些不常用且占了大量空间的列拆分出去。例如,前面示意图中的nickname 和 desc 字段,假设我们是一个婚恋网站,用户在筛选其他用户的时候,主要是用 age 和 sex 两个字段进行查询,而 nickname 和 description 两个字段主要用于展示,一般不会在业务查询中用到。description 本身又比较长,因此我们可以将这两个字段独立到另外—张表中,这样在查询 age 和 sex 时,就能带来一定的性能提升。垂直分表引入的复杂性主要体现在表操作的数量要增加。例如,原来只要一次查询就可以获取name、age、sex、nickname、description, 现在需要两次查询,—次查询获取 name、age、 sex, 另一次查询获取 nickname、desc。

不过相比接下来要讲的水平分表,这个复杂性就是小巫见大巫了。

  1. 水平分表

水平分表适合表行数特别大的表,有的公司要求单表行数超过 5000 万就必须进行分表,这个数字可以作为参考,但并不是绝对标准,关键还是要看表的访问性能。对于一些比较复杂的表,可能超过 1000 万就要分表了;而对于一些简单的表,即使存储数据超过 1 亿行,也可以不分表。但不管怎样,当看到表的数据量达到干万级别时,这很可能是架构的性能瓶颈或者隐患。

水平分表相比垂直分表,会引入更多的复杂性,主要表现在下面几个方面:

  • 路由

水平分表后,某条数据具体属于哪个切分后的子表,需要增加路由算法进行计算,这个算法会引入一定的复杂性。

常见的路由算法有:

范围路由Hash路由

范围路由:选取有序的数据列 (例如,整形、时间戳等) 作为路由的条件,不同分段分散到不同的数据库表中。以订单 Id 为例,路由算法可以按照 1000万 的范围大小进行分段。范围路由设计的复杂点主要体现在分段大小的选取上,分段太小会导致切分后子表数量过多,增加维护复杂度;分段太大可能会导致单表依然存在性能问题,一般建议分段大小在 100 万至2000 万之间,具体需要根据业务选取合适的分段大小。

范围路由的优点是可以随着数据的增加平滑地扩充新的表。例如,现在的用户是 100 万,如果增加到 1000 万,只需要增加新的表就可以了,原有的数据不需要动。范围路由的一个比较隐含的缺点是分布不均匀,假如按照 1000 万来进行分表,有可能某个分段实际存储的数据量只有 1000 条,而另外一个分段实际存储的数据量有 900 万条。

Hash 路由:选取某个列 (或者某几个列组合也可以) 的值进行 Hash 运算,然后根据 Hash 结果分散到不同的数据库表中。同样以订单 id 为例,假如我们一开始就规划了 4个数据库表,路由算法可以简单地用 id % 4 的值来表示数据所属的数据库表编号,id 为 12的订单放到编号为 50的子表中,id为 13的订单放到编号为 61的字表中。

Hash 路由设计的复杂点主要体现在初始表数量的选取上,表数量太多维护比较麻烦,表数量太少又可能导致单表性能存在问题。而用了 Hash 路由后,增加字表数量是非常麻烦的,所有数据都要重分布。

Hash 路由的优缺点和范围路由基本相反,Hash 路由的优点是表分布比较均匀,缺点是扩充新的表很麻烦,所有数据都要重分布。

配置路由:配置路由就是路由表,用一张独立的表来记录路由信息。

同样以订单id 为例,我们新增一张 order_router 表,这个表包含 orderjd 和 tablejd 两列 , 根据 orderjd 就可以查询对应的 table_id。

配置路由设计简单,使用起来非常灵活,尤其是在扩充表的时候,只需要迁移指定的数据,然后修改路由表就可以了。

配置路由的缺点就是必须多查询一次,会影响整体性能;而且路由表本身如果太大(例如,几亿条数据) ,性能同样可能成为瓶颈,如果我们再次将路由表分库分表,则又面临一个死循环式的路由算法选择问题。

  • join 操作

水平分表后,数据分散在多个表中,如果需要与其他表进行 join 查询,需要在业务代码或者数据库中间件中进行多次 join 查询,然后将结果合并。

  • count()操作

分表后就没那么简单了。常见的处理方式有下面两种:

count() 相加:具体做法是在业务代码或者数据库中间件中对每个表进行 count操作,然后将结果相加。这种方式实现简单,缺点就是性能比较低。例如,水平分表后切分为 20 张表,则要进行 2 0 次 count()操作,如果串行的话,可能需要几秒钟才能得到结果。

记录数表:具体做法是新建一张表,假如表名为 "记录数表” ,包含 table_name、 row_count两个字段,每次插入或者删除子表数据成功后,都更新 "记录数表“。这种方式获取表记录数的性能要大大优于 count()相加的方式,因为只需要一次简单查询就可以获取数据。缺点是复杂度增加不少,对子表的操作要同步操作 "记录数表" ,如果有一个业务逻辑遗漏了,数据就会不一致;且针对 "记录数表" 的操作和针对子表的操作无法放在同一事务中进行处理,异常的情况下会出现操作子表成功了而操作记录数表失败,同样会导致数据不一致。

此外,记录数表的方式也增加了数据库的写压力,因为每次针对子表的 insert 和 delete 操作都要 update 记录数表,所以对于一些不要求记录数实时保持精确的业务,也可以通过后台定时更新记录数表。定时更新实际上就是 "count()相加" 和 "记录数表" 的结合,即定时通过count()相加计算表的记录数,然后更新记录数表中的数据。

  • order by 操作

水平分表后,数据分散到多个子表中,排序操作无法在数据库中完成,只能由业务代码或者数据库中间件分别查询每个子表中的数据,然后汇总进行排序。

实现方法

和数据库读写分离类似,分库分表具体的实现方式也是 "程序代码封装" 和 "中间件封装" ,但实现会更复杂。读写分离实现时只要识别 SQL 操作是读操作还是写操作,通过简单的判断SELECT、UPDATE、 INSERT、DELETE 几个关键字就可以做到,而分库分表的实现除了要判断操作类型外,还要判断 SQL 中具体需要操作的表、操作函数(例如 count 函数)、order by、group by 操作等,然后再根据不同的操作进行不同的处理。例如 order by 操作,需要先从多个库查询到各个库的数据,然后再重新 order by 才能得到最终的结果。

数据异构

完成分库分表以后,我们看到存在一些问题,除了"程序代码封装" 和 "中间件封装"之外,我们还有一种办法,就是数据异构。数据异构就是将数据进行异地存储,比如业务上将MySQL的数据,写一份到Redis中,这就是实现了数据在集群中的异地存储,也就是数据异构。

在数据量和访问量双高时使用数据异构是非常有效的,但增加了架构的复杂度。异构时可以通过双写、订阅 MQ 或者 binlog 并解析实现。

  • 双写:在写入数据的时候,同时将数据写入MySQL和异构存储系统;
  • MQ:写入MySQL成功后,发一个mq消息,缓存读取mq消息并将消息写入异构存储系统;
  • binlog:写入MySQL后,缓存系统x消费binlog,将变动写入异构存储系统。

这是一个异构的数据架构示意图:

异构数据架构

在图中用到了ES搜索集群来处理搜索业务,同样也可以我们前面提到的跨库join的问题。

在设计异构的时候,我们可以充分利用一些流行的NoSQL数据库。NoSQL尽管已经被证明不能取代关系型数据库,但是在很多场景下是关系型数据库的有力补充。

举几个例子,像我们熟悉的Redis这样的KV存储,有极高的读写性能,在读写性能有要求的场景可以使用;

Hbase、Cassandra 这样的列式存储数据库。这种数据库的特点是数据不像传统数据库以行为单位来存储,而是以列来存储,适用于一些离线数据统计的场景;

MongoDB、CouchDB 这样的文档型数据库,具备 Schema Free(模式自由)的特点,数据表中的字段可以任意扩展,可以用于数据字段不固定的场景。

查询维度异构

比如对于订单库,当对其分库分表后,如果想按照商家维度或者按照用户维度进行查询,那么是非常困难的,因此可以通过异构数据库来解决这个问题。可以采用下图的架构。

异构1

或者采用下图的ES异构:

异构2

异构数据主要存储数据之间的关系,然后通过查询源库查询实际数据。不过,有时可以通过数据冗余存储来减少源库查询量或者提升查询性能。

聚合据异构

商品详情页中一般包括商品基本信息、商品属性、商品图片,在前端展示商品详情页时,是按照商品 ID 维度进行查询,并且需要查询 3 个甚至更多的库才能查到所有展示数据。此时,如果其中一个库不稳定,就会导致商品详情页出现问题,因此,我们把数据聚合后异构存储到 KV 存储集群(如存储 JSON ), 这样只需要一次查询就能得到所有的展示数据。这种方式也需要系统有了一定的数据量和访问量时再考虑。

聚合据异构

高并发架构要点

通过前面的内容,已经差不多了解高并发的架构是一个什么样,接下来做一些总结和补充。

高性能要点

高性能要点

高可用要点

高可用要点

除了从技术的角度来考虑,保证高可用同样需要良好的组织制度,来保证服务出现问题的快速恢复。

高扩展要点

1、合理的分层架构:比如上面谈到的互联网最常见的分层架构,另外还能进一步按照数据访问层、业务逻辑层对微服务做更细粒度的分层(但是需要评估性能,会存在网络多一跳的情况)。

2、存储层的拆分:按照业务维度做垂直拆分、按照数据特征维度进一步做水平拆分(分库分表)。

3、业务层的拆分:最常见的是按照业务维度拆(比如电商场景的商品服务、订单服务等),也可以按照核心请求和非核心请求拆分,还可以按照请求源拆(比如To C和To B,APP和H5 )。

 

好了,攒的这一篇终于完事了,更深入学习建议阅读书籍参考【11】。祝各位架构师能真的如江似海,把握高并发,多挣达不溜。





由于作者对于高并发的认识是从零开始,所以参考了很多经典资料!

参考与感谢:

【1】:极客时间 《从零开始学架构》

【2】:知乎问答:我没有高并发项目经验,但是面试的时候经常被问到高并发、性能调优方面的问题,有什么办法可以解决吗?

【3】:什么是高并发 ,详细讲解

【4】:【高并发】如何设计一个支撑高并发大流量的系统?这次我将设计思路分享给大家!

【5】:《淘宝技术这十年》

【6】:知乎问答:如何获得高并发的经验?

【7】:服务端高并发分布式架构演进之路

【8】:七年磨一剑,独家揭秘淘宝技术发展历程和架构经验

【9】:《大型网站技术架构核心原理与案例分析》

【10】:阿里技术专家:日活5亿的淘宝技术发展历程和架构经验分享!18页ppt详解

【11】:《亿级流量网站架构技术》

【12】:极客时间《从零开始学微服务》

【13】:面试题:如何保证消息不丢失?处理重复消息?消息有序性?消息堆积处理?

【14】:极客时间 高并发系统设计40问

【15】:《Redis深度历险:核心原理和应用实践》

【16】:Redis的缓存雪崩、缓存击穿、缓存穿透与缓存预热、缓存降级

【17】:如何优雅的设计和使用缓存?

【18】:缓存穿透、缓存击穿、缓存雪崩,看这篇就够了

【19】:数据异构

【20】:分库分表?如何做到永不迁移数据和避免热点?

posted @ 2021-05-20 15:34    阅读(343)  评论(0编辑  收藏  举报