RPC的超时设置,一不小心就是线上事故
尤其在微服务架构下,一次请求可能要经过一条很长的链路,跨多个服务调用后才能返回结果。当服务超时发生时,研发同学往往要抽丝剥茧般去分析自身系统的性能以及依赖服务的性能,这也是为什么服务超时相对于服务出错和服务调用量异常更难调查的原因。
从一次RPC接口超时引发的线上事故说起
超时的实现原理是什么?
设置超时时间到底是为了解决什么问题?
应该如何合理的设置超时时间?
01 从一次线上事故说起
上面的业务场景可以借助下面的调用链来理解
APP端发起一个HTTP请求到业务网关
业务网关RPC调用推荐服务,获取推荐商品list 如果第2步调用失败,则服务降级,改成RPC调用商品排序服务,获取热销商品list进行托底 如果第3步调用失败,则再次降级,直接获取Redis缓存中的热销商品list
粗看起来,两个依赖服务的降级策略都考虑进去了,理论上就算推荐服务或者商品排序服务全部挂掉,服务端都应该可以返回数据给APP端。但是APP端的推荐模块确实出现空白了,降级策略可能并未生效,下面详细说下定位过程。
第1步:APP端通过抓包发现:HTTP请求存在接口超时(超时时间设置的是5秒)。
第2步:业务网关通过日志发现:调用推荐服务的RPC接口出现了大面积超时(超时时间设置的是3秒),错误信息如下:
第3步:推荐服务通过日志发现:dubbo的线程池耗尽,错误信息如下:
通过以上3步,基本就定位到了问题出现在推荐服务,后来进一步调查得出:是因为推荐服务依赖的redis集群不可用导致了超时,进而导致线程池耗尽。详细原因这里不作展开,跟本文要讨论的主题相关性不大。
下面再接着分析下:当推荐服务调用失败时,为什么业务网关的降级策略没有生效呢?理论上来说,不应该降级去调用商品排序服务进行托底吗?
最终跟踪分析找到了根本原因:APP端调用业务网关的超时时间是5秒,业务网关调用推荐服务的超时时间是3秒,同时还设置了3次超时重试,这样当推荐服务调用失败进行第2次重试时,HTTP请求就已经超时了,因此业务网关的所有降级策略都不会生效。下面是更加直观的示意图:
将业务网关调用推荐服务的超时时间改成了800ms(推荐服务的TP99大约为540ms),超时重试次数改成了2次 将业务网关调用商品排序服务的超时时间改成了600ms(商品排序服务的TP99大约为400ms),超时重试次数也改成了2次
关于超时时间和重试次数的设置,需要考虑整个调用链中所有依赖服务的耗时、各个服务是否是核心服务等很多因素。这里先不作展开,后文会详细介绍具体方法。
02 超时的实现原理是什么?
熟悉dubbo的同学都知道,可在两个地方配置超时时间:分别是provider(服务端,服务提供方)和consumer(消费端,服务调用方)。服务端的超时配置是消费端的缺省配置,也就是说只要服务端设置了超时时间,则所有消费端都无需设置,可通过注册中心传递给消费端,这样:一方面简化了配置,另一方面因为服务端更清楚自己的接口性能,所以交给服务端进行设置也算合理。
dubbo支持非常细粒度的超时设置,包括:方法级别、接口级别和全局。如果各个级别同时配置了,优先级为:消费端方法级 > 服务端方法级 > 消费端接口级 > 服务端接口级 > 消费端全局 > 服务端全局。
1public class TimeoutFilter implements Filter {
2
3 public TimeoutFilter() {
4 }
5
6 public Result invoke(...) throws RpcException {
7 // 执行真正的逻辑调用,并统计耗时
8 long start = System.currentTimeMillis();
9 Result result = invoker.invoke(invocation);
10 long elapsed = System.currentTimeMillis() - start;
11
12 // 判断是否超时
13 if (invoker.getUrl() != null && elapsed > timeout) {
14 // 打印warn日志
15 logger.warn("invoke time out...");
16 }
17
18 return result;
19 }
20}
1public class FailoverClusterInvoker {
2
3 public Result doInvoke(...) {
4 ...
5 // 循环调用设定的重试次数
6 for (int i = 0; i < retryTimes; ++i) {
7 ...
8 try {
9 Result result = invoker.invoke(invocation);
10 return result;
11 } catch (RpcException e) {
12 // 如果是业务异常,终止重试
13 if (e.isBiz()) {
14 throw e;
15 }
16
17 le = e;
18 } catch (Throwable e) {
19 le = new RpcException(...);
20 } finally {
21 ...
22 }
23 }
24
25 throw new RpcException("...");
26 }
27}
FailoverCluster是集群容错的缺省模式,当调用失败后会切换成调用其他服务器。再看下doInvoke方法,当调用失败时,会先判断是否是业务异常,如果是则终止重试,否则会一直重试直到达到重试次数。
继续跟踪invoker的invoke方法,可以看到在请求发出后通过Future的get方法获取结果,源码如下:
1public Object get(int timeout) {
2 if (timeout <= 0) {
3 timeout = 1000;
4 }
5
6 if (!isDone()) {
7 long start = System.currentTimeMillis();
8 this.lock.lock();
9
10 try {
11 // 循环判断
12 while(!isDone()) {
13 // 放弃锁,进入等待状态
14 done.await((long)timeout, TimeUnit.MILLISECONDS);
15
16 // 判断是否已经返回结果或者已经超时
17 long elapsed = System.currentTimeMillis() - start;
18 if (isDone() || elapsed > (long)timeout) {
19 break;
20 }
21 }
22 } catch (InterruptedException var8) {
23 throw new RuntimeException(var8);
24 } finally {
25 this.lock.unlock();
26 }
27
28 if (!isDone()) {
29 // 如果未返回结果,则抛出超时异常
30 throw new TimeoutException(...);
31 }
32 }
33
34 return returnFromResponse();
35 }
进入方法后开始计时,如果在设定的超时时间内没有获得返回结果,则抛出TimeoutException。因此,消费端的超时逻辑同时受到超时时间和超时次数两个参数的控制,像网络异常、响应超时等都会一直重试,直到达到重试次数。
03 设置超时时间是为了解决什么问题?
04 应该如何合理的设置超时时间?
理解了RPC框架的超时实现原理和可能引入的副作用后,可以按照下面的方法进行超时设置:
设置调用方的超时时间之前,先了解清楚依赖服务的TP99响应时间是多少(如果依赖服务性能波动大,也可以看TP95),调用方的超时时间可以在此基础上加50%
如果RPC框架支持多粒度的超时设置,则:全局超时时间应该要略大于接口级别最长的耗时时间,每个接口的超时时间应该要略大于方法级别最长的耗时时间,每个方法的超时时间应该要略大于实际的方法执行时间 区分是可重试服务还是不可重试服务,如果接口没实现幂等则不允许设置重试次数。注意:读接口是天然幂等的,写接口则可以使用业务单据ID或者在调用方生成唯一ID传递给服务端,通过此ID进行防重避免引入脏数据 如果RPC框架支持服务端的超时设置,同样基于前面3条规则依次进行设置,这样能避免客户端不设置的情况下配置是合理的,减少隐患 如果从业务角度来看,服务可用性要求不用那么高(比如偏内部的应用系统),则可以不用设置超时重试次数,直接人工重试即可,这样能减少接口实现的复杂度,反而更利于后期维护 重试次数设置越大,服务可用性越高,业务损失也能进一步降低,但是性能隐患也会更大,这个需要综合考虑设置成几次(一般是2次,最多3次) 如果调用方是高QPS服务,则必须考虑服务方超时情况下的降级和熔断策略。(比如超过10%的请求出错,则停止重试机制直接熔断,改成调用其他服务、异步MQ机制、或者使用调用方的缓存数据)
最后,再简单总结下:
推荐阅读:
欢迎关注微信公众号:互联网全栈架构,收取更多有价值的信息。