设计高可用系统

2023/2/7

# 限流

限流是针对服务请求数量的一种自我保护机制,当请求数量超出服务的处理能力时,会自动丢弃新来的请求

为什么要限流: 任何一个服务的处理能力都是有极限的,假定服务A的处理能力为QPS = 100,当QPS < 100时服务A可以提供正常的服务。当QPS > 100时,由于请求量增大,会出现争抢服务资源的情况(数据库连接、CPU、内存等),导致服务A处理缓慢;当QPS继续增大时,可能会造成服务A响应更加缓慢甚至奔溃。如果不进行限流控制,服务A始终会面临着被大流量冲击的风险。做好系统请求流量的评估,制定合理的限流策略,是我们进行系统高可用保护的第一步

有两种方式:

  1. 请求,可以以用户为维度,对于一个URL(例如:wms/goods/section/export),每个用户1分钟内只能访问5次,否则就报错(计数器法、也是固定时间窗口法)
  2. 对于RPC调用,例如:a,b都需要调用c服务,c服务可以释放令牌,拿到令牌的才可以访问

# 滑动窗口

# 固定时间窗口(又称计数器法)

步骤如下:

  1. 先确定一个起始时间点,一般就是系统启动的时间
  2. 从起始时间点开始,根据我们的需求,设置一个最大值M,开始接受请求并从0开始为请求计数
  3. 在时间段T内,请求计数超过M时,拒绝所有剩下的请求
  4. 超过时间段T后,重置计数

问题:假设我们的时间段T是1秒,请求最大值是10,在第一秒内,请求数量分布是第500毫秒时有1个请求,第800毫秒时有9个请求,如图所示:

image.png

这是对于第一秒而言,这个请求分布是合理的 此时第二秒的第200毫秒(即两秒中的第1200毫秒)内,又来了10个请求,如图所示:

image.png

单独看第二秒依然是合理的,但是两个时间段连在一起的时候,就出现了问题,如图所示:

image.png

临界问题: 从500毫秒到1200毫秒,短短700毫秒的时间内后端服务器就接收了20个请求,这显然违背了一开始我们希望1秒最多10个的初衷。这种远远大于预期流量的流量加到后端服务器头上,是会造成不可预料的后果的。因此,人们改进了固定窗口的算法,将其改为检查任何一个时间段都不超过请求数量阈值的时间窗口算法:滑动时间窗口算法

# 滑动时间窗口

它将单位时间周期分为n个小周期,分别记录每个小周期内接口的访问次数,并且根据时间滑动删除过期的小周期 假设单位时间是1s,滑动窗口算法把它划分为2个小周期,也就是滑动窗口(单位时间)被划分为2个小格子。每格表示0.5s。每过0.5s,时间窗口就会往右滑动一格,如图:

我们来看下滑动窗口是如何解决临界问题的? 假设我们1s内的限流阀值还是5个请求,比如0.9s(窗口范围0.5s~1s)的时候来了5个请求,落在黄色格子里。1.3s(窗口范围1s~2s)这个点,又来5个请求,落在紫色格子里。这个窗口范围的请求已经超过限定的5了,已触发限流啦,实际上,紫色格子的请求都被拒绝了

缺点:

  1. 滑动窗口每移动一个格子,就淘汰一个格子里的请求数量,格子越大淘汰的请求数量越多,就可以接收的请求越多,不够平滑;例如:0.1s有5个请求,那么0.1s~0.5s内都不能接受请求;分的格子越多越平滑,但是管理起来越麻烦,而且不确定到底要划分几个格子

# 令牌桶

令牌桶(token bucket)算法,指的是设计一个容器(即“桶”),由某个组件持续运行往该容器中添加令牌(token),令牌可以是简单的数字、字符或组合,也可以仅仅是一个计数,然后每个请求进入系统时,需要从桶中领取一个令牌,所有请求都必须有令牌才能进入后端系统。当令牌桶空时,拒绝请求;当令牌桶满时,不再往其中添加新的令牌,架构图如下: image.png 实现逻辑:

  1. 假设是T秒内允许N个请求,那么令牌桶算法则会使令牌添加组件每T秒往令牌桶中添加N个令牌
  2. 其次,令牌桶需要有一个最大值M,当令牌添加组件检测到令牌桶中已经有M个令牌时,剩余的令牌会被丢弃。反映到限流系统中,可以认为是当前系统允许的瞬时最大流量,但不是持续最大流量。例如令牌桶中的令牌最大数量是100个,每秒钟会往其中添加10个新令牌,当令牌满的时候,突然出现100 TPS的流量,这时候是可以承受的,但是假如连续两秒的100 TPS流量就不行,因为令牌添加速度是一秒10个,添加速度跟不上使用速度

缺点/也是优点:

  1. (缺点)大量令牌累积会导致“伪限流失效”现象,会导致后端处理请求剧增
  2. (优点)能处理突发的请求

# 漏桶

  1. 是令牌桶的一种改进
  2. 将请求看作水流,用一个底下有洞的桶盛装,底下的洞漏出水的速率是恒定的,所有请求进入系统的时候都会先进入这个桶,并慢慢由桶流出交给后台服务。桶有一个固定大小,当水流量超过这个大小的时候,多余的请求都会被丢弃

image.png

漏桶算法和令牌桶算法在思想上非常接近,它们有如下的相同和不同之处:

  1. 令牌桶算法以固定速率补充可以转发的请求数量(令牌),而漏桶算法以固定速率转发请求
  2. 令牌桶算法在有爆发式增长的流量时可以一定程度上接受,漏桶算法也是,但当流量爆发时,令牌桶算法会使业务服务器直接承担这种流量,而漏桶算法的业务服务器感受到的是一样的速率变化

# 降级

  1. 降级是通过开关配置将某些不重要的业务功能屏蔽掉,以提高服务处理能力。在大促场景中经常会对某些服务进行降级处理,大促结束之后再进行复原
  2. 例如缓存服务挂掉之后,降级使用内存作为缓存

为什么要降级: 在不影响业务核心链路的情况下,屏蔽某些不重要的业务功能,可以节省系统的处理时间,提供系统的响应能力,在服务器资源固定的前提下处理更多的请求

# 设计一个降级机制

从架构设计的角度出发,降级设计就是在做取舍,你要从服务降级功能降级两方面来考虑。 在实现上,服务降级可以分为读操作降级和写操作降级。

  • 读操作降级: 做数据兜底服务,比如将兜底数据提前存储在缓存中,当系统触发降级时,读操作直接降级到缓存,从缓存中读取兜底数据,如果此时缓存中也不存在查询数据,则返回默认值,不在请求数据库。
  • 写操作降级: 同样的,将之前直接同步调用写数据库的操作,降级为先写缓存,然后再异步写入数据库。

我们提炼一下这两种情况的设计原则。

  • 读操作降级的设计原则,就是取舍非核心服务。
  • 写操作降级的设计原则,就是取舍系统一致性:实现方式是把强一致性转换成最终一致性。比如,两个系统服务通过 RPC 来交互,在触发降级时,将同步 RPC 服务调用降级到异步 MQ 消息队列中,然后再由消费服务异步处理。

而功能降级就是在做产品功能上的取舍,既然在做服务降级时,已经取舍掉了非核心服务,那么同样的产品功能层面也要相应的进行简化。在实现方式上,可以通过降级开关控制功能的可用或不可用。 另外,在设计降级时,离不开降级开关的配置,一般是通过参数化配置的方式存储在配置中心(如 Zookeeper),在高并发场景下,手动或自动开启开关,实现系统降级

# 熔断

在服务的依赖调用中,被调用方出现故障时,出于自我保护的目的,调用方会主动停止调用,并根据业务需要进行相应处理。调用方这种主动停止调用的行为我们称之为熔断

image.png

为什么要熔断:(防雪崩) 为什么要熔断假定服务A依赖服务B,当服务B处于正常状态,整个调用是健康的,服务A可以得到服务B的正常响应。当服务B出现故障时,比如响应缓慢或者响应超时,如果服务A继续请求服务B,那么服务A的响应时间也会增加,进而导致服务A响应缓慢。如果服务A不进行熔断处理,服务B的故障会传导至服务A,最终导致服务A也不可用

# 熔断设计的原理

形象一点儿说:熔断机制参考了电路中保险丝的保护原理,当电路出现短路、过载时,保险丝就会自动熔断,保证整体电路的安全

而在微服务架构中,服务的熔断机制是指:在服务 A 调用服务 B 时,如果 B 返回错误或超时的次数超过一定阈值,服务 A 的后续请求将不再调用服务 B。这种设计方式就是断路器模式

在这种模式下,服务调用方为每一个调用的服务维护一个有限状态机,在这个状态机中存在 关闭半打开打开 三种状态:

  • 关闭(断路器关闭):正常调用远程服务
  • 打开(断路器打开):直接返回错误,不调用远程服务
  • 半打开:在关闭状态下,如果远程调用持续超时报错,达到规定的阀值后,断路器状态就会从关闭进入打开

断路器模式下,保证了断路器在 open 状态时,保护服务不会被调用, 但我们还需要额外的措施可以在服务恢复服务后,可以重置断路器。一种可行的办法是断路器定期探测服务是否恢复, 一但恢复, 就将状态设置成 close

# 资源隔离

在故障的情况下,不会耗尽系统所有的资源

# 备份相关

# 同城灾备

最初,我们把应用只放在一个机器上,那么当这个服务器down机了,我们的应用便不可用了。 所以,我们考虑把我们的应用放在多个机器上,在公司单独开辟一个机房来放置这些机器,这样单独某一个台机器down机了并不影响我们的应用。 但是,如果你们公司某一天停电了呢?这个时候我们就考虑在这座城市的另外一个地方在放置一个机房,这是应用就被部署在了同城的两个机房;这个叫同城灾备

# 异地灾备

如果你们城市某一天经历了海啸、台风、地震等自然灾害,两个机房都不能使用了,这个时候我们就会考虑在另外一个城市再搭建一个机房来部署我们的应用,这样我们应用的可用性就更高了(这个叫异地灾备)。 好,到此为止不管出现什么样的状况,我们的应用基本上都可用

当主机房停电后,用户会去请求北京备份机房,当北京备份机房也停电后,用户会去请求上海备份机房。 好,对于这个架构,我们刚刚说只有主机房能对外提供服务,另外两个机房都只是作为容灾的备份,那么也就是说备份机房利用率不高

# 同城双活

# 两地三中心

我们可以让北京的备份机房也去接收部分业务请求,只是这些请求可以没那么重要,比如一些读请求,而上海的备份机房不去接收请求,还是单纯作为容灾备份机器;这个就叫两地三中心 有些资料这样说两地三中心(同城双活 + 异地灾备):

# 三地五中心

两地三中心这种架构具备容灾能力,比如生产数据中心停电了,那么可以把所有流量都切到同城灾备中心或异地灾备中心,那么现在的问题是假如真到了停电的那一天,你敢把所有的流量都切到灾备中心去吗? 灾备中心它主要的功能是作为生产数据中心的一个备份,所以它并没有如同生产数据中心一样不停的在对外提供服务;就算让备份的机器顶上,肯定也需要修改很多配置,要花费很长时间,来看下面的这个架构,同城双活 + 异地多活

可以看到上面的架构图:

  • 不再区分生产数据中心和灾备数据中心,只有数据中心,而且数据中心之间相互备份数据,保证每个数据中心都是全量数据
  • 用户可以在任意一个数据中心上进行读写操作

首先我们不管这种架构能不能实现,至少它的好处是非常明显的:

  1. 每个数据中心一直在对外提供服务(不是一个新手),所以当一个数据中心停电后,直接把用户流量切到另外一个数据中心也是问题不大的。
  2. 用户可以就近访问数据中心,这样用户的体验更好,并且整个架构的流量也比较平均

这种架构实现起来最重要的一点就是:用户同时向不同数据中心写入数据,数据中心双向同步数据时,如果出现冲突该如何解决? 这个问题,目前阿里和蚂蚁金服的解决办法是:将用户按某一个规则进行分组,每组用户写入数据时只能写入到指定的数据中心,相当于用户与数据中心绑定在一起了,这样保证了数据中心在双向同步之前数据是不会冲突的,因为按用户分组了,不同用户的数据不会冲突

用户使用网站时,由运营商网络或CDN选择最近的机房,机房内部署一个负载均衡,由这个负载均衡最终判断用户属于机房(前文中的数据中心),也就是可能出现,用户在注册时在北京,那么他的uid就和北京某个机房绑定了,那么当这个用户在上海使用时,会由上海的负载均衡按照用户分组规则将请求转发到北京绑定的那个机房去

这个架构中最重要的其实就是用户分组,所以包括我们的应用程序、数据库负载均衡、数据库分表等等都需要按用户进行分组,我们要保证针对同一个用户的请求与操作都在同一个机房内,不去跨机房,这样才是最快的,这就是单元化

上面这个架构实际上就是一个高级版的“两地三中心”,只是这种单元化架构我们可以任意去扩展,比如你在上海在增加一个数据中心,在杭州也增加一个,那么就如下图:

这就叫三地五中心

# 方案 —— Hystrix

# Hystrix 特性

  1. 请求熔断: 当 Hystrix Command 请求后端服务失败数量超过一定比例(默认50%), 断路器会切换到开路状态(Open)。这时所有请求会直接失败而不会发送到后端服务,断路器保持在开路状态一段时间后(默认5秒), 自动切换到半开路状态(HALF-OPEN)

这时会判断下一次请求的返回情况, 如果请求成功, 断路器切回闭路状态(CLOSED), 否则重新切换到开路状态(OPEN). Hystrix 的断路器就像我们家庭电路中的保险丝, 一旦后端服务不可用, 断路器会直接切断请求链, 避免发送大量无效请求影响系统吞吐量, 并且断路器有自我检测并恢复的能力

  1. 服务降级:Fallback 相当于是降级操作. 对于查询操作, 我们可以实现一个 fallback 方法, 当请求后端服务出现异常的时候, 可以使用 fallback 方法返回的值。 fallback 方法的返回值一般是设置的默认值或者来自缓存.告知后面的请求服务不可用了,不要再来了
  2. 依赖隔离(采用舱壁模式,Docker 就是舱壁模式的一种):在 Hystrix 中, 主要通过线程池来实现资源隔离. 通常在使用的时候我们会根据调用的远程服务划分出多个线程池.比如说,一个服务调用两外两个服务,你如果调用两个服务都用一个线程池,那么如果一个服务卡在哪里,资源没被释放

  1. 某一个服务调用 A、B 两个服务,如果这时我有 100 个线程可用,这个时候 A 服务挂了,那么可能这 100 个线程都卡在了 A 服务的调用上
  2. 隔离之后给A服务分配50个,给B服务分配50个,这样就算A服务挂了,我的B服务依然可以用
  1. 请求缓存:比如一个请求过来请求我userId=1的数据,你后面的请求也过来请求同样的数据,这时我不会继续走原来的那条请求链路了,而是把第一次请求缓存过了,把第一次的请求结果返回给后面的请求
  2. 请求合并:我依赖于某一个服务,我要调用N次,比如说查数据库的时候,我发了N条请求发了N条SQL然后拿到一堆结果,这时候我们可以把多个请求合并成一个请求,发送一个查询多条数据的SQL的请求,这样我们只需查询一次数据库,提升了效率

# Hystrix 流程

Hystrix 流程说明:

  • 每次调用创建一个新的 HystrixCommand,把依赖调用封装在 run() 方法中
  • 执行 execute()/queue 做同步或异步调用
  • 判断熔断器 (circuit-breaker) 是否打开,如果打开跳到步骤8,进行降级策略,如果关闭进入步骤 5
  • 判断线程池/队列/信号量是否跑满,如果跑满进入降级步骤 8,否则继续后续步骤 6
  • 调用 HystrixCommand 的 run 方法.运行依赖逻辑
    • 依赖逻辑调用超时,进入步骤 8
  • 判断逻辑是否调用成功
    • a. 返回成功调用结果
    • b. 调用出错,进入步骤
  • 计算熔断器状态,所有的运行状态(成功,失败,拒绝,超时)上报给熔断器,用于统计从而判断熔断器状态

# 方案 —— 阿里 sentinel

内容待补充