批评一下
前几天和一个读者聊天,聊到了 Dubbo。
他说他之前遇到了一个 Dubbo 的坑。
我问产生甚么事儿了?
而后他给我形容了一下前因后果,总结起来就八个字吧:超时之后,主动重试。
对此我就表白了两个观点。
- 读者对于应用框架的不相熟,不晓得 Dubbo 还有主动重试这回事。
- 是对于 Dubbo 这个主动重试性能,我感觉出发点很好,然而设计的不好。
第一个没啥说的,学艺不精,持续深造。
次要说说第二个。
有一说一,作为一个应用 Dubbo 多年的用户,依据我的应用教训我感觉 Dubbo 提供重试性能的想法是很好的,然而它错就错在不应该进行主动重试。
大部分状况下,我都会手动设置为 retries=0。
作为一个框架,当然能够要求使用者在充沛理解相干个性的状况下再去应用,其中就蕴含须要理解它的主动重试的性能。
然而,是否须要进行重试,应该是由使用者自行决定的,框架或者工具类,不应该被动帮使用者去做这件事。
等等,这句话说的有点太相对了。我改一下。
是否须要进行重试,应该是由使用者通过场景剖析后自行决定的,框架或者工具类,不应该染指到业务层面,帮使用者去做这件事。
本文就拿出两个大家比拟相熟的例子,来进行一个简略的比照。
第一个例子就是 Dubbo 默认的集群容错策略 Failover Cluster,即失败主动切换。
第二个例子就是 apache 的 HttpClient。
一个是框架,一个是工具类,它们都反对重试,且都是默认开启了重试的。
然而从我的应用感触说来,Dubbo 的主动重试染指到了业务中,对于使用者是有感知的。HttpClient 的主动重试是网络层面的,对于使用者是无感知的。
然而,必须要再次强调的一点是:
Dubbo 在官网上申明的清清楚楚的,默认主动重试,通常用于读操作。
如果你使用不当导致数据谬误,这事你不能怪官网,只能说这个设计有利有弊。
Dubbo 重试几次
都说 Dubbo 会主动重试,那么是重试几次呢?
先间接看个例子,演示一下。
首先看看接口定义:
能够看到在接口实现外面,我睡眠了 5s,目标是模仿接口超时的状况。
服务端的 xml 文件外面是这样配置的,超时工夫设置为了 1000ms:
客户端的 xml 文件是这样配置的,超时工夫也设置为了 1000ms:
而后咱们在单元测试外面模仿近程调用一次:
这就是一个原生态的 Dubbo Demo 我的项目。因为咱们超时工夫是 1000ms,即 1s,但接口解决须要 5s,所以调用必定会超时。
那么 Dubbo 默认的集群容错策略(Failover Cluster),到底会重试几次,跑一下测试用例,一眼就能看进去:
你看这个测试用例的工夫,跑了 3 s 226 ms,你先记住这个工夫,我等下再说。
咱们先关注重试次数。
有点看不太分明,我把要害日志独自拿进去给大家看看:
从日志能够出,客户端重试了 3 次。最初一次重试的开始工夫是:2020-12-11 22:41:05.094。
咱们看看服务端的输入:
我就调用一次,这里数据库插入三次。凉凉。
而且你关注一下申请工夫,每隔 1s 来一个申请。
我这里始终强调工夫是为什么呢?
因为这里有一个知识点:1000ms 的超时工夫,是一次调用的工夫,而不是整个重试申请(三次)的工夫。
之前面试的时候,有人问过我这个对于工夫的问题。所以我就独自写一下。
而后咱们把客户端的 xml 文件革新一下,指定 retries=0:
再次调用:
能够看到,只进行了一次调用。
到这里,咱们还是把 Dubbo 当个黑盒在用。测试进去了它的主动重试次数是 3 次,能够通过 retries 参数进行指定。
接下来,咱们扒一扒源码。
FailoverCluster 源码
源码位于 org.apache.dubbo.rpc.cluster.support.FailoverClusterInvoker
中:
通过源码,咱们能够晓得默认的重试次数是 2 次:
等等,不对啊,后面刚刚说的是 3 次,怎么一转眼就是 2 次了呢?
你别急啊。
你看第 61 行的最初还有一个 “+1” 呢?
你想一想。咱们想要在接口调用失败后,重试 n 次,这个 n 就是 DEFAULT_RETRIES,默认为 2。那么咱们总的调用次数就是 n+1 次了。
所以这个 “+1” 是这样来的,很小的一个知识点,送给大家。
另外图中标记了红色五角星★的中央,第 62 到 64 行。也是很要害的中央。对于 retries 参数,在官网上的形容是这样的:
不须要重试,请设为 0。咱们后面剖析了,当设置为 0 的时候,只会调用一次。
然而我也看见过 retries 配置为 -1 的。-1+1=0。调用 0 次显著是一个谬误的含意。然而程序也失常运行,且只调用一次。
这就是标记了红色五角星的中央的功绩了。
防御性编程。哪怕你设置为 -10000 也只会调用一次。
上面这个图片是我对 doInvoke 办法进行一个全面的解读,基本上每一行次要的代码都加了正文,能够点开大图查看:
如上所示,FailoverClusterInvoker 的 doInvoke 办法次要的工作流程是:
- 首先是获取重试次数,而后依据重试次数进行循环调用,在循环体内,如果失败,则进行重试。
- 在循环体内,首先是调用父类 AbstractClusterInvoker 的 select 办法,通过负载平衡组件抉择一个 Invoker,而后再通过这个 Invoker 的 invoke 办法进行近程调用。
- 如果失败了,记录下异样,并进行重试。
留神一个细节:在进行重试前,从新获取最新的 invoker 汇合,这样做的益处是,如果在重试的过程中某个服务挂了,能够通过调用 list 办法保障 copyInvokers 是最新的可用的 invoker 列表。
整个流程大抵如此,不是很难了解。
HttpClient 应用样例
接下来,咱们看看 apache 的 HttpClients 中的重试是怎么回事。
也就是这个类:org.apache.http.impl.client.HttpClients
。
首先,废话少说,弄个 Demo 跑一下。
先看 Controller 的逻辑:
`@RestController
public class TestController {
@PostMapping(value = “/testRetry”)
public void testRetry() {
try {
System.out.println(“ 工夫:” + new Date() + “, 数据库插入胜利 ”);
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
`
同样是睡眠 5s,模仿超时的状况。
HttpUtils 封装如下:
`public class HttpPostUtils {
public static String retryPostJson(String uri) throws Exception {
HttpPost post = new HttpPost(uri);
RequestConfig config = RequestConfig.custom()
.setConnectTimeout(1000)
.setConnectionRequestTimeout(1000)
.setSocketTimeout(1000).build();
post.setConfig(config);
String responseContent = null;
CloseableHttpResponse response = null;
CloseableHttpClient client = null;
try {
client = HttpClients.custom().build();
response = client.execute(post, HttpClientContext.create());
if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
responseContent = EntityUtils.toString(response.getEntity(), Consts.UTF_8.name());
}
} finally {
if (response != null) {
response.close();
}
if (client != null){
client.close();
}
}
return responseContent;
}
}
`
先解释一下其中的三个设置为 1000ms 的参数:
connectTimeout:客户端和服务器建设连贯的 timeout
connectionRequestTimeout:从连接池获取连贯的 timeout
socketTimeout:客户端从服务器读取数据的 timeout
大家都晓得一次 http 申请,形象来看,必定会有三个阶段
- 一:建设连贯
- 二:数据传送
- 三:断开连接
当建设连贯的操作,在规定的工夫内(ConnectionTimeOut)没有实现,那么此次连贯就宣告失败,抛出 ConnectTimeoutException。
后续的 SocketTimeOutException 就肯定不会产生。
当连贯建设起来后,才会开始进行数据传输,如果数据在规定的工夫内(SocketTimeOut)沒有传输实现,则抛出 SocketTimeOutException。如果传输实现,则断开连接。
测试 Main 办法代码如下:
`public class MainTest {
public static void main(String[] args) {
try {
String returnStr = HttpPostUtils.retryPostJson(“http://127.0.0.1:8080/testRetry/”);
System.out.println(“returnStr = ” + returnStr);
} catch (Exception e) {
e.printStackTrace();
}
}
}
`
首先咱们不启动服务,那么依据刚刚的剖析,客户端和服务器建设连贯会超时,则抛出 ConnectTimeoutException 异样。
间接执行 main 办法,后果如下:
合乎咱们的预期。
当初咱们把 Controller 接口启动起来。
因为咱们的 socketTimeout 设置的工夫是 1000ms,而接口外面进行了 5s 的睡眠。
依据刚刚的剖析,客户端从服务器读取数据必定会超时,则抛出 SocketTimeOutException 异样。
Controller 接口启动起来后,咱们运行 main 办法输入如下:
这个时候,其实接口是调用胜利了,只是客户端没有拿到返回。
这个状况和咱们后面说的 Dubbo 的状况一样,超时是针对客户端的。
即便客户端超时了,服务端的逻辑还是会继续执行,把此次申请解决实现。
执行后果的确抛出了 SocketTimeOutException 异样,合乎预期。
然而,说好的重试呢?
HttpClient 的重试
在 HttpClients 外面,其实也是有重试的性能,且和 Dubbo 一样,默认是开启的。
然而咱们这里为什么两种异样都没有进行重试呢?
如果它能够重试,那么默认重试几次呢?
咱们带着疑难,还是去源码中找找答案。
答案就藏在这个源码中,org.apache.http.impl.client.DefaultHttpRequestRetryHandler
。
DefaultHttpRequestRetryHandler 是 Apache HttpClients 的默认重试策略。
从它的构造方法能够看出,其默认重试 3 次:
该构造方法的 this 调用的是这个办法:
从该构造方法的正文和代码能够看出,对于这四类异样是不会进行重试的:
- 一:InterruptedIOException
- 二:UnknownHostException
- 三:ConnectException
- 四:SSLException
而咱们后面说的 ConnectTimeoutException 和 SocketTimeOutException 都是继承自 InterruptedIOException 的:
咱们敞开 Controller 接口,而后打上断点看一下:
能够看到,通过 if 判断,会返回 false,则不会发动重试。
为了模仿重试的状况,咱们就得革新一下 HttpPostUtils,来一个自定义 HttpRequestRetryHandler:
`public class HttpPostUtils {
public static String retryPostJson(String uri) throws Exception {
HttpRequestRetryHandler httpRequestRetryHandler = new HttpRequestRetryHandler() {
@Override
public boolean retryRequest(IOException exception, int executionCount, HttpContext context) {
System.out.println(“ 开始第 ” + executionCount + “ 次重试!”);
if (executionCount > 3) {
System.out.println(“ 重试次数大于 3 次,不再重试 ”);
return false;
}
if (exception instanceof ConnectTimeoutException) {
System.out.println(“ 连贯超时,筹备进行从新申请 ….”);
return true;
}
HttpClientContext clientContext = HttpClientContext.adapt(context);
HttpRequest request = clientContext.getRequest();
boolean idempotent = !(request instanceof HttpEntityEnclosingRequest);
if (idempotent) {
return true;
}
return false;
}
};
HttpPost post = new HttpPost(uri);
RequestConfig config = RequestConfig.custom()
.setConnectTimeout(1000)
.setConnectionRequestTimeout(1000)
.setSocketTimeout(1000).build();
post.setConfig(config);
String responseContent = null;
CloseableHttpResponse response = null;
CloseableHttpClient client = null;
try {
client = HttpClients.custom().setRetryHandler(httpRequestRetryHandler).build();
response = client.execute(post, HttpClientContext.create());
if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
responseContent = EntityUtils.toString(response.getEntity(), Consts.UTF_8.name());
}
} finally {
if (response != null) {
response.close();
}
if (client != null) {
client.close();
}
}
return responseContent;
}
}
`
在咱们的自定义 HttpRequestRetryHandler 外面,对于 ConnectTimeoutException,我进行了放行,让申请能够重试。
当咱们不启动 Controller 接口时,程序会主动重试 3 次:
下面给大家演示了 Apache HttpClients 的默认重试策略。下面的代码大家能够间接拿进去运行一下。
如果想晓得整个调用流程,能够在 debug 的模式下看调用链路:
HttpClients 的主动重试,同样是默认开启的,然而咱们在应用过程中是无感知的。
因为它的重试条件也是比拟刻薄的,针对网络层面的重试,没有侵入到业务中。
审慎审慎再审慎。
对于须要重试的性能,咱们在开发过程中肯定要审慎审慎再审慎。
比方 Dubbo 的默认重试,我感觉它的出发点是为了保障服务的高可用。
失常来说咱们的微服务至多都有两个节点。当其中一个节点不提供服务的时候,集群容错策略就会去主动重试另外一台。
然而对于服务调用超时的状况,Dubbo 也认为是须要重试的,这就相当于侵入到业务外面了。
后面咱们说了服务调用超时是针对客户端的。即便客户端调用超时了,服务端还是在失常执行这次申请。
所以官网文档中才说“通常用于读操作”:
http://dubbo.apache.org/zh/docs/v2.7/user/examples/fault-tolerent-strategy/
读操作,含意是默认幂等。所以,当你的接口办法不是幂等时请记得设置 retries=0。
这个货色,我给你举一个理论的场景。
假如你去调用了微信领取接口,然而调用超时了。
这个时候你怎么办?
间接重试?请你回去等告诉吧。
必定是调用查问接口,判断以后这个申请对方是否收到了呀,从而进行进一步的操作吧。
对于 HttpClients,它的主动重试没有侵入到业务之中,而是在网络层面。
所以绝大部分状况下,咱们零碎对于它的主动重试是无感的。
甚至须要咱们在程序外面去实现主动重试的性能。
因为你的革新是在最底层的 HttpClients 办法,这个时候你要留神的一个点:你要分辨进去,这个申请异样后是否反对重试。
不能间接无脑重试。
对于重试的框架,大家能够去理解一下 Guava-Retry 和 Spring-Retry。
奇闻异事
我晓得大家最喜爱的就是这个环节了。
看一下 FailoverClusterInvoker 的提交记录:
2020 年提交了两次。工夫距离还挺短的。
2 月 9 日的提交,是针对编号为 5686 的 issue 进行的修复。
而在这个 issue 外面,针对编号为 5684 和 5654 进行了修复:
https://github.com/apache/dubbo/issues/5654
它们都指向了一个问题:
多注册核心的负载平衡不失效。
官网对这个问题修复了之后,马上就带来另外一个大问题:
2.7.6 版本外面 failfast 负载平衡策略生效了。
你想,我晓得我一个接口不能失败重试,所以我成心改成了 failfast 策略。
然而理论框架用的还是 failover,进行了重试 2 次?
而理论状况更加蹩脚,2.7.6 版本外面负载平衡策略只反对 failover 了。
这玩意就有点坑了。
而这个 bug 始终到 2.7.8 版本才修复好。
所以,如果你应用的 Dubbo 版本是 2.7.5 或者 2.7.6 版本。肯定要留神一下,是否用了其余的集群容错策略。如果用了,实际上是没有失效的。
能够说,这的确是一个比拟大的 bug。
然而开源我的项目,独特保护。
咱们当然晓得 Dubbo 不是一个完满的框架,但咱们也晓得,它的背地有一群晓得它不完满,然而依然不言乏力、不言放弃的工程师。
他们在致力革新它,让它趋于完满。
咱们作为使用者,咱们少一点 ” 吐槽 ”,多一点激励,提出实质性的倡议。
只有这样我能力自豪的说,咱们为开源世界奉献了一点点的力量,咱们置信它的今天会更好。
向开源致敬,向开源工程师致敬。
总之,牛逼。
好了,这次的文章就到这里了。
满腹经纶,难免会有纰漏,如果你发现了谬误的中央,能够提出来,我对其加以批改。
感谢您的浏览,我保持原创,非常欢送并感谢您的关注。
我是 why,一个被代码耽搁的文学创作者,一个又暖又有料的四川好男人。
还有,欢送关注我呀。