在微服务项目中常常会遇到雪崩效应。什么是雪崩效应呢?雪崩效应最开始出现在密码学中,是指当输入发生最微小的改变,如反转一个二进制位时,也会导致输出的不可区分性的改变。也就是说,无论密钥或明文的任何细微变化都必将引起密文的改变,这对密码学来说是一个好事情,雪崩效应会导致你的加密算法无法仅仅通过输出和输入就被推算出来。因此,从加密算法或加密设备的设计者角度来说,满足雪崩效应是必不可缺的准则。
而在微服务中,雪崩效应就不是一个好事情了。雪崩效应就好比蝴蝶效应,任何一个微小的错误,都可能逐步被放大,产生雪崩效应,造成毁灭性的结果。那么,如何解决微服务中的雪崩效应呢?
微服务之间的相互关系往往比较复杂,最普遍的就是网状结构。下面举一个极端的例子,假如我有多个服务,相互之间都有依赖,如图3.2所示为极端情况下的微服务网状调用示意图。
图3.2 极端情况下的微服务网状调用示意图
最常见的场景就是当负载过高时,如果某个服务的性能或抗压能力不好,那么当请求到这个服务时就需要等待或直接出现超时、不可用等情况。在图3.2中,一旦服务C出现问题,可能会影响服务A和服务B,虽然服务D、E、F并没有直接与服务C相互依赖,但是服务C导致了服务A和服务B的阻塞,就会间接地影响服务D、E、F,从而让整个系统变得缓慢或不可用,这就是微服务的雪崩效应。
微服务调用都以远程调用为主,内存中调用和远程调用之间的一个重大区别是远程调用可能会失败,或者在达到某个超时限制之前挂起而没有响应。如果在没有响应的供应商上有许多呼叫者,那么更糟糕的是,你可能会耗尽关键资源,导致跨多个系统的级联故障。这些问题还是很容易发生的,那么应该如何避免呢?其实要避免雪崩效应,根本的问题就是如何让单个有问题的服务不去影响其他服务的正常运行,这样就能将“雪崩”的范围控制到最小,从而不会将问题的影响蔓延至整个系统。
断路器(Circuit Breaker)就是为了解决这个问题而产生的。那么什么是断路器呢?听起来就像日常生活中的保险丝或空气开关一样,很多高功率的电器、家中和生产场所中都会有电路的保险装置,这些装置一般称为断路器,断路器会在短路和严重超载的情况下切断电路,从而有效地保护回路中的电器,防止电器损坏和火灾的情况发生。
技术有很多时候的设计往往都源于生活,微服务中的断路器其实就是效仿真实生活中的断路器,当服务过载而导致响应过慢或不可用时,断路器能及时地切断真实的服务调用,并返回提前设定好的响应,保证调用方其他功能的正常运行。
那么,如何实现一个断路器呢?你可以自己去实现一个断路器,首先需要在服务调用方监控服务的调用情况,然后设置一些阈值,如响应时间不得超过15s、错误次数比例不得超过30%等,再去定义一些调用失败后预期的返回结果,如查询一个员工的姓名,返回张三,当调用结果情况超过了设定的阈值时,断路器就改变服务的调用策略,消费者将不再调用原来的服务,当请求发起时,直接返回之前设置的另一个调用策略,通常称这种方式为服务降级策略,这样就可以实现一个断路器的基本功能了。
下面来了解一下Spring Cloud系列的断路器:Spring Cloud Netflix Hystrix是如何使用的。Spring Cloud Netflix Hystrix是Spring Cloud关于解决微服务雪崩效应的解决方案,不过看名称可以知道,Hystrix仍然是由大名鼎鼎的Netflix开发并开源的产品,Spring Cloud很好地对它进行了封装,并且与Spring Cloud Ribbon、Spring Cloud Feign等框架可以无缝集成。
Spring Cloud Netflix Hystrix提供了断路器的全部功能,首先需要在服务调用端引入Hystrixstarter:
spring-cloud-starter-netflix-hystrix,配置如下。
在Spring Boot的启动类上添加@EnableCircuitBreaker注解即可,代码如下。
如果我们集成了Spring Cloud Netflix Eureka,还有一种简便的写法,可以直接使用@SpringCloudApplication注解来替代其他注解,代码如下。
通过查看@SpringCloudApplication的源码可以发现,实际上SpringCloudApplication就包括 了 @SpringBootApplication、@EnableDiscoveryClient和@EnableCircuitBreaker 3个注解,SpringCloudApplication的源码如下。
这样就完成了断路器的基本配置,之前提到过,断路器的开启有触发条件,通常通过设置阈值来作为断路器的触发条件,Spring Cloud Netflix Hystrix 提供了几种常用的阈值配置 。 首先由circuitBreakerEnabled的配置项来配置是否启用断路器,默认开启,但是如果使用Spring Cloud Feign作为远程调用框架,那么这里需要额外的配置来启动Hystrix,在application.yml中添加如下配置即可。
其实,设置阈值就是和现实情况的统计做对比,如服务失败比例、最大并发数等,一旦统计的结果超过阈值,就开启断路器。既然做统计,就会有一个维度,通过观察Hystrix的源码,我们发现在Hystrix中称这个维度为Statistical Window Buckets(统计窗口时段),即采用时间的维度,默认10s,也就是10个Bucket,通过在application.yml的配置设置
metrics.rollingStats.numBuckets的值可以改变这个窗口时段。例如,想设置统计时间是20s,那么代码如下。
需要注意的是,numBuckets的配置不能动态更新,如果修改了,只能重启服务器才能生效。断路器提供了错误百分比的阈值设置,表示错误的百分比,默认是50%。也就是说,如果我们的numBuckets是20,就表示在20s内,请求的失败比例超过50%时,断路器就开启,当然可以通过配置来修改它,具体如下。
断路器还提供了最大请求数的阈值设置,表示在指定的时段内,请求数量超过了阈值,则启动断路器,这项配置主要针对高并发时服务负载过大的情况下,有效地缓解服务的压力,做到负载保护,这也是断路器的一个比较核心的功能,对应的配置如下。
断路器中还有个配置,称为sleepWindowInMilliseconds,源码中的注释是seconds that we will sleep before trying again after tripping the circuit(我们将会切断线路之后在重试之前休眠的秒数),意思就是断路器一旦启动,就会熔断之前的请求线路,当新的请求进来时,则会采用一定的降级策略来处理请求,但断路器并不会一直运行,在一定时间后会进入半开启状态,即会释放一部分的请求进行重试,若重试结果正常则断路器关闭,若仍然有问题,则断路器再由半开启状态进入完全开启状态。sleepWindowInMilliseconds就是用来配置断路器开启后多长时间会进入半开启状态的,即配置断路器的工作时间,具体如下。
常用的配置还有很多,如设置请求的超时时间 :
execution.isolation.thread、timeoutIn Milliseconds等,这里就不一一介绍了,其他的阈值配置大家可以通过查看GitHub的Netflix Hystrix的教程wiki,或者查看HystrixCommandProperties类的源码来学习。
之前讲解了开启断路器的方法和断路器启动的阈值配置,那么断路器开启后会如何处理新到来的请求呢?最常用的就是采用服务降级的方式,即提前指定好降级方法,当断路器启动时,则调用降级方法而不再调用原来的服务,以达到服务降级、保护负载的目的。
下面来看一下在Spring Cloud Netflix Hystrix中如何做到服务降级。其实很简单,如果在服务消费者端使用的是SpringCloudRibbon,那么只需在调用服务的方法上增加@HystrixCommand注解,然后指定对应熔断后调用的方法名即可,代码如下。
在上述代码中,fallbackMethod="findUserNameByIdFallback"就指定了服务被熔断时的降级方法,当断路器启动后,原来调用restTemplate的方法(findUserNameById)就不会被执行,findUserNameById 方法将直接返回findUserNameByIdFallback的执行结果(ZhangGang)。
在之前介绍过,Spring Cloud Feign是对Spring Cloud Ribbon的封装,提供了更简便的远程方法调用方式,其实Spring Cloud Feign不仅封装了Ribbon,还封装了Hystrix,因为几乎在微服务架构中每个服务调用方都需要使用断路器,Spring Cloud干脆就开发了一个集成了远 程 调 用 ( Spring Cloud Ribbon ) 和 断 路 器 ( Spring Cloud Netflix Hystrix)的框架,它就是Spring Cloud Feign。
那么,在Feign中如何使用服务降级呢?配置代码如下。
在以上代码中,Spring Cloud Feign可以直接定义类级别的fallback,也可以直接在Feign的接口注解@FeignClient 中 配 置FallbackFactory ,指定降级服务为UserFeignClientFallBack。
UserFeignClientFallBack的代码如下。
由以上代码可以看出,fallback的类继承了FallbackFactory,实现的create方法会创建一个新的服务实现,用来代替之前的真实方法来达到降级的目的。
线程隔离又称为舱壁机制。一般为了提高船的生存能力,会将船体分为多个舱室,当船体发生事故导致进水时,只有受损的舱室会进水,其他舱室由于舱壁的隔离,并不会受到影响,从而将船体浮力的下降控制到最小。
Spring Cloud Netflix Hystrix也采用了同样的设计原理来保护微服务应用,在微服务的远程调用中,如果所有的请求都在一个线程池中,一旦有个别请求响应缓慢,这些请求可能会不断地消耗可用的资源,直至占满整个资源,其他的请求都会进入等待队列,从而拖垮整个应用。
那么,如何做到线程隔离呢?Hystrix提供了两种实现方式:线程池、信号量。
1. 线程池
线程池的做法很简单,可以将同一个请求分到同一个线程池中,使不同的请求拥有不同的线程池,这样每个请求就像有了自己的舱室,当其中一个舱室由于故障导致线程池被占满后,并不会影响其他“舱室”的请求。
Hystrix采用命令模式来完成线程池隔离线程的功能(命令模式是一种数据驱动的设计模式,也属于行为型模式)。也就是说,请求会以命令的形式包裹在对象中,并传给调用对象。调用对象寻找可以处理该命令的合适对象,并把该命令传给相应的对象,由该对象执行命令。图3.3所示为Hystrix的命令模式+线程池模式实现线程隔离的方式。
由图3.3可知,当依赖B调用的后端服务故障时,请求可能会超时或报错,但是只会阻碍自己资源池内的请求,并不会影响依赖A和依赖C中的请求。
Hystrix中要执行Command有4种方式:execute()、queue()、observe()和toObservable()。详细说明如下。
(1)execute()是以阻塞的方式运行Command的,在执行时会先以与queue()同样的方式获得Future对象,然后调用Future的get方法,get方法会阻塞execute的执行,直到方法运行完成。
(2)queue()是以非阻塞的方式运行Command的,调用queue方法会直接返回Future对象。需要调用者使用get方法来获取Command的返回,get是阻塞式的。
(3)observe()表示立即订阅并开始执行Command,然后返回一个Observable,当subscribe到该对象时,会重新触发流程的排序和通知。
( 4 ) toObservable() 与 observe 类 似 , 返 回 Observable , 但Command不会立即执行,必须subscribe后才能真正开始执行命令的流程。
下面来看一个简单的HelloCommand,代码如下。
在上述代码中,要执行一个hello的方法,那么首先可以定义一个HelloCommand,继承HystrixCommand,再通过构造器将参数传递到Command,然后通过设置HystrixCommand GroupKey.Factory.asKey("HelloGroup")的方法返回HystrixCommandGroupKey,Hystrix通过HystrixCommandGroupKey来定义线程池,当HystrixCommandGroupKey相同时,则会在同一个线程池内执行,最后通过getFallback方法来定义断路后的返回,当我们在执行execute时,run方法首先被执行,然后执行异常后,getFallback将被执行,以达到优雅的降级。
2. 信号量
尽管线程池的做法能够优雅地将应用程序的依赖服务保护起来,而不会在依赖服务出错时影响到服务本身,但是通常线程池更适合于远程调用依赖服务的模式,当我们在调用一些本地依赖服务时,或者网络开销基本忽略不计、服务响应延迟极低时,就没有必要再拥有另外一个线程,线程池本身就会增加一定计算的开销,从而影响到应用程序本身的性能。
Hystrix提供了信号量作为另一种方式来实现线程隔离,信号量又称为计数器,其实就是对任何给定依赖项的并发调用数进行计数,然后限制信号量的大小,而不是使用线程池/队列大小。因此,信号量的开销是很低的,其前提是保障依赖服务能够快速地返回异常,所以通常我们在非远程的依赖服务的调用中使用信号量来做线程隔离。
那么,具体是如何使用的呢?其实相比于线程池的用法,使用信号量只需在Command的构造器中设置隔离策略为SEMAPHORE,代码如下。
需要注意的是,在使用线程隔离时,无论是信号量的方式还是线程池的方式,客户端在调用依赖服务时都需要有超时的设置来防止服务被无限期阻塞,避免隔离区长期饱和。
除提供基本的断路器和服务降级功能之外,Hystrix还有其他的高级功能,如请求合并和请求缓存。请求合并就是把多次请求合并为一次请求,为什么要合并请求,合并什么样的请求?带着这些问题,我们来了解一下请求合并的功能。
请求合并是在前端开发中常用的手段。例如,我们往往将一些页面的JS、CSS和图片分别合并到一个文件中,如webpack,然后只通过一次请求就可以加载这个文件;再如,一些BFF(Backend For Frontend)的框架,也都将短时间内的请求合并为一次请求进行发送,这些都是请求合并的体现,这样做可以减少与后端服务建立连接的次数,有效地减少不必要的网络消耗,提高系统性能和负载。
那么,后端的请求合并是如何做的呢?请求未合并和合并的对比具体如图3.4所示。
由图3.4可以看出,当请求没有进行合并操作时,5次请求就会占用5个线程池中的线程,并且会与依赖服务建立5次新的连接,而当进行请求合并操作后,5次请求只会占用一个线程池的线程,并且与依赖服务建立一次连接。
Hystrix提供了请求合并的功能,采用的还是指令模式,我们通过继承HystrixCollapser类和自定义BatchCommand的方式来实现请求的合并。具体如何实现呢?假设现在有个请求是根据用户ID来查询用户,那么使用Hystrix合并请求的示例如下。
由上述代码可以看出,首先需要定义一个合并查询用户的指令,并继承HystrixCollapser类,而且需要定义HystrixCollapser的泛型,具体说明如下。
本例中批量查询返回值是List, User, String>,然后通过构造器的方式设置请求参数和依赖服务到全局属性中,通过createCommand方法,返回具体批量查询的指令,通过mapResponseToRequests来定义具体返回结果和请求的映射关系。在一般情况下,返回顺序与请求的顺序相同。
批量的查询指令需要另外定义,并且继承HystrixCommand,用法与之前一样,在run方法中定义具体的实现,如调用依赖服务。这里可以看出,如果要实现请求合并,不仅客户端需要进行改造,服务端也需要提供相应的批量查询方法,代码如下。
写个测试来验证一下,代码如下。
可以看到,采用queue的方式调用指令,批量查询只执行了一次,并且返回的数据也对应正确。这样便将多次GetUserById的方法合并了,减少了请求的次数。
那么,什么样的请求会被合并?是不是所有的GetUserById都会被合并?当然不是,如果所有的GetUserById请求都被合并,那么所有的请求都要等到在一起时进行合并,这显然不可能,Hystrix默认是使用一个单位的Window Bucket时间,即会合并10ms内的请求,那么可以再次验证一下,修改之前的main方法,代码如下。
在代码中发送了4次请求,并且在中间将主线程设置为sleep 20ms,保证请求不会全部被合并,那么在不加缓存时,理论上会发送两次请求,这两次请求都会查询ID为1和2的用户。当加了缓存后,第二次请求时缓存生效,并没有再次调用用户服务,而是直接返回结果。所以,只要我们在指令中添加getCacheKey方法,并且返回正确的key值,Hystrix的请求缓存机制就会生效。我们查看源码可以发现,getCacheKey方法被定义在AbstractCommand中,默认返回null,返回null时就代表不使用缓存。
使用缓存还涉及缓存失效的问题,假如数据发生变化,并不希望缓存一直存在,这时就需要清除缓存,Hystrix也提供了便捷清除缓存的方式,首先将之前的main方法改造一下,代码如下。
可以看出,此处f5和f6仍然命中了缓存的数据,并没有请求批量查询,若想要f5和f6不再使用缓存,则需要手动清除对应的缓存值,代码如下。
通过获取Hystrix Request Cache的实例,并调用clear方法可以清除CommandKey下指定的缓存key的缓存值。可以看出,缓存清除生效了,f5和f6再次请求了批量查询的服务。
资料免费获取方式:一键三连(点赞+转发+关注小编),扫下方二维码免费获取~