轉(zhuǎn)轉(zhuǎn)門店基于MQ的Http重試實(shí)踐
1 問題背景
在線下門店系統(tǒng)開發(fā)中,有很多地方需要使用Http請求和第三方系統(tǒng)進(jìn)行通信,比如將門店的商品信息同步到第三方的電子價(jià)簽上,再比如需要把門店店員的打卡信息同步到公司使用的第三方EHR系統(tǒng)中。
但在使用Http請求外部服務(wù)時(shí),由于網(wǎng)絡(luò)的不穩(wěn)定性,第三方接口出現(xiàn)超時(shí)的現(xiàn)象時(shí)有發(fā)生,為了減少對業(yè)務(wù)造成的影響,我們迫切需要尋找一種Http重試方案。
2 重試方案探索
2.1 簡單重試
我們最容易想到的一種重試方式是,在請求接口的代碼塊中加入循環(huán),如果請求失敗則繼續(xù)請求,直到請求成功或達(dá)到最大重試次數(shù)。示例代碼如下:
int retryTimes = 3;
for (int i = 0; i < retryTimes; i++) {
try {
// 請求接口的代碼
break;
} catch(Exception e) {
// 處理異常
}
}
這種重試方式比較簡單,只要請求發(fā)生異常就繼續(xù)重試,能在一定程度上解決我們的問題,但缺點(diǎn)是對于異常的捕獲處理邏輯過于簡單,重試起來會有一定的盲目性。
2.2 Apache HttpClient 重試機(jī)制
我們常用的一些Http客戶端通常也內(nèi)置了一些重試機(jī)制,接下來我將以我們系統(tǒng)中使用的Apache HttpClient為例,通過手撕源碼的方式探索一下它內(nèi)部的重試機(jī)制。
通常我們在使用HttpClient的時(shí)候,都需要以下幾個(gè)步驟;
CloseableHttpClient httpClient = HttpClientBuilder.create().build();
HttpGet httpGet = new HttpGet("url");
CloseableHttpResponse response = httpClient.execute(httpGet);
HttpEntity entity = response.getEntity();
在創(chuàng)建 HttpClient 的過程中,底層調(diào)用了HttpClientBuilder的build方法,我們直接找到跟重試相關(guān)的邏輯,源碼如下圖:
if (!automaticRetriesDisabled) {
HttpRequestRetryHandler retryHandlerCopy = this.retryHandler;
if (retryHandlerCopy == null) {
retryHandlerCopy = DefaultHttpRequestRetryHandler.INSTANCE;
}
execChain = new RetryExec(execChain, retryHandlerCopy);
}
automaticRetriesDisabled默認(rèn)是沒有禁用的,RetryExec是一個(gè)重試執(zhí)行器,它還需要一個(gè) RetryHandler,如果沒有指定的話,會使用DefaultHttpRequestRetryHandler作為默認(rèn)的重試處理器。
我們先來看一下RetryExec的邏輯,源碼如下圖:
public CloseableHttpResponse execute(
final HttpRoute route,
final HttpRequestWrapper request,
final HttpClientContext context,
final HttpExecutionAware execAware) throws IOException, HttpException {
final Header[] origheaders = request.getAllHeaders();
for (int execCount = 1;; execCount++) {
try {
return this.requestExecutor.execute(route, request, context, execAware);
} catch (final IOException ex) {
if (execAware != null && execAware.isAborted()) {
this.log.debug("Request has been aborted");
throw ex;
}
if (retryHandler.retryRequest(ex, execCount, context)) {
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);
} else {
if (ex instanceof NoHttpResponseException) {
final NoHttpResponseException updatedex = new NoHttpResponseException(
route.getTargetHost().toHostString() + " failed to respond");
updatedex.setStackTrace(ex.getStackTrace());
throw updatedex;
}
throw ex;
}
}
}
}
看到這里,怎么還感覺到有點(diǎn)眼熟了呢?是不是和我們上面簡單重試的思路是一樣的呢,有點(diǎn)大道至簡那個(gè)意思了。
我們來簡單總結(jié)一下RetryExec的主要邏輯:在執(zhí)行Http請求的時(shí)候,如果發(fā)生了IOException,會交給具體的RetryHandler來處理,然后由它的retryRequest方法來決定是繼續(xù)重試還是拋出異常。這里可能有的朋友會有疑問,為什么是IOException呢?
這就要說一下HttpClient的execute方法了,HttpClient執(zhí)行時(shí)可能會拋出兩種異常:IOException和ClientProtocolException;其中IOException被認(rèn)為是非致命性且可恢復(fù)的,而ClientProtocolException被認(rèn)為是致命性的,不可恢復(fù),所以這里只需要關(guān)注IOException異常即可。
接下來我們再來看一下DefaultHttpRequestRetryHandler,它定義了3個(gè)成員變量:
- retryCount:重試次數(shù);
- requestSentRetryEnabled:是否可以在請求成功發(fā)出后重試,這里的成功是指發(fā)送成功,并不指請求成功;
- nonRetriableClasses:不重試的異常類集合,如果異常為集合中指定的異常時(shí),不會重試。
DefaultHttpRequestRetryHandler經(jīng)過一系列構(gòu)造函數(shù),完成了對三個(gè)成員變量的賦值,其中默認(rèn)的重試次數(shù)是3次,并且默認(rèn)在請求發(fā)送成功之后就不會再重試,默認(rèn)的不重試異常有以下四類:
- InterruptedIOException
- UnknownHostException
- ConnectException
- SSLException
源碼如下圖:
public DefaultHttpRequestRetryHandler(final int retryCount, final boolean requestSentRetryEnabled) {
this(retryCount, requestSentRetryEnabled, Arrays.asList(
InterruptedIOException.class,
UnknownHostException.class,
ConnectException.class,
NoRouteToHostException.class,
SSLException.class));
}
public DefaultHttpRequestRetryHandler() {
this(3, false);
}
然后,我們再來看一下DefaultHttpRequestRetryHandler中的核心方法retryRequest方法的邏輯,源碼邏輯如下圖:
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;
}
final HttpClientContext clientContext = HttpClientContext.adapt(context);
final HttpRequest request = clientContext.getRequest();
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;
}
return false;
}
retryRequest的邏輯也比較簡單,首先超過重試次數(shù)就不會再重試,然后如果是指定不重試的異常也不會再重試;再然后如果請求方法不是冪等的,也不會繼續(xù)重試,這里我們熟悉的Post方法顯然是不會進(jìn)行重試的。不過還有機(jī)會,這里我們知道requestSentRetryEnabled默認(rèn)是false,也就是說只要請求發(fā)送成功之后也不會進(jìn)行重試。
到這里,我們可以總結(jié)一下了。HttpClient默認(rèn)的RetryHandler中指定了四類異常是不會進(jìn)行重試的,其中就包含了InterruptedIOException,而實(shí)際上我們經(jīng)常會遇到的SocketTimeoutException就屬于它的子類。
還有一點(diǎn),如果按照默認(rèn)的重試策略,顯然Post請求也不滿足重試的條件。這里必須說一下,從謹(jǐn)慎的角度來看,Post請求是否應(yīng)該重試,需要具體結(jié)合業(yè)務(wù)場景來看,如果請求本身不是冪等的,重試確實(shí)可能會帶來嚴(yán)重的副作用。
所以在實(shí)際的業(yè)務(wù)場景中,如果想要利用HttpClient的重試機(jī)制來進(jìn)行重試,這兩個(gè)問題都需要解決。
2.3 基于消息隊(duì)列的異步重試方案
考慮到在門店很多業(yè)務(wù)場景中,執(zhí)行完相關(guān)的邏輯之后都會發(fā)送MQ消息。那么我們很自然地也想到了通過引入一個(gè)消費(fèi)者的方式,來執(zhí)行通過Http調(diào)用第三方接口的邏輯。
采用這種方式的話,如果在消費(fèi)邏輯中通過Http調(diào)用第三方接口失敗,我們還可以充分利用MQ的消費(fèi)失敗重試機(jī)制。以我們使用的RocketMQ為例,消息在消費(fèi)失敗重試的時(shí)候會按照一定的退避時(shí)間來進(jìn)行重試,這個(gè)特性還能避免第三方服務(wù)因?yàn)槎虝r(shí)間的不可用而造成的重試失敗的情況。
3 門店業(yè)務(wù)場景中使用的重試方案
經(jīng)過以上多種方案的調(diào)研,我們最終采用的是方案二和方案三的綜合方案,具體思路如下。
首先,我們整體的重試方案采用基于消息隊(duì)列的異步執(zhí)行方案,一方面是因?yàn)檫@種方案可以充分地做到和業(yè)務(wù)之間解耦,同時(shí)消息隊(duì)列的消費(fèi)失敗重試機(jī)制可以很好地解決第三方服務(wù)短時(shí)間不可用的問題,這一點(diǎn)是同步重試方案做不到的,可以保障系統(tǒng)的最終一致性。
其次,因?yàn)槲覀兿到y(tǒng)中已經(jīng)在使用HttpClient 組件,所以我們決定充分利用它的重試機(jī)制,同步重試也可以盡可能保證接口調(diào)用的實(shí)時(shí)性。
考慮到默認(rèn)的重試策略不滿足我們的使用需求,針對這個(gè)問題,我們自定義了一個(gè)RetryHandler,源碼如下圖:
public boolean retryRequest(IOException exception, int executionCount, HttpContext context) {
if (executionCount > this.retryCount) {
RequestLine requestLine = null;
if (context instanceof HttpClientContext) {
requestLine = ((HttpClientContext)context).getRequest().getRequestLine();
}
return false;
} else if (exception instanceof NoHttpResponseException) {
return true;
} else if (exception instanceof SSLHandshakeException) {
return false;
} else if (exception instanceof InterruptedIOException) {
return true;
} else if (exception instanceof UnknownHostException) {
return false;
} else if (exception instanceof ConnectTimeoutException) {
return false;
} else if (exception instanceof SSLException) {
return false;
} else {
HttpClientContext clientContext = HttpClientContext.adapt(context);
HttpRequest request = clientContext.getRequest();
return !(request instanceof HttpEntityEnclosingRequest);
}
}
完成RetryHandler的自定義之后,只需要在初始化HttpClient的時(shí)候傳入指定的RetryHandler即可,設(shè)置方式如下:
CloseableHttpClient httpClient = HttpClientBuilder.create().setRetryHandler(StoreRequestRetryHandler.INSTANCE).build();
這樣我們就解決了默認(rèn)的重試機(jī)制對于Post請求默認(rèn)不重試和SocketTimeoutException異常不重試的問題,更加貼合我們的使用場景。
這里我舉個(gè)例子來說明一下整個(gè)重試方案的執(zhí)行流程:
- MQ在消費(fèi)的時(shí)候,會使用Apache HttpClient請求第三方接口,我們設(shè)置重試3次,如果請求一直失敗,會先同步重試3次,如果還是失敗,則本次消息消費(fèi)失敗,等待下一次重試消息繼續(xù)這個(gè)流程。
- RocketMQ默認(rèn)會重試16次,那么我們整個(gè)重試方案會最多進(jìn)行51次重試。
- Apache HttpClient的同步重試能盡可能保證同步的實(shí)時(shí)性,而如果第三方服務(wù)出現(xiàn)短時(shí)間不可用的現(xiàn)象,RocketMQ的退避重試也能繼續(xù)異步重試只到最終成功。
在我們使用了這種重試方案之后,就再也沒有聽到業(yè)務(wù)關(guān)于電子價(jià)簽未及時(shí)同步或者打卡信息未同步的抱怨了。
以上就是筆者在線下門店系統(tǒng)中的Http重試實(shí)踐過程,歡迎大家在評論區(qū)留言一起交流。
關(guān)于作者
侯萬興,轉(zhuǎn)轉(zhuǎn)門店業(yè)務(wù)后端研發(fā)工程師