服務容錯模式

_吹雪_發表於2018-08-05

0.背景

隨著服務框架和服務治理體系的逐步成熟,服務化已成為系統設計的趨勢。隨著業務複雜度的增加,依賴的服務也逐步增加,出現了不少由於服務呼叫出現異常問題而導致的重大事故,如:
1)系統依賴的某個服務發生延遲或者故障,數秒內導致所有應用資源(執行緒,佇列等)被耗盡,造成所謂的雪崩效應 (Cascading Failure),導致整個系統拒絕對外提供服務。
2)系統遭受惡意爬蟲襲擊,在放大效應下沒有對下游依賴服務做好限速處理,最終導致下游服務崩潰。
容錯是一個很大的話題,受篇幅所限,本文將介紹僅限定在服務呼叫間常用的一些容錯模式。

1.設計原則

服務容錯的設計有個基本原則,就是“Design for Failure”。為了避免出現“千里之堤潰於蟻穴”這種情況,在設計上需要考慮到各種邊界場景和對於服務間呼叫出現的異常或延遲情況,同時在設計和程式設計時也要考慮周到。這一切都是為了達到以下目標:
1)一個依賴服務的故障不會嚴重破壞使用者的體驗。
2)系統能自動或半自動處理故障,具備自我恢復能力。
基於這個原則和目標,衍生出下文將要介紹的一些模式,能夠解決分散式服務呼叫中的一些問題,提高系統在故障發生時的存活能力。

2. 一些經典的容錯模式

所謂模式,其實就是某種場景下一類問題及其解決方案的總結歸納,往往可以重用。模式可以指導我們完成任務,作出合理的系統設計方案,達到事半功倍的效果。而在服務容錯這個方向,行業內已經有了不少實踐總結出來的解決方案。

2.1 超時與重試(Timeout and Retry)

  • 超時模式
    是一種最常見的容錯模式,在工程實踐中大量存在。常見的有設定網路連線超時時間,一次RPC的響應超時時間等。在分散式服務呼叫的場景中,它主要解決了當依賴服務出現建立網路連線或響應延遲,不用無限等待的問題,呼叫方可以根據事先設計的超時時間中斷呼叫,及時釋放關鍵資源,如Web容器的連線數,資料庫連線數等,避免整個系統資源耗盡出現拒絕對外提供服務這種情況。

  • 重試模式
    一般和超時模式結合使用,適用於對於下游服務的資料強依賴的場景(不強依賴的場景不建議使用!),通過重試來保證資料的可靠性或一致性,常用於因網路抖動等導致服務呼叫出現超時的場景。與超時時間設定結合使用後,需要考慮介面的響應時間分佈情況,超時時間可以設定為依賴服務介面99.5%響應時間的值,重試次數一般1-2次為宜,否則會導致請求響應時間延長,拖累到整個系統。

一些實現說明:

public class RetryCommand<T> {
    private int maxRetries = 2;// 重試次數 預設2次
    private long retryInterval = 5;//重試間隔時間ms 預設5ms
    private Map<String, Object> params;

    public RetryCommand() {

    }

    public RetryCommand(long retryInterval, int maxRetries) {
        this.retryInterval = retryInterval;
        this.maxRetries = maxRetries;
    }

    public T command(Map<String, Object> params){
          //Some remote service call with timeout
        serviceA.doSomethingWithTimeOut(timeout);
    }

    private final T retry() throws RuntimeException {
        int retryCounter = 0;
        while (retryCounter < maxRetries) {
            try {
                return command(params);
            } catch (Exception e) {
                retryCounter++;
                if (retryCounter >= maxRetries) {
                    break;
                }
            }
       }
      throw new RuntimeException("Command failed on all of " + maxRetries + " retries");
    }

       //省略
}

2.2 限流(Rate Limiting/Load Shedder)

限流模式,常用於下游服務容量有限,但又怕出現突發流量猛增(如惡意爬蟲,節假日大促等)而導致下游服務因壓力過大而拒絕服務的場景。常見的限流模式有控制併發和控制速率,一個是限制併發的數量,一個是限制併發訪問的速率。

  • 控制併發
    屬於一種較常見的限流手段,在工程實踐中可以通過訊號量機制(如Java中的Semaphore)來控制,舉個例子:
    假如有一個需求,要讀取幾萬個檔案的資料,因為都是IO密集型任務,我們可以啟動幾十個執行緒併發的讀取,但是如果讀到記憶體後,還需要儲存到資料庫中,而資料庫的連線數只有10個,這時我們必須控制只有十個執行緒同時獲取資料庫連線儲存資料,否則會報錯無法獲取資料庫連線。這個時候,我們就可以使用Semaphore來控制併發數,如:
public class SemaphoreTest {

    private static final int THREAD_COUNT = 30;

    private static ExecutorService threadPool = Executors
        .newFixedThreadPool(THREAD_COUNT);

    private static Semaphore s = new Semaphore(10);

    public static void main(String[] args) {
        for (int i = 0; i < THREAD_COUNT; i++) {
            threadPool.execute(new Runnable() {
                @Override
                public void run() {
                    try {
                        s.acquire();
                        System.out.println("save data");
                        s.release();
                    } catch (InterruptedException e) {
                        e.printStack();
                    }
                }
            });
        }

        threadPool.shutdown();
    }
}

在程式碼中,雖然有30個執行緒在執行,但是隻允許10個併發的執行。Semaphore的構造方法Semaphore(int permits) 接受一個整型的數字,表示可用的許可證數量。Semaphore(10)表示允許10個執行緒獲取許可證,也就是最大併發數是10。Semaphore的用法也很簡單,首先執行緒使用Semaphore的acquire()獲取一個許可證,使用完之後呼叫release()歸還許可證,還可以用tryAcquire()方法嘗試獲取許可證。

  • 控制速率
    在我們的工程實踐中,常見的是使用令牌桶演算法來實現這種模式,其他如漏桶演算法也可以實現控制速率,但在我們的工程實踐中使用不多, 這裡不做介紹,讀者請自行了解。

在Wikipedia上,令牌桶演算法是這麼描述的:

  • 每秒會有r個令牌放入桶中,或者說,每過1/r秒桶中增加一個令牌。
  • 桶中最多存放b個令牌,如果桶滿了,新放入的令牌會被丟棄。
  • 當一個n位元組的資料包到達時,消耗n個令牌,然後傳送該資料包。
  • 如果桶中可用令牌小於n,則該資料包將被快取或丟棄。

令牌桶控制的是一個時間視窗內通過的資料量,在API層面我們常說的QPS、TPS,正好是一個時間視窗內的請求量或者事務量,只不過時間視窗限定在1s罷了。 以一個恆定的速度往桶裡放入令牌,而如果請求需要被處理,則需要先從桶裡獲取一個令牌,當桶裡沒有令牌可取時,則拒絕服務。令牌桶的另外一個好處是可以方便的改變速度,一旦需要提高速率,則按需提高放入桶中的令牌的速率。

在我們的工程實踐中,通常使用Guava中的Ratelimiter來實現控制速率,如我們不希望每秒的任務提交超過兩個:

//速率是每秒兩個許可
final RateLimiter rateLimiter = RateLimiter.create(2.0);

void submitTasks(List tasks, Executor executor) {
    for (Runnable task : tasks) {
        rateLimiter.acquire(); // 也許需要等待
        executor.execute(task);
    }
}

2.3 電路熔斷器(Circuit Breaker)

在我們的工程實踐中,偶爾會遇到一些服務由於網路連線超時,系統有異常或load過高出現暫時不可用等情況,導致對這些服務的呼叫失敗,可能需要一段時間才能修復,這種對請求的阻塞可能會佔用寶貴的系統資源,如:記憶體,執行緒,資料庫連線等等,最壞的情況下會導致這些資源被消耗殆盡,使得系統裡不相關的部分所使用的資源也耗盡從而拖累整個系統。在這種情況下,呼叫操作能夠立即返回錯誤而不是等待超時的發生或者重試可能是一種更好的選擇,只有當被呼叫的服務有可能成功時我們再去嘗試。

熔斷器模式可以防止我們的系統不斷地嘗試執行可能會失敗的呼叫,使得我們的系統繼續執行而不用等待修正錯誤,或者浪費CPU時間去等到長時間的超時產生。熔斷器模式也可以使我們系統能夠檢測錯誤是否已經修正,如果已經修正,系統會再次嘗試呼叫操作。 下圖是個使用熔斷器模式的呼叫流程:

可以從圖中看出,當超時出現的次數達到一定條件後,熔斷器會觸發開啟狀態,客戶端的下次呼叫將直接返回,不用等待超時產生。

在熔斷器內部,往往有以下幾種狀態:

1)閉合(closed)狀態:該狀態下能夠對目標服務或方法進行正常的呼叫。熔斷器類維護了一個時間視窗內呼叫失敗的次數,如果某次呼叫失敗,則失敗次數加1。如果最近失敗次數超過了在給定的時間視窗內允許失敗的閾值(可以是數量也可以是比例),則熔斷器類切換到斷開(Open)狀態。此時熔斷器設定了一個計時器,當時鍾超過了該時間,則切換到半斷開(Half-Open)狀態,該睡眠時間的設定是給了系統一次機會來修正導致呼叫失敗的錯誤。

2)斷開(Open)狀態:在該狀態下,對目標服務或方法的請求會立即返回錯誤響應,如果設定了fallback方法,則會進入fallback的流程。

3)半斷開(Half-Open)狀態:允許對目標服務或方法的一定數量的請求可以去呼叫服務。 如果這些請求對服務的呼叫成功,那麼可以認為之前導致呼叫失敗的錯誤已經修正,此時熔斷器切換到閉合狀態(並且將錯誤計數器重置);如果這一定數量的請求有呼叫失敗的情況,則認為導致之前呼叫失敗的問題仍然存在,熔斷器切回到斷開方式,然後開始重置計時器來給系統一定的時間來修正錯誤。半斷開狀態能夠有效防止正在恢復中的服務被突然而來的大量請求再次拖垮。

在我們的工程實踐中,熔斷器模式往往應用於服務的自動降級,在實現上主要基於Netflix開源的元件Hystrix來實現,下圖和程式碼分別是Hystrix中熔斷器的原理和定義,更多瞭解可以檢視Hystrix的原始碼:

public interface HystrixCircuitBreaker {

    /**
     * Every {@link HystrixCommand} requests asks this if it is allowed to proceed or not.
      * <p>
      * This takes into account the half-open logic which allows some requests through when determining if it should be closed again.
      *
      * @return boolean whether a request should be permitted
      */
     public boolean allowRequest();

     /**
      * Whether the circuit is currently open (tripped).
      *
      * @return boolean state of circuit breaker
     */
     public boolean isOpen();

    /**
     * Invoked on successful executions from {@link HystrixCommand} as part of feedback mechanism when in a half-open state.
     */
    public void markSuccess();
}

2.4 艙壁隔離(Bulkhead Isolation)

在造船行業,往往使用此類模式對船艙進行隔離,利用艙壁將不同的船艙隔離起來,這樣如果一個船艙破了進水,只損失一個船艙,其它船艙可以不受影響,而借鑑造船行業的經驗,這種模式也在軟體行業得到使用。

執行緒隔離(Thread Isolation)就是這種模式的常見的一個場景。例如,系統A呼叫了ServiceB/ServiceC/ServiceD三個遠端服務,且部署A的容器一共有120個工作執行緒,採用執行緒隔離機制,可以給對ServiceB/ServiceC/ServiceD的呼叫各分配40個執行緒。當ServiceB慢了,給ServiceB分配的40個執行緒因慢而阻塞並最終耗盡,執行緒隔離可以保證給ServiceC/ServiceD分配的80個執行緒可以不受影響。如果沒有這種隔離機制,當ServiceB慢的時候,120個工作執行緒會很快全部被對ServiceB的呼叫吃光,整個系統會全部慢下來,甚至出現系統停止響應的情況。

這種Case在我們實踐中經常遇到,如某介面由於資料庫慢查詢,外部RPC呼叫超時導致整個系統的執行緒數過高,連線數耗盡等。我們可以使用艙壁隔離模式,為這種依賴服務呼叫維護一個小的執行緒池,當一個依賴服務由於響應慢導致執行緒池任務滿的時候,不會影響到其他依賴服務的呼叫,它的缺點就是會增加執行緒數。

無論是超時/重試,熔斷器,還是艙壁隔離模式,它們在使用過程中都會出現異常情況,異常情況的處理方式間接影響到使用者的體驗,針對異常情況的處理也有一種模式支撐,這就是回退(fallback)模式。

2.5 回退(Fallback)

在超時,重試失敗,熔斷或者限流發生的時候,為了及時恢復服務或者不影響到使用者體驗,需要提供回退的機制,常見的回退策略有:

自定義處理:在這種場景下,可以使用預設資料,本地資料,快取資料來臨時支撐,也可以將請求放入佇列,或者使用備用服務獲取資料等,適用於業務的關鍵流程與嚴重影響使用者體驗的場景,如商家/產品資訊等核心服務。

故障沉默(fail-silent):直接返回空值或預設值,適用於可降級功能的場景,如產品推薦之類的功能,資料為空也不太影響使用者體驗。

快速失敗(fail-fast):直接丟擲異常,適用於資料非強依賴的場景,如非核心服務超時的處理。

3. 應用例項

在實際的工程實踐中,這四種模式既可以單獨使用,也可以組合使用,為了讓讀者更好的理解這些模式的應用,下面以Netflix的開源元件Hystrix的流程為例說明。

圖中流程的說明:

  1. 將遠端服務呼叫邏輯封裝進一個HystrixCommand。
  2. 對於每次服務呼叫可以使用同步或非同步機制,對應執行execute()或queue()。
  3. 判斷熔斷器(circuit-breaker)是否開啟或者半開啟狀態,如果開啟跳到步驟8,進行回退策略,如果關閉進入步驟4。
  4. 判斷執行緒池/佇列/訊號量(使用了艙壁隔離模式)是否跑滿,如果跑滿進入回退步驟8,否則繼續後續步驟5。
  5. run方法中執行了實際的服務呼叫。
    a. 服務呼叫發生超時時,進入步驟8。
  6. 判斷run方法中的程式碼是否執行成功。
    a. 執行成功返回結果。
    b. 執行中出現錯誤則進入步驟8。
  7. 所有的執行狀態(成功,失敗,拒絕,超時)上報給熔斷器,用於統計從而影響熔斷器狀態。
  8. 進入getFallback()回退邏輯。
    a. 沒有實現getFallback()回退邏輯的呼叫將直接丟擲異常。
    b. 回退邏輯呼叫成功直接返回。
    c. 回退邏輯呼叫失敗丟擲異常。
  9. 返回執行成功結果。

4. 總結

服務容錯模式在系統的穩定性保障方面應用很多,學習模式有助於新人直接利用熟練軟體工程師的經驗,對於提升系統的穩定性有很大的幫助。服務容錯的目的主要是為了防微杜漸,除此之外錯誤的及時發現和監控其實同等重要。隨著技術的演化,新的模式在不斷的學習與實踐中沉澱出來,在構建一個高可用高效能的系統目標之外,讓系統越來越有彈性(Resilience)也是我們新的追求。

參考:
https://tech.meituan.com/service_fault_tolerant_pattern.html
Netflix Hystrix Wiki(https://martinfowler.com/bliki/CircuitBreaker.html)
Martin Fowler. CircuitBreaker(https://martinfowler.com/bliki/CircuitBreaker.html)
Hanmer R. Patterns for Fault Tolerant Software. Wiley, 2007.
Nygard M. 釋出!軟體的設計與部署. 凃鳴 譯. 人民郵電出版社, 2015.

相關文章