【高可用】限流、降级、熔断
高可用手段
高可用系统,常用的保护手段有限流、降级和熔断。
限流 Rate Limit 是针对服务请求数量的一种自我保护机制,当请求数量超出服务的处理能力时,会自动丢弃新来的请求。限流是为了避免被大流量请求消耗掉系统资源,避免不可用。
熔断 Circuit 来源于电路保险丝的自我保护机制。在服务的依赖调用中,被调用方出现故障时,出于自我保护的目的,调用方会主动停止调用,并根据业务需要进行相应处理。调用方这种主动停止调用的行为称之为熔断。
降级 Degrade 是在不影响业务核心链路的情况下,屏蔽某些不重要的业务功能。这样可以节省系统的处理时间,提供系统的响应能力,在有限的服务器资源下处理更多的请求。降级就是为了解决资源不足和访问量增加的矛盾,保障核心功能的正常使用。
三者区别
- 限流:只允许部分请求得到响应和服务,超过的部分将被拒绝服务、排队或等待、降级等处理。
- 熔断:对向服务不能使用时开启自我保护停止调用,根据业务需要进行相应处理。
- 降级:有限的服务资源下保留系统核心需求,关闭非核心功能,保障核心正常使用。
一个服务既可以充当 Server 也可以充当 Client,充当 Server 的时候发生对其他 Server 的调用时,对向服务异常时可以使用熔断;充当 Client 在接收其他 Server 的请求时,可以使用限流阻断部分请求。因为限流和熔断两者可能同时处于同一个服务中,所以这两个概念才容易被混淆。
现在的系统对限流的支持各有不同,但是存在一些标准。在 HTTP RFC 6585 标准中规定了,429 状态码表示用户在给定时间内发送了太多的请求,需要进行限流/速率限制,同时包含一个 Retry-After 响应头用于告诉客户端多长时间后可以再次请求服务。
降级因为需要在使用时根据场景预先决定或者紧急情况下开启。降级的实现一般有两种策略。一种是系统配置降级,为每一个可降级服务提供一个业务开关配置,在业务出现故障后通过切换业务开关配置进行手动降级。主要缺点是如果服务器数量多,需要一台一台去操作,效率比较低,这在故障处理争分夺秒的场景下是比较浪费时间的。另一种是独立降级系统,为了解决系统后门降级方式的缺点,将降级操作独立到一个单独的系统中,可以实现复杂的权限管理、批量操作等功能,但引入独立系统,运维集成等复杂度会相应提高,Hystrix、Sentinel 等都有相应功能实现。
限流器实现
常用的限流算法有五种,分别是:计数器限流、固定窗口限流、滑动窗口限流、令牌桶限流、漏桶限流。
在对限流算法进行实现之前,首先需要规定限流器的实现需要哪些接口。
public interface RateLimiter {
/**
* 实现一个限流器,每秒只能通过 max 个请求;
* 如果超过,那么acquire返回false;需要考虑并发性能问题
*
* @return boolean
*/
default boolean acquire() {
return acquire(1);
}
/**
* 申请资源
*
* @param count 计数
* @return boolean 是否通过
*/
boolean acquire(int count);
}
计数器
当前接口限定 1s 只可以提供 n 次服务。对于此需求,可以记录当前是第几秒,并且记录这一秒内请求了多少次。在指定周期内累加访问次数,当访问次数达到设定的阈值时,触发限流策略,当进入下一个时间周期时会将访问次数清零。
public class CountRateRateLimiter implements RateLimiter {
/**
* 一秒可以接受多少个请求
*/
private final int numAcceptablePerSecond;
/***
* 版本号对应秒数
* 里面的 AtomicInteger 记录这一秒范围内的请求数量
*/
private final AtomicStampedReference<AtomicInteger> helper;
public CountRateRateLimiter(int numAcceptablePerSecond) {
this.numAcceptablePerSecond = numAcceptablePerSecond;
this.helper = new AtomicStampedReference<>(new AtomicInteger(numAcceptablePerSecond), -1);
}
@Override
public boolean acquire(int n) {
if (n > numAcceptablePerSecond) {
return false;
}
// 上一次请求是多少秒的请求
int oldSeconds = helper.getStamp();
// 当前多少秒
int currentSeconds = currentSeconds();
// 不是同一秒的请求
// 如果和当前不是一个版本(意味着不是同一秒) 那么cas 修改版本并重置许可数量
if (oldSeconds != currentSeconds) {
// 原剩余的许可数量
AtomicInteger oldPermits = helper.getReference();
// cas 修改 同时修改版本,并且扣减数量;新许可的数量为 numAcceptablePerSecond - n,避免为负数
if (helper.compareAndSet(oldPermits, new AtomicInteger(numAcceptablePerSecond - n), oldSeconds, currentSeconds)) {
// cas 成功 那么说明成功 拿到令牌
return true;
}
}
// 到这里说明 是同一秒(oldSeconds == currentSeconds)
// 或者上面的 if 存在多线程竞争当前线程竞争失败 其他线程重置了计数器 ==> 那么 cas 减少许可数量
// 这里判断了一下 当前秒还是相等的,避免由于 gc 在第一个if中停留太久
// 比如第一秒线程A和B进入到第一个if,线程B成功了,但是线程A失败了,并且暂停了2s,出来的时候时间已经是3s了,我们不能让1s的请求占用3s时候的令牌数
return currentSeconds() == currentSeconds
// 最后这里存在问题 如果在0.99999s的请求来到这里,但是时间来到1s,这个cas才成功,那么0.99999s的请求将打过来。导致1s的qps大于max
&& helper.getReference().addAndGet(-n) >= 0;
}
private static int currentSeconds() {
return (int) ((System.currentTimeMillis() / 1000) % Integer.MAX_VALUE);
}
}
需要注意的是,限流器的使用应该创建出对应速率的容器,然后一直沿用该限流器。
固定窗口
固定窗口限流算法(Fixed Window Rate Limiting Algorithm)是一种最简单的限流算法,其原理是在固定时间窗口(单位时间)内限制请求的数量。该算法将时间分成固定的窗口,并在每个窗口内限制请求的数量。具体来说,算法将请求按照时间顺序放入时间窗口中,并计算该时间窗口内的请求数量,如果请求数量超出了限制,则拒绝该请求。
固定窗口算法非常简单,易于实现和理解,但是存在明显的临界问题。
public class FixedWindowRateLimiter implements RateLimiter {
public static int counter = 0;
public static long lastAcquireTime = 0L;
/**
* 窗口时间长度,单位 ms
*/
private final long windowUnit;
/**
* 可以接受的请求次数
*/
private final long canAcceptRequestTimes;
public FixedWindowRateLimiter(long windowUnit, long canAcceptRequestTimes) {
this.windowUnit = windowUnit;
this.canAcceptRequestTimes = canAcceptRequestTimes;
}
@Override
public boolean acquire(int count) {
long currentTimeMillis = System.currentTimeMillis();
if (currentTimeMillis - lastAcquireTime > windowUnit) {
counter = 0;
lastAcquireTime = currentTimeMillis;
}
if (counter < canAcceptRequestTimes) {
counter++;
return true;
}
return false;
}
}
固定窗口的测试结果如下:
滑动窗口
滑动窗口限流算法是一种常用的限流算法,用于控制系统对外提供服务的速率,防止系统被过多的请求压垮。它将单位时间周期分为 n 个小周期,分别记录每个小周期内接口的访问次数,并且根据时间滑动删除过期的小周期。它可以缓解固定窗口临界值的问题。
滑动窗口分的细了,就要统计每个窗口中的请求数。但是统计总的请求数,需要将每个窗口中当前版本的请求数做累加,这个操作是要耗时的。要尽可能达到瞬时一致性,否则可能限流算法还没走完,时间都下一个单元了。这样会造成限流的不准确。
滑动窗口的限流实现如下:
/**
* 滑动窗口限流器
* <p>
* 假设指定窗口总时长 为 1s 可以接受 10个请求,窗口分为5格
* 说明单格时间长度为200毫秒
* |_____|_____|_____|_____|_____|
* 0 200 400 600 800 1000
* <p>
* 当前时间为 500毫秒 那么落在 (500/200)%5 也就是第二格
* 那么500 毫秒是否可以接受请求 需要统计所有格子中的数量
* <p>
* 当时间来到 1500 毫秒,落在 (1500/200)%5 也是第二格
* |_____|_____|_____|_____|_____|_____|_____|_____|
* 0 200 400 600 800 1000 1200 1400 1600
* 从500到1500才是我们需要记录的,窗口数组大小是不变的
* <p>
* 500的窗口版本是 500/1000 = 0
* 1500的窗口版本是 1500/1000 = 1
* <p>
* 根据窗口版本来统计 哪些格子我们是要统计的,如果旧窗口版本小于当前窗口版本 不要计数
* (这里的版本 可以理解为没过 1000秒 版本加1,版本不同意味着是一秒前的数据)
*
* @author Real
* @date 2023/08/16 23:25
*/
public class SlidingWindowRateLimiter implements RateLimiter {
/**
* 滑动窗口元素
*
* @author Real
* @date 2023/08/16 23:26
*/
private static class WindowElement {
/***
* 版本
*/
private volatile long version;
/**
* 计数
*/
private final AtomicInteger counter;
private WindowElement(long version, AtomicInteger counter) {
this.version = version;
this.counter = counter;
}
private void changeVersion(long newVersion) {
this.version = newVersion;
}
private void reset(int n) {
counter.set(n);
}
void add(int n) {
counter.addAndGet(n);
}
}
/**
* 整个窗口的大小,比如一秒 只能接受100个请求 那么此值设置为1000(毫秒)
*/
private final long windowTimeMillions = 1000;
/***
* 窗口的长度,窗口的长度,窗口越长,越能防止临界问题
*/
private final int windowLength;
/***
* 窗口数组
*/
private final AtomicReferenceArray<WindowElement> slidWindow;
/***
* 一秒接受 100个请求 那么此值设置为 100
*/
private final int canAcceptRequestTimes;
/**
* 记录 窗口每一个元素 对应的时间跨度
* 1秒接受100个请求 那么此处为 1000(毫秒)/100 = 10毫秒
*/
private final int millionsEachOne;
/**
* @param windowLength 指定窗口数量
* @param canAcceptRequestTimes 在 1s 内可以接受多少个请求
*/
public SlidingWindowRateLimiter(int windowLength,
int canAcceptRequestTimes) {
this.windowLength = windowLength;
this.canAcceptRequestTimes = canAcceptRequestTimes;
slidWindow = new AtomicReferenceArray<>(new WindowElement[windowLength]);
millionsEachOne = (int) (windowTimeMillions / windowLength);
}
@Override
public boolean acquire(int n) {
// 1s分为5格 那么 一格200ms
// 当前时间为 500毫秒 那么落在 (500/200)%5 也就是第二格
long currentTimeMillis = System.currentTimeMillis();
// 这次请求 落在 哪个桶
int index = (int) ((currentTimeMillis / millionsEachOne) % windowLength);
// 当前这次请求的 version 即当前是多少秒
long version = currentTimeMillis - currentTimeMillis % windowTimeMillions;
// 1. 拿到当前当前的计数
// 1.1 如果计数为空 说明从来没有其他请求设置元素,这时,我们需要cas初始化结束计数
// 1.2 如果计数不为空
// 1.2.1 是相同的版本 那么自增计数
// 1.2.3 如果不是相同的版本(之前版本小于当前版本),那么更新版本
// 1.2.4 如果不是相同的版本(之前版本大于当前版本),基本上不可能,因为时间是不会倒流的
//操作这次请求落下的桶
WindowElement currentIndex = slidWindow.accumulateAndGet(index,
new WindowElement(version, new AtomicInteger(n)), (old, now) -> {
// 计数为空 说明从来没有其他请求设置元素,这时,我们需要cas初始化结束计数
if (old == null) {
return now;
}
// 当前请求的次数
int currentRequest = now.counter.get();
// 是同一秒 那么自增
if (old.version == now.version) {
old.add(currentRequest);
} else {
// 如果不是相同的版本(之前版本小于当前版本),那么更新版本 更新计数
old.reset(currentRequest);
old.changeVersion(now.version);
}
return old;
});
// 大于最大数量返回false 这一瞬间对应的元素 就已经超出了我们的预期 那么返回 false
if (currentIndex.counter.get() > canAcceptRequestTimes) {
return false;
}
// 统计窗口内所有请求数
long sum = 0;
// 下面这一段 不具备瞬时一致性;需要考虑计数耗费的时间,理想情况下需要达到 O(1)
for (int i = 0; i < windowLength; i++) {
WindowElement e = slidWindow.get(i);
// 要注意版本的一致性,版本不一致时,表示不是在一个时间周期内,为历史数据
if (e != null && e.version == version) {
sum += e.counter.get();
if (sum > canAcceptRequestTimes) {
return false;
}
}
}
// 小于等于才通过
return sum <= canAcceptRequestTimes;
}
}
最后在统计总窗口耗时的时候,因为版本的不确定性,所以需要统计相同版本下的所有窗口的流量数。这里要注意瞬时一致性,也就是限流的效果。
格子周期划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。
漏桶
漏桶限流算法(Leaky Bucket Algorithm)是一种流量控制算法,用于控制流入网络的数据速率,以防止网络拥塞。它的思想是将数据包看作是水滴,漏桶看作是一个固定容量的水桶,数据包像水滴一样从桶的顶部流入桶中,并通过桶底的一个小孔以一定的速度流出,从而限制了数据包的流量。
漏桶限流算法的基本工作原理是:对于每个到来的数据包,都将其加入到漏桶中,并检查漏桶中当前的水量是否超过了漏桶的容量。如果超过了容量,就将多余的数据包丢弃。如果漏桶中还有水,就以一定的速率从桶底输出数据包,保证输出的速率不超过预设的速率,从而达到限流的目的。
漏桶的实现上,可以简化流出的水流速率,而是每次请求的时候,再去判断和设置当前桶内的容量。
public class LeakBucketRateLimiter implements RateLimiter {
// 桶的大小
private static final long CAPACITY = 10;
// 流出速率,每秒两个
private static final long RATE = 2;
//开始时间
private static long startTime = System.currentTimeMillis();
//桶中剩余的水
private static final AtomicLong WATER = new AtomicLong();
@Override
public synchronized boolean acquire(int count) {
// 如果桶里是空的,表示第一次使用,判断是否能直接通过
if (WATER.get() == 0 && count <= CAPACITY) {
startTime = System.currentTimeMillis();
WATER.set(count);
return true;
}
long currentTimeMillis = System.currentTimeMillis();
// 判断过往时间内流出的水量,得出现在桶内的剩余量
long currentBucketSize = WATER.get() - (currentTimeMillis - startTime) / 1000 * RATE;
// 防止出现 <0 的情况
WATER.set(Math.max(0, currentBucketSize));
// 设置新的开始时间
startTime += (currentTimeMillis - startTime) / 1000 * 1000;
// 如果申请的数量 + 现有的数量 < 容量,直接放行
if (WATER.get() + count <= CAPACITY) {
WATER.getAndAdd(count);
return true;
}
return false;
}
}
令牌桶
令牌桶算法是一种常用的限流算法,可以用于限制单位时间内请求的数量。该算法维护一个固定容量的令牌桶,每秒钟会向令牌桶中放入一定数量的令牌。当有请求到来时,如果令牌桶中有足够的令牌,则请求被允许通过并从令牌桶中消耗一个令牌,否则请求被拒绝。
优点:
- 稳定性高:令牌桶算法可以控制请求的处理速度,可以使系统的负载变得稳定。
- 精度高:令牌桶算法可以根据实际情况动态调整生成令牌的速率,可以实现较高精度的限流。
- 弹性好:令牌桶算法可以处理突发流量,可以在短时间内提供更多的处理能力,以处理突发流量。
Guava 的 RateLimiter 限流组件,就是基于令牌桶算法实现的。
缺点:
- 实现复杂:相对于固定窗口算法等其他限流算法,令牌桶算法的实现较为复杂。对短时请求难以处理:在短时间内有大量请求到来时,可能会导致令牌桶中的令牌被快速消耗完,从而限流。这种情况下,可以考虑使用漏桶算法。
- 时间精度要求高:令牌桶算法需要在固定的时间间隔内生成令牌,因此要求时间精度较高,如果系统时间不准确,可能会导致限流效果不理想。
令牌桶算法具有较高的稳定性和精度,但实现相对复杂,适用于对稳定性和精度要求较高的场景。
令牌桶的实现,在思路上漏桶差不多,都需要注意用 synchronized 修饰。在进入到方法的时候,判断这段时间内生成的 Token 数,无论后面是否成功,都应该在进入方法的时候首先更新桶内的 Token 数,这样才能尽可能保证准确性。
public class TokenBucketRateLimiter implements RateLimiter {
/**
* 当前令牌数
*/
private int tokens;
/**
* 令牌桶容量
*/
private final int capacity;
/**
* 令牌生成速率,单位:令牌/秒
*/
private final int rate;
/**
* 上次令牌生成时间戳
*/
private long lastRefillTimestamp;
public TokenBucketRateLimiter(int capacity, int rate) {
this.capacity = capacity;
this.rate = rate;
this.tokens = capacity;
this.lastRefillTimestamp = System.currentTimeMillis();
}
@Override
public synchronized boolean acquire(int count) {
// 每次进入同统计一下这段时间内生成的令牌数量
long now = System.currentTimeMillis();
if (now > lastRefillTimestamp) {
int generatedTokens = (int) ((now - lastRefillTimestamp) / 1000 * rate);
tokens = Math.min(tokens + generatedTokens, capacity);
lastRefillTimestamp = now;
}
// 判断令牌数是否满足要求
if (tokens - count >= 0) {
tokens -= count;
return true;
}
return false;
}
}
其他
前面的限流方式都是针对单体架构,无法进行跨 JVM 限流。在分布式、微服务架构下,需要利用一些其他的限流方式。
中间件限流
可以借助一些中间件进行限流。Sentinel 是 Spring Cloud Alibaba 中常用的熔断限流组件,为我们提供了开箱即用的限流方法。
使用起来也非常简单,在service层的方法上添加 @SentinelResource
注解,通过 value 指定资源名称,blockHandler 指定一个方法,该方法会在原方法被限流、降级、系统保护时被调用。
@Service
public class QueryService {
public static final String KEY = "query";
@SentinelResource(value = KEY, blockHandler = "blockHandlerMethod")
public String query(String name) {
return "begin query, name = " + name;
}
public String blockHandlerMethod(String name, BlockException e) {
e.printStackTrace();
return "blockHandlerMethod for Query : " + name;
}
}
网关限流
网关限流也是目前比较流行的一种方式,通常使用 Spring Cloud 的网关 Gateway 组件进行限流。
在项目中引入依赖,Gateway 的限流实际使用的是 Redis + Lua 脚本的方式实现的令牌桶,因此还需要引入 Redis 的相关依赖。
spring:
application:
name: gateway-test
cloud:
gateway:
routes:
- id: limit_route
uri: lb://sentinel-test
predicates:
- Path=/sentinel-test/**
filters:
- name: RequestRateLimiter
args:
# 令牌桶每秒填充平均速率
redis-rate-limiter.replenishRate: 1
# 令牌桶上限
redis-rate-limiter.burstCapacity: 2
# 指定解析器,使用spEl表达式按beanName从spring容器中获取
key-resolver: "#{@pathKeyResolver}"
- StripPrefix=1
redis:
host: 127.0.0.1
port: 6379
使用请求的路径作为限流的键,编写对应的解析器。
@Slf4j
@Component
public class PathKeyResolver implements KeyResolver {
public Mono<String> resolve(ServerWebExchange exchange) {
String path = exchange.getRequest().getPath().toString();
log.info("Request path: {}", path);
return Mono.just(path);
}
}
在被限流的情况下,Http 请求会返回 429 状态码。
除了上面的根据请求路径限流外,还可以灵活设置各种限流的维度,例如根据请求 header 中携带的用户信息、或是携带的参数等等。如果不想用 Gateway 自带的这个 Redis 的限流器的话,我们也可以自己实现 RateLimiter 接口来实现一个自己的限流工具。
Gateway 实现限流的关键是 spring-cloud-gateway-core 包中的 RedisRateLimiter 类,以及 META-INF/scripts 中的 request-rate-limiter.lua 这个脚本,这就是 Gateway 里面对于限流的具体实现。
参考
- 限流、降级、熔断区别:https://blog.csdn.net/claram/article/details/104725233
- 如何实现服务降级:https://zhuanlan.zhihu.com/p/614168987
- 限流、熔断、降级的区别:https://zhuanlan.zhihu.com/p/61363959
- 熔断器模式:https://www.jianshu.com/p/f3b6ed865dd0
- 限流应用场景:https://zhuanlan.zhihu.com/p/393178103
- 四种经典限流器的实现:https://mp.weixin.qq.com/s/E66XN_hrE7OlWRKuEPRj0w
- 服务限流的实际情况以及原理:https://mp.weixin.qq.com/s/zmC9GkJfwbAA86cCXo_leQ