服务容错

最后更新:2020-05-05

1. 为什么要实现服务容错?

假设系统中存在两个微服务,分别是服务 A 和服务 B,其中服务 B 会调用服务 A

现在,系统出现故障了。首先,服务 A 因为某种原因发生了宕机而变得不可用,这是故障的第一阶段:

服务 A 不可用的原因有很多,包括服务器硬件等环境问题,也包括服务自身存在 Bug 等因素。而当访问服务 A 得不到正常的响应时,服务 B 的常见处理方式是通过重试机制来进一步加大对服务 A 的访问流量。这样,服务 B 每进行一次重试就会启动一批线程。我们知道线程的不断创建是需要消耗系统资源的,一旦系统资源被耗尽,服务 B 本身也将变得不可用,这就是事故的第二个阶段:

我们进一步假设,微服务系统中还存在依赖于服务 B 的服务 C。这样,基于同样的原因,服务 B 的不可用同样会导致服务 C 的不可用。类似的,系统中可能还存在服务 D 等其他服务依赖服务 C……以此类推,最终在以服务 A 为起点的整个调用链路上的所有服务都会变得不可用。这种扩散效应就是所谓的服务雪崩效应。

服务雪崩效应本质上是一种服务依赖失败。服务依赖失败较之服务自身失败而言,影响更大,也更加难以发现和处理。因此,服务依赖失败是我们在设计微服务架构中所需要重点考虑的服务可靠性因素。

分布式系统,随着业务复杂度提高,系统不断拆分,一个面向 C 端的 API 调用,其内部的 RPC 调用层层嵌套,调用链变长,会造成下述 2 个问题:

  1. API 接口可用性降低: 假设一次 api 请求,内部涉及 30 次 rpc 调用,每个微服务可用性 99.99%。则api 请求的可用性为 99.99% 的 30 次方 = 99.7% ,即0.3% 的失败率
  2. 系统阻塞,拒绝请求接入: 假设一次 api 请求,内部涉及 10 次 rpc 调用,只要 10 次 rpc 中,有一次请求超时,则整个 api 调用就超时了。如果大量请求突发访问,则大量的线程都阻塞(block 等待超时)在这一服务上,新的请求无法接入

为了解决上述API 接口可用性降低系统阻塞、拒绝请求接入问题,可以采用下述 5 中方法:

  1. 容错:通过冗余,即某一个服务应该构建多个实例,这样当一个服务实例出现问题时可以重试其他实例
  2. 熔断:服务熔断,一旦触发异常统计条件,则,直接熔断服务,在调用方直接返回,不再 rpc 调用远端服务
  3. 降级:降级是配合熔断的,熔断后,不再调用远端服务器的 rpc 接口,而采用本地的 fallback 机制,返回一个备用方案(默认取值)
  4. 限流:限制速率,或从业务层限制总数,被限流的请求,直接进入降级流程;
  5. 隔离:对资源进行有效的管理,从而避免因为资源不可用、发生失败等情况导致系统中的其他资源也变得不可用。

2. 超时

通过网络调用外部依赖服务的时候,都必须应该设置超时。在健康的情况下,一般局域往的一次远程调用在几十毫秒内就返回了,但是当网络拥堵的时候,或者所依赖服务不可用的时候,这个时间可能是好多秒,或者压根就僵死了。通常情况下,一次远程调用对应了一个线程或者进程,如果响应太慢,或者僵死了,那一个进程/线程,就被拖死,短时间内得不到释放,而进程/线程都对应了系统资源,这就等于说我自身服务资源会被耗尽,导致自身服务不可用。假设我的服务依赖于很多服务,其中一个非核心的依赖如果不可用,而且没有超时机制,那么这个非核心依赖就能拖死我的服务,尽管理论上即使没有它我在大部分情况还能健康运转的。

当我们的服务访问某项依赖有大量超时的时候,再让新的请求去访问已经没有太大意义,那只会无谓的消耗现有资源。即使你已经设置超时1秒了,那明知依赖不可用的情况下再让更多的请求,比如100个,去访问这个依赖,也会导致100个线程1秒的资源浪费。这个时候,断路器就能帮助我们避免这种资源浪费,在自身服务和依赖之间放一个断路器,实时统计访问的状态,当访问超时或者失败达到某个阈值的时候(如50%请求超时,或者连续20次请失败),就打开断路器,那么后续的请求就直接返回失败,不至于浪费资源。断路器再根据一个时间间隔(如5分钟)尝试关闭断路器(或者更换保险丝),看依赖是否恢复服务了。

3. 容错

从设计思想上讲,容错机制的基本要素就是要做到冗余,即某一个服务应该构建多个实例,这样当一个服务实例出现问题时可以重试其他实例。一个集群中的服务本身就是冗余的。而针对不同的重试方式就诞生了一批集群容错策略,常见的包括 Failover(失效转移)、Failback(失败通知)、Failsafe(失败安全)和 Failfast(快速失败)等。

这里以最常见、最实用的集群容错策略 Failover 为例展开讨论。Failover 即失效转移,当发生服务调用异常时,请求会重新在集群中查找下一个可用的服务提供者实例。

4. 熔断(断路器)

假设有个应用程序每秒会与数据库沟通数百次,此时数据库突然发生了错误,程序员并不会希望在错误时还不断地访问数据库。因此会在等待TCP连线逾时之前直接处理这个错误,并进入正常的结束程序(而非直接结束程式)。简单来说,断路器会侦测错误并且“预防”应用程序不断地呼叫一个近乎毫无回应的服务(除非该服务已经安全到可重试连线了)。

断路器有分简单与较进阶的版本,简单的断路器只需要知道服务是否可用。而较进阶的版本比起前者更有效率。进阶的断路器带有至少三个状态:

  • 关闭:断路器在预设的情形下是呈现关闭的状态,而断路器本身“带有”计数功能,每当错误发生一次,计数器也就会进行“累加”的动作,到了一定的错误发生次数断路器就会被“开启”,这个时候亦会在内部启用一个计时器,一但时间到了就会切换成半开启的状态。
  • 开启:在开启的状态下任何请求都会“直接”被拒绝并且抛出异常讯息。
  • 半开启:在此状态下断路器会允许部分的请求,如果这些请求都能成功通过,那么就意味着错误已经不存在,则会被“切换回”关闭状态并“重置”计数。倘若请求中有“任一”的错误发生,则会回复到“开启”状态,并且重新计时,给予系统一段休息时间。

摘自https://en.wikipedia.org/wiki/Circuit_breaker_design_pattern

断路器的状态切换

  • State is in CLOSED test, everything is normal
  • The maximum consecutive allowed errors (cb_max_errors) is reached, the system changes to OPEN. No more connections to backend sent
  • System stays in OPEN state for N seconds (cb_timeout)
  • System changes to HALF-OPEN and allows 1 connection to pass.
  • If the connection succeeded change to CLOSED, everything back to normal. If it failed switch to OPEN again.

上面的两张图来源于krakend

从设计理念上讲,服务熔断也是快速失败的一种具体表现。当服务消费者向服务提供者发起远程调用时,服务熔断器会监控该次调用,如果调用的响应时间过长,服务熔断器就会中断本次调用并直接返回。请注意服务熔断器判断本次调用是否应该快速失败是有状态的,也就是说服务熔断器会把所有的调用结果都记录下来,如果发生异常的调用次数达到一定的阈值,那么服务熔断机制才会被触发,快速失败就会生效;反之,将按照正常的流程执行远程调用。

5. 降级

降级,是配合熔断存在的。对于一些非核心服务,如果出现大量的异常,可以通过技术手段,对服务进行降级并提供有损服务,保证服务的柔性可用,避免引起雪崩效应。

服务降级是一种被动的、临时的处理机制。当远程调用发生异常时,服务回退并不是直接抛出异常,而是产生一个另外的处理机制来应对该异常。这相当于执行了另一条路径上的代码或返回一个默认处理结果。而这条路径上的代码或这个默认处理结果并一定满足业务逻辑的实现需求,只是告知服务的消费者当前调用中所存在的问题。显然,服务回退不能解决由异常引起的实际问题,而是一种权宜之计。这种权宜之计在处理因为服务依赖而导致的异常时也是一种有效的容错机制。

在现实环境中,服务回退的实现方式可以很简单,原则上只需要保证异常被捕获并返回一个处理结果即可。但在有些场景下,回退的策略则可以非常复杂,我们可能会从其他服务或数据中获取相应的处理结果,需要具体问题具体分析。

5.1. 降级预案

在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保帅;从而梳理出哪些必须誓死保护,哪些可降级;比如可以参考日志级别设置预案:

一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;

警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;

错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;

严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。

降级按照是否自动化可分为:自动开关降级和人工开关降级。

降级按照功能可分为:读服务降级、写服务降级。

降级按照处于的系统层次可分为:多级降级。

4.2. 降级方式

  1. 延迟服务:比如发表了评论,重要服务,比如在文章中显示正常,但是延迟给用户增加积分,只是放到一个缓存中,等服务平稳之后再执行。
  2. 在粒度范围内关闭服务(片段降级或服务功能降级):比如关闭相关文章的推荐,直接关闭推荐区
  3. 页面异步请求降级:比如商品详情页上有推荐信息/配送至等异步加载的请求,如果这些信息响应慢或者后端服务有问题,可以进行降级;
  4. 页面跳转(页面降级):比如可以有相关文章推荐,但是更多的页面则直接跳转到某一个地址
  5. 写降级:比如秒杀抢购,我们可以只进行Cache的更新,然后异步同步扣减库存到DB,保证最终一致性即可,此时可以将DB降级为Cache。
  6. 读降级:比如多级缓存模式,如果后端服务有问题,可以降级为只读缓存,这种方式适用于对读一致性要求不高的场景。

6. 限流

https://edgar615.github.io/ratelimit.html

7. 隔离

隔离是指将系统或资源分割开,系统隔离是为了在系统发生故障时,能限定转播范围和影响范围,即发生故障后不会出现雪球效应,从而保证只有出问题的服务不可用,其他服务还是可用的。资源隔离通过隔离来减少资源竞争,保障服务间的互不影响和可用性。

常见的隔离收到有:线程隔离、进程隔离、集群隔离、机房隔离、读写隔离、快慢隔离、动静隔离等。

在日常开发过程中,我们主要的处理对象还是线程级别的隔离。要实现线程隔离,简单而主流的做法是使用线程池(Thread Pool)。针对不同的业务场景,我们可以设计不同的线程池。因为不同的线程池之间线程是不共享的,所以某个线程池因为业务异常导致资源消耗时,不会将这种资源消耗扩散到其他线程池,从而保证其他服务持续可用。

线程隔离主要有线程池隔离,在实际使用时我们会把请求分类,然后交给不同的线程池处理,当一种业务的请求处理发生问题时,不会将故障扩散到其他线程池,从而保证其他服务可用。

假设我们有 3 个服务一共能够使用的线程数是 300 个,其他服务调用这三个服务时会共享这 300 个线程,

如果其中的服务A不可用, 就会出现线程池里所有线程被这个服务消耗殆尽 从而造成服务雪崩,

现在,系统中的 300 个线程都被 服务A所占用,服务 B和服务C已经分不到任何线程来响应请求。

线程隔离机制的实现方法也很简单,就是为每个服务分配独立的线程池以实现资源隔离,例如我们可以为 3 个服务平均分配 100 个线程

当 线程A不可用时, 最差的情况也就是消耗分配给它的 100 个线程,而其他的线程都还是属于各个微服务中,不会受它的影响。

8. 参考资料

《Spring Cloud 原理与实战 》

Edgar

Edgar
一个略懂Java的小菜比