从 gRPC 的重试策略说起

2020-03-28 16:03:41 +08:00
 hechen0

本文首发在 技术成长之道 博客,访问 hechen0.com 查看更多,或者微信搜索「技术成长之道」关注我的公众号,或者扫描下方二维码关注 公众号获得第一时间更新通知!

本文让你了解

  1. 重试解决什么问题
  2. 短时故障的产生原因
  3. 处理短时故障的挑战
  4. 重试分为几步
  5. gRPC 是如何进行重试的

1. 重试解决什么问题

如今的互联网服务早已不是单体应用,而是由若干个模块组成的微服务,每个模块可以进行单独的扩容、缩容,独立上线部署等等;模块与模块之间通过网络进行联通。我们的应用必须对网络错误进行妥善的处理。从发生时长上而言,网络错误可以分为两类:

  1. 长时间不可用,如光纤被挖断,机房被炸等
  2. 短时间不可用,比如网络出现抖动,正在通信的对端机器正好重新上线等

而重试是应对短时故障利器,简单却异常有效。

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

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

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

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

3. 处理短时故障的挑战

短时故障处理以下两点挑战

  1. 感知。应用需要能够区分不同类型的错误,不同类型的错误对应的错误处理方式是不同的,没有哪种应对手段可以处理所有的错误。比如网络抖动我们简单重试即可,如果网络不可用,对于一个可靠的存储系统,可能就需要经历选主,副本切换等复杂操作才能保证数据的正确性。
  2. 处理。如何选择一个合适的处理策略对于快速恢复故障、缩短响应时间以及减少对对端的冲击是非常重要的。

4. 重试分为几步

  1. 感知错误。通常我们使用错误码识别不同类型的错误。比如在 REST 风格的应用里面,HTTP 的 status code 可以用来识别不同类型的错误。
  2. 决策是否应该重试。不是所有错误都应该被重试,比如 HTTP 的 4xx 的错误,通常 4xx 表示的是客户端的错误,这时候客户端不应该进行重试操作。什么错误可以重试需要具体情况具体分析,对于网络类的错误,我们也不是一股脑都进行重试,比如 zookeeper 这种强一致的存储系统,发生了 network partition 之后,需要经过一系列复杂操作,简单的重试根本不管用。
  3. 选择重试策略。选择一个合适的重试次数和重试间隔非常的重要。如果次数不够,可能并不能有效的覆盖这个短时间故障的时间段,如果重试次数过多,或者重试间隔太小,又可能造成大量的资源(CPU 、内存、线程、网络)浪费。合适的次数和间隔取决于重试的上下文。举例:如果是用户操作失败导致的重试,比如在网页上点了一个按钮失败的重试,间隔就应该尽量短,确保用户等待时间较短;如果请求失败成本很高,比如整个流程很长,一旦中间环节出错需要重头开始,典型的如转账交易,这种情况就需要适当增加重试次数和最长等待时间以尽可能保证短时间的故障能被处理而无需重头来过。
  4. 失败处理与自动恢复。短时故障如果短时间没有恢复就变成了长时间的故障,这个时候我们就不应该再进行重试了,但是等故障修复之后我们也需要有一种机制能自动恢复。

4.1 常见的重试时间间隔策略

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

5. gRPC 是如何进行重试的

5.1 如何感知错误

gRPC 有自己一套类似 HTTP status code 的错误码,每个错误码都是个字符串,如 INTERNAL 、ABORTED 、UNAVAILABLE 。

5.2 如何决策

对于哪些错误可以重试是可配置的。通常而言,只有那些明确标识对端没有接收处理请求的错误才需要被重试,比如对端返回一个 UNAVAILABLE 错误,这代表对端的服务目前处于不可用状态。但也可以配置一个更加激进的重试策略,但关键是需要保证这些被重试的 gRPC 请求是幂等的,这个需要服务使用者和提供者共同协商出一个可以被重试的错误集合。

5.3 重试策略

gRPC 的重试策略分为两类

  1. 重试策略,失败后进行重试。
  2. 对冲策略,一次请求会给对端发出多个相同请求,只要有一个成功就认为成功。

先说下重试策略

重试之时间策略

gPRC 用了上面我们提到的 指数避退+随机间隔 组合起来的方式进行重试,详见这里

/* 伪码 */

ConnectWithBackoff()
  current_backoff = INITIAL_BACKOFF
  current_deadline = now() + INITIAL_BACKOFF
  while (TryConnect(Max(current_deadline, now() + MIN_CONNECT_TIMEOUT))
         != SUCCESS)
    SleepUntil(current_deadline)
    current_backoff = Min(current_backoff * MULTIPLIER, MAX_BACKOFF)
    current_deadline = now() + current_backoff +
      UniformRandom(-JITTER * current_backoff, JITTER * current_backoff)

上面的算法里有这么几个关键的参数

  1. INITIAL_BACKOFF:第一次重试等待的间隔
  2. MULTIPLIER:每次间隔的指数因子
  3. JITTER:控制随机的因子
  4. MAX_BACKOFF:等待的最大时长,随着重试次数的增加,我们不希望第 N 次重试等待的时间变成 30 分钟这样不切实际的值
  5. MIN_CONNECT_TIMEOUT:一次成功的请求所需要的时间,因为即使是正常的请求也需要有响应时间,比如 200ms,我们的重试时间间隔显然要大于这个响应时间才不会出现请求明明已经成功,但却进行重试的操作。

通过指数的增加每次重试间隔,gRPC 在考虑对端服务和快速故障处理中间找到了一个平衡点。

重试之次数策略

上面的算法里面没有关于次数的限制,gRPC 中的最大重试次数是可配置的,硬限制的最大值为5 次,设置这个硬限制的目的我想主要还是出于对对端服务的保护,避免一些人为的错误。

再说下对冲策略

对冲之时间策略

对冲策略里面,请求是按照如下逻辑发出的:

  1. 第一次正常的请求正常发出
  2. 在等待固定时间间隔后,没有收到正确的响应,第二个对冲请求会被发出
  3. 再等待固定时间间隔后,没有收到任何前面两个请求的正确响应,第三个会被发出
  4. 一直重复以上流程直到发出的对冲请求数量达到配置的最大次数
  5. 一旦收到正确响应,所有对冲请求都会被取消,响应会被返回给应用层

对冲之次数策略

次数和上面重试是一样的限制,都是 5 次。

其它需要注意的问题

  1. 不同的对冲请求应该被对端不同的实例处理
  2. 对冲策略应该只用于幂等的操作,因为不同的对冲的请求通常是由不同的对端实例处理的

5.4 重试失败

当然不能一直重试,对于重试失败,gRPC 有以下的策略以顾全大局,对于每个 server,客户端都可配置一个针对该 server 的限制策略如下:

"retryThrottling": {
  "maxTokens": 10,
  "tokenRatio": 0.1
}

对于每个 server,gRPC 的客户端都维护了一个 token_count 变量,变量初始值为配置的 maxTokens 值,每次 RPC 请求都会影响这个 token_count 变量值:

  1. 每次失败的 RPC 请求都会对 token_count 减 1
  2. 每次成功的 RPC 请求都会对 token_count 增加 tokenRation 值

如果 token_count <= (maxTokens / 2),那么后续发出的请求即使失败也不会进行重试了,但是正常的请求还是会发出去,直到这个 token_count > (maxTokens / 2) 才又恢复对失败请求的重试。这种策略可以有效的处理长时间故障。

当然重试失败还能更进一步,比如 Netflix 出品的hytrix能对故障进行熔断&降级处理,感兴趣的读者可以进一步了解。

总结

本文从问题出发,介绍『重试』这种简单而又有效的故障处理手段,希望能对大家有所帮助,有任何问题欢迎在评论区留言交流,或扫描二维码 /微信搜索『技术成长之道』关注公众号后留言私信。

1843 次点击
所在节点    程序员
2 条回复
xuanbg
2020-03-28 23:10:12 +08:00
这种重试机制必须是同步的且只适用于很短时间内的网络波动造成的没有响应。时间稍微长一点的话,上游调用者都因为超时失败了,你重试成功反而有问题了。

如果真需要重试机制,还是用异步模式去处理比较好,譬如用一个延时队列进行重试。
feelinglucky
2020-03-30 11:46:09 +08:00
@xuanbg 如果是原子化的操作使用楼主的方法是没有问题的,但有可能造成重试阻塞

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

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

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX