有限状态机与分布式熔断器

4 mins to read

前言

在微服务框架或者网关设计当中,为保证整体系统的可用性,避免某些服务出现问题后,服务间调用出现阻塞,耗尽资源,导致系统瘫痪,通常会实现熔断器模式,避免整个系统发生雪崩。

在分布式系统设计时候要使用一定的降级策略,来保证当服务提供方服务不可用时候,服务调用方可以切换到降级后的策略进行执行。

Netflix 开源的 Hystrix 组件就是 Java 世界熔断器的经典实现。

熔断器 - CircuitBreaker

熔断器的翻译非常形象且精准,生活中的电路系统中,内部的电路连接和外部供电系统之间都会加一个保险丝,当电流过大,达到保险丝的熔点的时候,保险丝就会断掉,从而切断内外电路的连通,进而保护电路系统。

Hystrix 中的熔断器在分布式系统中起到的就是这样的作用,每次调用向熔断器反馈成功、失败、超时和拒绝的状态,熔断器维护计算统计的数据,根据这些统计的信息来确定熔断器的状态。

熔断器的状态有三种状态:

  • 打开 / Open:打开熔断,服务降级
  • 关闭 / Closed:熔断关闭,服务正常
  • 半开 / HalfOpen:允许调用,根据调用状态切换熔断状态

熔断器的三种状态之间的切换其实是一个简单的有限状态机(Finite-state Machine)模型:初始状态为关闭,当错误次数超过设定允许失败阈值,则切换到打开状态;所有服务调用将被降级,等超过设定时间窗口,切换到半开状态;允许服务调用,如果服务调用正常且超过时间窗口,切换到关闭状态,如果服务调用异常且超过阈值则切换到打开状态。

熔断状态机

Python 实现

这里是用 Python 实现一个简单的状态机来说明如何应用在熔断场景。首先定义一个基础类 CircuitBreaker,它需要实现一些对外使用的基本操作函数:

class CircuitBreaker:
    state = None

    def __init__(self, name, options):
        self.name = name
        self.options = options
        self.initialize()
    
    # 初始化计数与更新时间
    def initialize(self):
        self.count = 0
        self.update = self.now()

    # 增加熔断器计数
    def inc(self):
        if self.is_expired:
            self.transition(State.CLOSED)
        self.count += 1

    # 获取熔断器的状态
    def get_state(self):
        return self.state

    # 状态转移
    def transition(self, to):
        switch = {
            State.OPEN: Open,
            State.CLOSED: Closed,
            State.HALF_OPEN: HalfOpen,
        }
        self.__class__ = switch[to]
        self.initialize() # 状态转移后需重置计数

熔断器需要输入状态(inc)然后维护一个统计,然后根据设定的阈值,输出最新状态(get_state)。此例以失败次数作为统计参数,其他的场景可以选择使用失败率统计。

然后再继承这个基类实现三种熔断状态:Closed(熔断器关闭)、HalfOpen(熔断器半开)、Open(熔断器打开)。

class Closed(CircuitBreaker):
    state = State.CLOSED
    # ...

关闭状态只需要在增加失败次数的时候判断是否达到熔断器的打开阈值,然后转移状态即可。但打开状态有两种转移路径,所以在 inc 方法和 get 方法之前都需要预检查,根据 duration 与设定的超时时间校正当前所在状态,然后再增加次数或返回当前状态。

class Open(CircuitBreaker):
    state = State.OPEN

    def __init__(self, name, options):
        super().__init__(name, options)

    def inc(self):
        self.pre_check()
        super().inc()

    def get_state(self):
        self.pre_check()
        return self.state

    def pre_check(self):
        diff = self.now() - self.update
        if (diff > self.options.Open_Timeout + self.options.HalfOpen_Timeout): # 达到半开+ 打开状态的最长持续时间
            self.transition(State.CLOSED)
        elif diff > self.options.Open_Timeout: # 达到打开的最长持续时间
            self.transition(State.HALF_OPEN)
        else:
            pass

”半开“状态同样也有两种转移路径,一种是失败次数达到阈值,重新转移到打开状态;另外一种是没有新增失败次数的情况下持续时间达到了返回关闭状态。

class HalfOpen(CircuitBreaker):
    state = State.HALF_OPEN

    def inc(self):
        super().inc()
        if self.count > self.options.Failure_To_Open: # 达到重新打开的最小失败次数
            self.transition(State.OPEN)

    def get_state(self):
        if (self.now() - self.update) > self.options.HalfOpen_Timeout: # 达到半开的最长持续时间
            self.transition(State.CLOSED)
        return self.state

Redis 分布式实现

在实际应用中,无论是在微服务框架中还是 API 网关实现需求为中心化或分布式一致性,那就需要有介质存储这些状态数据。

这里选择使用 Redis + Lua 来实现分布式的状态记录以及转换,另外也可以单独为熔断服务实现分布式的状态同步。

由于 Redis 中 Lua 是以脚本形式运行,所以每次执行操作时均需从 Redis 中加载数据。

if (state == 0) then --- 关闭
    if (count == 1) then
        expire = timeout
    end
    if (count >= failure_to_open) then
        state, count, update = 1, 0, now
        expire = open_timeout + halfopen_timeout + timeout
    end
elseif (state == 1) then --- 打开
    if (now - update > (open_timeout + halfopen_timeout)) then
        state, count = 0, 1
        expire = timeout
    elseif (now - update > open_timeout) then
        state, count, update = 2, 1, now
        expire = halfopen_timeout + timeout
    end
else --- 半开
    if (count >= halfopen_max_failure) then
        state, count = 1, 0
        expire = open_timeout + halfopen_timeout + timeout
    elseif (now - update > halfopen_timeout) then
        state, count = 0, 1
        expire = timeout
    end
end

可以看到,与上面的 Python 写法不同,Lua 脚本中使用了过程式写法,主要是每次调用时都需重新从 Redis 加载状态数据,状态变更后又需将状态持久化到 Redis中。

总结

  1. 完整参考代码在:https://github.com/wayjam/circuitbreaker_example

  2. 在实际应用中,应当参考 Hystrix,增加更多监控指标,实时地监控运行指标和配置的变化,例如成功、失败、超时、以及被拒绝的请求等。

  3. 上述例子仅说明了如何将状态机的状态转移思想和熔断器的设计融合在一起,完整的熔断器设计主要根据 Hystrix 的三个思路:

    • 隔离:将资源池化,隔离不同服务的调用,避免相互之间影响
    • 熔断:避免雪崩效应
    • 降级:当服务异常时,可能会影响核心服务的调用和性能,此时需要一个补偿处理的机制,避免一直持续熔断,无法记录日志,关键服务持续不可用。服务降级的核心目的就是保证核心服务可用,即便是部分可用。