Lever's Castle

从零开始设计一个断路器

December 05, 2017

最近我们在尝试搞微服务,根据以往大家进行微服务化的一些经验,我们预见了这样一个问题:微服务之间不可避免的会有一些相互依赖,比如一个微服务 A 依赖于微服务 B,如果微服务 B 整个挂掉了,或者出了些问题,势必会影响到所有依赖它的其他服务,而这些依赖 B 的服务由于受到 B 的影响,也开始响应越来越慢或者整个挂掉,慢慢导致我们的整个服务都变得不可用。这像是一个蝴蝶效应,一个不稳定的服务导致了所有服务的崩溃,我们应该极力避免这种情况的发生。一个不健康的服务不应该干扰到健康的服务,除此之外,既然 B 服务已经不健康了,如果不及时制止来自其他服务的请求,B 服务会变得越来越不健康。

这个时候,我们就需要一个断路器出来救场啦~

什么是断路器 (Circuit Breaker)

在生活中,断路器是为了保护电路而存在,而在微服务中,断路器是为了保护服务而存在。

当某个微服务发生故障,我们不希望这个微服务的故障影响到调用方。所以我们要确定该微服务是否故障,如果故障,快速响应给调用方一个错误响应,以避免其长时间的等待,也避免了该故障微服务接收到大量的重试操作,从而导致其更加不健康。

一方面,断路器保证了微服务的故障不会沿着级联方向蔓延,另一方面,它给了故障微服务逐渐恢复的时间。

所以,断路器需要有两项最基本的功能:

  • 监控服务状态
  • 在服务故障时,屏蔽调用该服务的请求,并迅速返回错误响应

下面主要讲讲如何设计一个断路器

image

先看下这幅来自 Netflix 的 Hystrix 的图,应该大体就知道该怎么设计断路器了。

断路器的状态以及切换逻辑

一个断路器一般存在三种状态:OPEN、HALF_OPEN、CLOSED,即断路器打开状态、半打开状态、关闭状态。

断路器默认应该处于 CLOSED 状态,当处于 CLOSED 时,客户端能够正常与服务端通信。

当断路器发现统计到的请求响应状况数据的错误率达到阈值时,认为此时服务处于不健康状态,断路器从 CLOSED 状态切换到 OPEN 状态,开始屏蔽客户端的请求,并快速响应错误给客户端。

在切换到 OPEN 状态一段时间后,切换到 HALFOPEN 状态,此时允许一个请求发送到服务端,并根据该请求的响应状况来判定服务是否恢复,也就是说,如果这个请求响应成功,认为服务已经恢复,断路器把状态从 HALFOPEN 切换到 CLOSED,如果该响应失败或者超时,断路器重新切换到 OPEN,并继续等待,再重复上述过程。

需要统计的请求数据

每个请求有 4 种可能的情况:

  • failure -> 请求返回错误
  • timeout -> 请求超时
  • success -> 请求响应成功
  • shortCircuit -> 请求被跳过,直接返回失败或者 fallback

我们根据前三种状态,来推测服务的健康状况,第四种状态只是具有统计意义。

除了这些,我们还需要统计每个请求的响应时间。

有了这些数据,我们就可以得到服务的健康状态、平均响应时间、响应时间分布、请求失败率等等。

统计数据的维护

断路器监控的是服务当下的状态,因此不需要维护太旧的数据,过旧的数据的存在,会影响其对服务当前状态的判断。

简单一点,我们可以维护一个数组,把每个请求的响应情况 push 到数组中,数组的长度要设置边界,超过这个边界,就把最开头的数据(最旧的数据)扔掉一部分。

但是这样做最大的一个问题是,数组中的数据没有跟时间挂钩。想象一下这个场景:一开始来了一些请求,之后一段时间都没有请求,然后再来一些请求,之前产生的数据已经没有参考意义了,应该被扔掉,但是由于数组中的数据跟时间没有关联,断路器不知道该扔哪些数据。

这样直接的扔掉数据,可能扔掉的数据不够多,或者过多,破坏了数据的有效性,我们需要一种更合理的方式。

看下上面的图,我们可以研究下 Hystrix 是怎样解决这个问题的。

Hystrix 维护了一个 Buckets 数组,按照默认配置,这个 Buckets 数组最多包含 10 个 bucket,每隔 1s 会产生一个新的 bucket 并将其 push 到数组中,如果超过 10 个,就把最早的 bucket 扔掉。也就是说,每个 bucket 只能存活 10s ,10s 之后就会被扔掉。这样就保证了数据的新鲜度。

bucket 可以理解成这样一个数据结构:

{
  "Success": 0,
  "Failure": 0,
  "Timeout": 0,
  "ShortCircuit": 0
}

每个 bucket 存储这 1s 里(该 bucket 被创建到下一个 bucket 被创建)请求响应的情况,不断累加。

监控微服务

某个微服务健不健康,其实就是看该服务是不是能够在合理时间内正常响应来自客户端的请求。那么响应时间多久算是合理呢?响应错误率达到多少,或者连续响应失败达到多少次才算服务不健康呢?为此我们需要设定一些阈值来当做服务健康的标准。

const windowDuration = 10000 //ms
const numBuckets = 10
const timeoutDuration = 3000 // ms
const errorThreshold = 50 // %
const volumeThreshold = 5

在这个例子中,我们设定了一些配置项:

  • windowDuration -> 我们根据 10s 内的请求数据判定服务当前的健康状态,这个配置如果过大,收集的数据涉及的时间范围越广,推测的出的结论就会与当前时间的关系越弱。同样的,如果过小,样本数据又会不足,同样无法断定服务的健康状态。因此要根据微服务的具体状况,采用合适的配置。这个数据其实就是每个 bucket 的存活时间,也是从 OPEN 切换到 HALF_OPEN 需要等待的时间。
  • numBuckets -> 最多可以有多少个 bucket 存在,默认为 10,windowDuration / numBuckets 就表示每隔多久产生一个 bucket
  • timeoutDuration -> 超时时间,请求多久没响应就算超时
  • errorThreshold -> 错误率阈值,超过这个错误率就认为服务不健康
  • volumeThreshold -> 达到多少个请求后,可以认为请求响应数据有效?设置这个值是为了保证样本数量,不要在样本数量太少的时候做决策。

请求是从客户端发出的,所以断路器应该是工作在客户端上的。我们把发送请求的控制权移交给断路器,断路器发出请求并收集每次请求的响应情况。把这些响应情况数据写在当前的 bucket 中。每次响应结束后,将所有 bucket 的数据聚合计算一下失败率,如果超过阈值,打开断路器。

断路器每次要发起到服务器的请求时,先看下断路器的状态,当断路器处于打开状态时,就不再发送请求,而是直接返回失败给客户端,或者执行响应的 fallback 函数。

最后

结合以上思路,我们就可以实现一个简单的断路器了。

这里是我们基于 Node.js 的实现:https://github.com/shimohq/yato

欢迎 issure 和 pr ~


Lever

痕迹
没有过去,就没法认定现在的自己