崔世宁
16年毕业加入 Qunar 金融事业部,两年过去了,仍然是一只萌新。
目录
一、背景
由于工作上的业务本人经常与第三方系统交互,所以经常会使用 HttpClient 与第三方进行通信。对于交易类的接口,订单状态是至关重要的。
这就牵扯到一系列问题:
HttpClient 是否有默认的重试策略?重试策略原理?如何禁止重试?
接下来,本文将从源码中探讨这些问题。
源码下载地址:
,版本是4.5.5。
二、一般使用方法
一般而言,获得 HttpClient 实例的方法有两种:
1.HttpClients.custom().setXXX().build()
2.HttpClients.build()
第一种方法用来定制一些 HttpClient 的属性,比如 https 证书,代理服务器, http 过滤器,连接池管理器等自定义的用法。
第二种方法用来获得一个默认的 HttpClient 实例。
这两种方法获得都是 CloseableHttpClient 实例,且都是通过 HttpClientBuilder 的 build() 构建的。
可以看到,上面的两种用法最终都得到了一个 InternalHttpClient ,是抽象类 CloseableHttpClient 的一种实现。
public CloseableHttpClient build() {
//省略若干行
return new InternalHttpClient(
execChain,
connManagerCopy,
routePlannerCopy,
cookieSpecRegistryCopy,
authSchemeRegistryCopy,
defaultCookieStore,
defaultCredentialsProvider,
defaultRequestConfig != null ? defaultRequestConfig : RequestConfig.DEFAULT,
closeablesCopy);
}
}
这里有很多配置化参数,这里我们重点关注一下 execChain 这个执行链。
可以看到执行链有多种实现,比如:
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 ,这里看下他的执行发送数据的方法。
protected CloseableHttpResponse doExecute(
final HttpHost target,
final HttpRequest request,
final HttpContext context) throws IOException, ClientProtocolException {
//省略一些代码
return this.execChain.execute(route, wrapper, localcontext, execAware);
}
}
最后一行可以看到,最终的执行 execute 方式使用的是 execChain 的执行方法,而 execChain 是通过 InternalHttpClient 构造器传进来的,就是上面看到的 RetryExec 。
所以, HttpClient 有默认的执行器 RetryExec ,其默认的重试策略是 DefaultHttpRequestRetryHandler 。
四、重试策略分析
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 是否还可以再次发起;
如果重试策略判断不可以重试了,就抛相应异常并退出。
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.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 也会进行重试,不过范围过广不好定位。
六、如何禁止重试?
回到 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;
七、本文总结
通过本文分析,可以得知 HttpClient 默认是有重试机制的,其重试策略是:
只有发生 IOExecetion 时才会发生重试;
InterruptedIOException、UnknownHostException、ConnectException、SSLException ,发生这4中异常不重试;
get 方法可以重试3次, post 方法在 socket 对应的输出流没有被 write 并 flush 成功时可以重试3次;
读/写超时不进行重试;
socket 传输中被重置或关闭会进行重试;
以及一些其他的 IOException ,暂时分析不出来。