斷路器

2014 年 3 月 6 日

軟體系統通常會呼叫執行於不同程序(可能在網路上的不同機器上)的軟體。記憶體內呼叫和遠端呼叫之間的一大差異在於,遠端呼叫可能會失敗,或在達到某個逾時限制之前掛起而不回應。更糟的是,如果沒有回應的供應商上有許多呼叫者,則可能會耗盡關鍵資源,導致多個系統發生連鎖故障。在出色的書籍 Release It 中,Michael Nygard 推廣了斷路器模式,以防止這種災難性的連鎖反應。

斷路器的基本概念非常簡單。您將受保護的函式呼叫包裝在斷路器物件中,該物件會監控失敗。一旦失敗達到某個閾值,斷路器就會跳閘,而對斷路器的所有進一步呼叫都會傳回錯誤,而根本不會執行受保護的呼叫。通常,您還希望在斷路器跳閘時收到某種監視警示。

以下是此行為在 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

這個範例是一個簡單的說明範例,在實務上,斷路器提供更多功能和參數化。它們通常會防護受保護的呼叫可能引發的各種錯誤,例如網路連線失敗。並非所有錯誤都應該觸發斷路器,有些錯誤應該反映正常的失敗,並作為一般邏輯的一部分來處理。

在流量大的情況下,您可能會遇到許多呼叫僅等待初始逾時的問題。由於遠端呼叫通常很慢,因此通常建議使用未來或承諾將每個呼叫放在不同的執行緒上,以便在結果返回時處理結果。透過從執行緒池中提取這些執行緒,您可以安排在執行緒池耗盡時斷開電路。

範例顯示了觸發斷路器的一個簡單方法 - 在成功的呼叫時重設的計數。更精密的做法可能是查看錯誤的頻率,例如,在獲得 50% 失敗率時觸發。您也可以針對不同的錯誤設定不同的閾值,例如逾時的閾值為 10,但連線失敗的閾值為 3。

我展示的範例是同步呼叫的斷路器,但斷路器也適用於非同步通訊。這裡常見的技術是將所有要求放入佇列中,由供應商以其速度使用 - 這是避免伺服器過載的有用技術。在這種情況下,當佇列填滿時,斷路器會斷開。

電路中斷器本身有助於減少與可能失敗的作業相關的資源。您避免等待客戶端逾時,而斷路器避免對負載過重的伺服器施加負載。我在這裡討論遠端呼叫,這是電路中斷器的常見情況,但它們可用於您想要保護系統部分免於其他部分失敗的任何情況。

電路中斷器是監控的寶貴位置。斷路器狀態的任何變更都應記錄,並且斷路器應揭露其狀態的詳細資訊以進行更深入的監控。斷路器行為通常是環境中更深層問題的良好警告來源。作業人員應能夠跳脫或重設斷路器。

斷路器本身很有價值,但使用它們的客戶端需要對斷路器故障做出反應。與任何遠端呼叫一樣,您需要考慮在發生故障時該怎麼辦。它會使您執行的作業失敗,還是有您可以執行的解決方法?信用卡授權可以排隊稍後處理,無法取得某些資料的失敗可能會透過顯示一些足夠顯示的舊資料來減輕。

進一步閱讀

netflix 技術部落格包含許多有關改善具有大量服務的系統可靠性的有用資訊。他們的相依命令討論使用電路中斷器和執行緒池限制。

Netflix 已開放原始碼 Hystrix,這是一個用於處理分散式系統的延遲和容錯的精密工具。它包含電路中斷器模式的實作,以及執行緒池限制

電路中斷器模式在 RubyJavaGrails 外掛程式C#AspectJScala 中有其他開放原始碼實作

致謝

Pavel Shpak 發現並回報了範例程式碼中的錯誤