1 问题背景
在线下门店系统开发中,有很多地方需要使用Http请求和第三方系统进行通信,比如将门店的商品信息同步到第三方的电子价签上,再比如需要把门店店员的打卡信息同步到公司使用的第三方EHR系统中。
但在使用Http请求外部服务时,由于网络的不稳定性,第三方接口出现超时的现象时有发生,为了减少对业务造成的影响,我们迫切需要寻找一种Http重试方案。
2 重试方案探索
2.1 简单重试
我们最容易想到的一种重试方式是,在请求接口的代码块中加入循环,如果请求失败则继续请求,直到请求成功或达到最大重试次数。示例代码如下:
int retryTimes = 3;
for (int i = 0; i < retryTimes; i++) {
try {
// 请求接口的代码
break;
} catch(Exception e) {
// 处理异常
}
}
这种重试方式比较简单,只要请求发生异常就继续重试,能在一定程度上解决我们的问题,但缺点是对于异常的捕获处理逻辑过于简单,重试起来会有一定的盲目性。
2.2 Apache HttpClient 重试机制
我们常用的一些Http客户端通常也内置了一些重试机制,接下来我将以我们系统中使用的Apache HttpClient为例,通过手撕源码的方式探索一下它内部的重试机制。
通常我们在使用HttpClient的时候,都需要以下几个步骤;
CloseableHttpClient httpClient = HttpClientBuilder.create().build();
HttpGet httpGet = new HttpGet("url");
CloseableHttpResponse response = httpClient.execute(httpGet);
HttpEntity entity = response.getEntity();
在创建 HttpClient 的过程中,底层调用了HttpClientBuilder的build方法,我们直接找到跟重试相关的逻辑,源码如下图:
if (!automaticRetriesDisabled) {
HttpRequestRetryHandler retryHandlerCopy = this.retryHandler;
if (retryHandlerCopy == null) {
retryHandlerCopy = DefaultHttpRequestRetryHandler.INSTANCE;
}
execChain = new RetryExec(execChain, retryHandlerCopy);
}
automaticRetriesDisabled默认是没有禁用的,RetryExec是一个重试执行器,它还需要一个 RetryHandler,如果没有指定的话,会使用DefaultHttpRequestRetryHandler作为默认的重试处理器。
我们先来看一下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;
}
}
}
}
看到这里,怎么还感觉到有点眼熟了呢?是不是和我们上面简单重试的思路是一样的呢,有点大道至简那个意思了。
我们来简单总结一下RetryExec的主要逻辑:在执行Http请求的时候,如果发生了IOException,会交给具体的RetryHandler来处理,然后由它的retryRequest方法来决定是继续重试还是抛出异常。这里可能有的朋友会有疑问,为什么是IOException呢?
这就要说一下HttpClient的execute方法了,HttpClient执行时可能会抛出两种异常:IOException和ClientProtocolException;其中IOException被认为是非致命性且可恢复的,而ClientProtocolException被认为是致命性的,不可恢复,所以这里只需要关注IOException异常即可。
接下来我们再来看一下DefaultHttpRequestRetryHandler,它定义了3个成员变量:
- retryCount:重试次数;
- requestSentRetryEnabled:是否可以在请求成功发出后重试,这里的成功是指发送成功,并不指请求成功;
- nonRetriableClasses:不重试的异常类集合,如果异常为集合中指定的异常时,不会重试。
DefaultHttpRequestRetryHandler经过一系列构造函数,完成了对三个成员变量的赋值,其中默认的重试次数是3次,并且默认在请求发送成功之后就不会再重试,默认的不重试异常有以下四类:
- 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的逻辑也比较简单,首先超过重试次数就不会再重试,然后如果是指定不重试的异常也不会再重试;再然后如果请求方法不是幂等的,也不会继续重试,这里我们熟悉的Post方法显然是不会进行重试的。不过还有机会,这里我们知道requestSentRetryEnabled默认是false,也就是说只要请求发送成功之后也不会进行重试。
到这里,我们可以总结一下了。HttpClient默认的RetryHandler中指定了四类异常是不会进行重试的,其中就包含了InterruptedIOException,而实际上我们经常会遇到的SocketTimeoutException就属于它的子类。
还有一点,如果按照默认的重试策略,显然Post请求也不满足重试的条件。这里必须说一下,从谨慎的角度来看,Post请求是否应该重试,需要具体结合业务场景来看,如果请求本身不是幂等的,重试确实可能会带来严重的副作用。
所以在实际的业务场景中,如果想要利用HttpClient的重试机制来进行重试,这两个问题都需要解决。
2.3 基于消息队列的异步重试方案
考虑到在门店很多业务场景中,执行完相关的逻辑之后都会发送MQ消息。那么我们很自然地也想到了通过引入一个消费者的方式,来执行通过Http调用第三方接口的逻辑。
采用这种方式的话,如果在消费逻辑中通过Http调用第三方接口失败,我们还可以充分利用MQ的消费失败重试机制。以我们使用的RocketMQ为例,消息在消费失败重试的时候会按照一定的退避时间来进行重试,这个特性还能避免第三方服务因为短时间的不可用而造成的重试失败的情况。
3 门店业务场景中使用的重试方案
经过以上多种方案的调研,我们最终采用的是方案二和方案三的综合方案,具体思路如下。
首先,我们整体的重试方案采用基于消息队列的异步执行方案,一方面是因为这种方案可以充分地做到和业务之间解耦,同时消息队列的消费失败重试机制可以很好地解决第三方服务短时间不可用的问题,这一点是同步重试方案做不到的,可以保障系统的最终一致性。
其次,因为我们系统中已经在使用HttpClient 组件,所以我们决定充分利用它的重试机制,同步重试也可以尽可能保证接口调用的实时性。
考虑到默认的重试策略不满足我们的使用需求,针对这个问题,我们自定义了一个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的时候传入指定的RetryHandler即可,设置方式如下:
CloseableHttpClient httpClient = HttpClientBuilder.create().setRetryHandler(StoreRequestRetryHandler.INSTANCE).build();
这样我们就解决了默认的重试机制对于Post请求默认不重试和SocketTimeoutException异常不重试的问题,更加贴合我们的使用场景。
这里我举个例子来说明一下整个重试方案的执行流程:
- MQ在消费的时候,会使用Apache HttpClient请求第三方接口,我们设置重试3次,如果请求一直失败,会先同步重试3次,如果还是失败,则本次消息消费失败,等待下一次重试消息继续这个流程。
- RocketMQ默认会重试16次,那么我们整个重试方案会最多进行51次重试。
- Apache HttpClient的同步重试能尽可能保证同步的实时性,而如果第三方服务出现短时间不可用的现象,RocketMQ的退避重试也能继续异步重试只到最终成功。
在我们使用了这种重试方案之后,就再也没有听到业务关于电子价签未及时同步或者打卡信息未同步的抱怨了。
以上就是笔者在线下门店系统中的Http重试实践过程,欢迎大家在评论区留言一起交流。
关于作者
侯万兴,转转门店业务后端研发工程师