高性能系统在使用过程中可能会出现服务不可用,对用户造成不好的影响。归根节点,造成这个现象的原因有两类:
局部故障最终导致全局故障,这种情况有一个专业的名词,叫做雪崩。那么,为什么会发生雪崩呢?要知道,系统在运行的时候是需要消耗一些资源的,比如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的响应时间变长,就可以导致整体服务宕机,这就是我们经常遇到的一种服务雪崩的情况。
雪崩效应产生的几种场景
在分布式环境下最怕的是服务或者组件慢,因为这样会导致调用者持有的资源无法释放,最终拖垮整体服务
怎么避免呢?
它们的出发点都是为了实现自我保护,所以一旦发生了这种行为,业务都是有损的。
熔断机制是应对雪崩效应的一种微服务链路保护机制,在互联网系统中当下游的服务因为某种原因突然变得不可用或响应过慢,上游服务为了保证自己整体服务的可用性,暂时不再继续调用目标服务,直接快速返回失败标志,快速释放资源。如果目标服务情况好转则恢复调用。
在这种模型下,服务调用方为每一个调用的服务维护一个有限状态机,在这个状态机会有三种状态:关闭(调用远程服务)、半打开(尝试调用远程服务)和打开(返回错误)。这三种状态之间切换的过程如下:
其实,不仅仅微服务之间调用需要熔断的机制,我们在调用 Redis、Memcached 等资源的时候也可以引入这套机制。比如Redis客户端中,在系统初始化的时候,定义了一个定时器,当熔断器处于Open状态时,定期地检测Redis组件是否可用:
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中的数据时,我们会在其中加入熔断器的逻辑。比如,当节点处于熔断状态时,直接返回空值以及熔断器三种状态之间的转换,具体的示例代码像下面这样:
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节点,避免单个节点的故障导致整体系统的雪崩
什么是缓存降级:
什么是服务降级:
降级一般是有损的操作,所以尽量减少降级对业务的影响程度
相比熔断来说,降级是一个更大的概念。因为它是站在整体系统负载的角度上,放弃部分非核心功能或者服务,保证整体的可用性的方法,是一种有损的系统容错方法。这样看来,熔断也是降级的一种,除此之外,还有限流降级、开关降级等等
在进行降级之前要对系统进行梳理,首先要区分哪些是核心服务,哪些是非核心服务。因为我们只能针对非核心服务来做降级处理,然后就可以针对具体的业务,指定不同的降级策略了。
比如可以参考日志级别设置预案
服务降级五种方式
实际实现中,我们会在代码中预先埋设一些“开关”,用来控制服务调用的返回值。比如说,开关关闭的时候正常调用远程服务,开关打开时则执行降级的策略。这些开关的值可以存储在配置中心中,当系统出现问题需要降级时,只需要通过配置中心动态更改开关的值,就可以实现不重启服务快速地降级远程服务了。
boolean switcherValue = getFromConfigCenter("degrade.comment"); // 从配置中心获取
if (!switcherValue) {
List<Comment> comments = getCommentList(); // 开关关闭则获取评论数据
} else {
List<Comment> comments = new ArrayList(); // 开关打开,则直接返回空评论数据
}
在为系统增加降级开关时,一定要在流量低峰期的时候做验证演练,也可以在不定期的压力测试过程中演练,保证开关的可用性
限流就是限制系统的输入和输出流量已达到保护系统的目的。
一般来说系统的吞吐量是可以被测算的,为了保证系统的稳定运行,一旦达到的需要限制的阈值,就需要限制流量并采取一些措施以完成限制流量的目的。比如:延迟处理,拒绝处理,或者部分拒绝处理等等。
限流的目的是限制一段时间内发向系统的总体请求量。
限流策略一般部署在服务的入口层,比如:
我们可以在多个维度上对到达系统的流量做控制,比如:
既然要限流,就是要允许一部分请求进入,阻止另外一部分请求进入,那么根据什么规则来筛选进入的请求和被拒绝的请求呢。有四种规则:
举例:
通常应用在池化技术上面比如:「数据库连接池、线程池」等中应用。这种方式的话限流不是「平均速率」的。
这种方法实现起来很简单,但是有一个很大的缺陷 :扛不住突增的流量。
量。假如我们需要限制每秒钟只能处理 10 次请求,如果前一秒钟产生了 10 次请求,这 10次请求全部集中在最后的 10 毫秒中,而下一秒钟的前 10 毫秒也产生了 10 次请求,那么在这 20 毫秒中就产生了 20 次请求,超过了限流的阈值。但是因为这 20 次请求分布在两个时间窗口内,所以没有触发限流,这就造成了限流的策略并没有生效。
因此,引入了滑动窗口算法
原理:
举例:
因此,在实际项目中很少用基于时间的限流算法,而是用其他的算法,比如漏桶算法、令牌桶算法
漏桶算法的原理:
重点:两个速率:
流入速率即实际的用户请求速率或压力测试的速率,流出速率即服务端处理速率。
一般来说,流出速度是固定的,即不管你请求有多少,速率有多快,我反正就这么个速度处理。当然,特殊情况下,需要加快速度处理,也可以动态调整流出速率。
令牌筒算法:
每个请求过来必须拿到桶里面拿到了令牌才允许请求(拿令牌的速度是不限制的,这就意味着如果瞬间有大量的流量请求进来,可以短时间内拿到大量的令牌),拿不到令牌的话直接拒绝。这个令牌桶的思想是不是跟我们java里面的「Semaphore」 有点类似。Semaphore 是拿信号量,用完了就还回去。但是令牌桶的话,不需要还回去,因为令牌会定时的补充。令牌桶算法我们可以通过Google开源的guava包创建一个令牌桶算法的限流器。
令牌桶算法和漏桶算法能够塑形流量,让流量更加平滑,但是令牌桶算法能够应对一定的突发流量,所以在实际应用中更多:
可以看到,使用令牌桶算法需要存储令牌的数量,如果是单击上限流,可以在进程中使用一个变量来存储;但是如果在分布式环境下,不同的机器之间无法共享进程中的变量,我们就一般会使用redis来存储这个令牌的数量,这样的话,每次请求的时候都需要请求一次redis来获取一个令牌,会增加几毫秒的延迟,性能上会有一些损耗。因此,一个折中的思路是: 我们可以在每次取令牌的时候,不再只获取一个令牌,而是获取一批令牌,这样可以尽量减少请求 Redis 的次数。
任何限流组件都要设置阈值
第一,不管是哪种限流方法,直接拒绝也要,漏桶、令牌桶也好,限流算法里面一定有一个阈值(解释:直接拒绝要设置阈值,漏桶令牌桶要设置桶大小),这个阈值设置为多少是不是比较难。阈值设置过大的话,服务可能扛不住,阈值设置小了会把用户请求给误杀,资源没有得到最大的一个利用。
第二,任何限流组件都要设置阈值,这是限流和其他两种保护系统稳定运行的方式(降级、熔断)的最大区别,即限流一定要好设置阈值。
缓存预热:
缓存预热解决方案: