zoukankan      html  css  js  c++  java
  • 关于HttpClient重试策略的研究

    一、背景

      由于工作上的业务本人经常与第三方系统交互,所以经常会使用HttpClient与第三方进行通信。对于交易类的接口,订单状态是至关重要的。

      这就牵扯到一系列问题:

      HttpClient是否有默认的重试策略?重试策略原理?如何禁止重试?

      接下来,本文将从源码中探讨这些问题。源码下载地址:http://hc.apache.org/downloads.cgi,版本是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这个执行链。

    可以看到执行链有多种实现,比如

    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.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.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也会进行重试,不过范围过广不好定位。

    六、如何禁止重试?

    回到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默认是有重试机制的,其重试策略是:

      1.只有发生IOExecetion时才会发生重试

      2.InterruptedIOException、UnknownHostException、ConnectException、SSLException,发生这4中异常不重试

      3.get方法可以重试3次,post方法在socket对应的输出流没有被write并flush成功时可以重试3次。

      4.读/写超时不进行重试

      5.socket传输中被重置或关闭会进行重试

      6.以及一些其他的IOException,暂时分析不出来。

  • 相关阅读:
    两种unix网络编程线程池的设计方法
    僵尸进程处理方法
    僵尸进程概念
    fork()父子进程文件描述符的关系
    getsockname()和getpeername()
    linux文件系统总结
    deque时间复杂度和vector,list比较
    stl仿函数和适配器
    linux中断和异常睡眠问题
    umask码和文件权限
  • 原文地址:https://www.cnblogs.com/kingszelda/p/8886403.html
Copyright © 2011-2022 走看看