Circuit breaker 是微服务架构里常见的一种设计模式,用来避免某个出问题的服务拖垮整条链路的情况。它的职责在于,当一个服务不能正常响应的时候,与其继续执行该服务的逻辑,不如断开该部分链路,直接返回错误或者某个降级的内容(比如只显示个静态页面)。
今天我们就来聊聊如何实现一个 circuit breaker。为简单起见,本文中的代码均为伪代码描述。如果你需要在项目中使用 circuit breaker,请尽量用久经考验的开源项目。
我们先搭一个 circuit breaker 的架子。首先 circuit breaker 是一个 decorator,把真正的业务逻辑包裹起来。在入口处,需要判断是否执行该业务逻辑;在出口处,更新 circuit breaker 的状态。
def circuit_breaker(f):
breaker = new_breaker()
def wrapper(f, …):
if not breaker.allow():
return breaker.fallback()
res = f(…)
breaker.update_state(res)
return res
return wrapper
基于 rate limiter 的伪 circuit breaker
初看之下,我们可以用 rate limiter 来实现 allow 方法里的逻辑。rate limiter 通常是用令牌桶(或者漏桶,两者只是硬币的正反面)判断当前请求会不会超过限制的平均速率。如果超过了,需要把当前请求拖迟多久。如果超过平均速率的部分比配置的 burst 还多,则会干脆直接丢弃请求。假使舍弃了中间的拖迟阶段,不就是个 circuit breaker 吗?
表面上,用 rate limiter 就能满足 circuit breaker 的需求了。但实际上两者是不一样的。rate limiter 实现的是如果某个服务达到一定的访问频率,就进行降级;而 circuit breaker 需要实现的是如果某个服务不能正常响应,就进行降级。用 rate limiter 实现 circuit breaker,只能通过预估什么样的访问频率可能导致服务不能正常响应,来设置限制。但这个预估可能是错的,也可能服务因为访问频率大之外的原因导致不能正常响应。
真正的 circuit breaker — 状态机模块
我们现在来看看真正的 circuit breaker 是怎么实现的。
这张图来自于 Azure 关于 circuit breaker 的介绍:https://docs.microsoft.com/en…
真正的 circuit breaker 需要有个 success / failure 的反馈,以及 half-open 这个中间状态。在不同状态下,根据 success / failure,做状态的变化。我们来看下这个状态机:
def breaker.update_state(res):
failure = not is_success(res)
breaker.count_res(failure)
switch breaker.state:
case CLOSED:
if failure and breaker.reach_failure_threshod():
breaker.state = OPEN
kick off a timer to run cooldown
case HALF_OPEN:
if failure:
breaker.state = OPEN
kick off a timer to run cooldown
elif breaker.reach_success_threshod():
breaker.state = CLOSED
breaker.clear_failure_counter()
def breaker.cooldown():
assert(breaker.state == OPEN)
breaker.state = HALF_OPEN
breaker.clear_success_counter()
规则很简单,顺时针从 CLOSED 到 OPEN 到 HALF_OPEN 即可。其中 CLOSED 到 OPEN 是因为失败率太高,OPEN 到 HALF_OPEN 是因为冷却时间到了,HALF_OPEN 到 CLOSED 是满足有足够的成功尝试。然后 HALF_OPEN 也有可能返回到 OPEN,如果尝试失败的话。
注意伪代码中我没有考虑多个线程同时获取 / 更新状态的情况,假定所有的逻辑都只在一个线程里运行。
再看下 allow 函数的实现:
def breaker.allow():
switch breaker.state:
case CLOSED:
return true
case OPEN:
return false
case HALF_OPEN:
return breaker.allow_more_tries()
需要解释下 HALF_OPEN 状态下的逻辑。因为 HALF_OPEN 的时候,我们需要给个机会继续尝试下,说不定服务已经好了。但是我们也不能一下子全放开,所以需要设置一个 max tries。如果 max tries 里面有失败的,就重新退回到 OPEN 状态来;如果全部成功了(reach_success_threshod),就走到 CLOSED 状态中去。
这里可以有一个名为 lazy state 的变化:我们可以根据当前时间判断现在真正的状态是不是 HALF_OPEN。这样能省下创建 cool down timer,和 timer 跟 circuit 交互的开销。但同时却增加了每次执行函数 f 前获取当前时间的开销。对于像是 OpenResty 那样 timer 开销大而获取时间开销小的环境,这个算得上一个优化。不过对于其他环境,需要 benchmark 一把。
真正的 circuit breaker — 统计模块
讲完状态机模块,我们来看下 circuit breaker 的统计模块,即伪代码中 count_res 和 reach_failure_threshod 的实现。统计模块的作用在于计算一段时间间隔内的失败率,如果失败率过高,就会触发 CLOSED 到 OPEN 的状态切换。
各家 circuit breaker 的状态机部分的实现都大同小异,毕竟这里有个可遵循的标准。而统计模块的实现则各有各的不同。
一个普遍的实现是,使用 ring buffer 存储每秒的 counts bucket,然后在计算失败率时把每个 bucket 汇总计算。由于这是 Hystrix 所采用的方式,我称之为“Hystrix 式”。
在 rate limit 的计算中,我们只需维护一个随时间变化的变量,因为 rate limit 的数据是一维的。而 circuit breaker 不仅仅要知道请求的数目,还要知道每秒对应的失败的请求数目。这是个二维的指标,所以需要维护一个随时间变化的 buckets。
注意这里一定要维护一个滑动窗口。假如采用每过一个时间间隔,就把之前的数据清空的策略,虽然不需要维护多个 buckets,但是存在逻辑问题。考虑下面的情况:假设同时有 10000 个请求打到后端服务上来,时间间隔为 10 秒,从第 1 秒开始,每秒有 1000 个请求完成,直到第 11 秒结束。如果第 11 秒时有 900 个请求因为超时失败了,在采用滑动窗口的情况下,失败率是 9%(900 / (1000 * 10)),而如果采用丢弃前面间隔的数据的做法,则失败率是 90%(900 / 1000)。显然,前者才更符合实际情况。
再回到 ring buffer 的讨论上来。如果我们完成了一个时间间隔,就会重新写到 ring buffer 的开头,无需另外开辟空间。注意这里的步长是每秒,如果两个相邻的请求间隔了 N 秒,需要把 N 个位置清零然后写入,而不是直接写到 pos + 1 的位置上。
这里可以做一个优化,与其每次有请求失败时汇总所有的 buckets,我们可以维护各个 buckets 的失败总和与请求总和。如果有 buckets 在 ring buffer 中被清零,则在总和中减去该 bucket 的值。你可以认为该优化就是把汇总的开销分摊到每次需要准备新 buckets 的时候。由于只有某个请求跟上一个请求不位于同一秒时,才会创建新的 bucket;而且失败请求往往是扎堆出现的,我认为这个应该算是个优化。
resilience4j 采用了跟 Hystrix 不同的统计方式。它不是存储每秒的 count,而是存储每个请求的结果。它用一个 bit 代表一个请求,0 表示成功,1 表示失败。这么一来,统计的就不是一个时间间隔内的失败率,而是若干个请求内的失败率。resilience4j 宣称这种方式更能适应各种并发度下的后端系统。但是我怀疑它能否达到这个目的。对于高并发系统,resilience4j 式的统计需要占用比 Hystrix 式更多的内存。比如每秒 1000 个请求的系统,以 10 秒为间隔,Hystrix 式只需要几十 byte,而 resilience4j 式需要有容纳 10000 个请求的空间,大概 1 KB 多一点吧。如果给的空间不够大,比如只容纳 1000 个请求,则会犯前面不用滑动窗口时的错误,算出 90% 的失败率。一个需要根据并发度调整预留空间的设计,是称不上“能适应各种并发度下的后端系统”的。