断路器

2014年3月6日

软件系统通常会进行远程调用,调用运行在不同进程中的软件,这些进程可能位于网络中的不同机器上。内存内调用和远程调用之间的一个主要区别是,远程调用可能会失败,或者在达到某个超时限制之前一直挂起,没有响应。更糟糕的是,如果一个无响应的供应商有多个调用者,那么你可能会耗尽关键资源,导致多个系统出现级联故障。在迈克尔·奈加德的优秀著作《Release It》中,他推广了断路器模式来防止这种灾难性的级联故障。

断路器背后的基本思想非常简单。你将一个受保护的函数调用包装在一个断路器对象中,该对象会监控故障。一旦故障达到某个阈值,断路器就会跳闸,所有对断路器的后续调用都会返回错误,而不会执行受保护的调用。通常,你还需要在断路器跳闸时进行某种监控警报。

以下是用 Ruby 编写的简单示例,它可以防止超时。

我使用一个块(Lambda)来设置断路器,该块是受保护的调用。

cb = CircuitBreaker.new {|arg| @supplier.func arg}

断路器存储块,初始化各种参数(用于阈值、超时和监控),并将断路器重置为关闭状态。

class CircuitBreaker...

  attr_accessor :invocation_timeout, :failure_threshold, :monitor
  def initialize &block
    @circuit = block
    @invocation_timeout = 0.01
    @failure_threshold = 5
    @monitor = acquire_monitor
    reset
  end

调用断路器将在电路关闭时调用底层块,但在电路打开时返回错误。

# client code
    aCircuitBreaker.call(5)


class CircuitBreaker...

  def call args
    case state
    when :closed
      begin
        do_call args
      rescue Timeout::Error
        record_failure
        raise $!
      end
    when :open then raise CircuitBreaker::Open
    else raise "Unreachable Code"
    end
  end
  def do_call args
    result = Timeout::timeout(@invocation_timeout) do
      @circuit.call args
    end
    reset
    return result
  end

如果出现超时,我们将增加故障计数器,成功调用会将其重置为零。

class CircuitBreaker...

  def record_failure
    @failure_count += 1
    @monitor.alert(:open_circuit) if :open == state
  end
  def reset
    @failure_count = 0
    @monitor.alert :reset_circuit
  end

我通过比较故障计数和阈值来确定断路器的状态。

class CircuitBreaker...

  def state
     (@failure_count >= @failure_threshold) ? :open : :closed
  end

这个简单的断路器在电路打开时会避免执行受保护的调用,但需要外部干预才能在情况好转时将其重置。对于建筑物中的电气断路器来说,这是一种合理的方法,但对于软件断路器,我们可以让断路器本身检测底层调用是否正常工作。我们可以通过在适当的时间间隔后再次尝试受保护的调用来实现这种自重置行为,如果成功,则重置断路器。

创建这种断路器意味着添加一个阈值来尝试重置,并设置一个变量来保存上次错误的时间。

class ResetCircuitBreaker...

  def initialize &block
    @circuit = block
    @invocation_timeout = 0.01
    @failure_threshold = 5
    @monitor = BreakerMonitor.new
    @reset_timeout = 0.1
    reset
  end
  def reset
    @failure_count = 0
    @last_failure_time = nil
    @monitor.alert :reset_circuit
  end

现在存在第三种状态——半开——这意味着电路已准备好进行真实调用,作为测试以查看问题是否已解决。

class ResetCircuitBreaker...

  def state
    case
    when (@failure_count >= @failure_threshold) && 
        (Time.now - @last_failure_time) > @reset_timeout
      :half_open
    when (@failure_count >= @failure_threshold)
      :open
    else
      :closed
    end
  end

在半开状态下被要求调用会导致试用调用,如果成功,则会重置断路器,否则会重新启动超时。

class ResetCircuitBreaker...

  def call args
    case state
    when :closed, :half_open
      begin
        do_call args
      rescue Timeout::Error
        record_failure
        raise $!
      end
    when :open
      raise CircuitBreaker::Open
    else
      raise "Unreachable"
    end
  end
  def record_failure
    @failure_count += 1
    @last_failure_time = Time.now
    @monitor.alert(:open_circuit) if :open == state
  end

这个示例是一个简单的解释性示例,在实践中,断路器提供了更多功能和参数化。通常,它们会防止受保护调用可能引发的各种错误,例如网络连接故障。并非所有错误都会使电路跳闸,有些错误应该反映正常故障,并作为常规逻辑的一部分进行处理。

在流量很大的情况下,你可能会遇到许多调用只是等待初始超时的问题。由于远程调用通常很慢,因此使用 future 或 promise 将每个调用放在不同的线程上通常是一个好主意,以便在结果返回时处理它们。通过从线程池中提取这些线程,你可以安排断路器在线程池耗尽时跳闸。

该示例展示了一种简单的断路器跳闸方式——一个在成功调用时重置的计数器。更复杂的方法可能会查看错误的频率,例如,一旦你获得 50% 的失败率,就会跳闸。你还可以为不同的错误设置不同的阈值,例如,超时阈值为 10,而连接故障阈值为 3。

我展示的示例是用于同步调用的断路器,但断路器对于异步通信也很有用。这里的一种常见技术是将所有请求放在一个队列中,供应商以其速度消费这些请求——这是一种避免服务器过载的有用技术。在这种情况下,当队列填满时,电路就会断开。

断路器本身有助于减少与可能失败的操作相关的资源占用。你避免了客户端等待超时,而断开的电路避免了对正在挣扎的服务器施加负载。我在这里讨论远程调用,这是断路器的常见情况,但它们可以用于任何你想要保护系统的一部分免受其他部分故障的情况。

断路器是一个有价值的监控位置。任何断路器状态的变化都应该被记录,断路器应该显示其状态的详细信息,以便进行更深入的监控。断路器行为通常是环境中更深层问题的良好预警来源。操作人员应该能够跳闸或重置断路器。

断路器本身很有价值,但使用它们的客户端需要对断路器故障做出反应。与任何远程调用一样,你需要考虑在发生故障时该怎么办。它是否会使你正在执行的操作失败,或者是否有你可以采取的解决方法?信用卡授权可以放在队列中以供以后处理,无法获取某些数据可以通过显示一些足够好的陈旧数据来缓解。

进一步阅读

Netflix 技术博客包含大量关于使用大量服务的系统来提高可靠性的有用信息。他们的《依赖命令》讨论了使用断路器和线程池限制。

Netflix 已开源 Hystrix,这是一个用于处理分布式系统的延迟和容错的复杂工具。它包括断路器模式的实现,以及线程池限制。

在 Ruby、Java、Grails 插件、C#、AspectJ 和 Scala 中还有其他断路器模式的开源实现。

致谢

Pavel Shpak 发现了示例代码中的错误并报告了它。