背景
最近在开发自研日志平台的日志采集agent。这里记录下高可用设计的一些思考和技术细节。
当遇到发送到下游失败需要重试的场景。我们如何合理设计重试?如何优雅重试呢?
首先,我们需要明确重试的本质是什么?
重试的本质
重试的本质是我们认为这个故障是暂时的,而不是永久的,所以我们会去重试
因此,我们需要清晰的定义什么情况下需要重试,什么情况下重试是没有意义的
何时重试
对于日志采集这个场景来说,为了保证at least once
语义,大部分场景我们都需要无限重试。例如下游超时、限流,甚至宕机等。所以我们只需要定义出不需要重试的几种场景:
- 未知错误:通常为下游代码bug。重试有限次数即丢弃(可配置记录到磁盘)
- 业务错误:例如格式错误(非法数据)。这种错误不管重试几次永远都是错误,没有重试的必要
如何重试
通常重试会设置一个上界,例如最大重试次数、最大重试间隔
对于日志采集的场景,为了保证数据的可靠投递,上界选择最大重试间隔更合理。部分情况结合了最大重试次数(例如有限重试的场景:下游未知异常)
具体如何进行重试呢?常见的方式是每次重试失败时都会“休息”一段时间再重试,以避免一窝蜂的过快重试导致下游过大的负担和无谓的资源消耗
而常见的”重修->休息->重试”的算法叫做backoff退避算法。所以什么是backoff呢?
backoff是什么
这里用wiki中的Exponential backoff定义:
Exponential backoff is an algorithm that uses feedback to multiplicatively decrease the rate of some process, in order to gradually find an acceptable rate
简单来说:backoff退避算法就是一种利用反馈对某个过程的速率进行成倍的降低,以逐步找到一个可接受的速率的算法
从重试场景看,就是用某种算法,找到一个合理的重试时间,而不是所有异常请求都一窝蜂的直接去重试
为什么要有backoff
backoff使用场景是在网络中的节点发送数据发送冲突的时候,等待一定时间后再发,从而避免频繁的发生冲突。通常作为避免网络堵塞的一部分用于同一数据块的重发策略
想想一下这种场景,某个rpc服务的其中一个节点挂了,突然大规模的流量全部重试打到另一个节点,另一个节点很可能瞬间也被压垮。这也是一种“惊群效应”
另一个场景是,下游服务挂了,上游一直在重试直至下游服务恢复。如果没有backoff,上游全部无限循环重试,也是一种资源浪费
为了避免这些问题,我们希望发生异常的时候不是立即重试,而且等待一定时间后再重试
所以,大部分重试、重发等场景都会利用backoff算法来降低冲突和无谓的资源消耗
backoff的原理
backoff基本都需要设定最大重试次数或者最大间隔时间。因为无限次的重试往往没有意义,过长的间隔时间也不利于响应下游的恢复
backoff算法有以下几种常见实现方式:
fixed backoff
固定间隔时间的退避算法。每次重试都会间隔固定的interval
时间
- 优点:实现非常简单
- 缺点:
interval
不太好设定。设置的过小,下游长时间的故障可能造成大量资源浪费;设置的过大,对于偶现的网络抖动不能及时投递数据
random backoff
给定一个重试等待的最大时间maxInterval
,直接随机一个等待时间出来。范围是[0,maxInterval)
,比较暴力
- 优点:实现非常简单,较好的避免冲突
- 缺点也很明显:可能一次偶然的网络抖动,却等待了相当长一段时间才重试成功
fibonacci backoff
基于fibonacci数列的退避算法。能较好的避免冲突,及时响应短暂的下游故障
核心算法:
|
|
输出结果如下:
|
|
- 优点:能够快速恢复数据投递。适合下游故障能够快速恢复或者故障率很低的情况
- 缺点:对于下游较长时间的故障,比较浪费资源
exponential backoff
指数退避算法。也就是每次重试的间隔时间都是指数增长的。那为什么是指数增长呢?
可以从指数分布来看:
指数分布是独立事件的时间间隔的概率分布
指数分布满足下图:
可以看到,随着间隔时间变长,事件的发生概率急剧下降,呈指数式衰减
所以,指数退避算法随着重试次数的增加,时间间隔变长,发生冲突的概率是非常低的。因此很适合作为backoff的一种实现算法
核心代码如下:
|
|
minInterval
: 表示初始的时间间隔。例如10ms
factor
: 表示指数因子。例如2
attempts
: 表示重试的次数
输出结果如下:
|
|
- 优点:实现比较简单。同时冲突的概率也很低,在重试初期可以在相对比较短的时间内完成。对于服务宕机时间较长的情况,也可以在一个稳定的长时段内重试,不会空耗系统资源
- 缺点:当大部分节点恰好都在同一个时间点发生异常,那由于每次重试的间隔时间都是一致的会导致容易发生冲突
exponential jitter backoff
指数抖动退避算法,就是弥补指数退避算法的缺点。每次计算出下一次重试的间隔时间的时候加上一定的随机抖动时间,使同一时间需要重试的请求错开
核心代码:
|
|
minInterval
: 表示初始的时间间隔。例如10ms
factor
: 表示指数因子。例如2
attempts
: 表示重试的次数
jitterFactor
: 表示抖动的因子。例如0.5
输出结果如下:
|
|
自己用go实现了exponential jitter backoff
:传送门
欢迎大家使用和反馈
总结
理解了重试的本质后,我们只需要根据特定场景选择合适的重试backoff算法即可。backoff的实现有很多,大部分场景exponential jitter backoff
都能胜任