高并发系统设计:降级、熔断、限流与缓存预热

简介

转载于:高并发系统设计:降级、熔断、限流与缓存预热

高性能系统在使用过程中可能会出现服务不可用,对用户造成不好的影响。归根节点,造成这个现象的原因有两类:

  • 第一类原因是由于依赖的资源或者服务不可用,最终导致整体服务宕机。举个例子,比如说数据库访问缓慢,会导致整体服务不可用。可以采用降级熔断的方案降级
  • 第二类原因是乐观的预估了可能到来的流量,当有超过系统承载能力的流量到来时,系统不堪重负,从而出现拒绝服务的情况。可以采用限流的方案降级

什么是雪崩

局部故障最终导致全局故障,这种情况有一个专业的名词,叫做雪崩。那么,为什么会发生雪崩呢?要知道,系统在运行的时候是需要消耗一些资源的,比如CPU、内存等系统资源,也包括正在执行业务逻辑的时候,需要的线程资源。

举个例子,一般在业务执行的容器内,都会定义一些线程池来分配执行任务的线程,比如在tomcat这种web容器的内部,定义了线程池来处理HTTP请求;RPC框架也给RPC服务端初始化了线程池来处理RPC请求。

这些线程池中的线程资源是有限的,如果这些线程资源被耗尽,那么服务自然也就无法处理新的请求,服务提供方也就宕机了。

  • 比如,当前系统有四个服务A、B、C、D。A调用B、B 调用 C 和 D。其中,A、B、D 服务是系统的核心服务(像是电商系统中的订单服务、支付服务等等),C 是非核心服务(像反垃圾服务、审核服务)。
  • 所以,一旦作为入口的A流量增加,你可能会考虑把A、B、D扩容,忽略C。那么C就有可能因为无法承担这么大的流量,导致请求处理缓慢,进一步会让B在调用C的时候,B中的请求被阻塞,等待C返回响应结果。这样一来,B服务中被占用的线程资源就不能释放。
  • 久而久之,B就会因为线程资源被占满,无法处理后继的请求。那么从A发往B的请求,就会被放入B服务线程池的队列中,然后A调用B的响应时间变长,进而拖垮A服务。可以看到,仅仅因为非核心服务C的响应时间变长,就可以导致整体服务宕机,这就是我们经常遇到的一种服务雪崩的情况。

雪崩效应产生的几种场景

  • 突增流量:比如一大波爬虫,或者黑客攻击等。
  • 程序bug:代码死循环,或者资源未释放等。
  • 硬件原因:机器宕机、机房断电、光纤被挖断等。

在分布式环境下最怕的是服务或者组件慢,因为这样会导致调用者持有的资源无法释放,最终拖垮整体服务

怎么避免呢?

  • 从上面可以看到,因为服务调用方等待服务提供方的响应时间变长,它的资源被耗尽,才引发了级联发硬,发生雪崩。
  • 所以在分布式环境下,系统最怕的不是某一个服务或者组件宕机,而是最怕它响应缓慢,因为,某一个服务或者组件宕机也许只会影响系统的部分功能,但是响应慢会导致雪崩从而拖垮整个系统
  • 解决思路:在检测到某一个服务的响应时间出现异常时,切掉调用它的服务与它之间的联系,让服务的调用快速返回失败,从而释放这次请求持有的资源。这个思路也就是我们经常提到的降级和熔断机制

怎么应对

  • 对于非核心服务我们可以采用降级/熔断的方法

  • 对于核心服务我们就只能采用限流的方法了

    • 对于突发流量,限流固然是一种手段
    • 但其实面对复杂的业务以及高并发场景时,我们还有别的手段,可以最大限度的保障业务无损,那就是隔离流量

它们的出发点都是为了实现自我保护,所以一旦发生了这种行为,业务都是有损的。

  • 熔断/降级是调用方为了避免在调用过程中,服务提供方出现问题的时候,自身资源被耗尽的一种保护行为;
  • 限流是服务提供方为防止自己被突发流量打垮的一种保护行为。

熔断

熔断机制是应对雪崩效应的一种微服务链路保护机制,在互联网系统中当下游的服务因为某种原因突然变得不可用或响应过慢,上游服务为了保证自己整体服务的可用性,暂时不再继续调用目标服务,直接快速返回失败标志,快速释放资源。如果目标服务情况好转则恢复调用。

在这种模型下,服务调用方为每一个调用的服务维护一个有限状态机,在这个状态机会有三种状态:关闭(调用远程服务)、半打开(尝试调用远程服务)和打开(返回错误)。这三种状态之间切换的过程如下:

  • 当调用失败的次数累积到一定的阈值时,熔断机制从关闭态切换到打开态。一般在实现时,如果调用成功一次,就会重置调用失败次数
  • 当熔断处于打开状态时,我们会启动一个计时器,当计时器超时后,状态切换到半打开态。也可以通过设置一个定时器,定期的探测服务是否恢复
  • 当熔断处于半打开状态时,请求可以达到后端服务,如果累计一定的成功次数后,状态切换到关闭态;如果出现调用失败的情况,则切换到打开态

其实,不仅仅微服务之间调用需要熔断的机制,我们在调用 Redis、Memcached 等资源的时候也可以引入这套机制。比如Redis客户端中,在系统初始化的时候,定义了一个定时器,当熔断器处于Open状态时,定期地检测Redis组件是否可用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
new Timer("RedisPort-Recover", true).scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
if (breaker.isOpen()) {
Jedis jedis = null;
try {
jedis = connPool.getResource();
jedis.ping(); //验证redis是否可用
successCount.set(0); //重置连续成功的计数
breaker.setHalfOpen(); //设置为半打开态
} catch (Exception ignored) {
} finally {
if (jedis != null) {
jedis.close();
}
}
}
}
}, 0, recoverInterval); //初始化定时器定期检测redis是否可用

在通过Redis客户端操作Redis中的数据时,我们会在其中加入熔断器的逻辑。比如,当节点处于熔断状态时,直接返回空值以及熔断器三种状态之间的转换,具体的示例代码像下面这样:

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
if (breaker.isOpen()) {
return null; // 断路器打开则直接返回空值
}

K value = null;
Jedis jedis = null;

try {
jedis = connPool.getResource();
value = callback.call(jedis);
if(breaker.isHalfOpen()) { // 如果是半打开状态
if(successCount.incrementAndGet() >= SUCCESS_THRESHOLD) {// 成功次数超
failCount.set(0); // 清空失败数
breaker.setClose(); // 设置为关闭态
}
}
return value;
} catch (JedisException je) {
if(breaker.isClose()){ // 如果是关闭态
if(failCount.incrementAndGet() >= FAILS_THRESHOLD){ // 失败次数超过阈值
breaker.setOpen(); // 设置为打开态
}
} else if(breaker.isHalfOpen()) { // 如果是半打开态
breaker.setOpen(); // 直接设置为打开态
}
throw je;
} finally {
if (jedis != null) {
jedis.close();
}
}

这样,当某一个redis节点出现问题时,redis客户端中的熔断器就会实时检测到,并且不再请求有问题的redis节点,避免单个节点的故障导致整体系统的雪崩。

降级

什么是缓存降级:

  • 缓存降级是指缓存失效或者缓存服务器挂掉的情况下,不去访问数据库,直接返回默认数据或者访问服务的内存数据。
  • 举个例子:比如双十一的时候淘宝购物车无法修改地址只能使用默认地址,这个服务就是被降级了,这是阿里为了保证订单可以正常提交和付款,但修改地址的服务可以在服务器压力降低,并发量相对减少的时候再恢复。

什么是服务降级:

  • 服务降级,表示处理的对象是服务,是被动降级

  • 当访问量剧增时,导致服务出现问题(比如响应时间慢、不响应、非核心服务影响到核心流程的性能时)

  • 此时可以做服务降级:
    • 即使是有损部分其他服务,仍然需要保证主服务可用。
    • 目的:保证核心服务可用,即使可能有损其他操作

降级一般是有损的操作,所以尽量减少降级对业务的影响程度。

相比熔断来说,降级是一个更大的概念。因为它是站在整体系统负载的角度上,放弃部分非核心功能或者服务,保证整体的可用性的方法,是一种有损的系统容错方法。这样看来,熔断也是降级的一种,除此之外,还有限流降级、开关降级等等。

怎么做

在进行降级之前要对系统进行梳理,首先要区分哪些是核心服务,哪些是非核心服务。因为我们只能针对非核心服务来做降级处理,然后就可以针对具体的业务,指定不同的降级策略了。

比如可以参考日志级别设置预案

  • 一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级
  • 警告:有些服务在一段时间内成功率有所波动(如在95~100%之间),可以自动降级或者人工降级,并发送告警
  • 错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能够承受的最大阈值,此时可以根据情况自动降级或者人工降级
  • 严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级

服务降级五种方式:

  • 第一,延迟服务:定时任务处理、或者mq延时处理。比如新用户注册送多少优惠券可以提示用户优惠券会24小时到达用户账号中,我们可以选择再凌晨流量较小的时候,批量去执行送券。
  • 第二,被动降价,前端页面降级:页面点击按钮全部置灰,或者页面调整成为一个静态页面显示“系统正在维护中,。。。。”。
  • 第三,主动降级,关闭非核心服务:比如电商关闭推荐服务、关闭运费险、退货退款等。保证主流程的核心服务下单付款就好。
  • 第四,写降级:
    • 对于写数据的场景,一般会考虑把同步写转换成异步写,这样可以牺牲一致性和时效性来保证系统的可用性。
    • 比如秒杀抢购,我们可以只进行Cache的更新返回,然后通过mq异步扣减库存到DB,保证最终一致性即可,此时可以将DB降级为Cache。
  • 第五,读降级:
    • 一般采用的策略是直接返回降级数据。比如,如果数据库的压力比加大,我们在降级的时候,可以考虑只读取缓存的数据,而不再读取数据库中的数据;
    • 如果非核心接口出现问题太,可以直接返回服务繁忙或者返回固定的降级数据
    • 对于一些轮询查询数据的场景,比如每隔30s获取未读数,可以降低获取数据的频率(将获取频率下降到 10 分钟一次)。

实际实现中,我们会在代码中预先埋设一些“开关”,用来控制服务调用的返回值。比如说,开关关闭的时候正常调用远程服务,开关打开时则执行降级的策略。这些开关的值可以存储在配置中心中,当系统出现问题需要降级时,只需要通过配置中心动态更改开关的值,就可以实现不重启服务快速地降级远程服务了。

1
2
3
4
5
6
boolean switcherValue = getFromConfigCenter("degrade.comment"); // 从配置中心获取
if (!switcherValue) {
List<Comment> comments = getCommentList(); // 开关关闭则获取评论数据
} else {
List<Comment> comments = new ArrayList(); // 开关打开,则直接返回空评论数据
}

在为系统增加降级开关时,一定要在流量低峰期的时候做验证演练,也可以在不定期的压力测试过程中演练,保证开关的可用性。

限流

限流就是限制系统的输入和输出流量已达到保护系统的目的。

  • 一般来说系统的吞吐量是可以被测算的,为了保证系统的稳定运行,一旦达到的需要限制的阈值,就需要限制流量并采取一些措施以完成限制流量的目的。比如:延迟处理,拒绝处理,或者部分拒绝处理等等。
  • 限流的目的是限制一段时间内发向系统的总体请求量。
  • 限流策略一般部署在服务的入口层,比如:
    • API网关中,这样可以对系统整体流量做塑形
    • 在微服务架构这种,可以在RPC客户端中引入限流的策略
  • 我们可以在多个维度上对到达系统的流量做控制,比如:
    • 对系统每分钟处理多少请求做限制
    • 可以针对单个接口设置每分钟请求流量的限制
    • 可以限制单个IP、用户ID或者设备ID在一段时间内发送请求的数量
    • 对于服务于多个第三方应用的开发平台来说,每一个第三方应用对于平方方来说都有一个唯一的appkey 来标识,那么你也可以限制单个 appkey 的访问接口的速率。
  • 既然要限流,就是要允许一部分请求进入,阻止另外一部分请求进入,那么根据什么规则来筛选进入的请求和被拒绝的请求呢。有四种规则:
    • 后端不做任何干涉,完全交给不确定的网络,先达到的请求先处理,后达到的请求后处理,达到阈值直接拒绝服务;
    • 后端对服务分级,对于核心服务的请求就处理,对于非核心的请求的服务不处理,又称服务的主动降级;
    • 后端所有请求都处理,但是放到延迟队列中,一点一点的处理;
    • 后端对用户分级,对于重要用户的请求优先处理,对于普通用户的请求普通处理,这就是限流规则。

举例:

  • 比如,到了十一黄金周的时候你想去九寨沟游玩,结果到了九寨沟才发现景区有了临时的通知,每天仅仅售卖 10 万张门票,而当天没有抢到门票的游客就只能第二天起早继续来抢了。这就是一种常见的限流策略,也就是对一段时间内(在这里是一天)流量做整体的控制,它可以避免出现游客过多导致的景区环境受到影响的情况。
  • 比如,TCP协议中的滑动窗口,可以实现对网络传输流量的控制。
    • 可以想象一下,如果没有流量控制,当流量接收方处理速度变慢而发送方还是继续以之前的速率发送数据,那么必然会导致流量拥塞。而TCP的滑动窗口实际上可以理解为为接收方所能提供的缓冲区的大小。
    • 在接收方回复发送方ACK消息中,会带上这个窗口的大小。这样,发送方就可以通过这个滑动窗口的大小决定发送数据的速率了。如果接收方处理了一些缓冲区的数据,那么这个滑动窗口就会变大,发送方发送数据的速率就会提升;反之,如果接收方接收了一些数据还没有来得及处理,那么这个滑动窗口救护变小,发送方发送数据的速率就会变慢。

算法

计数器方法(达到阈值直接拒绝访问)

固定窗口算法
  • 限制一分钟之内系统只能承接1万次请求,那么最暴力的一种方法是记录这一分钟之内访问系统的访问量有多少
  • 如果超过了 1 万次的限制,那么就触发限流的策略返回请求失败的错误。
  • 如果这一分钟的请求量没有达到限制,那么在下一分钟到来的时候先重置请求量的计数,再统计这一分钟的请求量是否超过限制

通常应用在池化技术上面比如:「数据库连接池、线程池」等中应用。这种方式的话限流不是「平均速率」的。

这种方法实现起来很简单,但是有一个很大的缺陷 :扛不住突增的流量。
量。假如我们需要限制每秒钟只能处理 10 次请求,如果前一秒钟产生了 10 次请求,这 10次请求全部集中在最后的 10 毫秒中,而下一秒钟的前 10 毫秒也产生了 10 次请求,那么在这 20 毫秒中就产生了 20 次请求,超过了限流的阈值。但是因为这 20 次请求分布在两个时间窗口内,所以没有触发限流,这就造成了限流的策略并没有生效。

因此,引入了滑动窗口算法

滑动窗口算法

原理:

  • 将时间的窗口分为多个小窗口,每个小窗口都有单独的请求计数。

举例:

  • 比如下图,我们将1s的时间窗口划分为5份,每一份就是200ms;
  • 那么在 1s 和 1.2s 之间来了一次新的请求时,我们就需要统计之前的一秒钟内的请求量,也就是 0.2s~1.2s 这个区间的总请求量,如果请求量超过了限流阈值那么就执行限流策略。

优缺点:

  • 解决了窗口边界的大流量问题,但是却因为要存储每个小时间窗口内的计数,所以空间复杂度有所增加。
  • 而且它也无法限制短时间之内的集中流量,也就是说无法控制流量使得它们更加平滑。

因此,在实际项目中很少用基于时间的限流算法,而是用其他的算法,比如漏桶算法、令牌桶算法。

漏桶算法

漏桶算法的原理:

  • 它就像流量产生段和接收端增加一个漏桶,流量会进入和暂存到漏桶里面,而漏桶的出口处会按照一个固定的速率将流量漏出到接收端(也就是服务接口)。
  • 如果流入的流量在某一段时间内大增,超过了漏桶的承受极限,那么多余的流量就会触发限流策略,被拒绝服务
  • 经过了漏桶算法之后,随机产生的流量就会被整形成为比较平滑的流量到达服务端,从而避免了突发的大流量对于服务接口的影响
  • 在实现时,我们一般会使用消息队列作为漏桶的实现,流量首先被放入的消息队列中排队,由固定的几个队列处理程序来消费流量,如果消息队列中流量溢出,那么后续的流量就会被拒绝。

重点:两个速率:

  • 流入速率即实际的用户请求速率或压力测试的速率,流出速率即服务端处理速率。
  • 一般来说,流出速度是固定的,即不管你请求有多少,速率有多快,我反正就这么个速度处理。当然,特殊情况下,需要加快速度处理,也可以动态调整流出速率。

令牌筒算法(推荐)

令牌筒算法:

  • 如果我们需要在一秒内限制访问次数为N次,那么每隔1/N的时间,往桶内放入一个令牌
  • 在处理请求之前先要从桶内获取一个令牌,如果桶内已经没有了令牌,那么就需要等待新的令牌或者直接拒绝服务。
  • 桶内的令牌总数也要有一个限制,如果超过了限制就不能向桶内再增加新令牌了。这样可以限制令牌的总数,一定程度上可以避免瞬间流量高峰的问题

每个请求过来必须拿到桶里面拿到了令牌才允许请求(拿令牌的速度是不限制的,这就意味着如果瞬间有大量的流量请求进来,可以短时间内拿到大量的令牌),拿不到令牌的话直接拒绝。这个令牌桶的思想是不是跟我们java里面的「Semaphore」 有点类似。Semaphore 是拿信号量,用完了就还回去。但是令牌桶的话,不需要还回去,因为令牌会定时的补充。令牌桶算法我们可以通过Google开源的guava包创建一个令牌桶算法的限流器。

令牌桶算法和漏桶算法能够塑形流量,让流量更加平滑,但是令牌桶算法能够应对一定的突发流量,所以在实际应用中更多:

  • 漏桶算法在面对突发流量的时候,采用的解决方法是缓存在漏桶中,这样流量的响应时间变长,这与低延迟的要求不符。
  • 而令牌桶算法可以在令牌中暂存一定的令牌,能够应对一定的突发流量

可以看到,使用令牌桶算法需要存储令牌的数量,如果是单击上限流,可以在进程中使用一个变量来存储;但是如果在分布式环境下,不同的机器之间无法共享进程中的变量,我们就一般会使用redis来存储这个令牌的数量,这样的话,每次请求的时候都需要请求一次redis来获取一个令牌,会增加几毫秒的延迟,性能上会有一些损耗。因此,一个折中的思路是: 我们可以在每次取令牌的时候,不再只获取一个令牌,而是获取一批令牌,这样可以尽量减少请求 Redis 的次数。

注意

  • 任何限流组件都要设置阈值,这是限流和其他两种保护系统稳定运行的方式(降级、熔断)的最大区别,即限流一定要好设置阈值。
  • 不管是哪种限流方法,直接拒绝也要,漏桶、令牌桶也好,限流算法里面一定有一个阈值(解释:直接拒绝要设置阈值,漏桶令牌桶要设置桶大小),这个阈值设置为多少是不是比较难。阈值设置过大的话,服务可能扛不住,阈值设置小了会把用户请求给误杀,资源没有得到最大的一个利用。

缓存预热

缓存预热:

  • 指系统上线后,提前将相关的缓存数据加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题,用户直接查询事先被预热的缓存数据。
  • 如果不进行预热,那么Redis初始状态数据为空,系统上线初期,对于高并发的流量,都会访问到数据库中, 对数据库造成流量的压力。

缓存预热解决方案:

  • 数据量不大的情况下,工程启动的时候进行加载缓存动作
  • 数据量大的情况下,设置一个定时任务脚本,进行缓存的刷新
  • 数据量非常大的情况下,优先保证热点数据进行提前加载到缓存
一分一毛,也是心意。