关于java:请求失败应该重试吗不应该吗

52次阅读

共计 6331 个字符,预计需要花费 16 分钟才能阅读完成。

先点赞再看,养成好习惯

前言

在网络申请中,因为网络是不牢靠的,所以常常会有申请失败的场景。针对这种问题,通常的做法是减少重试机制,在申请失败后从新申请,尽量保障申请的胜利,从而进步服务的稳定性。

重试的危险

可是大多数人不违心轻易的重试,因为往往重试会带来更大的危险。比方过多的重试,会给被调用服务造成更大的压力,放大原有的问题。

如下图所示,服务 A 调用 服务 B,服务 B 依据申请数据不同,会调用 服务 C 和 服务 D。此时服务 C 呈现故障,不可用了,那么服务 B 中所有对服务 C 的申请都会超时,但服务 D 当初还是可用的;可因为服务 A 中大量的重试导致服务 B 的负载疾速升高,很快的将服务 B 的负载打满(比方连接池沾满)。当初调用服务 D 的分支申请也不可用了,因为服务 B 曾经被重试申请打满,无奈再解决任何申请了。

如果服务本身是可用的,但网络呈现较大的提早、抖动或者丢包,导致申请达到指标服务或返回发动服务超时;此时如果客户端发动重试,那么对于接收端来说,很可能会收到多个雷同的申请。所以 服务端还须要减少幂等的解决,保障屡次申请下后果统一

既然重试有危险,那难道就不应该重试吗?失败就间接失败,啥都不论吗?

不同机会下的失败重试

是否进行重试,这个须要辨别以后失败的起因,不能简略粗犷的决定重试或者不重试。网络很简单,链路很长,不同类型的协定,决定是否重试的策略也有所不同。

HTTP 协定下的重试

一个根本的 HTTP 申请,会蕴含以下几个阶段:

  1. DNS 解析
  2. TCP 三次握手
  3. 发送 & 承受对端数据

在 DNS 解析阶段时,如果域名不存在,或者域名没有 DNS 记录,依据域名无奈解析到对应的主机地址列表,那么基本就无奈发动申请,此时重试没有任何意义,所以并不需要重试

在 TCP 握手阶段,如果指标服务不可用,那么此时重试也没有什么意义,因为在申请的第一步 - 握手都不胜利,大概率这个 host 是不可用的。

挺过了 DNS 和握手两个阶段之后,终于到了收发数据的阶段。到了这一步一旦呈现失败,是否重试要思考的因素可就更多了。

如下图所示的这种状况中,因为网络拥塞等起因,导致数据达到服务端工夫过长,但最终服务端也收到了残缺的报文,曾经开始解决申请,可此时客户端因为超时放弃了该申请 ,那么如果客户端此时新建一条 TCP 连贯发动重试,那么 对于服务端来说就会收到两次雷同的申请报文,解决两次该申请,可能造成重大的结果

所以这种 曾经发送胜利 的状况,就不适宜重试

问题来了,怎么样能力晓得我发送胜利了呢?socket.write 没有报错就算胜利了?SocketChannel.write 之后,Buffer 写空了就算胜利了?

并没有那么简略,应用层的 socket write,只是将数据写入 SND Buffer 中,至于 SND Buffer 中的数据什么时候被操作系统发送至网络,这个并没有任何保障。阻塞和非阻塞也只是针对 socket.write 这个操作的,当 SND Buffer 已满,无奈将数据写入内核 SND Buffer 时,就会产生阻塞。

但咱们能够粗略的认为,socket.write 胜利 并且 应用层 buffer 被写空,就是曾经发送胜利了。

当初看看另一种状况,当数据发送时对端就间接敞开了 socket,返回 rst 标识:

那么这种状况,就很适宜进行重试。因为对于服务端来说,并没有开始解决这个申请,所以重试(重建连贯重发申请)只会进步可用性,并不会造成什么累赘


HTTP 协定中,对 Request Method 还有一些语义上的约定:

GET POST PUT DELET
列出URI,以及该资源组中每个资源的详细信息(后者可选)。 在本组资源中 创立 / 追加 一个新的资源。该操作往往返回新资源的 URL。 应用给定的一组资源 替换 以后整组资源。 删除 整组资源。
平安(更是幂等) 非幂等 幂等 幂等

PUT/DELETE 是幂等操作,所以就算解决雷同报文的申请也不会有数据反复之类的问题。但 POST 可不是,POST 的语义是创立 / 增加,这是一个非幂等的申请类型。

当初回到下面重试的问题,如果申请报文曾经发送胜利,但响应超时,但由申请的 API Method 是一个 DELETE 类型,这种状况就能够思考重试,因为 DELETE 语义上是幂等的;GET/PUT 同理,语义上幂等的就能够思考重试。

但 POST 可不行,因为语义上是非幂等的,重试很可能造成反复的解决申请

可是……所有真的那么美妙吗?能严格准守语义的 API 能有几家?所以单靠语义上的约定,十分不稳当,肯定要足够理解服务端的接口是否反对幂等,才能够思考重试问题。

HTTPS 下的重试

HTTPS 面世这么多年,终于在近几年齐全遍及了,没降级的网站在浏览器中都会提醒不平安,目前能裸露在公网的 Web API 也根本都是上 HTTPS 的。

在 HTTPS 中,重试的策略又会有一些变动:

上图是 HTTPS 握手的流程,在 TCP 建设连贯之后,会先进行 SSL 的握手,验证对端证书,生成长期对称密钥之列的操作。

如果在 SSL 握手阶段就产生失败,比方证书到期,证书不受信等问题,那么也是齐全不须要重试的。因为这种问题不会是短暂的,一旦呈现就是长时间失败,重试也是失败。

支流网络库 & RPC 框架中的重试机制

介绍完了 HTTP(S) 协定下对重试的思考,当初来看看支流网络库对重试的解决形式,看看这种支流开源我的项目中的解决机制够不够“正当”

Apache HttpClient 的重试机制(v4.x)

Apache HttpClient 是 Java 里最支流的一个 HTTP 工具库了(后端方向),尽管 JDK 也提供了根本的 HTTP SDK,但……太根底了,没法间接应用。而 Apache HttpClient(简称 Apache HC)补救了这个有余,提供了一套超级弱小的 HTTP SDK,功能强大、应用简略、所有组件都能够定制。

Apache HC 默认的重试策略类在org.apache.http.impl.client.DefaultHttpRequestRetryHandler,先来看看实现(省略了一些不重要的代码):

// 返回 true,代表须要重试,false 不重试
@Override
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;} 
    // 判断是否是幂等申请
    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;
    }
    // otherwise do not retry
    return false;
}

简略总结下 Apache HC 的重试策略:

  1. 判断重试次数是否曾经超过最大次数(默认 3 次),超过就不重试
  2. 判断哪些异样不须要重试

    1. UnknownHostException – 找不到主机
    2. ConnectException – TCP 握手失败
    3. SSLException – SSL 握手失败
    4. InterruptedIOException(ConnectTimeoutException/SocketTimeoutException) – 握手超时,Socket 读取超时(也能够粗略的认为是响应超时)
  3. 判断是否是幂等申请,幂等申请才能够重试
  4. 判断申请报文是否曾经实现发送,若未实现发送才能够重试
  5. 重试时间接从新申请,没有距离

这样看起来,Apache HC 中默认的重试策略,和咱们上一节介绍的“正当的”重试策略完全一致。由此可见这种支流的开源我的项目真的很优良,品质十分高,所有设计都依照规范来,拿这种我的项目的源码当学习材料更能事倍功半。

Dubbo 的重试机制(v2.6.x)

Dubbo 中重试机制的代码在com.alibaba.dubbo.rpc.cluster.support.FailoverClusterInvoker(2.7 当前包名更新为 org.apache.dubbo)

public Result doInvoke(Invocation invocation, final List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
    // 获取配置的重试次数,默认 1 即不重试
    int len = getUrl().getMethodParameter(invocation.getMethodName(), Constants.RETRIES_KEY, Constants.DEFAULT_RETRIES) + 1;
    Set<String> providers = new HashSet<String>(len);
    for (int i = 0; i < len; i++) {Invoker<T> invoker = select(loadbalance, invocation, copyinvokers, invoked);
        invoked.add(invoker);
        RpcContext.getContext().setInvokers((List) invoked);
        try {Result result = invoker.invoke(invocation);
            if (le != null && logger.isWarnEnabled()) {logger.warn("Although retry the method" + invocation.getMethodName()
                            + "in the service" + getInterface().getName()
                            + "was successful by the provider" + invoker.getUrl().getAddress()
                            + ", but there have been failed providers" + providers
                            + "(" + providers.size() + "/" + copyinvokers.size()
                            + ") from the registry" + directory.getUrl().getAddress()
                            + "on the consumer" + NetUtils.getLocalHost()
                            + "using the dubbo version" + Version.getVersion() + ". Last error is:"
                            + le.getMessage(), le);
            }
            return result;
        } catch (RpcException e) {
            //Biz 类型的异样,会抛出异样,不进行不重试,非 Biz 类的 RpcException 都会进行重试
            if (e.isBiz()) { // biz exception.
                throw e;
            }
            le = e;
        } catch (Throwable e) {le = new RpcException(e.getMessage(), e);
        } finally {providers.add(invoker.getUrl().getAddress());
        }
    }
}

从代码中能够看出,只有 不是 Biz 类型的 RpcException,才会触发重试。持续剖析代码看看什么场景会触发重试……算了不贴代码了,间接上答案!

简略总结一下 Dubbo 中的重试策略:

  1. 默认重试次数为 3(包含第一次申请),配置大于 1 时才会触发重试
  2. 默认是 Failover 策略,所以重试不会重试以后节点,只会重试(可用节点 -> 负载平衡 -> 路由之后的)下一个节点
  3. TCP 握手超时会触发重试
  4. 响应超时会触发重试
  5. 报文谬误或其余谬误导致无奈找到对应的 request,也会导致 Future 超时,超时就会重试
  6. 对于服务端返回的 Exception(比方 provider 抛出的),属于调用胜利,不会进行重试

Dubbo 的重试策略还是有一些激进的,并没有像 Apache HC 那样审慎……所以在应用 Dubbo 时,重试策略肯定要小心,防止重试到一些不反对幂等的服务。如果你的 provider 不反对幂等,最好将重试次数配置为 0

Feign 的重试机制(v11.1)

Feign 是一个应用简略 Java 的 Http 客户端,也是 Spring Cloud 中举荐的 RPC 框架。尽管 Feign 也属于 Http 客户端,但它和 Apache HC 之类的库相比却有很大的不同。

上面 Feign 的外围结构图,从图上能够看到,Feign 的客户端局部,除了反对 JDK 内置的 Http Client 以外,还反对 Apache HC,Google Http,OK Http 等 Http 库。

而且还提有 encoders/decoder 的形象……这么看起来,它并不能算是一款根底的 Http 客户端,更应该称为“Http 工具”?或者叫一个 RPC 的根底形象?

那 Feign 中的重试策略是怎么样的呢?这个问题切实不好答复,因为要辨别很多状况,在不同的 Feign Client 下,重试策略都有所不同

首先,Feign 是有内置的重试策略的,如下图所示,Feign 的重试在调用 HttpClient 之外,而且每次重试之前有肯定距离。

默认配置下,最大重试 5 次(包含第一次),每次重试前会距离肯定工夫(sleep),而且这个每次间隔时间是随着重试次数的减少而递增的,重试距离计算公式为:

$$ 重试距离 = 重试距离(默认 100ms)* 1.5 ^ {以后重试次数 -1}$$

如下图所示,重试的次数越大,每次的重试距离就会越长

但这是在调用 HttpClient 之外的重试,如果只是应用 Feign 内置默认 JDK HTTP Client 的话也不会出什么问题,因为 JDK HTTP Client 很简略,没有重试机制,单靠 Feign 的重试机制就足够了。

可是在配合三方 Http Client(比方 Apache HC)时,状况就不太一样了,因为三方的 Http Client 外部往往是有重试机制的。

如果三方的 HttpClient 有重试,Feign 又有重试,那么相当于重试了两层,重试次数变成了 N * N

比方在 Apache HC 下,依照后面的介绍,默认重试 3 次,而 Feign 默认重试 5 次,那么最坏的状况下,重试次数高达 15 次。

而且这还只是 Feign 在根本用法下的重试机制,如果在 Spring Cloud 下,配合 Ribbon 之类的负载均衡器,状况会更简单一些,本文就不过多介绍了,有趣味的看看 Spring Cloud 下 Feign 的配置

总结

重试尽管看着很简略,但如果想平安稳固的重试,要思考的因素还是很多的。肯定要联合以后业务场景,上下文信息去综合思考是否应该重试和每次重试的次数;而不是一拍脑袋就定一个重试机制,暴力重试往往只会放大问题,带来更重大的结果。

如果不确定是否能够平安的重试,那么就不要重试,禁用这些框架的重试,Failfast 总比问题扩充更好。

参考

  • 如何优雅地重试 -InfoQ
  • Apache Dubbo – Github
  • Feign – Github
  • Apache HttpClient – Github

原创不易,禁止未受权的转载。如果我的文章对您有帮忙,就请点赞 / 珍藏 / 关注激励反对一下吧❤❤❤❤❤❤

正文完
 0