關於HttpClient重試策略的研究

 

關於HttpClient重試策略的研究

1、背景

  因爲工做上的業務本人常常與第三方系統交互,因此常常會使用HttpClient與第三方進行通訊。對於交易類的接口,訂單狀態是相當重要的。html

  這就牽扯到一系列問題:java

  HttpClient是否有默認的重試策略?重試策略原理?如何禁止重試?apache

  接下來,本文將從源碼中探討這些問題。源碼下載地址:http://hc.apache.org/downloads.cgi,版本是4.5.5。安全

2、通常使用方法

  通常而言,得到HttpClient實例的方法有兩種:服務器

1.HttpClients.custom().setXXX().build()
2.HttpClients.build()

第一種方法用來定製一些HttpClient的屬性,好比https證書,代理服務器,http過濾器,鏈接池管理器等自定義的用法。cookie

第二種方法用來得到一個默認的HttpClient實例。app

這兩種方法得到都是CloseableHttpClient實例,且都是經過HttpClientBuilder的build()構建的。less

3、有沒有重試策略

能夠看到,上面的兩種用法最終都獲得了一個InternalHttpClient,是抽象類CloseableHttpClient的一種實現。異步

複製代碼
public CloseableHttpClient build() {
        //省略若干行
        return new InternalHttpClient(
                execChain,
                connManagerCopy,
                routePlannerCopy,
                cookieSpecRegistryCopy,
                authSchemeRegistryCopy,
                defaultCookieStore,
                defaultCredentialsProvider,
                defaultRequestConfig != null ? defaultRequestConfig : RequestConfig.DEFAULT,
                closeablesCopy);
    }

}
複製代碼

這裏有不少配置化參數,這裏咱們重點關注一下execChain這個執行鏈。socket

能夠看到執行鏈有多種實現,好比

  1. RedirectExec執行器的默認策略是,在接收到重定向錯誤碼301與307時會繼續訪問重定向的地址
  2. 以及咱們關注的RetryExec能夠重試的執行器。

這麼多執行器,是怎麼用到了重試執行器呢?

複製代碼
public CloseableHttpClient build() {
    //省略一些代碼  
        // Add request retry executor, if not disabled
        if (!automaticRetriesDisabled) {
            HttpRequestRetryHandler retryHandlerCopy = this.retryHandler;
            if (retryHandlerCopy == null) {
                retryHandlerCopy = DefaultHttpRequestRetryHandler.INSTANCE;
            }
            execChain = new RetryExec(execChain, retryHandlerCopy);
        }  
}
複製代碼

能夠看到在build() httpclient實例的時候,判斷了是否關閉了自動重試,這個automaticRetriesDisabled類型是boolean,默認值是false,因此if這裏是知足的。

即若是沒有指定執行鏈,就是用RetryExec執行器,默認的重試策略是DefaultHttpRequestRetryHandler。

前面已經看到咱們使用的HttiClient本質上是InternalHttpClient,這裏看下他的執行發送數據的方法。

複製代碼
@Override
    protected CloseableHttpResponse doExecute(
            final HttpHost target,
            final HttpRequest request,
            final HttpContext context) throws IOException, ClientProtocolException {
            //省略一些代碼
return this.execChain.execute(route, wrapper, localcontext, execAware); } }
複製代碼

最後一行能夠看到,最終的執行execute方式使用的是exeChain的執行方法,而execChain是經過InternalHttpClient構造器傳進來的,就是上面看到的RetryExec。

因此,HttpClient有默認的執行器RetryExec,其默認的重試策略是DefaultHttpRequestRetryHandler。

4、重試策略分析

4.1 是否須要重試的判斷在哪裏?

http請求是執行器執行的,因此先看RetryExec發送請求的部分。

複製代碼
public CloseableHttpResponse execute(
            final HttpRoute route,
            final HttpRequestWrapper request,
            final HttpClientContext context,
            final HttpExecutionAware execAware) throws IOException, HttpException {
        //參數校驗
        Args.notNull(route, "HTTP route");
        Args.notNull(request, "HTTP request");
        Args.notNull(context, "HTTP context");
        final Header[] origheaders = request.getAllHeaders();
       //這個for循環記錄了當前http請求的執行次數
        for (int execCount = 1;; execCount++) {
            try {
          //調用基礎executor執行http請求
                return this.requestExecutor.execute(route, request, context, execAware);
            } catch (final IOException ex) {
          //發生IO異常的時候,判斷上下文是否已經中斷,若是中斷則拋異常退出
                if (execAware != null && execAware.isAborted()) {
                    this.log.debug("Request has been aborted");
                    throw ex;
                }
                //根據重試策略,判斷當前執行情況是否要重試,若是是則進入下面邏輯
                if (retryHandler.retryRequest(ex, execCount, context)) {
            //日誌
                    if (this.log.isInfoEnabled()) {
                        this.log.info("I/O exception ("+ ex.getClass().getName() +
                                ") caught when processing request to "
                                + route +
                                ": "
                                + ex.getMessage());
                    }
            //日誌
                    if (this.log.isDebugEnabled()) {
                        this.log.debug(ex.getMessage(), ex);
                    }
            //判斷當前請求是否能夠被重複發起
                    if (!RequestEntityProxy.isRepeatable(request)) {
                        this.log.debug("Cannot retry non-repeatable request");
                        throw new NonRepeatableRequestException("Cannot retry request " +
                                "with a non-repeatable request entity", ex);
                    }
                    request.setHeaders(origheaders);
                    if (this.log.isInfoEnabled()) {
                        this.log.info("Retrying request to " + route);
                    }
                } else {
            //若是重試策略判斷不能重試了,則根據異常狀態拋異常,退出當前流程
                    if (ex instanceof NoHttpResponseException) {
                        final NoHttpResponseException updatedex = new NoHttpResponseException(
                                route.getTargetHost().toHostString() + " failed to respond");
                        updatedex.setStackTrace(ex.getStackTrace());
                        throw updatedex;
                    } else {
                        throw ex;
                    }
                }
            }
        }
    }
複製代碼

 關於RetryExec執行器的執行過程,作一個階段小結:

  1.   RetryExec在執行http請求的時候使用的是底層的基礎代碼MainClientExec,並記錄了發送次數
  2.   當發生IOException的時候,判斷是否要重試
    1.     首先是根據重試策略DefaultHttpRequestRetryHandler判斷,若是能夠重試就繼續
      1.      判斷當前request是否還能夠再次發起
    2.   若是重試策略判斷不能夠重試了,就拋相應異常並退出

4.2 DefaultHttpRequestRetryHandler的重試策略

  在上文咱們看到了默認的重試策略是DefaultHttpRequestRetryHandler.INSTANCE。

複製代碼
//單例模式
    public static final DefaultHttpRequestRetryHandler INSTANCE = new DefaultHttpRequestRetryHandler();

    //重試次數
    private final int retryCount;

    //若是一個請求發送成功過,是否還會被再次發送
    private final boolean requestSentRetryEnabled;

    private final Set<Class<? extends IOException>> nonRetriableClasses;

    public DefaultHttpRequestRetryHandler() {
        this(3, false);
    }

    public DefaultHttpRequestRetryHandler(final int retryCount, final boolean requestSentRetryEnabled) {
        this(retryCount, requestSentRetryEnabled, Arrays.asList(
                InterruptedIOException.class,
                UnknownHostException.class,
                ConnectException.class,
                SSLException.class));
    }
    protected DefaultHttpRequestRetryHandler(
            final int retryCount,
            final boolean requestSentRetryEnabled,
            final Collection<Class<? extends IOException>> clazzes) {
        super();
        this.retryCount = retryCount;
        this.requestSentRetryEnabled = requestSentRetryEnabled;
        this.nonRetriableClasses = new HashSet<Class<? extends IOException>>();
        for (final Class<? extends IOException> clazz: clazzes) {
            this.nonRetriableClasses.add(clazz);
        }
    }
複製代碼

經過構造器能夠看到,默認的重試策略是:

  1. 重試3次
  2. 若是請求被成功發送過,就再也不重試了
  3. InterruptedIOException、UnknownHostException、ConnectException、SSLException,發生這4中異常不重試

 

說句題外話,這是一個單例模式,屬於餓漢模式。

餓漢模式的缺點是,這個類在被加載的時候就會初始化這個對象,對內存有佔用。不過這個對象維護的filed比較小,因此對內存的影響不大。

另外因爲這個類全部的field都是final的,因此是一個不可變的對象,是線程安全的。  

複製代碼
public boolean retryRequest(
            final IOException exception,
            final int executionCount,
            final HttpContext context) {
        //參數校驗
        Args.notNull(exception, "Exception parameter");
        Args.notNull(context, "HTTP context");
     //若是已經執行的次數大於設置的次數,則不繼續重試
        if (executionCount > this.retryCount) {
            return false;
        }
     //若是是上面規定的幾種異常,則不重試
        if (this.nonRetriableClasses.contains(exception.getClass())) {
            return false;
        } else {
       //若是是上面規定的集中異常的子類,則不重試
            for (final Class<? extends IOException> rejectException : this.nonRetriableClasses) {
                if (rejectException.isInstance(exception)) {
                    return false;
                }
            }
        }
        final HttpClientContext clientContext = HttpClientContext.adapt(context);
        final HttpRequest request = clientContext.getRequest();
     //判斷當前請求是否已經被終止了,這個是避免當前請求被放入異步的異步的HttpRequestFutureTask中
     //跟進去能夠看到,當這個異步任務被cancel的時候,會經過AtomicBoolean的compareAndSet的方法,保證狀態被更改
     //這部分不作詳細討論了
        if(requestIsAborted(request)){
            return false;
        }
     //判斷請求是不是冪等請求,跟進去能夠看到,全部包含http body的請求都認爲是非冪等的,好比post/put等
     //冪等的請求能夠直接重試,好比get
        if (handleAsIdempotent(request)) {
            return true;
        }
     //根據上下文判斷請求是否發送成功了,或者根據狀態爲是否永遠能夠重複發送(默認的是否)
     //這個下面會分析
        if (!clientContext.isRequestSent() || this.requestSentRetryEnabled) {
            return true;
        }
        //不然不須要重試
        return false;
    }
    }
複製代碼

 

  關於默認的重試策略,作一個階段小結:

  1. 若是重試超過3次,則再也不重試
  2. 幾種特殊異常及其子類,不進行重試
  3. 同一個請求在異步任務重已經被終止,則不進行重試
  4. 冪等的方法能夠進行重試,好比Get
  5. 若是請求沒有發送成功,能夠進行重試。

 

那麼關鍵問題來了,如何判斷請求是否已經發送成功了呢?

複製代碼
public static final String HTTP_REQ_SENT    = "http.request_sent";

    public boolean isRequestSent() {
        final Boolean b = getAttribute(HTTP_REQ_SENT, Boolean.class);
        return b != null && b.booleanValue();
    }
複製代碼

可看到若是當前的httpContext中的http.request_sent屬性爲true,則認爲已經發送成功,不然認爲尚未發送成功。

那麼就剩下一個問題了,一次正常的http請求中http.request_sent屬性是若是設置的?

上面有提到過,RetryExec在底層通訊使用了MainClientExec,而MainCLientExec底層調用了HttpRequestExecutor.doSendRequest()

複製代碼
protected HttpResponse doSendRequest(
            final HttpRequest request,
            final HttpClientConnection conn,
            final HttpContext context) throws IOException, HttpException {
            Args.notNull(request, "HTTP request");
        Args.notNull(conn, "Client connection");
        Args.notNull(context, "HTTP context");

        HttpResponse response = null;

        context.setAttribute(HttpCoreContext.HTTP_CONNECTION, conn);
     //首先在請求發送以前,將http.request_sent放入上下文context的屬性中,值爲false
        context.setAttribute(HttpCoreContext.HTTP_REQ_SENT, Boolean.FALSE);
      //將request的Header放入鏈接中
        conn.sendRequestHeader(request);
        //若是是post/put這種有body的請求,須要先判斷100-cotinue擴展協議是否支持
     //即發送包含body請求前,先判斷服務端是否支持一樣的協議若是不支持,則不發送了。除非特殊約定,默認雙端是都不設置的。
        if (request instanceof HttpEntityEnclosingRequest) {
            boolean sendentity = true;
            final ProtocolVersion ver =
                request.getRequestLine().getProtocolVersion();
            if (((HttpEntityEnclosingRequest) request).expectContinue() &&
                !ver.lessEquals(HttpVersion.HTTP_1_0)) {
                conn.flush();
                if (conn.isResponseAvailable(this.waitForContinue)) {
                    response = conn.receiveResponseHeader();
                    if (canResponseHaveBody(request, response)) {
                        conn.receiveResponseEntity(response);
                    }
                    final int status = response.getStatusLine().getStatusCode();
                    if (status < 200) {
                        if (status != HttpStatus.SC_CONTINUE) {
                            throw new ProtocolException(
                                    "Unexpected response: " + response.getStatusLine());
                        }
                        // discard 100-continue
                        response = null;
                    } else {
                        sendentity = false;
                    }
                }
            }
       //若是能夠發送,則將body序列化後,寫入當前流中
            if (sendentity) {
                conn.sendRequestEntity((HttpEntityEnclosingRequest) request);
            }
        }
     //刷新當前鏈接,發送數據
        conn.flush();
     //將http.request_sent置爲true
        context.setAttribute(HttpCoreContext.HTTP_REQ_SENT, Boolean.TRUE);
        return response;
    }
複製代碼

 上面是一個完成的http通訊部分,步驟以下:

  1. 開始前將http.request_sent置爲false
  2. 經過流flush數據到服務端
  3. 而後將http.request_sent置爲true

 顯然,對於conn.flush()這一步是會發生異常的,這種狀況下就認爲沒有發送成功。

 說句題外話,上面對coon的操做都是基於鏈接池的,每次都是從池中拿到一個可用鏈接。

5、重試策略對業務的影響 

5.1 咱們的業務重試了嗎?

  對於咱們的場景應用中的get與post,能夠總結爲:

  1. 只有發生IOExecetion時纔會發生重試
  2. InterruptedIOException、UnknownHostException、ConnectException、SSLException,發生這4中異常不重試
  3. get方法能夠重試3次,post方法在socket對應的輸出流沒有被write並flush成功時能夠重試3次。

  首先分析下不重試的異常:

  1. InterruptedIOException,線程中斷異常
  2. UnknownHostException,找不到對應host
  3. ConnectException,找到了host可是創建鏈接失敗。
  4. SSLException,https認證異常

  另外,咱們還常常會提到兩種超時,鏈接超時與讀超時:

  1. java.net.SocketTimeoutException: Read timed out
  2. java.net.SocketTimeoutException: connect timed out

  這兩種超時都是SocketTimeoutException,繼承自InterruptedIOException,屬於上面的第1種線程中斷異常,不會進行重試。

5.2 哪些場景會進行重試?

  對於大多數系統而言,不少交互都是經過post的方式與第三方交互的。

  因此,咱們須要知道有哪些狀況HttpClient給咱們進行了默認重試。

  咱們關心的場景轉化爲,post請求在輸出流進行write與flush的時候,會發生哪些除了InterruptedIOException、UnknownHostException、ConnectException、SSLException之外的IOExecetion。

  可能出問題的一步在於HttpClientConnection.flush()的一步,跟進去能夠得知其操做的對象是一個SocketOutputStream,而這個類的flush是空實現,因此只須要看wirte方法便可。

複製代碼
private void socketWrite(byte b[], int off, int len) throws IOException {


        if (len <= 0 || off < 0 || len > b.length - off) {
            if (len == 0) {
                return;
            }
            throw new ArrayIndexOutOfBoundsException("len == " + len
                    + " off == " + off + " buffer length == " + b.length);
        }

        FileDescriptor fd = impl.acquireFD();
        try {
            socketWrite0(fd, b, off, len);
        } catch (SocketException se) {
            if (se instanceof sun.net.ConnectionResetException) {
                impl.setConnectionResetPending();
                se = new SocketException("Connection reset");
            }
            if (impl.isClosedOrPending()) {
                throw new SocketException("Socket closed");
            } else {
                throw se;
            }
        } finally {
            impl.releaseFD();
        }
    }
複製代碼

能夠看到,這個方法會拋出IOExecption,代碼中對SocketException異常進行了加工。從以前的分析中能夠得知,SocketException是不在能夠忽略的範圍內的。

因此從上面代碼上就能夠分析得出對於傳輸過程當中socket被重置或者關閉的時候,httpclient會對post請求進行重試。

以及一些其餘的IOExecption也會進行重試,不過範圍過廣很差定位。

6、如何禁止重試?

回到HttpClientBuilder中,其build()方法中之因此選擇了RetryExec執行器是有前置條件的,即沒有手動禁止。

複製代碼
// Add request retry executor, if not disabled
        if (!automaticRetriesDisabled) {
            HttpRequestRetryHandler retryHandlerCopy = this.retryHandler;
            if (retryHandlerCopy == null) {
                retryHandlerCopy = DefaultHttpRequestRetryHandler.INSTANCE;
            }
            execChain = new RetryExec(execChain, retryHandlerCopy);
        }
複製代碼

因此咱們在構建httpClient實例的時候手動禁止掉便可。

複製代碼
/**
     * Disables automatic request recovery and re-execution.
     */
    public final HttpClientBuilder disableAutomaticRetries() {
        automaticRetriesDisabled = true;
        return this;
    }
複製代碼

7、本文總結

經過本文分析,能夠得知HttpClient默認是有重試機制的,其重試策略是:

  1.只有發生IOExecetion時纔會發生重試

  2.InterruptedIOException、UnknownHostException、ConnectException、SSLException,發生這4中異常不重試

  3.get方法能夠重試3次,post方法在socket對應的輸出流沒有被write並flush成功時能夠重試3次。

  4.讀/寫超時不進行重試

  5.socket傳輸中被重置或關閉會進行重試

  6.以及一些其餘的IOException,暫時分析不出來。

相關文章
相關標籤/搜索