• 关于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,暂时分析不出来。

  • 相关阅读:
    array and ram
    char as int
    pointer of 2d array and address
    Install SAP HANA EXPRESS on Google Cloud Platform
    Ubuntu remount hard drive
    Compile OpenSSL with Visual Studio 2019
    Install Jupyter notebook and tensorflow on Ubuntu 18.04
    Build OpenCV text(OCR) module on windows with Visual Studio 2019
    Reinstall VirtualBox 6.0 on Ubuntu 18.04
    Pitfall in std::vector<cv::Mat>
  • 原文地址:https://www.cnblogs.com/kingszelda/p/8886403.html
Copyright © 2020-2023  润新知