請求失敗,應該重試嗎?不該該嗎?

先點贊再看,養成好習慣

前言

在網絡請求中,因爲網絡是不可靠的,因此常常會有請求失敗的場景。針對這種問題,一般的作法是增長重試機制,在請求失敗後從新請求,儘可能保證請求的成功,從而提升服務的穩定性。java

重試的風險

但是大多數人不肯意輕易的重試,由於每每重試會帶來更大的風險。好比過多的重試,會給被調用服務形成更大的壓力,放大原有的問題。git

以下圖所示,服務 A 調用 服務 B,服務 B 根據請求數據不一樣,會調用 服務 C 和 服務 D。此時服務 C 出現故障,不可用了,那麼服務 B 中全部對服務 C 的請求都會超時,但服務 D 如今仍是可用的;可因爲服務 A 中大量的重試致使服務 B 的負載快速升高,很快的將服務 B 的負載打滿(好比鏈接池沾滿)。如今調用服務 D 的分支請求也不可用了,由於服務 B 已經被重試請求打滿,沒法再處理任何請求了。github

image.png

若是服務自身是可用的,但網絡出現較大的延遲、抖動或者丟包,致使請求到達目標服務或返回發起服務超時;此時若是客戶端發起重試,那麼對於接收端來講,極可能會收到多個相同的請求。因此服務端還須要增長冪等的處理,保證屢次請求下結果一致apache

image.png

既然重試有風險,那難道就不該該重試嗎?失敗就直接失敗,啥都無論嗎?後端

不一樣時機下的失敗重試

是否進行重試,這個須要區分當前失敗的緣由,不能簡單粗暴的決定重試或者不重試。網絡很複雜,鏈路很長,不一樣類型的協議,決定是否重試的策略也有所不一樣。瀏覽器

HTTP 協議下的重試

一個基本的 HTTP 請求,會包含如下幾個階段:安全

  1. DNS 解析
  2. TCP 三次握手
  3. 發送&接受對端數據

在 DNS 解析階段時,若是域名不存在,或者域名沒有 DNS 記錄,根據域名沒法解析到對應的主機地址列表,那麼根本就沒法發起請求,此時重試沒有任何意義,因此並不須要重試網絡

在 TCP 握手階段,若是目標服務不可用,那麼此時重試也沒有什麼意義,由於在請求的第一步- 握手都不成功,大機率這個 host 是不可用的。app

挺過了 DNS 和握手兩個階段以後,終於到了收發數據的階段。到了這一步一旦出現失敗,是否重試要考慮的因素可就更多了。負載均衡

以下圖所示的這種狀況中,由於網絡擁塞等緣由,致使數據到達服務端時間過長,但最終服務端也收到了完整的報文,已經開始處理請求,可此時客戶端由於超時放棄了該請求,那麼若是客戶端此時新建一條 TCP 鏈接發起重試,那麼對於服務端來講就會收到兩次相同的請求報文,處理兩次該請求,可能形成嚴重的後果

因此這種已經發送成功的狀況,就不適合重試

image.png

問題來了,怎麼樣才能知道我發送成功了呢?socket.write沒有報錯就算成功了?SocketChannel.write以後,Buffer 寫空了就算成功了?

並無那麼簡單,應用層的 socket write,只是將數據寫入 SND Buffer 中,至於 SND Buffer 中的數據何時被操做系統發送至網絡,這個並無任何保證。阻塞和非阻塞也只是針對 socket.write 這個操做的,當 SND Buffer已滿,沒法將數據寫入內核 SND Buffer 時,就會發生阻塞。

但咱們能夠粗略的認爲,socket.write 成功 而且 應用層 buffer 被寫空,就是已經發送成功了。

如今看看另外一種狀況,當數據發送時對端就直接關閉了socket,返回 rst 標識:

image.png

那麼這種狀況,就很適合進行重試。由於對於服務端來講,並無開始處理這個請求,因此重試(重建鏈接重發請求)只會提升可用性,並不會形成什麼負擔


HTTP 協議中,對 Request Method 還有一些語義上的約定

GET POST PUT DELET
列出URI,以及該資源組中每一個資源的詳細信息(後者可選)。 在本組資源中建立/追加一個新的資源。該操做每每返回新資源的URL。 使用給定的一組資源替換當前整組資源。 刪除整組資源。
安全(更是冪等) 非冪等 冪等 冪等

PUT/DELETE 是冪等操做,因此就算處理相同報文的請求也不會有數據重複之類的問題。但 POST 可不是,POST 的語義是建立/添加,這是一個非冪等的請求類型。

如今回到上面重試的問題,若是請求報文已經發送成功,但響應超時,但由請求的 API Method 是一個DELETE 類型,這種狀況就能夠考慮重試,由於 DELETE 語義上是冪等的;GET/PUT 同理,語義上冪等的就能夠考慮重試。

但 POST 可不行,由於語義上是非冪等的,重試極可能形成重複的處理請求

但是……一切真的那麼美好嗎?能嚴格準守語義的 API 能有幾家?因此單靠語義上的約定,很是不穩妥,必定要足夠了解服務端的接口是否支持冪等,才能夠考慮重試問題。

HTTPS 下的重試

HTTPS 面世這麼多年,終於在近幾年徹底普及了,沒升級的網站在瀏覽器中都會提示不安全,目前能暴露在公網的 Web API 也基本都是上 HTTPS 的。

在 HTTPS 中,重試的策略又會有一些變化:

image.png

上圖是HTTPS 握手的流程,在 TCP 創建鏈接以後,會先進行 SSL 的握手,驗證對端證書,生成臨時對稱密鑰之列的操做。

若是在 SSL 握手階段就發生失敗,好比證書到期,證書不受信等問題,那麼也是徹底不須要重試的。由於這種問題不會是短暫的,一旦出現就是長時間失敗,重試也是失敗。

主流網絡庫 & RPC 框架中的重試機制

介紹完了 HTTP(S) 協議下對重試的考慮,如今來看看主流網絡庫對重試的處理方式,看看這種主流開源項目中的處理機制夠不夠「合理」

Apache HttpClient 的重試機制(v4.x)

Apache HttpClient 是 Java 裏最主流的一個 HTTP 工具庫了(後端方向),雖然 JDK 也提供了基本的 HTTP SDK,但……太基礎了,無法直接使用。而 Apache HttpClient(簡稱Apache HC)彌補了這個不足,提供了一套超級強大的 HTTP SDK,功能強大、使用簡單、全部組件均可以定製。

Apache HC 默認的重試策略類在org.apache.http.impl.client.DefaultHttpRequestRetryHandler,先來看看實現(省略了一些不重要的代碼):

//返回true,表明須要重試,false不重試
@Override
public boolean retryRequest(
    final IOException exception,
    final int executionCount,
    final HttpContext context) {
    
    //判斷重試次數是否達到上線
    if (executionCount > this.retryCount) {
        // Do not retry if over max retry count
        return false;
    }
    //判斷哪些異常不用重試
    if (this.nonRetriableClasses.contains(exception.getClass())) {
        return false;
    } 
    //判斷是不是冪等請求
    if (handleAsIdempotent(request)) {
        // Retry if the request is considered idempotent
        return true;
    }
    //請求報文是否已經發送
    if (!clientContext.isRequestSent() || this.requestSentRetryEnabled) {
        // Retry if the request has not been sent fully or
        // if it's OK to retry methods that have been sent
        return true;
    }
    // otherwise do not retry
    return false;
}

簡單總結下 Apache HC的重試策略:

  1. 判斷重試次數是否已經超過最大次數(默認3次),超過就不重試
  2. 判斷哪些異常不須要重試

    1. UnknownHostException - 找不到主機
    2. ConnectException - TCP 握手失敗
    3. SSLException - SSL 握手失敗
    4. InterruptedIOException(ConnectTimeoutException/SocketTimeoutException) - 握手超時,Socket讀取超時(也能夠粗略的認爲是響應超時)
  3. 判斷是不是冪等請求,冪等請求才能夠重試
  4. 判斷請求報文是否已經完成發送,若未完成發送才能夠重試
  5. 重試時直接從新請求,沒有間隔

這樣看起來,Apache HC 中默認的重試策略,和咱們上一節介紹的「合理的」重試策略徹底一致。因而可知這種主流的開源項目真的很優秀,質量很是高,全部設計都按照標準來,拿這種項目的源碼當學習資料更能事半功倍。

Dubbo 的重試機制(v2.6.x)

Dubbo 中重試機制的代碼在com.alibaba.dubbo.rpc.cluster.support.FailoverClusterInvoker(2.7之後包名更新爲 org.apache.dubbo)

public Result doInvoke(Invocation invocation, final List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
    //獲取配置的重試次數,默認1即不重試
    int len = getUrl().getMethodParameter(invocation.getMethodName(), Constants.RETRIES_KEY, Constants.DEFAULT_RETRIES) + 1;
    Set<String> providers = new HashSet<String>(len);
    for (int i = 0; i < len; i++) {
        Invoker<T> invoker = select(loadbalance, invocation, copyinvokers, invoked);
        invoked.add(invoker);
        RpcContext.getContext().setInvokers((List) invoked);
        try {
            Result result = invoker.invoke(invocation);
            if (le != null && logger.isWarnEnabled()) {
                logger.warn("Although retry the method " + invocation.getMethodName()
                            + " in the service " + getInterface().getName()
                            + " was successful by the provider " + invoker.getUrl().getAddress()
                            + ", but there have been failed providers " + providers
                            + " (" + providers.size() + "/" + copyinvokers.size()
                            + ") from the registry " + directory.getUrl().getAddress()
                            + " on the consumer " + NetUtils.getLocalHost()
                            + " using the dubbo version " + Version.getVersion() + ". Last error is: "
                            + le.getMessage(), le);
            }
            return result;
        } catch (RpcException e) {
            //Biz類型的異常,會拋出異常,不進行不重試,非Biz類的RpcException都會進行重試
            if (e.isBiz()) { // biz exception.
                throw e;
            }
            le = e;
        } catch (Throwable e) {
            le = new RpcException(e.getMessage(), e);
        } finally {
            providers.add(invoker.getUrl().getAddress());
        }
    }
}

從代碼中能夠看出,只有不是 Biz 類型的 RpcException,纔會觸發重試。繼續分析代碼看看什麼場景會觸發重試……算了不貼代碼了,直接上答案!

簡單總結一下 Dubbo 中的重試策略:

  1. 默認重試次數爲3(包括第一次請求),配置大於1時纔會觸發重試
  2. 默認是 Failover 策略,因此重試不會重試當前節點,只會重試(可用節點 -> 負載均衡 ->路由以後的)下一個節點
  3. TCP 握手超時會觸發重試
  4. 響應超時會觸發重試
  5. 報文錯誤或其餘錯誤致使沒法找到對應的 request,也會致使 Future 超時,超時就會重試
  6. 對於服務端返回的 Exception(好比provider拋出的),屬於調用成功,不會進行重試

Dubbo 的重試策略仍是有一些激進的,並無像 Apache HC 那樣謹慎……因此在使用 Dubbo 時,重試策略必定要當心,避免重試到一些不支持冪等的服務。若是你的 provider 不支持冪等,最好將重試次數配置爲 0

Feign 的重試機制(v11.1)

Feign 是一個使用簡單 Java 的 Http 客戶端,也是 Spring Cloud 中推薦的 RPC 框架。雖然 Feign 也屬於 Http 客戶端,但它和 Apache HC 之類的庫相比卻有很大的不一樣。

下面 Feign 的核心結構圖,從圖上能夠看到,Feign 的客戶端部分,除了支持 JDK 內置的 Http Client 之外,還支持 Apache HC,Google Http,OK Http 等 Http 庫。

image.png

並且還提有 encoders/decoder 的抽象……這麼看起來,它並不能算是一款基礎的 Http 客戶端,更應該稱爲 「Http 工具」?或者叫一個 RPC 的基礎抽象?

那 Feign 中的重試策略是怎麼樣的呢?這個問題實在很差回答,由於要區分不少狀況,在不一樣的 Feign Client 下,重試策略都有所不一樣

首先,Feign 是有內置的重試策略的,以下圖所示,Feign 的重試在調用 HttpClient 以外,並且每次重試以前有必定間隔。

image.png

默認配置下,最大重試5次(包括第一次),每次重試前會間隔必定時間(sleep),並且這個每次間隔時間是隨着重試次數的增長而遞增的,重試間隔計算公式爲:

$$重試間隔 = 重試間隔(默認100ms) * 1.5 ^ {當前重試次數 -1}$$

以下圖所示,重試的次數越大,每次的重試間隔就會越長

image.png

但這是在調用 HttpClient 以外的重試,若是隻是使用 Feign 內置默認 JDK HTTP Client 的話也不會出什麼問題,由於JDK HTTP Client 很簡單,沒有重試機制,單靠 Feign 的重試機制就足夠了。

但是在配合三方 Http Client (好比 Apache HC)時,狀況就不太同樣了,由於三方的 Http Client 內部每每是有重試機制的。

若是三方的 HttpClient 有重試,Feign 又有重試,那麼至關於重試了兩層,重試次數變成了 N * N

好比在 Apache HC 下,按照前面的介紹,默認重試3次,而 Feign 默認重試5次,那麼最壞的狀況下,重試次數高達 15 次。

image.png

並且這還只是 Feign 在基本用法下的重試機制,若是在 Spring Cloud 下,配合 Ribbon 之類的負載均衡器,狀況會更復雜一些,本文就不過多介紹了,有興趣的看看 Spring Cloud 下 Feign 的配置

總結

重試雖然看着很簡單,但若是想安全穩定的重試,要考慮的因素仍是不少的。必定要結合當前業務場景,上下文信息去綜合考慮是否應該重試和每次重試的次數;而不是一拍腦殼就定一個重試機制,暴力重試每每只會放大問題,帶來更嚴重的後果。

若是不肯定是否能夠安全的重試,那麼就不要重試,禁用這些框架的重試,Failfast 總比問題擴大更好。

參考

原創不易,禁止未受權的轉載。若是個人文章對您有幫助,就請點贊/收藏/關注鼓勵支持一下吧❤❤❤❤❤❤
相關文章
相關標籤/搜索