超时重试机制

超时重试机制,第1张

超时重试机制

参考 https://zhuanlan.zhihu.com/p/115918351

数据从起点数据库【上游】,经过数据处理层、数据传递层、数据应用层到终端【下游】用户。

RPC 请求的结果有三种状态:成功、失败、超时

数据库超时

数据库超时的情况:业务模块在某些时间段可能会出现对数据库的请求量过多的情况,数据库机器会因为CPU打满而无法提供服务,大量请求都超时了,此时最大的问题往往是慢SQL,分析慢SQL日志可以找出问题并且解决。

我们平时写访问数据库的代码时,可以考虑优化:

  1. 评估SQL语句的执行时间,尽量避免写慢SQL
  2. 考虑能不能用一定大小的本地缓存来减少数据库访问次数,但是要保证缓存命中率比较高,因为低命中率的缓存是没有意义的,单纯浪费内存。

但是即使很小心的设计对数据库的访问,也依然可能会出错,导致因为大量访问数据库而宕机,结果就是业务服务不稳定,总是要处理现网问题。所以需要从架构上想办法,即使错误访问数据库也能够保持服务稳定,提高容错率。

重试

必须是幂等 *** 作才能重试,所以读 *** 作是完美适配重试机制的。重试机制可以应对网络波动一类的情况,比如数据包丢失、访问的服务有波动等等,此时重试或许就能够得到正常响应。但是重试也有许多问题需要思考

  • 怎么定义超时,需要提前评估服务的正常响应时间
  • 如果重试后还是失败,应该再继续重试多少次
  • 每次重试相隔多少时间

参考文章如何优雅地重试,其中提到的是字节的直播中台团队写的重试组件,对重试机制进行了详细的深挖,下面是这篇文章的笔记。

重试机制与业务代码解耦

在很多框架都不自带重试机制的情况下,往往是程序员自己在业务代码里用for函数进行重试的,缺点是如果需要修改重试的一些配置参数时,都需要修改业务模块,然后重新编译上线,非常麻烦。因此,高效的重试应该与业务代码解耦,字节团队提出使用Golang 开发框架的中间件 (Milddleware)来开发重试组件, 并且把重试的配置信息存放在分布式存储中心,可以随时方便地修改配置信息,从而将重试机制与业务代码解耦。

退避策略

退避策略,决定等待多久之后再重试的方法叫做退避策略,字节团队实现了三个简单的退避策略

  • 线性退避:每次等待固定时间后重试。

  • 随机退避:在一定范围内随机等待一个时间后重试。

  • 指数退避:连续重试时,每次等待时间都是前一次的倍数。

重试风暴

重试还有链路放大效应,越到上游重试的次数越多,负担也越重。比如A访问数据库失败,重试n次,B访问A失败,也重试n次,那数据库最后就会被访问n的平方次。很多业务团队自己用for循环重试时,都只考虑到自己的业务模块,没有考虑到整体的链路放大的问题,出问题时可能会导致上游服务被过量请求打垮的问题。对此,字节团队提出从单点和链路两个角度限制重试。

限制单点重试

既然链路上的重试次数是指数级别上涨的,那如果能够限制每个服务的重试次数,那整体增长的程度也没那么大,所以文章提出用滑动窗口的方法来限制是否可以重试

用一个窗口大小为10s的滑动窗口装每一秒的请求成功和失败的数量,统计窗口内的失败率,如果失败率高于10%就不准重试了,直到旧的1秒被滑动过去了,失败率降下去了,才允许再重试,这样就可以保证上游最多只会受到原本的1.1倍的负担。这样即使有重试风暴,最后的指数增长也不会那么大。

限制链路重试

最理想的情况下应该是只有最上游的服务,比如访问数据库的那一个服务才重试,其他下游服务都不重试。所以字节团队在他们的重试组件里加一个特殊的错误码,收到这个错误码的服务不重试,没有收到这个错误码的就重试。这样就可以防止链路放大的效应。

  • 统一约定一个特殊的 status code ,它表示:调用失败,但别重试。

  • 任何一级重试失败后,生成该 status code 并返回给上层。

因此就可以做到只有最上游的一层重试,下游的都不重试。阻止了重试风暴。(因为最上游的那个服务访问数据库,数据库是基础架构团队,不用这一套东西,所以不会返回这个错误码,所以会重试。)

而且是由Middleware 完成错误码的生成、识别、传递等整个生命周期的管理,不需要业务方修改本身的 RPC 逻辑,错误码的方案对业务来说是透明的。(在下图的扩展字段中传递错误码标识 nomore_retry )

超时处理

上面的限制链路重试中,假如有一个请求链路是A->B->C,如果B请求C超时,那么由于C没有返回响应,B会重试,但是B重试时A还没有收到B的响应,于是A也觉得超时了,要重试,这种情况下还是会发生重试风暴。因此,超时会使得特殊错误码防止重试风暴的方法失效,为此,除了在Response里加特殊字段nomore_retry外,也应该在Request里加特殊字段retry_flag,比如A请求B时,如果发送的是一个重试请求,就会携带一个retry_flag字段告诉B这是一个重试请求,这种情况下B访问C即使失败了也不会重试,这样的情况下,重试次数只会倍数增长而不是指数级增长。

总的来说,文章提出了三种改善重试风暴的方法

  • 通过重试熔断来限制单点的放大倍数
  • 通过在Response里回传nomore_retry的方式来保证只有最下层发生重试
  • 通过在Request里传递retry_flag的方式来保证对重试请求不重试

多种控制策略结合,可以有效地较少重试放大效应。

超时场景优化

同样是请求链路是A->B->C,如果A的超时时间和B的超时时间相同,那么当B访问C超时,并且重试时,A也已经超时并且开始重试了,也就是说B的重试已经毫无意义了,即使重试拿到了数据再想返回给A时,A早就断开与B的连接了。这种问题的本质就是链路上的超时时间设置不合理,而且实际的业务部门都没有考虑过链路都超时问题,往往是设置相同的超时时间。

对此,文章提出,Req1发出去后,如果t3时间后还没有响应,就直接异步发送一个重试请求,这样后面只要任意一个请求返回响应就可以结束了。

但这种做法实际上是用访问量来换时间,虽然可以让serverA不用等那么久的超时时间,但是提前异步发送重试请求也会加重上游服务的负担,所以也算是一个需要权衡利弊的思路吧。

限流/熔断

限流、熔断(降级):最直观的方法就是在数据库那边做限流,在业务代码这边做熔断了。

从数据库团队的角度看,他们的数据库集群需要为许多不同的业务提供服务,如果机器被其中一个业务的请求打满了导致无法提供服务,就会导致其他业务也受到影响。因此应该对请求做限流处理,保证服务的稳定性。(但是数据库团队的事情我们管不了)

从业务团队的角度看,我们的服务依赖于数据库服务,超时的情况下,如果只是网络或者服务波动,还可以通过重试来解决,但是如果上游服务已经彻底无法提供服务了,绝大部分的请求都超时或者失败了,那么这个时候就需要考虑熔断措施了。

这部分参考流量治理方案(限流算法、熔断机制)

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/zaji/5683265.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-12-17
下一篇 2022-12-17

发表评论

登录后才能评论

评论列表(0条)

保存