重试的风险
重试能够提高做事稳定性,但是一样平常情形下大家都不会轻易去重试,或者说不敢重试,紧张是由于重试有放大故障的风险。
首先,重试会加大直接下贱的负载。如下图,假设 A 做事调用 B 做事,重试次数设置为 r(包括首次要求),当 B 高负载时很可能调用不堪利,这时 A 调用失落败重试 B ,B 做事的被调用量快速增大,最坏情形下可能放大到 r 倍,不仅不能要求成功,还可能导致 B 的负载连续升高,乃至直接打挂。
更恐怖的是,重试还会存在链路放大的效应,结合下图解释一下:

假设现在场景是 Backend A 调用 Backend B,Backend B 调用 DB Frontend,均设置重试次数为 3 。如果 Backend B 调用 DB Frontend,要求 3 次都失落败了,这时 Backend B 会给 Backend A 返回失落败。但是 Backend A 也有重试的逻辑,Backend A 重试 Backend B 三次,每一次 Backend B 都会要求 DB Frontend 3 次,这样算起来,DB Frontend 就会被要求了 9 次,实际是指数级扩大。假设正常访问量是 n,链路一共有 m 层,每层重试次数为 r,则末了一层受到的访问量最大,为 n r ^ (m - 1) 。这种指数放大的效应很恐怖,可能导致链路上多层都被打挂,全体系统雪崩。
重试的利用本钱其余利用重试的本钱也比较高。之前在字节跳动的内部框架和做事管理平台中都没有支持重试,在一些很须要重试的业务场景下(比如调用一些第三方业务常常失落败),业务方可能用大略 for 循环来实现,基本不会考虑重试的放大效应,这样很不屈安,公司内部涌现过多次由于重试而导致的事件,且出事件的时候还须要修正代码上线才能关闭重试,导致事件规复也不迅速。
其余也有一些业务利用开源的重试组件,这些组件常日会考虑对直接下贱的保护,但不会考虑链路级别的重试放大,其余须要业务方修正 RPC 调用代码才能利用,对业务代码入侵较多,而且也是静态配置,须要修正配置时都必须重新上线。
基于以上的背景,为了让业务方能够灵巧安全的利用重试,我们字节跳动直播中台团队设计和实现了一个重试管理组件,具有以下优点:
能够在链路级别防重试风暴。担保易用性,业务接入本钱小。具有灵巧性,能够动态调度配置。下面先容详细的实现方案。
重试管理动态配置如何让业务方大略接入是首先要办理的问题。如果还是普通组件库的办法,依旧免不了要大量入侵用户代码,且很难动态调度。
字节跳动的 Golang 开拓框架支持中间件 (Milddleware) 模式,可以注册多个自定义 Middleware 并依次递归调用,常日是用于完成打印日志、上报监控等非业务逻辑,能够有效将业务和非业务代码功能进行解耦。因此我们决定利用 Middleware 的办法来实现重试功能,定义一个 Middleware 并在内部实现对 RPC 的重复调用,把重试的配置信息用字节跳动的分布式配置存储中央存储,这样 Middleware 中能够读取配置中央的配置并进行重试,对用户来说不须要修正调用 RPC 的代码,而只须要在做事中引入一个全局的 Middleware 即可。
如下面的整体架构图所示,我们供应配置的网页和后台,用户能够在专门进行做事管理的页面上很方便的对 RPC 进行配置修正并自动生效,内部的实现逻辑对用户透明,对业务代码无入侵。
配置的维度按照字节跳动的 RPC 调用特点,选定 [调用方做事,调用方集群,被调用做事, 被调用方法] 为一个元组,按照元组来进行配置。Middleware 中封装了读取配置的方法,在 RPC 调用的时候会自动读取并生效。
这种 Middleware 的办法能够让业务方很随意马虎接入,相对付之前普通组件库的办法要方便很多,并且一次接入往后就具有动态配置的能力,可能很方便地调度或者关闭重试配置。
退避策略确定了接入办法往后就可以开始实现重试组件的详细功能,一个重试组件所包含的基本功能中,除了重试次数和总延时这样的根本配置外,还须要有退避策略。
对付一些暂时性的缺点,如网络抖动等,可能立即重试还是会失落败,常日等待一小会儿再重试的话成功率会较高,并且也可能打散上游重试的韶光,较少由于同时都重试而导致的下贱瞬间流量高峰。决定等待多久之后再重试的方法叫做退避策略,我们实现了常见的退避策略,如:
线性退避:每次等待固定时间后重试。随机退避:在一定范围内随机等待一个韶光后重试。指数退避:连续重试时,每次等待韶光都是前一次的倍数。防止 retry storm如何安全重试,防止 retry storm 是我们面临的最大的难题。
限定单点重试首先要在单点进行限定,一个做事不能不受限定的重试下贱,很随意马虎造成下贱被打挂。除了限定用户设定的重试次数上限外,更主要的是限定重试要求的成功率。
实现的方案很大略,基于断路器的思想,限定 要求失落败/要求成功 的比率,给重试增加熔断功能。我们采取了常见的滑动窗口的方法来实现,如下图,内存中为每一类 RPC 调用掩护一个滑动窗口,比如窗口分 10 个 bucket ,每个 bucket 里面记录了 1s 内 RPC 的要求结果数据(成功、失落败)。新的一秒到来时,天生新的 bucket ,并淘汰最早的一个 bucket ,只坚持 10s 的数据。在新要求这个 RPC 失落败时,根据前 10s 内的 失落败/成功 是否超过阈值来判断是否可以重试。默认阈值是 0.1 ,即下贱最多承受 1.1 倍的 QPS ,用户可以根据须要自行调度熔断开关和阈值。
限定链路重试
前面说过在多级链路中如果每层都配置重试可能导致调用量指数级扩大,虽然有了重试熔断之后,重试不再是指数增长(每一单节点重试扩大限定了 1.1 倍),但还是会随着链路的级数增长而扩大调用次数,因此还是须要从链路层面来考虑重试的安全性。
链路层面的防重试风暴的核心是限定每层都发生重试,空想情形下只有最下一层发生重试。Google SRE 中指出了 Google 内部利用分外缺点码的办法来实现:
统一约定一个分外的 status code ,它表示:调用失落败,但别重试。任何一级重试失落败后,天生该 status code 并返回给上层。上层收到该 status code 后停滞对这个下贱的重试,并将缺点码再传给自己的上层。这种办法空想情形下只有最下一层发生重试,它的上游收到缺点码后都不会重试,链路整体放大倍数也便是 r 倍(单层的重试次数)。但是这种策略依赖于业务方通报缺点码,对业务代码有一定入侵,而且常日业务方的代码差异很大,调用 RPC 的办法和场景也各不相同,须要业务方合营进行大量改造,很可能由于漏改等缘故原由导致没有把从下贱拿到的缺点码通报给上游。
好在字节跳动内部用的 RPC 协议中有扩展字段,我们在 Middleware 中做了很多考试测验,封装了缺点码处理和通报的逻辑,在 RPC 的 Response 扩展字段中通报缺点码标识 nomore_retry ,它见告上游不要再重试了。Middleware 完成缺点码的天生、识别、通报等全体生命周期的管理,不须要业务方修正本身的 RPC 逻辑,缺点码的方案对业务来说是透明的。
在链路中,推进每层都接入重试组件,这样每一层都可以通过识别这个标志位来停滞重试,并逐层往上通报,上层也都停滞重试,做到链路层面的防护,达到“只有最靠近缺点发生的那一层才重试”的效果。
超时处理在测试缺点码上传的方案时,我们创造超时的情形可能导致通报缺点码的方案失落效。
对付 A -> B -> C 的场景,假设 B -> C 超时,B 重试要求 C ,这时候很可能 A -> B 也超时了,以是 A 没有拿到 B 返回的缺点码,而是也会重试 B , 这个时候虽然 B 重试 C 且天生了重试失落败的缺点码,但是却不能再通报给 A 。这种情形下,A 还是会重试 B ,如果链路中每一层都超时,那么还是会涌现链路指数扩大的效应。
因此为了处理这种情形,除了下贱通报重试缺点标志以外,我们还实现了“对重试要求不重试”的方案。
对付重试的要求,我们在 Request 中打上一个分外的 retry flag ,在上面 A -> B -> C 的链路,当 B 收到 A 的要求时会先读取这个 flag 判断这个要求是不是重试要求,如果是,那它调用 C 纵然失落败也不会重试;否则调用 C 失落败后会重试 C 。同时 B 也会把这个 retry flag 下传,它发出的要求也会有这个标志,它的下贱也不会再对这个要求重试。
这样纵然 A 由于超时而拿不到 B 的返回,对 B 发出重试要求后,B 能感知到并且不会对 C 重试,这样 A 最多要求 r 次,B 最多要求 r + r - 1,如果后面还有更下层次的话,C 最多要求 r + r + r - 2 次, 第 i 层最多要求 i r - (i-1) 次,最坏情形下是倍数增长,不是指数增长了。加上实际还有重试熔断的限定,增长的幅度要小很多。
通过重试熔断来限定单点的放大倍数,通过重试缺点标志链路回传的办法来担保只有最下层发生重试,又通过重试要求 flag 链路下传的办法来担保对重试要求不重试,多种掌握策略结合,可以有效地较少重试放大效应。
超时场景优化分布式系统中,RPC 要求的结果有三种状态:成功、失落败、超时,个中最难处理的便是超时的情形。但是超时每每又是最常常发生的那一个,我们统计了字节跳动直播业务线上一些主要做事的 RPC 缺点分布,创造占比最高的便是超时缺点,怕什么偏来什么。
在超时重试的场景中,虽然给重试要求添加 retry flag 能防止指数扩大,但是却不能提高要求成功率。如下图,如果 A 和 B 的超时时间都是 1000ms ,当 C 负载很高导致 B 访问 C 超时,这时 B 会重试 C ,但是韶光已经超过了 1000ms ,韶光 A 这里也超时了并且断开了和 B 的连接,以是 B 这次重试 C 不管是否成功都是无用功,从 A 的视角看,本次要求已经失落败了。
这种情形的实质缘故原由是由于链路上的超时时间设置得不合理,上游和下贱的超时时间设置的一样,乃至上游的超时时间比下贱还要短。在实际情形中业务一样平常都没有专门配置过 RPC 的超时时间,以是可能高下游都是默认的超时,时长是一样的。为了应对这种情形,我们须要有一个机制来优化超时情形下的稳定性,并减少无用的重试。
如下图,正常重试的场景是等拿到 Resp1 (或者拿到超时结果) 后再发起第二次要求,整体耗时是 t1 + t2 。我们剖析下,service A 在发出去 Req1 之后可能等待很长的韶光,比如 1s ,但是这个要求的 pct99 或者 pct999 可能常日只有 100ms 以内,如果超过了 100ms ,有很大概率是这次访问终极会超时,能不能不要傻等,而是提前重试呢?
基于这种思想,我们引入并实现了 Backup Requests 的方案。如下图,我们预先设定一个阈值 t3(比超时时间小,常日建议是 RPC 要求延时的 pct99 ),当 Req1 发出去后超过 t3 韶光都没有返回,那我们直接发起重试要求 Req2 ,这样相称于同时有两个要求运行。然后等待要求返回,只要 Resp1 或者 Resp2 任意一个返回成功的结果,就可以立即结束这次要求,这样整体的耗时便是 t4 ,它表示从第一个要求发出到第一个成功结果返回之间的韶光,比较于等待超时后再发出要求,这种机制能大大减少整体延时。
实际上 Backup Requests 是一种用访问量来换成功率 (或者说低延时) 的思想,当然我们会掌握它的访问量增大比率,在发起重试之前,会为第一次的要求记录一次失落败,并检讨当前失落败率是否超过了熔断阈值,这样整体的访问比率还是会在掌握之内。
结合 DDLBackup Requests 的思路能在缩短整体要求延时的同时减少一部分的无效要求,但不是所有业务场景下都适宜配置 Backup Requests ,因此我们又结合了 DDL 来掌握无效重试。
DDL 是“ Deadline Request 调用链超时”的简称,我们知道 TCP/IP 协议中的 TTL 用于判断数据包在网络中的韶光是否太长而应被丢弃,DDL 与之类似,它是一种全链路式的调用超时,可以用来判断当前的 RPC 要求是否还须要连续下去。如下图,字节跳动的根本团队已经实现了 DDL 功能,在 RPC 要求调用链中会带上超时时间,并且每经由一层就减去该层处理的韶光,如果剩下的韶光已经小于即是 0 ,则可以不须要再要求下贱,直接返回失落败即可。
DDL 的办法能有效减少对下贱的无效调用,我们在重试管理中也结合了 DDL 的数据,在每一次发起重试前都会判断 DDL 的剩余值是否还大于 0 ,如果已经不知足条件了,那也就没必要对下贱重试,这样能做到最大限度的减少无用的重试。
实际的链路放大效应之前说的链路指数放大是空想情形下的剖析,实际的情形要繁芜很多,由于有很多影响成分:
策略解释重试熔断要求失落败 / 成功 > 0.1 时停滞重试链路上传缺点标志下层重试失落败后上传缺点标志,上层不再重试链路下传重试标志重试要求分外标记,下层对重试要求不会重试DDL当剩余韶光不足时不再发起重试要求框架熔断微做事框架本身熔断、过载保护等机制也会影响重试效果
各种成分综合下来,终极实际方法情形不是一个大略的打算公式能解释,我们布局了多层调用链路,在线上实际测试和记录了在不同缺点类型、不同缺点率的情形下利用重试管理组件的效果,创造接入重试管理组件后能够在链路层面有效的掌握重试放大倍数,大幅减少重试导致系统雪崩的概率。
总结如上所述,基于做事管理的思想我们开拓了重试管理的功能,支持动态配置,接入办法基本无需入侵业务代码,并利用多种策略结合的办法在链路层面掌握重试放大效应,兼顾易用性、灵巧性、安全性,在字节跳动内部已经有包括直播在内的很多做事接入利用并上线验证,对提高做事本身稳定性有良好的效果。目前方案已经被验证并在字节跳动直播等业务推广,后续将为更多的字节跳动业务做事。
加入我们我们是字节跳动跳动直播中台团队,卖力旗下抖音、抖音火山版、西瓜视频、今日头条等各 APP 的直播业务的根本做事研发,业务快速发展,用户群体巨大。如果你对技能充满激情亲切,欢迎加入字节跳动直播中台团队,和我们一同办理各种技能难题。目前我们在北京、深圳、杭州等地均有招聘需求,内推可以联系邮件: tech@bytedance.com ; 邮件标题: 姓名 - 事情年限 - 直播 - 平台 。
欢迎关注「 字节跳动技能团队 」
简历投递联系邮箱「 tech@bytedance.com 」