批评一下
前几天和一个读者聊天,聊到了 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,一个被代码耽搁的文学创作者,一个又暖又有料的四川好男人。
还有,欢送关注我呀。