熔断、限流、降级入门

本文将大概讲解下高并发场景下经常用到的熔断、限流、降级基本概念。

熔断

简介

熔断本质上是一个过载保护机制。

在互联网系统中的熔断机制是指:

当下游服务因访问压力过大而响应变慢或失败,上游服务为了保护自己以及系统整体的可用性,可以暂时切断对下游服务的调用。

做熔断的思路大体上就是:一个中心思想,分四步走

中心思想是:量力而行。因为软件和人不同,没有奇迹会发生,什么样的性能撑多少流量是固定的。这是根本。

然后,这四步走分别是:

  • (1)定义一个识别是否处于“不可用”状态的策略

  • (2)切断联系

  • (3)定义一个识别是否处于“可用”状态的策略,并尝试探测

  • (4)重新恢复正常

定义一个识别是否处于“不正常”状态的策略,识别一个系统是否正常,无非是两个点:

  • 是不是能调通
  • 如果能调通,耗时是不是超过预期的长

熔断状态机

不可用状态

由于分布式系统被建立在一个并不是 100% 可靠的网络上,所以上述的情况总有发生,因此我们不能将偶发的瞬时异常等同于系统“不可用”(避免以偏概全)。由此我们需要引入一个「时间窗口」的概念,这个时间窗口用来“放宽”判定“不可用”的区间,也意味着多给了系统几次证明自己“可用”机会。但是,如果系统还是在这个时间窗口内达到了你定义“不可用”标准,那么我们就要“断臂求生”了。

这个标准可以有两种方式来指定。

  • 阈值: 比如,在 10 秒内出现 100 次“无法连接”或者出现 100 次大于 5 秒的请求。

  • 百分比: 比如,在 10 秒内有 30% 请求“无法连接”或者 30% 的请求大于 5 秒。

最终会形成这样这样的一段代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
全局变量 errorcount = 0; // 有个独立的线程每隔 10 秒(时间窗口)重置为 0。
全局变量 isOpenCircuitBreaker = false;

//do some thing...

if(success){
return success;
}else{
errorcount++;
if(errorcount == 不可用阈值){
isOpenCircuitBreaker = true;
}
}

切断联系

切断联系要尽可能的“果断”,既然已经认定了对方“不可用”,那么索性就默认“失败”,避免做无用功,也顺带能缓解对方的压力。

分布式系统中的程序间调用,一般都会通过一些 RPC 框架进行。那么,这个时候作为客户端一方,在自己进程内通过代理发起调用之前就可以直接返回失败,不走网络。这就是常说的「fail fast」机制。就是在前面提到的代码段之前增加下面的这段代码。

1
2
3
4
5
if(isOpenCircuitBreaker == true){
return fail;
}

//do some thing...

可用状态

定义一个识别是否处于“可用”状态的策略,并尝试探测

切断联系后,功能的完整性必然会受影响,所以还是需要尽快恢复回来,以提供完整的服务能力。这事肯定不能人为去干预,及时性必然会受到影响。那么如何能够自动的识别依赖系统是否“可用”呢?这也需要你来定义一个策略。

一般来说这个策略与识别“不可用”的策略类似,只是这里是一个反向指标。

  • 阈值。比如,在 10 秒内出现 100 次“调用成功”并且耗时都小于 1 秒。

  • 百分比。比如,在 10 秒内有 95% 请求“调用成功”并且 98% 的请求小于 1 秒。

同样包含「时间窗口」、「阈值」以及「百分比」。

稍微不同的地方在于,大多数情况下,一个系统“不可用”的状态往往会持续一段时间,不会那么快就恢复过来。所以我们不需要像第一步中识别“不可用”那样,无时无刻的记录请求状况,而只需要在每隔一段时间之后去进行探测即可。所以,这里多了一个「间隔时间」的概念。这个间隔幅度可以是固定的,比如 30 秒。也可以是动态增加的,通过线性增长或者指数增长等方式。

这个用代码表述大致是这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
全局变量 successCount = 0; 
// 有个独立的线程每隔 10 秒(时间窗口)重置为 0。
// 并且将下面的 isHalfOpen 设为 false。
全局变量 isHalfOpen = true;
// 有个独立的线程每隔 30 秒(间隔时间)重置为 true。

//do some thing...
if(success){
if(isHalfOpen){
successCount ++;
if(successCount = 可用阈值){
isOpenCircuitBreaker = false;
}
}

return success;
} else {
errorcount++;
if(errorcount == 不可用阈值){
isOpenCircuitBreaker = true;
}
}

另外,尝试探测本质上是一个“试错”,要控制下“试错成本”。所以我们不可能拿 100% 的流量去验证,一般会有以下两种方式:

  • 放行一定比例的流量去验证。

  • 如果在整个通信框架都是统一的情况下,还可以统一给每个系统增加一个专门用于验证程序健康状态检测的独立接口。这个接口额外可以多返回一些系统负载信息用于判断健康状态,如 CPU、I/O 的情况等。

重新恢复正常

一旦通过了衡量是否“可用”的验证,整个系统就恢复到了“正常”状态,此时需要重新开启识别“不可用”的策略。就这样,系统会形成一个循环。

熔断的最佳实践

什么场景最适合做熔断
一个事物在不同的场景里会发挥出不同的效果。以下是我能想到最适合熔断发挥更大优势的几个场景:

  • 所依赖的系统本身是一个共享系统,当前客户端只是其中的一个客户端。这是因为,如果其它客户端进行胡乱调用也会影响到你的调用。

  • 所以依赖的系统被部署在一个共享环境中(资源未做隔离),并不独占使用。比如,和某个高负荷的数据库在同一台服务器上。

  • 所依赖的系统是一个经常会迭代更新的服务。这点也意味着,越“敏捷”的系统越需要“熔断”。

  • 当前所在的系统流量大小是不确定的。比如,一个电商网站的流量波动会很大,你能抗住突增的流量不代表所依赖的后端系统也能抗住。这点也反映出了我们在软件设计中带着“面向怀疑”的心态的重要性。

与所有事物一样,熔断也不是一个完美的事物,我们特别需要注意 2 个问题:

  • 首先,如果所依赖的系统是多副本或者做了分区的,那么要注意其中个别节点的异常并不等于所有节点都存在异常,所以需要区别对待。

  • 其次,熔断往往应作为最后的选择,我们应优先使用一些「降级」或者「限流」方案。因为“部分胜于无”,虽然无法提供完整的服务,但尽可能的降低影响是要持续去努力的。比如,抛弃非核心业务、给出友好提示等等。

总结

上面的这些代码示例中也可以看到,熔断代码所在的位置要么在实际方法之前,要么在实际方法之后。它非常适合 AOP 编程思想的发挥,所以我们平常用到的熔断框架都会基于 AOP 去做。

熔断只是一个保护壳,在周围出现异常的时候保全自身。但是从长远来看平时定期做好压力测试才能更好的防范于未然,降低触发熔断的次数。如果清楚的知道每个系统有几斤几两,在这个基础上再把「限流」和「降级」做好,这基本就将“高压”下触发熔断的概率降到最低了。

限流

想象一个稍微极端一点的场景,如果系统流量不是很稳定,导致频繁触发熔断的话,是不是意味着系统一直熔断的三种状态中不断切换。导致的结果是每次从开启熔断到关闭熔断的期间,必然会导致大量的用户无法正常使用。

那么限流的作用就很显而易见了:只要系统没宕机,系统只是因为资源不够,而无法应对大量的请求,为了保证有限的系统资源能够提供最大化的服务能力,因而对系统按照预设的规则进行流量(输出或输入)限制的一种方法,确保被接收的流量不会超过系统所能承载的上限。

限流最好能“限”在一个系统处理能力的上限附近,所以:

  1. 通过「压力测试」等方式获得系统的能力上限在哪个水平是第一步。

  2. 其次,就是制定干预流量的策略。比如标准该怎么定、是否只注重结果还是也要注重过程的平滑性等。

  3. 最后,就是处理“被干预掉”的流量。能不能直接丢弃?不能的话该如何处理?

常用的策略就 4 种,我给它起了一个简单的定义——「两窗两桶」。两窗就是:固定窗口、滑动窗口,两桶就是:漏桶、令牌桶。

固定窗口

固定窗口就是定义一个“固定”的统计周期,比如 1 分钟或者 30 秒、10 秒这样。然后在每个周期统计当前周期中被接收到的请求数量,经过计数器累加后如果达到设定的阈值就触发「流量干预」。直到进入下一个周期后,计数器清零,流量接收恢复正常状态。

这个策略最简单,写起代码来也没几行。

1
2
3
4
5
6
7
8
全局变量 int totalCount = 0;  // 有一个「固定周期」会触发的定时器将数值清零。

if(totalCount > 限流阈值) {
return; // 不继续处理请求。
}
totalCount++;

// do something...

固定窗口有一点需要注意的是,假如请求的进入非常集中,那么所设定的「限流阈值」等同于你需要承受的最大并发数。所以,如果需要顾忌到并发问题,那么这里的「固定周期」设定的要尽可能的短。因为,这样的话「限流阈值」的数值就可以相应的减小。甚至,限流阈值就可以直接用并发数来指定。比如,假设固定周期是 3 秒,那么这里的阈值就可以设定为「平均并发数 *3」。

不过不管怎么设定,固定窗口永远存在的缺点是:由于流量的进入往往都不是一个恒定的值,所以一旦流量进入速度有所波动,要么计数器会被提前计满,导致这个周期内剩下时间段的请求被“限制”。要么就是计数器计不满,也就是「限流阈值」设定的过大,导致资源无法充分利用。

「滑动窗口」可以改善这个问题。

滑动窗口

滑动窗口其实就是对固定窗口做了进一步的细分,将原先的粒度切的更细,比如 1 分钟的固定窗口切分为 60 个 1 秒的滑动窗口。然后统计的时间范围随着时间的推移同步后移。

同时,我们还可以得出一个结论是:如果固定窗口的「固定周期」已经很小了,那么使用滑动窗口的意义也就没有了。举个例子,现在的固定窗口周期已经是 1 秒了,再切分到毫秒级别能反而得不偿失,会带来巨大的性能和资源损耗。

滑动窗口大致的代码逻辑是这样:

1
2
3
4
5
6
7
8
9
10
11
12
全局数组 链表 []  counterList = new 链表 [切分的滑动窗口数量];
// 有一个定时器,在每一次统计时间段起点需要变化的时候就将索引 0 位置的元素移除,并在末端追加一个新元素。

int sum = counterList.Sum();
if(sum > 限流阈值) {
return; // 不继续处理请求。
}

int 当前索引 = 当前时间的秒数 % 切分的滑动窗口数量 ;
counterList[当前索引]++;

// do something...

虽然说滑动窗口可以改善这个问题,但是本质上还是预先划定时间片的方式,属于一种“预测”,意味着几乎肯定无法做到 100% 的物尽其用。

但是,「桶」模式可以做的更好,因为「桶」模式中多了一个缓冲区(桶本身)。

漏桶

首先聊聊「漏桶」吧。漏桶模式的核心是固定“出口”的速率,不管进来多少量,出去的速率一直是这么多。如果涌入的量多到桶都装不下了,那么就进行「流量干预」。

实现代码的简化表示如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 全局变量 
int unitSpeed; // 出口当前的流出速率。每隔一个速率计算周期(比如 1 秒)会触发定时器将数值清零。

// 全局变量
int waterLevel; // 当前缓冲区的水位线。

if(unitSpeed < 速率阈值) {
unitSpeed++;

//do something...
} else {
if(waterLevel > 水位阈值){
return; // 不继续处理请求。
}

waterLevel++;

while(unitSpeed >= 速率阈值){
sleep(一小段时间)。
}
unitSpeed++;
waterLevel--;

//do something...
}

令牌桶

令牌桶算法和漏桶算法效果一样但方向相反的算法,更加容易理解。随着时间流逝,系统会按恒定1/QPS时间间隔(如果QPS=100,则间隔是10ms)往桶里加入令牌(想象和漏洞漏水相反,有个水龙头在不断的加水),如果桶已经满了就不再加了。新请求来临时,会各自拿走一个令牌,如果没有令牌可拿了就阻塞或者拒绝服务。这种算法可以应对突发程度的请求,因此比漏桶算法好。 示意图(来源网络)如下:

Guava单机版实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import com.google.common.util.concurrent.RateLimiter;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class RateLimiterExample {

public static void main(String[] args) throws InterruptedException {
// qps设置为5,代表一秒钟只允许处理五个并发请求
RateLimiter rateLimiter = RateLimiter.create(5);
ExecutorService executorService = Executors.newFixedThreadPool(5);
int nTasks = 10;
CountDownLatch countDownLatch = new CountDownLatch(nTasks);
long start = System.currentTimeMillis();
for (int i = 0; i < nTasks; i++) {
final int j = i;
executorService.submit(() -> {
rateLimiter.acquire(1);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
System.out.println(Thread.currentThread().getName() + " gets job " + j + " done");
countDownLatch.countDown();
});
}
executorService.shutdown();
countDownLatch.await();
long end = System.currentTimeMillis();
System.out.println("10 jobs gets done by 5 threads concurrently in " + (end - start) + " milliseconds");
}
}

输出结果:

1
2
3
4
5
6
7
8
9
10
11
pool-1-thread-1 gets job 0 done
pool-1-thread-2 gets job 1 done
pool-1-thread-3 gets job 2 done
pool-1-thread-4 gets job 3 done
pool-1-thread-5 gets job 4 done
pool-1-thread-6 gets job 5 done
pool-1-thread-7 gets job 6 done
pool-1-thread-8 gets job 7 done
pool-1-thread-9 gets job 8 done
pool-1-thread-10 gets job 9 done
10 jobs gets done by 5 threads concurrently in 2805 milliseconds

上面例子中我们提交10个工作任务,每个任务大概耗时1000微秒,开启10个线程,并且使用RateLimiter设置了qps为5,一秒内只允许五个并发请求被处理,虽然有10个线程,但是我们设置了qps为5,一秒之内只能有五个并发请求。我们预期的总耗时大概是2000微秒左右,结果为2805和预期的差不多。

Redisson分布式实现

基于Redis的分布式限流器(RateLimiter)可以用来在分布式环境下现在请求方的调用频率。既适用于不同Redisson实例下的多线程限流,也适用于相同Redisson实例下的多线程限流。该算法不保证公平性。除了同步接口外,还提供了异步(Async)、反射式(Reactive)和 RxJava2 标准的接口。

Redisson 3.10+ 版本以上

1
2
3
4
5
6
7
8
9
10
11
12
public void limt() {
Config config = new Config();
RedissonClient redisson = Redisson.create(config);
RRateLimiter rateLimiter = redisson.getRateLimiter("myRateLimiter");
// 初始化, 最大流速 = 每1秒钟产生10个令牌
rateLimiter.trySetRate(RateType.OVERALL, 10, 1, RateIntervalUnit.SECONDS);
rateLimiter.tryAcquire(2);
Thread t = new Thread(() -> {
rateLimiter.acquire(2);
// ...
});
}

限流的最佳实践

一个成熟的分布式系统大致是这样的。

每一个上游系统都可以理解为是其下游系统的客户端。可能你发现前面聊的「限流」都没有提到到底是在客户端做限流还是服务端做,甚至看起来更倾向是建立在服务端的基础上做。但是你知道,在一个分布式系统中,一个服务端本身就可能存在多个副本,并且还会提供给多个客户端调用,甚至其自身也会作为客户端角色。那么,在如此交错复杂的一个环境中,该如何下手做限流呢?我的思路是通过「一纵一横」来考量。


都知道「限流」是一个保护措施,那么可以将它想象成一个盾牌。另外,一个请求在系统中的处理过程是链式的。那么,正如古时候军队打仗一样,盾牌兵除了有小部分在老大周围保护,剩下的全在最前线。因为盾的位置越前,能受益的范围越大。

分布式系统中最前面的是什么?接入层。如果你的系统有接入层,比如用 nginx 做的反向代理。那么可以通过它的 ngx_http_limit_conn_module 以及 ngx_http_limit_req_module 来做限流,是很成熟的一个解决方案。

如果没有接入层,那么只能在应用层以 AOP 的思路去做了。但是,由于应用是分散的,出于成本考虑你需要针对性的去做限流。比如 ToC 的应用必然比 ToB 的应用更需要做,高频的缓存系统必然比低频的报表系统更需要做,Web 应用由于存在 Filter 的机制做起来必然比 Service 应用更方便。

那么应用间的限流到底是做到客户端还是服务端呢?

z 哥的观点是,从效果上客户端模式肯定是优于服务端模式的,因为当处于被限流状态的时候,客户端模式连建立连接的动作都省了。另一个潜在的好处是,与集中式的服务端模式相比,可以把少数的服务端程序的压力分散掉。但是在客户端做成本也更高,因为它是去中心化的,假如需要多个节点之间的数据共通的话,是一个很麻烦的事情。

所以,最终 z 哥建议你:如果考虑成本就服务端模式,考虑效果就客户端模式。当然也不是绝对,比如一个服务端的流量大部分都来源于某一个客户端,那么就可以直接在这个客户端做限流,这也不失为一个好方案。

数据库层面的话,一般连接字符串中本身就会包含「最大连接数」的概念,就可以起到限流的作用。如果想做更精细的控制就只能做到统一封装的数据库访问层框架中了。


不管是多个客户端,还是同一个服务端的多个副本。每个节点的性能必然会存在差异,如何设立合适的阈值?以及如何让策略的变更尽可能快的在集群中的多个节点生效?说起来很简单,引入一个性能监控平台和配置中心。但这些真真要做好不容易。

降级

降级的目的用一句话概括就是:将有限的资源效益最大化。

根据 28 原则,我们知道一个系统 80% 的效益是由最核心的 20% 的功能产出的。剩下的 20% 效益需要投入 80% 的资源才能达到。

这就意味着,假如系统平时需要花费 100% 资源做 100% 的事情,如果现在访问量增多 3 倍的话必定扛不住(需要 300% 的资源)。那么,在不增加资源的情况下,我希望系统不能宕机,依旧能正常工作,必然需要让出那解决剩下 20% 问题的 80% 资源。如此一来,理论上这 100% 的资源就可以支撑原先 5 倍的访问量。副作用是功能的完整性上受损 80%。

当然,在实际的场景中不会降级掉 80% 的功能这么夸张,毕竟还得为用户的体验考虑。

举个电商场景典型的例子,在大促的时候,最重要的是什么?转化咯~赚钱咯~ 那么这个时候如果说「评论」功能占用了很多资源,你会怎么处理?其实我们可以选择临时关闭提交评论入口、关闭翻页功能等等,让下单的过程有更多的资源来处理。

主要分为两个环节:定级定序降级实现

参考文章

[1] 微服务架构下的分布式限流方案全解析

[2] 分布式系统关注点:99% 人看得懂的“熔断”以及最佳实践

[3] 分布式系统关注点——限流该怎么做?

[4] 让你的系统“坚挺不倒”的最后一个大招——「降级」

[5] Guava RateLimiter限流