【深入理解SpringCloud微服务】手写实现断路器算法
- 断路器状态切换
- 断路器接口
- 断路器算法实现
- 相关属性
- failed()
- success()
- canPass()
断路器状态切换
在分析断路器算法前,我们先复习一下断路器的状态转换。
断路器一般有三个状态:关闭、打开、半开。
- 断路器一开始处于关闭状态,此时请求能正常通过;
- 断路器会记录调用失败数,当失败数(或失败率)达到一定阈值,断路器就会变成打开状态,此时请求将不会被正常处理;
- 当断路器处于打开状态经过一段时间后,会切换为半开状态,此时断路器会允许一个请求通过,如果请求处理成功,则断路器切换为关闭状态,否则还是切换为打开状态。
断路器接口
/**
* 断路器
* @author huangjunyi
* @date 2023/12/27 15:40
* @desc
*/
public interface Breaker {
/**
* 记录成功调用
*/
void success();
/**
* 记录失败调用
*/
void failed();
/**
* 判断是否可通行
*/
boolean canPass();
}
Breaker接口有success()、failed()、canPass()三个方法,其中canPass()就是根据断路器状态判断请求能否正常通过。
断路器算法实现
相关属性
我们给Breaker接口提供默认实现类BasicBreaker。
/**
* @author huangjunyi
* @date 2023/12/27 19:11
* @desc
*/
public class BasicBreaker implements Breaker {
// 时间窗长度(单位秒)
private int timeSpan;
// 时间窗内最大允许错误数量
private int maxFailedNum;
// 断路器状态,闭合状态为true,表示请求可以通过,开路状态为false,请求不可通过
private boolean close;
// 断路器最后一次打开的时间点,闭合状态为-1
private long lastOpenTime;
// 断路器开路状态时间(断路器处于开路状态超过该时间,就会转为半开状态)
private long openTime;
// 时间跨度内错误数量统计,一个AtomicInteger对应一个时间窗格,一个时间窗格的跨度为1s
private AtomicInteger[] failedCounter;
// 时间窗数组
// 一个数组元素表示一个时间窗格
// 每个窗格记录的是以秒为单位的时间戳,用于判断时间窗是否过期
private long[] times;
...
}
我们的BasicBreaker实现的是滑动时间窗统计失败数的断路器。这里的滑动时间窗算法与上一篇文章《手写实现各种限流算法——固定时间窗、滑动时间窗、令牌桶算法、漏桶算法》中实现的滑动时间窗算法是基本一样的。
但是这里的时间窗是1秒一个时间窗格,而时间窗长度是需要用户指定的timeSpan(单位秒)。AtomicInteger[] failedCounter则是记录失败数的计数器数组,每个时间窗格对应一个计算器。
比如我们设置了timeSpan=5,表示时间窗长度为5,于是时间窗数组times和计数器数组failedCounter长度为5,那么就是下面那样:
当失败数达到阈值maxFailedNum是,断路器切换为打开,也就是close由true变为false,此时请求将不再正常通过。
failed()
failed()方法的作用是当接口处理失败或超时时记录失败数。
@Override
public void failed() {
long currentTimeMillis = System.currentTimeMillis();
// 当前这一秒,是从1970年1月1日零点到现在的第几秒
long currentTimeSeconds = currentTimeMillis / 1000L;
// 计算时间窗格下标
int timeIndex = (int) (currentTimeSeconds % times.length);
// 判断时间窗格对应的计数器是否为空,
// 如果不为空再判断时间窗格的秒值与currentTimeSeconds是否相等
// 如果不相等,表示该时间窗格已经过期
if (failedCounter[timeIndex] == null || times[timeIndex] != currentTimeSeconds) {
// 时间窗格对应的计数器为空或已过期,重置计数器和窗格的秒值
failedCounter[timeIndex] = new AtomicInteger(1);
times[timeIndex] = currentTimeSeconds;
} else {
// 时间窗格不为空且没过期,增加失败数
failedCounter[timeIndex].incrementAndGet();
}
// 统计失败数,是否要修改为开路
if (sum(currentTimeSeconds) > maxFailedNum) {
lastOpenTime = currentTimeMillis;
close = false;
}
}
由于我们设计的时间窗是1秒一个窗格,那么只要用System.currentTimeMillis()除以1000,再模上时间窗格数组的长度times.length,就能得出目标时间窗格下标timeIndex。
然后判断时间窗格对应的计数器是否为空,或者时间窗格是否已过期。如果是,那么重置计数器和时间窗格;否则增加时间窗格对应的计数器的计数。
这里怎么判断一个时间窗格是否已过期呢?由于我们已经得到了了从1970年1月1日零点到当前的秒值currentTimeSeconds,而时间窗格times[timeIndex]记录的是从1970年1月1日零点到它当时被重置时的秒值,因此两者比较一下是否相等,就能得知时间窗格是否过期。
比如当前时间是2024-04-05 20:09:20,时间戳是1712318960000,假如时间窗数组是这样:
那么currentTimeSeconds = 1712318960000 / 1000 = 1712318960,然后timeIndex = 1712318960 % 5 = 0,那么得到的时间窗格数组下标为0,然后判断times[timeIndex]又等于1712318960,那么得知该时间窗格没有过期。
假如时间往后走了5秒,此时时间是2024-04-05 20:09:25,时间戳是1712318965000,那么currentTimeSeconds = 1712318965000 / 1000 = 1712318965,然后timeIndex = 1712318965 % 5 = 0,得到的时间窗格数组下标又是0,然后times[timeIndex]是1712318960,不等于1712318965,那么得知时间窗格已过期。
最后通过sum(currentTimeSeconds)得出当前时间窗失败数的统计值,如果超过断路器阈值,则切换断路器状态为打开状态。
再看下sum()方法如何做统计。
private int sum(long currentTimeSeconds) {
int sum = 0;
for (int i = 0; i < times.length; i++) {
// timeSpan是用户指定的时间窗长度
// 如果currentTimeSeconds - times[i]大于等于timeSpan,表示该时间窗格已过期
if (currentTimeSeconds - times[i] >= timeSpan) {
// 已不在当前时间窗内的窗格,忽略
continue;
}
if (failedCounter[i] != null) {
// 计数器的值累加到sum
sum += failedCounter[i].get();
}
}
return sum;
}
success()
@Override
public void success() {
// 请求成功,清空所有的错误记录
failedCounter = new AtomicInteger[timeSpan];
// 修改断路器为闭合状态
close = true;
}
我们设计的断路器,一旦请求处理成功,那么清空所有的错误记录,然后修改断路器为闭合状态。
canPass()
@Override
public boolean canPass() {
// 闭合状态,放行
if (close) {
return true;
}
long currentTimeMillis = System.currentTimeMillis();
// 计算断路器打开时间是否已超过指定打开时间openTime
if (lastOpenTime != -1 && currentTimeMillis - lastOpenTime > openTime) {
// 断路器打开时间已超过指定时间,此时处于半开状态,放行一个请求
// 更新lastOpenTime断路器最后一次打开时间
lastOpenTime = currentTimeMillis;
return true;
}
// 断路器处于打开状态,拒绝处理请求
return false;
}
我们的断路器用一个boolean类型的close变量记录状态,true是关闭状态,false是打开状态。半开状态是根据时间计算的,当前时间currentTimeMillis减去上一次打开的时间lastOpenTime如果大于等于openTime,表示断路器转为半开状态。
代码已经提交到gitee,可以自行下载阅读。
https://gitee.com/huang_junyi/simple-microservice/tree/master/simple-microservice-protector/src/main/java/com/huangjunyi1993/simple/microservice/protector/breaker