分布式B站高可用用架构实践

Posted 九师兄

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了分布式B站高可用用架构实践相关的知识,希望对你有一定的参考价值。

1.概述

转载:B站高可用用架构实践

流量洪峰下要做好高服务质量的架构是一件具备挑战的事情,本文是B站技术总监毛剑老师在「腾讯云开发者社区沙龙online」的分享整理,详细阐述了从Google SRE的系统方法论以及实际业务的应对过程中出发,一些体系化的可用性设计。对我们了解系统的全貌、上下游的联防有更进一步的帮助。

bilibili技术总监毛剑:B站高可用架构实践

2.负载均衡

负载均衡具体分成两个方向,一个是前端负载均衡,另一个是数据中心内部的负载均衡。


前端负载均衡方面,一般而言用户流量访问层面主要依据DNS,希望做到最小化用户请求延迟。将用户流量最优地分布在多个网络链路上、多个数据中心、多台服务器上,通过动态CDN的方案达到最小延迟。

有些企业做了异地多活,多个机房在北京。上海等。

以上图为例,用户流量会先流入BFE的前端接入层,第一层的BFE实际上起到一个路由的作用,尽可能选择跟接入节点比较近的一个机房,用来加速用户请求。然后通过API网关转发到下游的服务层,可能是内部的一些微服务或者业务的聚合层等,最终构成一个完整的流量模式。

基于此,前端服务器的负载均衡主要考虑几个逻辑:

  1. 第一,尽量选择最近节点;这个一般都是尽可能选择跟接入节点比较近的一个机房,用来加速用户请求

  2. 第二,基于带宽策略调度选择API进入机房;有的机房带宽是被限制的,比如新疆的和上海的是不一样的。

  3. 第三,基于可用服务容量平衡流量。当某个数据中心负载比较高或者服务容量比较少。

2.1 数据中心内部的负载均衡

当用户经过第一层负载均衡后,跑到某个服务集群内了,到了数据中心后,会有一个负载均衡将请求分发给每个机器,理想情况下,大家应该都一样,跟右图一样,最忙的机器和和最不忙的机器消耗的内存和CPU应该不会太大。

如果你的负载均衡做的不好,你可能会发现一批节点,最忙的机器和和最不忙的机器消耗的内存和CPU很大。如果差异比较大,我们的比如k8s等无法很好的分配资源和容器。

数据中心内部的负载均衡方面,理想情况下会像上图右边显示那样,最忙和最不忙的节点所消耗的CPU相差幅度较小。但如果负载均衡没做好,情况可能就像上图左边一样相差甚远。由此可能导致资源调度、编排的困难,无法合理分配容器资源。

因此,数据中心内部负载均衡主要考虑:

  1. 均衡流量分发;
  2. 可靠识别异常节点;比如一个节点都已经坏了,但是你一直没识别出来就很糟糕。
  3. scale-out,增加同质节点以扩容;随着企业规模越来越大,如果你能扩容100个节点带来100个节点的收益,这是很好的,但是可能会造成不好的结果,比如连接数过多。
  4. 减少错误,提高可用性。

2.2 子集选择和多集群

我们此前通过同质节点来扩容就发现,内网服务出现CPU占用率过高的异常,在平常不工作的时候,发现负载也在30%左右,通过排查发现背后RPC点到点通信间的 health check 成本过高,产生了一些问题。微服务会有心跳服务来验证节点是否正常。

另外一方面,底层的服务如果只有单套集群,当出现抖动的时候故障面会比较大,因此需要引入多集群来解决问题。

通过实现 client 到 backend 的子集连接,我们做到了将后端平均分配给客户端,同时可以处理节点变更,持续不断均衡连接,避免大幅变动。多集群下,则需要考虑集群迁移的运维成本,同时集群之间业务的数据存在较小的交集。

回到CPU忙时、闲时占用率过大的问题,我们会发现这背后跟负载均衡算法有关。

第一个问题,对于每一个qps,实际上就是每一个query、查询、API请求,它们的成本是不同的。节点与节点之间差异非常大,即便你做了均衡的流量分发,但是从负载的角度来看,实际上还是不均匀的。

第二个问题,存在物理机环境上的差异。因为我们通常都是分年采购服务器,新买的服务器通常主频CPU会更强一些,所以服务器本质上很难做到强同质。


多集群可以在顶层减少一些单集群出现的问题,所以当时我们是搞了多套集群,比如直播的一套集群,打游戏的一套集群。

多集群也会出现问题,比如当我们的直播的账号出现了问题,我们要进行流量迁移的时候,迁移成本比较高,因为首先你要通知直播部门把他上游的流量都迁移到另外一个集群,这个你只能手工,因为你不敢一下子全部迁移到另外一套集群,因为可能会导致另外一套集群扛不住,你只能慢慢的把流量迁移过来。

多套集群分割后,业务间的数据交集可能不是很大,比如打游戏的不直播。玩直播的不打游戏,你切换的时候会导致很多cache miss,这些cache miss可能最终透传到我们的存储层。可能会给存储带来一些压力。

我们现在做法是客户端来连接的时候,负载均衡到多个集群里面,多个集群中可能缓存的有相同的数据,但是这样的好处是,当一个集群中有问题的时候,负载均衡会自动均衡到其他多个集群,不会导致问题。

2.3 复杂均衡算法

如果你的负载均衡算法做的好,那么案例说你的集群间的差异应该是比较小的。


但是实际上我们发现最忙的机器和和最闲的机器差异比较大。为什么会出现这样呢?因为你的随机算法是轮训或者随机或者其他的,但是这个都是根据QPS来计算的,但是没考虑到每个API请求的成本是不一样的,比如有个用户查询一条数据,有的用户查询几万条数据,这些成本是不相同的,这样即便你的负载均衡算法是均衡的,但是到机器上负载是不均衡的。

第二个就是我们物理机环境的差异,因为我们买服务器都是一年一年的采购,众所周知每年的机器的性能都是不同的,比如新的比旧的好。

还比如网络带宽什么的存在资源竞争。又比如你的服务是java开发的,然后郑州FullGC的时候,其实你的负载是很高的。然后你发请求到这个服务会导致更高。

除此之外,还有新启动的节点,刚刚开始会发生抖动,一般他的负载是很低的,所以新启动的一般要预热。

因为你的客户端缺少全局的视图,很难决定到底发生到那一台机器上。

基于此,参考JSQ(最闲轮训)负载均衡算法带来的问题,发现缺乏的是服务端全局视图,因此我们的目标需要综合考虑负载和可用性。我们参考了《The power of two choices in randomized load balancing》的思路,使用the choice-of-2算法,随机选取的两个节点进行打分,选择更优的节点

  1. 选择backend:CPU,client:health、inflight、latency作为指标,使用一个简单的线性方程进行打分;

  2. 对新启动的节点使用常量惩罚值(penalty),以及使用探针方式最小化放量,进行预热;我么知道比如java启动的时候会做一些JIT(代码本地化实时编译),以前我们是手动触发一些预热代码,然后再去引入流量,但是这种不是很通用,我们使用惩罚值来预热。

  3. 打分比较低的节点,避免进入“永久黑名单”而无法恢复,使用统计衰减的方式,让节点指标逐渐恢复到初始状态(即默认值)。我们会对进入黑名单的定期进入一个流量,然后让他把相关的指标给刷新掉。使用一个衰减算法,然后逐步让服务回到正常值。

通过优化负载均衡算法以后,我们做到了比较好的收益。

3.限流

避免过载,是负载均衡的一个重要目标。随着压力增加,无论负载均衡策略如何高效,系统某个部分总会过载。我们优先考虑优雅降级,返回低质量的结果,提供有损服务。在最差的情况,妥善的限流来保证服务本身稳定。

限流这块,我们认为主要关注以下几点:

  1. 一是针对qps的限制,带来请求成本不同、静态阈值难以配置的问题;

  2. 二是根据API的重要性,按照优先级丢弃;根据不同的api做不同的阈值。

  3. 三是给每个用户设置限制,全局过载发生时候,针对某些“异常”进行控制非常关键;

  4. 四是拒绝请求也需要成本;

  5. 五是每个服务都配置限流带来的运维成本。这个是最烦人的。

想一下我们限流主要是保护自己,或者保护下游,我们能不能做到自适应限流呢?

3.1 分布式限流

在限流策略上,我们首先采用的是分布式限流。我们通过实现一个quota-server,用于给backend针对每个client进行控制,即backend需要请求quota-server获取quota。

这样做的好处是减少请求Server的频次,获取完以后直接本地消费,本地用完后会再去申请。算法层面使用最大最小公平算法,解决某个大消耗者导致的饥饿。

不推荐静态限流,有个原因是,比如你10台机器限流200,然后如果扩容了呢,20台机器你限流多少。节点与节点之间的限流是有异常的。

我们使用最大最小公平算法限流。

3.2 客户端限流

在客户端侧,当出现某个用户超过资源配额时,后端任务会快速拒绝请求,返回“配额不足”的错误,有可能后端忙着不停发送拒绝请求,导致过载和依赖的资源出现大量错误,处于对下游的保护两种状况,我们选择在client侧直接进行流量,而不发送到网络层。

我们在Google SRE里学到了一个有意思的公式,max(0, (requests- K*accepts) / (requests + 1))。通过这种公式,我们可以让client直接发送请求,一旦超过限制,按照概率进行截流。

3.3 过载保护

我们每个服务不可能都配置限流,我们能不能再自己负责比较高的时候做一些过载保护呢?

在过载保护方面,核心思路就是在服务过载时,丢弃一定的流量,保证系统临近过载时的峰值流量,以求自保护。常见的做法有基于CPU、内存使用量来进行流量丢弃;使用队列进行管理;可控延迟算法:CoDel 等。

简单来说,当我们的CPU达到80%的时候,这个时候可以认为它接近过载,如果这个时候的吞吐达到100,瞬时值的请求是110,我就可以丢掉这10个流量,这种情况下服务就可以进行自保护,我们基于这样的思路最终实现了一个过载保护的算法。

我们使用CPU的滑动均值(CPU > 800 )作为启发阈值,一旦触发就进入到过载保护阶段。可以看到绿色的线,可以看到跳动不是很厉害。

算法为:(MaxPass * AvgRT) < InFlight。其中MaxPass、AvgRT都为触发前的滑动时间窗口的统计值。

限流效果生效后,CPU会在临界值(800)附近抖动,如果不使用冷却时间,那么一个短时间的CPU下降就可能导致大量请求被放行,严重时会打满CPU,比如上图的黄色线条,可以看到抖动厉害。在冷却时间后,重新判断阈值(CPU > 800 ),是否持续进入过载保护,可以看到绿色线条比较稳定。

4. 重试

流量的走向,一般会从BFE到SLB然后经过API网关再到BFF、微服务最后到数据库,这个过程要经过非常多层。在我们的日常工作中,当请求返回错误,对于backend部分节点过载的情况下,我们应该怎么做?

  1. 首先我们需要限制重试的次数,以及基于重试分布的策略;没有设置最大重试次数会一直重试,这是不好的。

  2. 其次,我们只应该在失败层进行重试,当重试仍然失败时,我们需要全局约定错误码,避免级联重试;比如A调用B重试3次,B调用C重试4次…这样的重试次数是指数级别增长的 3*4*xxx

  3. 此外,我们需要使用随机化、指数型递增的充实周期,这里可以参考Exponential Backoff和Jitter;

  4. 最后,我们需要设定重试速率指标,用于诊断故障。

4.1 客户端限速

客户端比如一个用户突然发现某个按钮不可用了,他会连续的点击多次,这种情况呀要客户端限流。

而在客户端侧,则需要做限速。因为用户总是会频繁尝试去访问一个不可达的服务,因此客户端需要限制请求频次,可以通过接口级别的error_details,挂载到每个API返回的响应里。意思就是把限流的策略放到客户端返回的请求里面,然后这样客户端就可以实时的更改限流策略。

5.超时

我们之前讲过,大部分的故障都是因为超时控制不合理导致的。首当其冲的是高并发下的高延迟服务,导致client堆积,引发线程阻塞,此时上游流量不断涌入,最终引发故障。

所以,从本质上理解超时它实际就是一种Fail Fast的策略,就是让我们的请求尽可能消耗,类似这种堆积的请求基本上就是丢弃掉或者消耗掉。

另一个方面,客户端和服务端不一致的超时策略,比如客户端是10秒,服务端是2秒,当上游超时已经返回给用户后,下游可能还在执行,这就会引发资源浪费的问题。

再一个问题,当我们对下游服务进行调优时,到底如何配置超时,默认值策略应该如何设定?生产环境下经常会遇到手抖或者错误配置导致配置失败、出现故障的问题,有个同事就把redis超时设置的特别长,然后导致很多客户端都在挂着,然后最终导致不可用。所以我们最好是在框架层面做一些防御性的编程,让它尽可能让取在一个合理的区间内。

假设我们一个请求必须1秒内返回,但是很多情况下这个接口是超过1秒才返回的,因为你可能要访问redis、数据库或者rpc,这些时间加起来就超过1秒了,这些东西配置起来很烦很烦,一般情况下我们怎么做呢?

这就是强调的进程内的超时控制。我们比方说一个流量进来,你最开始的位置设置的是1秒钟超时如图,我们请求redis花费了100毫秒,然后我们剩下900毫秒,然后防滑纹serveice B耗费了500毫秒,还剩下400毫秒,容纳后请求数据库,花费了350毫秒,但是我们这里访问数据库可能是设置了500毫秒超时,此时left是400毫秒,我们会取值最小的值,来作为超时。

进程内的超时控制,关键要看一个请求在每个阶段(网络请求)开始前,检查是否还有足够的剩余来处理请求。另外,在进程内可能会有一些逻辑计算,我们通常认为这种时间比较少,所以一般不做控制。

5.1 跨进程超时

现在很多RPC框架都在做跨进程超时控制,为什么要做这个?跨进程超时控制同样可以参考进程内的超时控制思路,通过RPC的源数据传递,把它带到下游服务,然后利用配额继续传递,最终使得上下游链路不超过一秒。

思路是一样的,我们也可以使用request header 从开头一直把超时时间带到最末尾。

6.应对连锁故障

结合我们上面讲到的四个方面,应对连锁故障,我们有以下几大关键点需要考虑。

第一,我们需要尽可能避免过载。因为节点一个接一个挂了的话,最终服务会雪崩,有可能机群都会跟着宕掉,所以我们才提到要做自保护。

第二,我们通过一些手段去做限流。它可以让某一个client对服务出现高流量并发请求时进行管控,这样的话服务也不容易死。另外,当我们无法正常服务的时候,还可以做有损服务,牺牲掉一些非核心服务去保证关键服务,做到优雅降级。

第三,在重试策略上,在微服务内尽可能做退避,尽可能要考虑到重试放大的流量倍数对下游的冲击。另外还要考虑在移动端用户用不了某个功能的情况下,通常会频繁刷新页面,这样产生的流量冲击,我们在移动端也要进行配合来做流控。

第四,超时控制强调两个点,进程内的超时和跨进程的传递。最终它的超时链路是由最上层的一个节点决定的,只要这一点做到了,我觉得大概率是不太可能出现连锁故障的。

第五,变更管理。我们通常情况下发布都是因为一些变更导致的,所以说我们在变更管理上还是要加强,变更流程中出现的破坏性行为应该要进行惩罚,尽管是对事不对人,但是还是要进行惩罚以引起重视。

第六,极限压测和故障演练。在做压测的时候,可能压到报错就停了。我建议最好是在报错的情况下,仍然要继续加压,看你的服务到底是一个什么表现?它能不能在过载的情况下提供服务?在上了过载保护算法以后,继续加压,积极拒绝,然后结合熔断的话,可以产生一个立体的保护效果。 经常做故障演练可以产生一个品控手册,每个人都可以学习,经常演练不容易慌乱,当在生产环境中真的出现问题时也可以快速投入解决。

第七,考虑扩容、重启、消除有害流量。

如上图所示的参考,就是对以上几个策略的经典补充,也是解决各种服务问题的玄学

「腾讯云开发者社区」公众号回复「在线沙龙」获取PPT下载链接~

7.Q&A

Q:请问负载均衡依据的 Metric是什么?

A:我们用服务端的话,主要是用CPU,我觉得CPU是最能体现的。从客户端角度,我是用的健康度,健康度指的是连接的成功率。延迟也是一个很重要的指标,另外我们要考虑到每一个client往不同的back end发了多少个请求。

Q:BFE到SLB是走公网还是专线?

A:这个其实有公网也有专线。

Q:如果client就几千量级,每10s pingpong 一下,其实也就几百 qps?会造成蛮高的cpu开销?

A:如果你的client是几千,但上游你的各种服务加起来client实际上是非常多的可能过万。 所以它是会造成蛮高的CPU开销的,因为好多个不同的应用来healthcheck,其实这个量就非常大了。

Q:多集群的成本是怎么考虑的?

A:分集群。前文提到的多集群更多是在同一个机房内布置多套机群,那么这个多套集群,首先它肯定资源是冗余和翻倍的。 这个确实是需要一定成本,所以我们也不是所有服务都会来做这种冗余,只会针对核心服务。所以本质上就是花些钱,做些冗余,来尽可能提升我们的可用性,因为你越底层的服务一旦故障,它的故障面真的是扩散非常大。

Q:超时传递是不是要求太严格了,如果有一个节点出问题就不行了。

A:这个策略就是超时传递,我们默认是传递的,那么在有一些case情况下,即便超时仍然要继续运行,这个行为实际上是可以通过我们的context上下文把它覆盖掉,所以还是看你代码的逻辑处理。

Q:用户的接入节点的质量和容量是怎么平衡的?

A:取决于调度的策略。通常来讲需要先看你的服务是什么用途,如果是那种面向用户体验型的,或者功能型的,我觉得质量是优先考虑的。其次,在你转发的机房不过载的情况下,尽可能交付到最近的节点,那么极端情况下有可能你机房过载,那么这种情况下其实是不得已通过接入节点来转发到其他的核心机房。

以上是关于分布式B站高可用用架构实践的主要内容,如果未能解决你的问题,请参考以下文章

B站崩溃的背后,b站高可用架构到底是怎么样的?

缓存异步集群和分布式等架构模式的实践

高可用架构设计与实践

高并发高可用的架构实践-静态架构蓝图

中培专家 现场讲述 互联网大型高可用高并发微服务架构设计与最佳实践

分布式系统架构的基本原则和实践(转)