關於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
能夠看到執行鏈有多種實現,好比
- RedirectExec執行器的默認策略是,在接收到重定向錯誤碼301與307時會繼續訪問重定向的地址
- 以及咱們關注的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執行器的執行過程,作一個階段小結:
- RetryExec在執行http請求的時候使用的是底層的基礎代碼MainClientExec,並記錄了發送次數
- 當發生IOException的時候,判斷是否要重試
- 首先是根據重試策略DefaultHttpRequestRetryHandler判斷,若是能夠重試就繼續
- 判斷當前request是否還能夠再次發起
- 若是重試策略判斷不能夠重試了,就拋相應異常並退出
- 首先是根據重試策略DefaultHttpRequestRetryHandler判斷,若是能夠重試就繼續
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); } }
經過構造器能夠看到,默認的重試策略是:
- 重試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; } }
關於默認的重試策略,作一個階段小結:
- 若是重試超過3次,則再也不重試
- 幾種特殊異常及其子類,不進行重試
- 同一個請求在異步任務重已經被終止,則不進行重試
- 冪等的方法能夠進行重試,好比Get
- 若是請求沒有發送成功,能夠進行重試。
那麼關鍵問題來了,如何判斷請求是否已經發送成功了呢?
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通訊部分,步驟以下:
- 開始前將http.request_sent置爲false
- 經過流flush數據到服務端
- 而後將http.request_sent置爲true
顯然,對於conn.flush()這一步是會發生異常的,這種狀況下就認爲沒有發送成功。
說句題外話,上面對coon的操做都是基於鏈接池的,每次都是從池中拿到一個可用鏈接。
5、重試策略對業務的影響
5.1 咱們的業務重試了嗎?
對於咱們的場景應用中的get與post,能夠總結爲:
- 只有發生IOExecetion時纔會發生重試
- InterruptedIOException、UnknownHostException、ConnectException、SSLException,發生這4中異常不重試
- get方法能夠重試3次,post方法在socket對應的輸出流沒有被write並flush成功時能夠重試3次。
首先分析下不重試的異常:
- InterruptedIOException,線程中斷異常
- UnknownHostException,找不到對應host
- ConnectException,找到了host可是創建鏈接失敗。
- SSLException,https認證異常
另外,咱們還常常會提到兩種超時,鏈接超時與讀超時:
- java.net.SocketTimeoutException: Read timed out
- 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,暫時分析不出來。