RPC(3)- 异常重试

最后更新:2020-05-17

1. 重试解决什么问题

假设A,B两个系统,当A->B的RPC调用失败时,我们可以采取的策略有以下几种:

  • failfast,即快速失败向上层抛出远程调用异常
  • failover,即A->B失败,选择集群其他机器A->Bn(1…N)
  • failsafe,失败吞异常
  • failback, 过一会再重试,比如网络抖动,等一小会在重试。

针对网络抖动、正在通信的对端机器正好重新上线等短时故障,重试是应对利器

我们可以考虑这样一个场景。

我们发起一次 RPC 调用,去调用远程的一个服务,比如用户的登录操作,我们会先对用户的用户名以及密码进行验证,验证成功之后会获取用户的基本信息。 当我们通过远程的用户服务来获取用户基本信息的时候,恰好网络出现了问题,比如网络突然抖了一下,导致我们的请求失败了,而这个请求我们希望它能够尽可能地执行成功,那这时我们需要重新发起一次 RPC 调用

1.1. 短时间故障的产生原因

在任何环节下应用都会有可能产生短时故障。即使是在没有网络参与的应用里,软件 bug 或硬件故障或一次意外断电都会造成短时故障。短时故障是常态,想做到高可用不是靠避免这些故障的发生,而是去思考短时故障发生之后的应对策略。

就互联网公司的服务而言,通过冗余,各种切换等已经极大提高了整体应用的可用性,但其内部的短时故障却是连绵不断,原因有这么几个:

  • 应用所使用的资源是共享的,比如 docker 、虚拟机、物理机混布等,如果多个虚拟单位(docker 镜像、虚拟机、进程等)之间的资源隔离没有做好,就可能产生一个虚拟单位侵占过多资源导致其它共享的虚拟单元出现错误。这些错误可能是短时的,也有可能是长时间的。
  • 现在服务器都是用比较便宜的硬件,即使是最重要的数据库,互联网公司的通常做法也是通过冗余去保证高可用。贵和便宜的硬件之间有个很重要的指标差异就是故障率,便宜的机器更容易发生硬件故障,虽然故障率很低,但如果把这个故障率乘以互联网公司数以万计、十万计的机器,每天都会有机器故障自然是家常便饭。这里有个硬盘故障率统计很有意思可以看看。
  • 除掉本身的问题外,现今的互联网架构所需要的硬件组件也更多了,比如路由和负载均衡等等,更多的组件,意味着通信链路上更多的节点,意味着增加了更多的不可靠。
  • 应用之间的网络通信问题,在架构设计时,对网络的基本假设就是不可靠,我们需要通过额外的机制弥补这种不可靠。

2. RPC重试机制

重试分为下面几步:

  1. 感知错误 通常我们使用错误码识别不同类型的错误。比如在 REST 风格的应用里面,HTTP 的 status code 可以用来识别不同类型的错误。
  2. 决策是否应该重试 不是所有错误都应该被重试,因为这个异常可能是服务提供方抛回来的业务异常,它是应该正常返回给调用方的,所以我们要在触发重试之前对捕获的异常进行判定,只有符合重试条件的异常才能触发重试,比如网络超时异常、网络连接异常等等。
  3. 选择重试策略 选择一个合适的重试次数和重试间隔非常的重要。如果次数不够,可能并不能有效的覆盖这个短时间故障的时间段,如果重试次数过多,或者重试间隔太小,又可能造成大量的资源(CPU 、内存、线程、网络)浪费。合适的次数和间隔取决于重试的上下文。
  4. 失败处理与自动恢复 短时故障如果短时间没有恢复就变成了长时间的故障,这个时候我们就不应该再进行重试了,但是等故障修复之后我们也需要有一种机制能自动恢复。

使用异常重试时需要注意哪些问题呢

2.1. 幂等

当网络突然抖动了一下导致请求超时了,但这个时候调用方的请求信息可能没有发送到服务提供方的节点上,也可能已经发送到服务提供方的服务节点上。 如果请求信息成功地发送到了服务节点上,这个节点就会执行业务逻辑。这个时候如果发起了重试,业务逻辑会再次执行。如果这个服务业务逻辑不是幂等的,比如插入数据操作,那触发重试的话就会引发问题。

在使用 RPC 框架的时候,我们要确保被调用的服务的业务逻辑是幂等的,这样我们才能考虑根据事件情况开启 RPC 框架的异常重试功能。

2.2. 重试的时间策略

  1. 指数避退 重试间隔时间按照指数增长,如等 3s 9s 27s 后重试。指数避退能有效防止对对端造成不必要的冲击,因为随着时间的增加,一个故障从短时故障变成长时间的故障的可能性是逐步增加的,对于一个长时间的故障,重试基本无效。
  2. 重试间隔线性增加 重试间隔的间隔按照线性增长,而非指数级增长,如等 3s 7s 13s 后重试。间隔增长能避免长时间等待,缩短故障响应时间。
  3. 固定间隔 重试间隔是一个固定值,如每 3s 后进行重试。
  4. 立即重试 有时候短时故障是因为网络抖动造成的,可能是因为网络包冲突或者硬件有问题等,这时候我们立即重试通常能解决这类问题。但是立即重试不应该超过一次,如果立即重试一次失败之后,应该转换为指数避退或者其它策略进行,因为大量的立即重试会给对端造成流量上的尖峰,对网络也是一个冲击。
  5. 随机间隔 当服务有多台实例时,我们应该加入随机的变量,比如 A 服务请求 B 服务,B 服务发生短时间不可用,A 服务的实例应该避免在同一时刻进行重试,这时候我们对间隔加入随机因子会很好的在时间上平摊开所有的重试请求。

2.3. 请求的超时时间

连续的异常重试可能会出现一种不可靠的情况,那就是连续的异常重试并且每次处理的请求时间比较长,最终会导致请求处理的时间过长,超出用户设置的超时时间。 例如:调用端的请求超时时间设置为 5s,结果连续重试 3 次,每次都耗时 2s,那最终这个请求的耗时是 6s,调用端设置的超时时间就不准确了。

解决这个问题最直接的方式就是,在每次重试后都重置一下请求的超时时间。当调用端发起 RPC 请求时,如果发送请求发生异常并触发了异常重试,我们可以先判定下这个请求是否已经超时,如果已经超时了就直接返回超时异常,否则就先重置下这个请求的超时时间,之后再发起重试。

2.3. 请求的负载均衡

当调用方设置了异常重试策略,发起了一次 RPC 调用,通过负载均衡选择了一个节点,将请求消息发送到这个节点,这时这个节点由于负载压力较大,导致这个请求处理失败了。 调用方触发了重试,再次通过负载均衡选择了一个节点,结果恰好仍选择了这个节点,在这种情况下,这个请求依然会失败,重试的效果就受到的影响。

因此,我们需要在所有发起重试、负载均衡选择节点的时候,去掉重试之前出现过问题的那个节点,以保证重试的成功率。

# 3. 参考资料

《RPC实战与核心原理》

https://www.v2ex.com/t/657090

Edgar

Edgar
一个略懂Java的小菜比