在微服务架构中,我们将系统拆分成了很多服务单元,各单元的应用间互相依赖。由于每个单元都在不同的进程中运行,依赖通过远程调用的方式执行,这样就有可能因为网络原因或是依赖服务自身间题出现调用故障或延迟,而这些问题会直接导致调用方的对外服务也出现延迟,若此时调用方的请求不断增加,最后就会因等待出现故障的依赖方响应形成任务积压,最终导致自身服务的瘫痪。(雪崩效应)
在分布式架构中,当某个服务单元发生故障之后,通过断路器的故障监控,向调用方返回一个错误响应,而不是长时间的等待。这样就不会使得线程因调用故障服务被长时间占用不释放,避免了故障在分布式系统中的蔓延。
Spring Cloud Hystrix实现了断路器、线程隔离等一系列服务保护功能。它也是基于Netflix的开源框架Hystrix实现的,该框架的目标在于通过控制那些访问远程系统、服务和第三方库的节点,从而对延迟和故障提供更强大的容错能力。Hystrix具备服务降级、服务熔断、线程和信号隔离、请求缓存、请求合并以及服务监控等强大功能。
一.Spring Cloud Hystrix的简单应用
1.在ribbon-consumer工程的pom.xml中引入spring-cloud-starter-hystrix依赖。
2.在ribbon-consumer工程的主类ConsumerApplication中使用@EnableCircuitBreaker或者@SpringCloudApplication(里面包含@EnableCircuitBreaker)注解开启断路器功能。
3.创建HelloService类,注入RestTemplate实例。在helloService函数上增加@HystrixCornrnand注解来指定回调方法。
4.创建ConsumerController类,注入上面实现的 HelloService实例,并在helloConsumer中进行调用。
SERVICE-ONE/hello服务正常返回结果为 “hello” 字符串。
这样就简单的完成了客户端的断路器功能。我们开启两个SERVICE-ONE服务,定义不同端口,我们重复访问/ribbon-consumer,结合上节的负载均衡,输出结果为:
hello
hello
hello
hello
...
当我们断掉其中一个服务或者在此服务业务方法中让线程休眠2000毫秒以上(Hystrix默认超时时间为2000毫秒),再重复访问/ribbon-consumer,输出结果为:
hello
error
hello
error
...
因为我们宕掉或访问超时的服务,无法正确返回,所以触发了@HystrixCornrnand注解来指定回调方法,也称为服务降级。
二.Hystrix的工作流程
1.创建HystrixCommand(依赖的服务返回单个操作结果)或HystrixObservableCommand(依赖的服务返回多个操作结果)对象,用来表示对依赖服务的操作请求,同时传递所有需要的参数。
2.命令执行:共存在4种命令的执行方式,而Hystrix在执行时会根据创建的Command对象以及具体的情况来选择一个执行。
(1) hystrixComrnand.execute()
该方法是同步(阻塞)的,从依赖请求中接收到单个响应(或者出错时抛出异常)。是通过queue()返回的异步对象Future<R>的get()方法来实现同步执行的。
K value = command.execute();
(2) hystrixComrnand.queue()异步执行,直接返回一个Future对象,其中包含了服务执行结束时要返回的单一结果对象。
Future<K> fValue = command.queue();
(3) hystrixObservableCommand.observe():订阅一个从依赖请求中返回的代表响应的Observable对象,代表了操作的多个结果。
Observable<K> ohValue = command.observe(); //HotObservable
(4) hystrixObservableCommand.toObservable():返回一个Observable对象,代表了操作的多个结果,只有当你订阅它时,才会执行Hystrix命令并发射响应。
Observable<K> ocValue = command.toObservable();//ColdObservable
3.结果是否被缓存
若当前命令的请求缓存功能是被启用的,并且该命令缓存命中,那么缓存的结果会立即以Observable对象的形式返回。
4.断路器是否打开
在命令结果没有缓存命中的时候,Hystrix在执行命令前需要检查断路器是否为打开状态:
• 如果断路器是打开的,那么Hystrix不会执行命令,而是转接到fallback处理逻辑(第8步)。
• 如果断路器是关闭的,那么Hystrix跳到第5步,检查是否有可用资源来执行命令。
5.线程池 / 请求队列 / 信号量是否占满
如果与命令相关的线程池和请求队列,或者信号量(不使用线程池的时候)已经被占满,那么Hystrix也不会执行命令,而是转接到fallback处理逻辑(第8步)。这里Hystrix所判断的线程池并非容器的线程池,而是每个依赖服务的专有线程池。Hystrix为了保证不会因为某个依赖服务的间题影响到其他依赖服务而采用了舱壁模式来隔离每个依赖的服务。
6.HystrixObservableCommand.construct()或HystrixCommand.run()
Hystrix会根据我们编写的方法来决定采取什么样的方式去请求依赖服务。
• HystrixCommand.run(): 返回一个单一的结果,或者抛出异常。
• HystrixObservableCommand.construct(): 返回一个Observable对象来发射多个结果,或通过onError发送错误通知。
如果run()或construct()方法的执行时间超过了命令设置的超时值,当前处理线程将会抛出一个TimeoutException。在这种情况下,Hystrix会转接到fallback处理逻辑(第8步)。同时,如果当前命令没有被取消或中断,那么它最终会忽略run()或者construct ()方法的返回。如果命令没有抛出异常并返回了结果,那么Hystrix在记录一些日志并采集监控报告之后将该结果返回。
7.计算断路器的健康度
Hystrix会将“成功”、“失败”、“拒绝”、“超时” 等信息报告给断路器,而断路器会维护一组计数器来统计这些数据。断路器会使用这些统计数据来决定是否要将断路器打开,来对某个依赖服务的请求进行 “熔断/短路 ”,直到恢复期结束。若在恢复期结束后,根据统计数据判断如果还是未达到健康指标,就再次“熔断/短路”。
8.fallback处理
当命令执行失败的时候, Hystrix会进入fallback尝试回退处理, 我们通常也称该操作为“ 服务降级”。而能够引起服务降级处理的清况有下面几种:
• 第4步,当前命令处于 “熔断 短路 ” 状态, 断路器是打开的时候。
• 第5步,当前命令的线程池、 请求队列或者信号量被占满的时候。
• 第6步,HystrixObservableCommand.construct()或HystrixCommand.run()抛出异常的时候。
在服务降级逻辑中,我们需要实现一个通用的响应结果,并且该结果的处理逻辑应当是从缓存或是根据一些静态逻辑来获取,而不是依赖网络请求获取。如果一定要在降级逻辑中包含网络请求,那么该请求也必须被包装在HystrixCommand或是HystrixObservableCommand中,从而形成级联的降级策略,而最终的降级逻辑一定是一个能够稳定地返回结果的非网络处理逻辑。
如果我们没有为命令实现降级逻辑或者降级处理中抛出了异常,Hystrix 依然会返回一个Observable对象,但是它不会发射任何结果数据,而是通过onError方法通知命令立即中断请求,并通过onError方法将引起命令失败的异常发送给调用者。实现一个有可能失败的降级逻辑是一种非常糟糕的做法,我们应该在实现降级策略时尽可能避免失败的情况。当然完全不可能出现失败的完美策略是不存在的,如果降级执行发现失败的时候,Hystrix会根据不同的执行方法做出不同的处理。
9.返回成功的响应
当Hystrix命令执行成功之后,它会将处理结果直接返回或是以Observable的形式返回。
三.断路器原理
1.HystrixCircuitBreaker
断路器在HystrixCommand和HystrixObservableCommand执行过程中起到了举足轻重的作用,是Hystrix的核心部件。那么断路器是如何决策熔断和记录信息的呢?我们先来看看断路器 HystrixCircuitBreaker 的定义:
可以看到它的接口定义并不复杂,主要定义了三个断路器的抽象方法还有三个静态类。
1.1 allowRequest():每个Hystrix命令的请求都通过它判断是否被执行。
1.2 isOpen():返回当前断路器是否打开。
1.3 markSuccess():用来闭合断路器。
1.4 Factory维护了一个Hystrix命令与HystrixCircuitBreaker的关系集合:ConcurrentHashMap<String, HystrixCircuitBreaker> circuitBreakersByCommand, 其中String类型的key通过HystrixCommandKey定义,每一个 Hystrix命令需要有一个key来标识,同时一个Hystrix命令也会在该集合中找到它对应的断路器HystrixCircuitBreaker实例。
1.5 NoOpCicuitBreaker 定义了一个什么都不做的断路器实现,它允许所有请求,并且断路器状态始终闭合。
1.6 HystrixCircuitBreakerImpl是断路器接口HystrixCircuitBreaker的实现类。
2.HystrixCircuitBreakerImpl
2.1HystrixCircuitBreakerImpl类中定义了断路器的4个核心对象。
(1)HystrixCommandProperties properties:断路器对应HystrixCommand实例的属性对象。
(2)HystrixCommandMetrics metrics:用来让HystrixCommand记录各类度量指标的对象。
(3)AtomicBoolean circuitOpen:断路器是否打开的标志,默认为 false。
(4)AtomicLong circuitOpenedOrLastTestedTime:断路器打开或是上一次测试的时间戳。
2.2HystrixCircuitBreakerImpl实现了HystrixCircuitBreaker的三个方法。
(1)isOpen():判断断路器的打开/关闭状态。如果断路器打开标识为true, 则直接返回true, 表示断路器处于打开状态。否则,就从度量指标对象metrics中获取建健康度统计对象做进一步判断。
如果它的请求总数(QPS)在预设的阙值范围内就返回false, 表示断路器处于未打开状态,该阙值的配置参数默认值为20。
如果错误百分比在阑值范围内就返回false, 表示断路器处于未打开状态。该阙值的配置参数默认值为50。
如果上面的两个条件都不满足,则将断路器设置为打开状态(熔断/短路)。同时,如果是从关闭状态切换到打开状态的话,就将当前时间记录到上面提到的
circuitOpenedOrLastTestedTime(断路器打开或是上一次测试的时间戳)对象中。
(2)allowRequest():判断请求是否被允许,这个实现非常简单。先根据配置对象properties中的断路器判断强制打开或关闭属性是否被设置。如果强制打开,就直接返回false, 拒绝请求。如果强制关闭,它会允许所有请求,但是同时也会调用isOpen ()来执行断路器的计算逻辑,用来模拟断路器打开/关闭的行为。在默认情况下,断路器并不会进入这两个强制打开或关闭的分支中去,而是通过 !isOpen ()||allowSingleTest() 来判断是否允许请求访问。isOpen()之前已经介绍过,用来判断和计算当前断路器是否打开,如果是断开状态就允许请求。那么allowSingleTest()是用来做什么的呢?从allowSingleTest()使用了在isOpen()函数中当断路器从闭合到打开时候所记录的时间戳。简单地说,通过断路器打开之后的休眠时间(默认为5秒),在该休眠时间到达之后,将再次允许请求尝试访问,此时断路器处于“半开”状态,若此时请求继续失败,断路器又进入打开状态,并继续等待下一个休眠窗口过去之后再次尝试;若请求成功,则将断路器重新置于关闭状态。所以通过 allowSingleTest()与isOpen()方法的配合,实现了断路器打开和关闭状态的切换。
(3) markSuccess(): 该函数用来在“半开路”状态时使用。若Hystrix命令调用成功,通过调用它将打开的断路器关闭,并重置度量指标对象。
四.依赖隔离
Hystrix会使用舱壁模式实现线程池的隔离,它会为每一个依赖服务创建一个独立的线程池,这样就算某个依赖服务出现延迟过高的情况,也只是对该依赖服务的调用产生影响,而不会拖慢其他的依赖服务。通过实现对依赖服务的线程池隔离,可以带来如下优势:
• 应用自身得到完全保护,不会受不可控的依赖服务影响。即便给依赖服务分配的线程池被填满,也不会影响应用自身的其余部分。
• 可以有效降低接入新服务的风险。如果新服务接入后运行不稳定或存在问题,完全不会影响应用其他的请求。
• 当依赖的服务从失效恢复正常后,它的线程池会被清理并且能够马上恢复健康的服务,相比之下,容器级别的清理恢复速度要慢得多。
• 当依赖的服务出现配置错误的时候,线程池会快速反映出此问题(通过失败次数、延迟、超时、拒绝等指标的增加情况)。同时,我们可以在不影响应用功能的情况下通过实时的动态属性刷新来处理它。
• 当依赖的服务因实现机制调整等原因造成其性能出现很大变化的时候,线程池的监控指标信息会反映出这样的变化。同时,我们也可以通过实时动态刷新自身应用对依赖服务的阙值进行调整以适应依赖方的改变。
• 除了上面通过线程池隔离服务发挥的优点之外,每个专有线程池都提供了内置的并发实现,可以利用它为同步的依赖服务构建异步访问。
虽然线程池隔离的方案带来如此多的好处,但是很多使用者可能会担心为每一个依赖服务都分配一个线程池是否会过多地增加系统的负载和开销。对于这一点, 完全不用过于担心,Netflix在设计Hystrix的时候,认为线程池上的开销相对于隔离所带来的好处是无法比拟的。 同时,Netflix也针对线程池的开销做了相关的测试,以用结果打消Hystrix实现对性能影响的顾虑。
五.定义服务降级
服务降级:fallback是Hystrix命令执行失败时使用的后备方法,用来实现服务的降级处理逻辑。在HystrixCommand中可以通过重载 getFallback ()方法来实现服务降级逻辑,Hystrix会在run()执行过程中出现错误、超时、线程池拒绝、断路器熔断等情况时,执行getFallback ()方法内的逻辑。
HystrixObservableCommand实现的 Hystrix命令中,我们可以通过重载resumeWithFallback方法来实现服务降级逻辑。该方法会返回一个 Observable对象,当命令执行失败的时候,Hystrix会将 Observable中的结果通知给所有的订阅者。若要通过注解实现服务降级只需要使用@HystrixCommand中的fallbackMethod参数来指定具体的服务降级实现方法。
六.异常处理
异常传播,在HystrixComrnand实现的run()方法中抛出异常时,除了HystrixBadRequestException之外,其他异常均会被Hystrix认为命令执行失败并触发服务降级的处理逻辑,所以当需要在命令执行中抛出不触发服务降级的异常时来使用它。而在使用注册配置实现 Hystrix 命令时,它还支持忽略指定异常类型功能,只需要通过设置@HystrixComrnand注解的ignoreExceptions参数。异常获取:当Hystrix 命令因为异常(除了HystrixBadRequestException 的异常)进入服务降级逻辑之后,往往需要对不同异常做针对性的处理,那么我们如何来获取当前抛出的异常呢?
在以传统继承方式实现的 Hystrix命令中,我们可以用getFallback()方法通过Throwable getExecutionException() 方法来获取具体的异常,通过判断来进入不同的处理逻辑。除了传统的实现方式之外,注解配置方式也同样可以实现异常的获取。它的实现也非常简单,只需要在 fallback实现方法的参数中增加Throwable对象的定义,这样在方法内部就可以获取触发服务降级的具体异常内容了。比如:
七.请求缓存
当系统用户不断增长时,每个微服务需要承受的并发压力也越来越大。 在分布式环境下,通常压力来自于对依赖服务的调用,因为请求依赖服务的资源需要通过通信来实现,这样的依赖方式比起进程内的调用方式会引起一部分的性能损失,同时HTTP相比其他高性能的通信协议在速度上没有任何优势,所以它有些类似于对数据库这样的外部资源进行读写操作,在高并发的情况下可能会成为系统的瓶颈。在高并发的场景之下,Hystrix中提供了请求缓存的功能,我们可以方便地开启和使用请求缓存来优化系统,达到减轻高并发时的请求线程消耗、降低请求响应时间的效果。
开启请求缓存功能Hystrix请求缓存的使用非常简单,我们只需要在实现 HystrixCommand 或HystrixObservableCommand时,通过重载 getCacheKey ()方法来开启请求缓存。我们通过在 getCacheKey 方法中返回的请求缓存key值,就能让该请求命令具备缓存功能。此时,当不同的外部请求处理逻辑调用了同一个依赖服务时,Hystrix 会根据 getCacheKey 方法返回的值来区分是否是重复的请求,如果它们的cacheKey相同,那么该依赖服务只会在第一个请求到达时被真实地调用一次,另外一个请求则是直接从请求缓存中返回结果,所以通过开启请求缓存可以让我们实现的 Hystrix 命令具备下面几项好处:
• 减少重复的请求数,降低依赖服务的并发度。
• 在同一用户请求的上下文中,相同依赖服务的返回数据始终保持一致。
• 请求缓存在run()和construct()执行之前生效,所以可以有效减少不必要的线程开销。
清理失效缓存功能:使用请求缓存时,如果只是读操作,那么不需要考虑缓存内容是否正确的问题,但是如果请求命令中还有更新数据的写操作,那么缓存中的数据就需要我们在进行写操作时进行及时处理,以防止读操作的请求命令获取到了失效的数据。在Hystrix中,我们可以通过 HystrixRequestCache.clear() 方法来进行缓存的清理。
八.请求合并
微服务架构中的依赖通常通过远程调用实现,而远程调用中最常见的问题就是通信消耗与连接数占用。在高并发的情况之下,因通信次数的增加,总的通信时间消耗将会变得不那么理想。同时,因为依赖服务的线程池资源有限,将出现排队等待与响应延迟的清况。为了优化这两个问题,Hystrix提供了HystrixCollapser来实现请求的合并,以减少通信消耗和线程数的占用。
HystrixCollapser实现了在 HystrixCommand 之前放置一个合并处理器,将处于一个很短的时间窗(默认10 毫秒)内对同一依赖服务的多个请求进行整合并以批量方式发起请求的功能(服务提供方也需要提供相应的批量实现接口)。通过HystrixCollapser的封装,开发者不需要关注线程合并的细节过程,只需关注批量化
服务和处理。
请求合并的额外开销:虽然通过请求合并可以减少请求的数量以缓解依赖服务线程池的资源,但是在使用的时候也需要注意它所带来的额外开销:用于请求合并的延迟时间窗会使得依赖服务的请求延迟增高。 比如,某个请求不通过请求合并器访问的平均耗时为5ms, 请求合并的延迟时间窗为lOms(默认值),那么当该请求设置了请求合并器之后,最坏情况下(在延迟时间窗结束时才发起请求)该请求需要 15ms才能完成。由于请求合并器的延迟时间窗会带来额外开销, 所以我们是否使用请求合并器需要根据依赖服务调用的实际情况来选择,主要考虑下面两个方面。
• 请求命令本身的延迟。如果依赖服务的请求命令本身是一个高延迟的命令,那么可以使用请求合并器,因为延迟时间窗的时间消耗显得微不足道了。
• 延迟时间窗内的并发量。如果一个时间窗内只有1-2个请求, 那么这样的依赖服务不适合使用请求合并器。 这种情况不但不能提升系统性能,反而会成为系统瓶颈,因为每个请求都需要多消耗一个时间窗才响应。相反,如果一个时间窗内具有很高的并发量,并且服务提供方也实现了批量处理接口,那么使用请求合并器可以有效减少网络连接数量并极大提升系统吞吐量, 此时延迟时间窗所增加的消耗就可以忽略不计了。
本文链接:《Spring Cloud》学习(三) 容错保护!
转载声明:本博客由静影残月创作。可自由转载、引用,但需署名作者且注明文章出处。