断路器模式

当连接到远程服务或资源到时候,处理那些需要一段时间才能修复的系统缺陷。这能优化应用对稳定性和可靠性。

上下文和问题

在分布式环境中,对远端服务或资源的请求可能会由于诸如以下临时性错误而失败:缓慢的网络请求,连接超时,资源被过度使用,或服务临时不可用。通常情况下,这些错误能够在短暂的中断后自我修复。一个健壮的云端应用应该能够通过重试模式等策略来处理这些问题。

然而,有的时候这些错误缘于一些未知的事件,从而需要更长的时间修复。这些错误可能是系统一部分无法连接,或是整个服务都响应失败。在这些情况下,盲目的去重试之前的操作可能并没有意义,而且也不太可能会成功,取而代之系统应该快速识别出操作失败然后去处理这些失败。

另外,如果一个服务非常繁忙,系统中的一部分出错将导致级连的错误。例如,一个调用其他服务的操作可以设定一个超时,然后在超时后返回错误。然而,这个策略可能导致很多访问这个服务的并发请求阻塞,直到超时。这些阻塞的请求可能占用了重要的系统资源,诸如内存,线程,数据库链接等。因此可能导致这些资源被耗尽,进而导致其他不相干的模块因为资源竞争而失败。在这些情况下,直接让这些操作失败,然后在合适的时候再去尝试调用这些服务,似乎是更合理的选择。设定一个短一些的超时时长可能会有助于解决这个问题,但是又不能设定的太短而中断那些最终可能成功的请求。

解决方案

由 Michael Nygard 在其[书中](https://pragprog.com/book/mne...)普及的断路器模式,能够阻止应用重复的尝试执行可能失败的请求。这允许系统继续运行,而不用等待那些错误被修复,也不用浪费 CPU 循环,因为它已经识别到该错误是持续性的。断路器模式也使系统能够检测出错误是否已被修复。如果问题已经被修复,系统能够重新调用该操作。

断路器模式的目的和重试模式有所不同。重试模式使应用能够重试期望成功的操作。断路器模式阻止应用去调用很可能失败的操作。应用可以联合使用两种模式。然而,重试逻辑应该能够处理断路器模式抛出的异常,并在断路器指示该错误非短期可修复的错误时,停止重试。

断路器为可能会失败的操作充当代理的角色。这个代理监视最近发生的失败的数量,然后用这些信息判断是否继续执行该操作,还是直接返回异常。

该代理可以通过一个状态机来实现,该状态机应模拟电子断路器来实现以下状态:

  • 关闭:来自应用的请求直接路由到对应的操作。代理维护一个计数器来记录最近失败的次数。如果一个操作失败,该计数器加一。如果最近失败的次数在指定时间段内超过一个阈值,代理被设定到 开启 状态。同时,代理启动一个计时器,当计时器超时后,代理被设定到 半开状态。

    设定计时器的目的是在应用重试该操作前,给系统留出时间修复导致该错误的问题。
  • 开启:从应用发送给该服务的请求直接失败,并返回异常。
  • 半开:允许少量的请求通过代理调用该操作。如果请求成功,系统假定之前引起操作失败的错误已被修复,断路器设定到 关闭状态(且将失败计数器重置)。如果任何请求失败,断路器便假定之前的错误依旧存在,然后把状态重新置为打开,重启超时计时器,并为系统恢复该错误设定更长的恢复时间。

    半开 状态有助于使恢复中的系统避免遭受突发的大量请求。在服务恢复过程中,它可能只能支撑有限数量的请求,直至恢复完全完成。在恢复过程中接收大量请求,可能会使服务超时,甚至再次失败。

断路器状态机

在上图中,关闭状态下使用的计数器是基于时间的,它会自动定期重置。这能够使断路器避免因偶发性失败而切换到失败状态。失败阈值设定使断路器只有在指定的时间内失败的次数达到了指定值后才切换到失败状态。半开状态下使用的计数器用来记录请求成功的次数。当连续成功的请求数量超过一个指定值后,断路器将切换到 关闭状态。如果任一调用失败,断路器将直接进入打开状态,下次进入半开状态的时候,成功计数器将被清零。

系统如何修复是属于本模式以外的内容,可能通过重新加载数据,重启失败的组件,或是修复网络问题。

断路器模式为在从错误中恢复的系统提供稳定性,同时降低对性能的影响。它通过快速驳回可能失败的请求来降低系统响应时间。如果每次断路器切换状态时都触发一个时间,则可以用来监视断路器保护部分的系统状态,或在断路器切换到 打开状态时为管理员提供报警。

这个模式是可定制的,而且可适配不同类型的错误。例如,你可以将超时计数器的值调高,你可以将断路器处在状态的初始值设为几秒,然后如果到时后失败未解决将超时器设为几分钟等。在一些情况下,除了让处在状态的断路器返回失败和异常,也可以将其配置为返回一个对应用有意义的默认值。

问题和注意事项

当考虑如何实现该模式时,需要考虑如下问题:

异常处理。应用通过断路器调用服务需准备好如何处理因服务无法访问而产生的异常。处理异常的方式因应用不同而不同。例如,应用应能临时降级它对应的功能,调用候选的能获得同样数据的应用,或向用户报告错误,请其过后重试。

异常的类型。请求可能由于各种原因而失败,其中一些导致的问题可能比其他更严重。例如,请求可能由于外部服务宕机而失败从而中断数分钟,或者由于服务过载而导致超时。断路器可能能够检测异常的类型从而使用不同的策略。例如,如果要把断路器设定到 状态,超时类型到错误次数的阈值要比系统完全不可用的阈值要高很懂。

日志。断路器应该记录所有失败的请求(如何可以,也可以记录成功的)来允许管理员来监控操作的健康状况。

可恢复性。你应该为断路器配置其保护的操作可能的恢复模型。例如,如果断路器在打开状态维持了很长时间,可能导致即使错误已经修复,断路器仍抛出异常。类似的,如果断路器从半开的时间太短,可能导致它上下波动,减少应用的响应时间(??没懂)。

测试失败的操作。在的状态下,除了用计数器来决定何时切换到半开状态,断路器还可以启用一个定时任务来周期性 ping 远端服务来判断该服务是否已可以访问。可以采用尝试调用之前失败的服务的形式,或调用远端服务提供的专门用来测试服务状态的操作,如健康状况健康模式 所描述的那样。

手动重载。对于系统恢复时间波动非常大的系统,提供一个手动重置选项来方便管理员关闭断路器(同时重置失败计数器)是很有用的。类似的,如果断路器所保护的服务临时不可用,管理员可以强制打开断路器将其置为状态(同时重置计时器)。

并发。断路器可能同时被大量客户端访问。其实现不用阻塞并发的请求,也不能给操作添加过多的额外负载。

资源区分。当我们为一个由多个独立的提供者提供的同一个资源使用断路器时,我们需要额外注意。例如,在一个由多个分片的数据存储资源中,即便其他分片遇到临时错误,单个分片也可以接受完全的访问。如果在这种场景中,这些错误被合并成同一错误,应用可能会在某些分片错误时尝试去访问其他分片,但由于断路器的存在,对其他分片的访问也可能会被阻塞,即使它们可能成功。

加速熔断。有时候返回的错误信息包含足够信息令断路器断路。例如,一个共享资源过载,可直接另断路器断路而避免应用马上重试。

[!注意事项]
一个服务可能在限流时返回 HTTP 429(太多的请求),或者在服务当前不可用时返回 HTTP 503(服务不可用)。HTTP 返回信息中可能包含了额外信息,比如下次重试的间隔时间等。

重放失败的请求。在打开的状态下,除了直接返回失败,断路器也可以将每个请求的详细信息记录到日志中,然后然后在远程资源可访问后,重放该请求。
外部服务不适合的超时。断路器不适合用来保护那些设置了过长超时时长的外部服务。如果超时时间过长,断路器的线程可能阻塞,在这段时间内,其他应用可能耶尝试调用这个服务,从而导致断路器消耗大量的线程。

什么时候使用该模式

在以下场景可以使用该模式:

  • 阻止应用访问一个很可能失败的共享的远程服务或资源。

以下场景不应该用该模式:

  • 访问本地资源,比如内存中的数据结构。在这个场景中,使用断路器将为你的系统带来额外的开销。
  • 用来替代业务逻辑中的异常处理。

例子

在 web 应用中,页面是根据外部服务获得的数据计算生成的。如果系统设定较少的缓存策略,大多数页面点击都会调用一次服务。从 web 应用到服务的请求可以设定超时时间(通常是60秒),如果服务在这段时间内未响应,页面的逻辑将认为服务不可用并抛出异常。

然而,如果服务失败并且系统非常繁忙,用户可能需要等60秒才会被提示异常。最终内存,链接,线程等资源可能会用尽,阻止其他用户连接系统,即使它们并不是访问失败的那个服务。

通过添加更多的网络服务器和实现负载均衡来为系统扩容能够延缓资源耗尽的时间,但这并不会解决这个问题因为用户的请求仍会未响应并且最终所以网络服务器的资源终会耗尽。

为访问该服务查询数据的连接包裹一层断路器能够解决该问题,并且能更优雅地解决服务失败。用户的请求仍会失败,但失败将会更迅速并且不会阻塞资源。

The CircuitBreaker class maintains state information about a circuit breaker in an object that implements the ICircuitBreakerStateStore interface shown in the following code.

interface ICircuitBreakerStateStore
{
  CircuitBreakerStateEnum State { get; }

  Exception LastException { get; }

  DateTime LastStateChangedDateUtc { get; }

  void Trip(Exception ex);

  void Reset();

  void HalfOpen();

  bool IsClosed { get; }
}

The State property indicates the current state of the circuit breaker, and will be either Open, HalfOpen, or Closed as defined by the CircuitBreakerStateEnum enumeration. The IsClosed property should be true if the circuit breaker is closed, but false if it's open or half open. The Trip method switches the state of the circuit breaker to the open state and records the exception that caused the change in state, together with the date and time that the exception occurred. The LastException and the LastStateChangedDateUtc properties return this information. The Reset method closes the circuit breaker, and the HalfOpen method sets the circuit breaker to half open.

The InMemoryCircuitBreakerStateStore class in the example contains an implementation of the ICircuitBreakerStateStore interface. The CircuitBreaker class creates an instance of this class to hold the state of the circuit breaker.

The ExecuteAction method in the CircuitBreaker class wraps an operation, specified as an Action delegate. If the circuit breaker is closed, ExecuteAction invokes the Action delegate. If the operation fails, an exception handler calls TrackException, which sets the circuit breaker state to open. The following code example highlights this flow.

public class CircuitBreaker
{
  private readonly ICircuitBreakerStateStore stateStore =
    CircuitBreakerStateStoreFactory.GetCircuitBreakerStateStore();

  private readonly object halfOpenSyncObject = new object ();
  ...
  public bool IsClosed { get { return stateStore.IsClosed; } }

  public bool IsOpen { get { return !IsClosed; } }

  public void ExecuteAction(Action action)
  {
    ...
    if (IsOpen)
    {
      // The circuit breaker is Open.
      ... (see code sample below for details)
    }

    // The circuit breaker is Closed, execute the action.
    try
    {
      action();
    }
    catch (Exception ex)
    {
      // If an exception still occurs here, simply
      // retrip the breaker immediately.
      this.TrackException(ex);

      // Throw the exception so that the caller can tell
      // the type of exception that was thrown.
      throw;
    }
  }

  private void TrackException(Exception ex)
  {
    // For simplicity in this example, open the circuit breaker on the first exception.
    // In reality this would be more complex. A certain type of exception, such as one
    // that indicates a service is offline, might trip the circuit breaker immediately.
    // Alternatively it might count exceptions locally or across multiple instances and
    // use this value over time, or the exception/success ratio based on the exception
    // types, to open the circuit breaker.
    this.stateStore.Trip(ex);
  }
}

The following example shows the code (omitted from the previous example) that is executed if the circuit breaker isn't closed. It first checks if the circuit breaker has been open for a period longer than the time specified by the local OpenToHalfOpenWaitTime field in the CircuitBreaker class. If this is the case, the ExecuteAction method sets the circuit breaker to half open, then tries to perform the operation specified by the Action delegate.

If the operation is successful, the circuit breaker is reset to the closed state. If the operation fails, it is tripped back to the open state and the time the exception occurred is updated so that the circuit breaker will wait for a further period before trying to perform the operation again.

If the circuit breaker has only been open for a short time, less than the OpenToHalfOpenWaitTime value, the ExecuteAction method simply throws a CircuitBreakerOpenException exception and returns the error that caused the circuit breaker to transition to the open state.

Additionally, it uses a lock to prevent the circuit breaker from trying to perform concurrent calls to the operation while it's half open. A concurrent attempt to invoke the operation will be handled as if the circuit breaker was open, and it'll fail with an exception as described later.

    ...
    if (IsOpen)
    {
      // The circuit breaker is Open. Check if the Open timeout has expired.
      // If it has, set the state to HalfOpen. Another approach might be to
      // check for the HalfOpen state that had be set by some other operation.
      if (stateStore.LastStateChangedDateUtc + OpenToHalfOpenWaitTime < DateTime.UtcNow)
      {
        // The Open timeout has expired. Allow one operation to execute. Note that, in
        // this example, the circuit breaker is set to HalfOpen after being
        // in the Open state for some period of time. An alternative would be to set
        // this using some other approach such as a timer, test method, manually, and
        // so on, and check the state here to determine how to handle execution
        // of the action.
        // Limit the number of threads to be executed when the breaker is HalfOpen.
        // An alternative would be to use a more complex approach to determine which
        // threads or how many are allowed to execute, or to execute a simple test
        // method instead.
        bool lockTaken = false;
        try
        {
          Monitor.TryEnter(halfOpenSyncObject, ref lockTaken);
          if (lockTaken)
          {
            // Set the circuit breaker state to HalfOpen.
            stateStore.HalfOpen();

            // Attempt the operation.
            action();

            // If this action succeeds, reset the state and allow other operations.
            // In reality, instead of immediately returning to the Closed state, a counter
            // here would record the number of successful operations and return the
            // circuit breaker to the Closed state only after a specified number succeed.
            this.stateStore.Reset();
            return;
          }
          catch (Exception ex)
          {
            // If there's still an exception, trip the breaker again immediately.
            this.stateStore.Trip(ex);

            // Throw the exception so that the caller knows which exception occurred.
            throw;
          }
          finally
          {
            if (lockTaken)
            {
              Monitor.Exit(halfOpenSyncObject);
            }
          }
        }
      }
      // The Open timeout hasn't yet expired. Throw a CircuitBreakerOpen exception to
      // inform the caller that the call was not actually attempted,
      // and return the most recent exception received.
      throw new CircuitBreakerOpenException(stateStore.LastException);
    }
    ...

To use a CircuitBreaker object to protect an operation, an application creates an instance of the CircuitBreaker class and invokes the ExecuteAction method, specifying the operation to be performed as the parameter. The application should be prepared to catch the CircuitBreakerOpenException exception if the operation fails because the circuit breaker is open. The following code shows an example:

var breaker = new CircuitBreaker();

try
{
  breaker.ExecuteAction(() =>
  {
    // Operation protected by the circuit breaker.
    ...
  });
}
catch (CircuitBreakerOpenException ex)
{
  // Perform some different action when the breaker is open.
  // Last exception details are in the inner exception.
  ...
}
catch (Exception ex)
{
  ...
}

相关模式与指导

在实现该模式时,以下模式也会有帮助:

当应用尝试连接服务或网络资源时遇到临时性错误时,简单的重试之前失败的操作。

断路器应该能够通过给服务端点发送请求来测试服务的健康状况。服务应该能够返回信息来表明自身状况。

翻译自 Azure Cloud Design Patterns


FingerLiu
562 声望28 粉丝

先广后精。Explore the whole world,and then do one thing but do it best.