Zacard's Notes

日志采集高可用之重试

背景

最近在开发自研日志平台的日志采集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数列的退避算法。能较好的避免冲突,及时响应短暂的下游故障

核心算法:

1
2
3
4
next:=prev+prevPrev
prevPrev = prev
prev = next
return next

输出结果如下:

1
2
3
4
5
6
7
8
9
=== RUN TestFibonacci_Next
0ms
10ms
10ms
20ms
30ms
50ms
80ms
130ms
  • 优点:能够快速恢复数据投递。适合下游故障能够快速恢复或者故障率很低的情况
  • 缺点:对于下游较长时间的故障,比较浪费资源

exponential backoff

指数退避算法。也就是每次重试的间隔时间都是指数增长的。那为什么是指数增长呢?

可以从指数分布来看:

指数分布是独立事件的时间间隔的概率分布

指数分布满足下图:

可以看到,随着间隔时间变长,事件的发生概率急剧下降,呈指数式衰减

所以,指数退避算法随着重试次数的增加,时间间隔变长,发生冲突的概率是非常低的。因此很适合作为backoff的一种实现算法

核心代码如下:

1
next := float64(minInterval) * math.Pow(factor, attempts)

minInterval: 表示初始的时间间隔。例如10ms

factor: 表示指数因子。例如2

attempts: 表示重试的次数

输出结果如下:

1
2
3
4
5
6
7
8
9
10
=== RUN TestExponential_Next
10ms
20ms
40ms
80ms
160ms
320ms
640ms
1280ms
...
  • 优点:实现比较简单。同时冲突的概率也很低,在重试初期可以在相对比较短的时间内完成。对于服务宕机时间较长的情况,也可以在一个稳定的长时段内重试,不会空耗系统资源
  • 缺点:当大部分节点恰好都在同一个时间点发生异常,那由于每次重试的间隔时间都是一致的会导致容易发生冲突

exponential jitter backoff

指数抖动退避算法,就是弥补指数退避算法的缺点。每次计算出下一次重试的间隔时间的时候加上一定的随机抖动时间,使同一时间需要重试的请求错开

核心代码:

1
2
3
4
5
6
7
next := float64(minInterval) * math.Pow(factor, attempts)
if jitterFactor > 0 {
j := jitterFactor * next
min := next - j
max := next + j
next = min + rand.Float64()*(max-min+1)
}

minInterval: 表示初始的时间间隔。例如10ms

factor: 表示指数因子。例如2

attempts: 表示重试的次数

jitterFactor: 表示抖动的因子。例如0.5

输出结果如下:

1
2
3
4
5
6
7
8
9
=== RUN TestExponential_NextWithJitter
11ms
28ms
46ms
75ms
147ms
379ms
362ms
840ms

自己用go实现了exponential jitter backoff传送门

欢迎大家使用和反馈

总结

理解了重试的本质后,我们只需要根据特定场景选择合适的重试backoff算法即可。backoff的实现有很多,大部分场景exponential jitter backoff都能胜任

坚持原创技术分享,您的支持将鼓励我继续创作!

热评文章