RPC 的超时设置,一不小心就是线上事故!
上面这张监控图,对于服务端的研发同学来说再熟悉不过了。在日常的系统维护中,『服务超时』应该属于监控报警最多的一类问题。
尤其在微服务架构下,一次请求可能要经过一条很长的链路,跨多个服务调用后才能返回结果。当服务超时发生时,研发同学往往要抽丝剥茧般去分析自身系统的性能以及依赖服务的性能,这也是为什么服务超时相对于服务出错和服务调用量异常更难调查的原因。
这篇文章将通过一个真实的线上事故,系统性地介绍下:在微服务架构下,该如何正确理解并设置RPC接口的超时时间,让大家在开发服务端接口时有更全局的视野。内容将分成以下4个部分:
从一次RPC接口超时引发的线上事故说起 超时的实现原理是什么? 设置超时时间到底是为了解决什么问题? 应该如何合理的设置超时时间?
APP端发起一个HTTP请求到业务网关 业务网关RPC调用推荐服务,获取推荐商品list 如果第2步调用失败,则服务降级,改成RPC调用商品排序服务,获取热销商品list进行托底 如果第3步调用失败,则再次降级,直接获取Redis缓存中的热销商品list
将业务网关调用推荐服务的超时时间改成了800ms(推荐服务的TP99大约为540ms),超时重试次数改成了2次 将业务网关调用商品排序服务的超时时间改成了600ms(商品排序服务的TP99大约为400ms),超时重试次数也改成了2次
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}
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 }
重复请求:有可能provider执行完了,但是因为网络抖动consumer认为超时了,这种情况下重试机制就会导致重复请求,从而带来脏数据问题,因此服务端必须考虑接口的幂等性。
降低consumer的负载能力:如果provider并不是临时性的抖动,而是确实存在性能问题,这样重试多次也是没法成功的,反而会使得consumer的平均响应时间变长。比如正常情况下provider的平均响应时间是1s,consumer将超时时间设置成1.5s,重试次数设置为2次,这样单次请求将耗时3s,consumer的整体负载就会被拉下来,如果consumer是一个高QPS的服务,还有可能引起连锁反应造成雪崩。
爆炸式的重试风暴:假如一条调用链路经过了4个服务,最底层的服务D出现超时,这样上游服务都将发起重试,假设重试次数都设置的3次,那么B将面临正常情况下3倍的负载量,C是9倍,D是27倍,整个服务集群可能因此雪崩。
设置调用方的超时时间之前,先了解清楚依赖服务的TP99响应时间是多少(如果依赖服务性能波动大,也可以看TP95),调用方的超时时间可以在此基础上加50% 如果RPC框架支持多粒度的超时设置,则:全局超时时间应该要略大于接口级别最长的耗时时间,每个接口的超时时间应该要略大于方法级别最长的耗时时间,每个方法的超时时间应该要略大于实际的方法执行时间 区分是可重试服务还是不可重试服务,如果接口没实现幂等则不允许设置重试次数。注意:读接口是天然幂等的,写接口则可以使用业务单据ID或者在调用方生成唯一ID传递给服务端,通过此ID进行防重避免引入脏数据 如果RPC框架支持服务端的超时设置,同样基于前面3条规则依次进行设置,这样能避免客户端不设置的情况下配置是合理的,减少隐患 如果从业务角度来看,服务可用性要求不用那么高(比如偏内部的应用系统),则可以不用设置超时重试次数,直接人工重试即可,这样能减少接口实现的复杂度,反而更利于后期维护 重试次数设置越大,服务可用性越高,业务损失也能进一步降低,但是性能隐患也会更大,这个需要综合考虑设置成几次(一般是2次,最多3次) 如果调用方是高QPS服务,则必须考虑服务方超时情况下的降级和熔断策略。(比如超过10%的请求出错,则停止重试机制直接熔断,改成调用其他服务、异步MQ机制、或者使用调用方的缓存数据)
更多精彩推荐
☞拿下 Gartner 容器产品第一,阿里云打赢云原生关键一战!
☞斩获GitHub 2000+ Star,阿里云开源的 Alink 机器学习平台如何跑赢双11数据“博弈”?| AI 技术生态论
☞微软为一人收购一公司?破解索尼程序、写黑客小说,看他彪悍的程序人生!
☞IBM、微软、苹果、谷歌、三星……这些区块链中的科技巨头原来已经做了这么多事!
☞资深程序员总结:分析Linux进程的6个方法,我全都告诉你