用 100 行代碼手寫一個 Hystrix

熔斷與降級

離小眼睛家不遠的地方,開了一個熟食店。店內有兩個窗口總能排起長龍,一個窗口是選好的涼菜讓師傅調味,一個窗口是買到的扒雞讓胖師傅現場脫骨。顧客的正常的流程,大致是這個樣子滴:

 

 

炎炎夏日,邀三五好友,喝杯啤酒吹吹牛皮,豈不美哉。可能大家跟小眼睛想法一致,小店的生意日漸火爆。這天,小眼睛選好了菜,付了錢,正準備排隊讓師傅調口味、脫骨。目測兩個窗口排隊時間不會少於 20 分鐘,加之幾個朋友輪番催促,果斷放棄,拎着菜直接回家。於是我到流程就變成了:

 

 

當下遊的服務(調料、脫骨)因爲某種原因突然變得不可用或響應過慢(買菜3分鐘排隊半小時),上游服務爲了保證自己整體服務的可用性(等不及了),不再繼續調用目標服務,直接返回,快速釋放資源。如果目標服務情況好轉則恢復調用。這就叫做服務熔斷。

小眼睛因爲排隊時間過長,果斷放棄後續流程,提供了「降低品質」的菜品。這叫做服務降級。

 

熔斷有多種方式

服務降級的方式有很多種,比如限流、開關、熔斷,熔斷是降級的一種。

熔斷,在 Spring Cloud 中有熔斷降級庫 Hystrix ,在分佈式項目中也可以使用阿里開源的 Sentinel 達到熔斷降級目的。無論是 Hystrix 還是 Sentinel 都需要引入第三方組件,搞明白實現原理,不適合簡單場景下的使用。

 

手寫熔斷器的使用

本文介紹一種適合簡單應用的熔斷方法,核心代碼不超過 100 行。使用方法大致如下:


// 初始化一個熔斷器
private CircuitBreaker breaker = new CircuitBreaker(0.1, 10, true, "serviceDemo");

public void doSomething() {
    // 每次調用都檢查服務狀態
    breaker.checkStatus();
    // 如果熔斷器返回 true 認爲服務可用,繼續執行邏輯
    if (breaker.isWorked()) {
        try {
            service.doSomething();
        } catch (Exception e) {
            e.printStackTrace();
            // 出現調用失敗,記錄失敗次數
            breaker.addFailTimes();
        } finally {
            // 每一次調用,增加調用次數
            breaker.addInvokeTimes();
        }
    }
    // 服務不可用,執行降級邏輯
}

這段僞代碼中,熔斷器做了三件事兒:

  1. 檢查服務狀態,並且輸出統計日誌

  2. 返回服務狀態 breaker.isWorked()

  3. 記錄調用次數和失敗次數,作爲熔斷依據

 

熔斷器的實現

熔斷器具體實現如下:


public class CircuitBreaker {
  /**
   * 記錄失敗次數
   */
  private AtomicLong failTimes =
          new AtomicLong(0);
  /**
   * 記錄調用次數
   */
  private AtomicLong invokeTimes =
          new AtomicLong(0);
  /**
   * 降級閾值,比如 0.1
   * 請求失敗次數/請求總次數的比例
   */
  private double failedRate = 0.1;
  /**
   * 降級最小條件,請求總次數大於該值
   * 纔會執行閾值判斷
   * 比如 設置爲 10 ,
   * 當請求次數大於10次時纔會執行判斷
   */
  private double minTimes;
  /**
   * 熔斷開關,默認關閉
   */
  private boolean enabled;
  /**
   * 熔斷後是否發送郵件告警
   */
  private boolean mail;
  /**
   * 熔斷後是否發送短信告警
   */
  private boolean sms;
  /**
   * 熔斷器名字
   */
  private String name;
  /**
   * 保存上一次統計的時間戳,記錄單位是分鐘
   */
  private AtomicLong currentTime =
          new AtomicLong(
             System.currentTimeMillis() / 60000);
  /**
   * 記錄服務是否是不可用狀態
   */
  private AtomicBoolean isFailed =
          new AtomicBoolean(false);
  /**
   * 服務宕掉的狀態放到線程容器中
   */
  private ThreadLocal<Boolean> fail =
          new ThreadLocal<Boolean>();


  private Logger log =
          LoggerFactory.getLogger(getClass());


  /**
   * 構造熔斷器
   *
   * @param failedRate 熔斷的閾值,
   *                   請求失敗次數/請求總次數
   * @param minTimes   熔斷的最小條件,
   *                   請求總次數大於該值纔會根據閾值判斷,
   *                   執行降級操作
   * @param enabled    是否需開啓熔斷操作
   */
  public CircuitBreaker(double failedRate,
                        double minTimes,
                        boolean enabled,
                        String name) {
    fail.set(false);
    this.failedRate = failedRate;
    this.minTimes = minTimes;
    this.enabled = enabled;
    this.name = name;
  }

  /**
   * 判斷服務是否是失敗狀態
   *
   * @return
   */
  public boolean isFailed() {
    return isFailed.get();
  }

  /**
   * 增加錯誤次數
   */
  public void addFailTimes() {
    fail.set(true);
    if (enabled) {
      failTimes.incrementAndGet();
    }
  }

  /**
   * 增加一次調用次數
   */
  public void addInvokeTimes() {
    if (enabled) {
      invokeTimes.incrementAndGet();
    }
  }

  /**
   * 判斷服務是否可用
   *
   * @return
   */
  public boolean isWorked() {
    if (!enabled) {
      return true;
    }
    // 當服務不可用時,犧牲掉 1% 的流量做探活請求
    if (isFailed.get() &&
        System.currentTimeMillis() % 100 == 0) {
      return true;
    }
    if (isFailed.get()) {
      fail.set(true);
      return false;
    }
    return true;
  }

  public void checkStatus() {
    if (!enabled) {
      return;
    }
    long newTime =
       System.currentTimeMillis() / 60000;
    if ((newTime > currentTime.get())
       && (invokeTimes.get() > minTimes)) {

      double percent =
              failTimes.get() * 1.0 /
                      invokeTimes.get();

      if (percent > failedRate) {
        if (isFailed.get()) {
          // 日誌輸出
          if (mail) {
            // 發送郵件通知
          }
        } else {
          // 日誌輸出
          isFailed.set(true);
          if (sms) {
            // 發送短信通知
          }
          if (mail) {
            // 發送郵件通知
          }
        }
      } else { // 服務恢復
        if (isFailed.get()) {
          // 日誌輸出
          if (sms) {
            // 發送短信通知
          }
          if (mail) {
            // 發送郵件通知
          }
        }
        isFailed.set(false);
      }
      if (log.isInfoEnabled()) {
        // 日誌輸出
      }
      currentTime.set(newTime);
      failTimes.set(0);
      invokeTimes.set(0);
    }
  }

}

總體思路:

  1. 基於統計信息做熔斷,錯誤請求佔比超過閾值做熔斷

  2. 統計週期在分鐘級別內(1 分鐘內的統計達到閾值)

  3. 如果分鐘內,總請求次數未達到 minTimes 次數不做熔斷(請求頻次太低,統計信息無意義)

  4. 即便是到達熔斷條件,仍然犧牲 1% (可修改)的請求做探活

優缺點

  1. Hystrix 提供了服務熔斷、線程隔離等一系列服務保護功能。我們手寫的熔斷器只能提供基於調用方的手工熔斷方法。

  2. Hystrix 提供了線程池、信號量兩種方式。手寫熔斷器功能相對單一隻基於統計信息,且以分鐘爲維度的顆粒度較爲粗糙。

  3. Hystrix 命令式編程和註冊回調的方式,代碼複雜度高。手寫熔斷器在侵入代碼過程中,偏面向過程,理解成本低。

去掉註釋和無效空行後實際有效代碼不足 100 行,我們用了不到一百行代碼實現了熔斷功能。雖然應用到大型服務場景下會有諸多缺陷,也希望至少能爲大家提供了一個思路。

 

關注我

歡迎關注,隨時與我交流溝通~

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章