SpringCloud详解 第三章 服务容错保护 Hystrix(七)
本章对Hystrix进行原理分析摘自《Spring Cloud 微服务实战》
通过上面的快速入门示例,我们对Hystrix的使用场景 和使用方法已经有了一 个基础的认识。 接下来我们通过解读NetflixHystrix官方的流程图来详细了解一 下:当 一 个请求调用了相关服务依赖之后Hystrix是如何工作的(即如上例中所示,当访问了http://localhost:9001/hello请求之后, 在RIBBON-SERVER中是如何处理的)。
工作流程:
下面我们根据图中标记的数字顺序来解释每 一 个环节的 详细内容。
1. 创建HystrixCommand或HystrixObservableCommand对象:
首先,构建 一 个HystrixCommand或是HystrixObservableCommand对象,用来表示对依赖服务的操作请求, 同时传递所有需要的参数。 从其命名中我们就能知道它采用了“ 命令模式 ”来实现对服务调用操作的封装。关于命令模式可以参考:https://www.cnblogs.com/wuzhenzhao/p/12557341.html
而这两个 Command 对象分别针对不同的应用场景。
- HystrixCommand: 用在依赖的服务返回单个操作结果的时候。
- HystrixObservableCommand: 用在依赖的服务返回多个操作结果的时候。
命令模式主要包含四种角色:
- 接收者角色(Receiver) :该类负责具体实施或执行一个请求;
- 命令角色(Command) :定义需要执行的所有命令行为;
- 具体命令角色(Concrete Command) 该类内部维护一个接收者(Receiver) 在其execute()方法中调用Receiver的相关方法;
- 请求者角色(Invoker) :接收客户端的命令, 并执行命令。
我们可以看到,调用者Invoker与操作者Receiver通过 Command命令接口实现了解耦。对于调用者来说, 我们可以为其注入多个命令操作, 比如新建文件、复制文件、 删除文件这样三个操作, 调用者只需在需要的时候直接调用即可, 而不需要知道 这些操作命令实际是如何实现的。 而在这里所 提到的 HystrixComrnand 和 HystrixObservableComrnand 则是在 Hystrix中对 Command 的进 一 步抽象定义。
从上面的命令模式示例中我们也可以发现, Invoker和 Receiver 的关系非常类似于 “ 请求-响应 ” 模式, 所以它比较适用于实现记录日志、 撤销操作、 队列请求等。在下面这些情况下应考虑使用命令模式。
- 使用命令模式作为“回调 (CallBack) "在面向对象系统中的替代。"CallBack" 讲的便是先将一个函数登记上, 然后在以后调用此函数。
- 需要在不同的时间指定请求、 将请求排队。一 个命令对象和原先的请求发出者可以有不同的生命期。 换言之, 原先的请求发出者可能已经不在了, 而命令对象本身仍然是活动的。这时命令的接收者可以是在本地, 也可以在网络的另外 一 个地址。命令对象可以在序列化之后传送到另外 一 台机器上去。
- 系统需要支持命令的撤销。命令对象可以把状态存储起来, 等到客户端需要撤销命令所产生的效果时, 可以调用 undo() 方法, 把命令所产生的效果撤销掉。命令对象还可以提供 redo()方法, 以供客户端在需要时再重新实施命令效果。
- 如果要将系统中所有的数据更新到日志里,以便在系统 崩溃时,可以根据日志读回所有的数据更新命令, 重新 调用 Execute()方法一 条 一 条执行 这些命令, 从而恢复系统在崩溃前所做的数据更新。
2.命令执行:
从图中我们可以看到一 共存在4种命令的执行方式,而 Hystrix在执行 时 会根据创建的Command对象以及具体的情况来选择 一 个执行。其中HystrixComrnand实现了下面两个执行方式。
- execute (): 同步执行,从依赖的服务 返回一 个单 一的结果对象, 或是在发生错误的时候抛出异常。
- queue (): 异步执行,直接返回一 个Future对象, 其中包含了服务 执行 结束时要返回的单一结果对象。
而HystrixObservableCommand实现了另外两种 执行方式。
- observe () : 返回Observable对象,它代表了操作的多个结果,它是一 个Hot Observable。
- toObservable(): 同样会返回Observable对象, 也代表了操作的多个结果,但它返回的是一 个Cold Observable。
在Hystrix的底层实现中大量地使用了RxJava,在这里对RxJava的观察者-订阅者模式做 一 个简单的入门介绍。上面我们所提到的Observable对象就是RxJava中的核心内容之 一 ,可以把它理解为“事件源”或是“被观察者 ” , 与其对应的Subscriber对象,可以理解为“订阅者 ”或是“观察者 ” 。 这两个对象 是RxJava响应式编程的重要组成部分。
- Observable用来向订阅者Subscriber对象 发布事件,Subscriber对象则在接收到事件后对其进行处理, 而在这里所指的事件 通常就是对依赖 服务的调用。
- 一 个Observable可以发出多个事件, 直到结束或是 发生异常。
- Observable 对象每发出一 个事件,就会调用对应 观察者 Subscriber 对象的onNext ()方法。
- 每一 个Observable的执行,最后 一 定会通过调用 Subscriber. onCompleted () 或者Subscriber.onError()来结束该事件的操作流。
下面我们通过 一 个简单的例子来直观理解 一 下 Observable与Subscribers:
public class RxJavaTest { public static void main(String[] args) { //例子1 Observable<String> observable = Observable.create(new Observable.OnSubscribe<String>() { @Override public void call(Subscriber<? super String> subscriber) { subscriber.onNext("Hello RxJava"); subscriber.onNext("I am程序猿DD"); subscriber.onCompleted(); } }); //创建订阅者subscriber Subscriber<String> subscriber = new Subscriber<String>() { @Override public void onCompleted() { System.out.println("Subscriber : onCompleted"); } @Override public void onError(Throwable e) { } @Override public void onNext(String s) { System.out.println("Subscriber : " + s); } }; //订阅 observable.subscribe(subscriber); //例子2 System.out.println("========================================="); //正常情况是先产生数据,再注册,再消费。 Observable.just(dataProducer()).doOnSubscribe(() -> { System.out.println("Subscribe!"); }).subscribe(s -> { System.out.println("Consume Data :" + s); }); //使用了defer之后,是先注册,再生产数据,再消费。 Observable.defer(() -> { return Observable.just(dataProducer()); }).doOnSubscribe(() -> { System.out.println("Subscribe!"); }).subscribe(s -> { System.out.println("Consume Data :" + s); }); System.out.println("========================================="); //我们不关心onError和onComplete,所以只需要第一个参数就可以 //例子3 Observable<String> myObservable = Observable.just("Hello, world! just"); Action1<String> onNextAction = new Action1<String>() { @Override public void call(String s) { System.out.println(s); } }; myObservable.subscribe(onNextAction); } private static String dataProducer() { String str = "Hello,RxJava!"; System.out.println("Produce Data :" + str); return str; } }
在该示例中, 创建了 一 个简单的事件源 observable,一 个对事件传递内容输出的订阅者 subscriber, 通过 observable.subscribe(subscriber) 来触发事件的发布。在这里我们对于事件源 observable 提到了两个不同的概念: Hot Observable 和 Cold Observable, 分别对应了上面 command. observe ()和command.toObservable() 的返回对象。 其中 Hot Observable, 它不论” 事件源 ”是否有“ 订阅者 ” , 都会在创建后对事件进行发布, 所以对于 Hot Observable 的每 一 个“ 订阅者 ”都有可能是从“事件源”的中途开始的, 并可能只是看到了整个操作的局部过程。 而 Cold Observable 在没有“ 订阅者 ”的时候并不会发布事件, 而是进行等待, 直到有“ 订阅者 ”之后才发布事件, 所以对于 ColdObservable 的订阅者, 它可以保证从 一 开始看到整个操作的全部过程。大家从表面上可能会认为只是在 HystixObservableCommand 中使用了 RxJava,然而实际上execute()、 queue()也都使用了Rx.Java来实现。 从下面的源码中我们可以看到:
- execute ()是通过queue()返回的异步对象Future<R>的get()方法来实现同步执行的。 该方法会等待任务执行结束, 然后获得R类型的结果进行返回。
- queue ()则是通过toObservable()来获得 一 个Cold Observable, 并且通过 toBlocking ()将该Observable转换成BlockingObservable, 它可以把数据以阻塞的方式 发射出来。 而toFuture 方法则是 把BlockingObservable转换为一 个Future, 该方法只是创建 一 个Future 返回并不会阻塞,这使得消费者可以自己决定如何处理异步操作。 而execute()就是直接使用了queue()返回的 Future中的阻塞方法 get()来实现同步操作的。 同时通过这种方式转换的Future要求Observable 只发射 一 个数据,所以 这两个实现都只能返回单 一 结果。
-
public R execute() { try { return queue().get(); } catch (Exception e) { throw Exceptions.sneakyThrow(decomposeException(e)); } } public Future<R> queue() { /* * The Future returned by Observable.toBlocking().toFuture() does not implement the * interruption of the execution thread when the "mayInterrupt" flag of Future.cancel(boolean) is set to true; * thus, to comply with the contract of Future, we must wrap around it. */ final Future<R> delegate = toObservable().toBlocking().toFuture(); final Future<R> f = new Future<R>() { if (f.isDone()) { ...... } return f; }
3.结果是否被缓存:
若当前命令的请求缓存功能是被启用的, 并且该命令缓存命中, 那么缓存的结果会立即以Observable 对象的形式 返回。
4.断路器是否打开:
在命令结果没有缓存命中的时候, Hystrix在执行命令前需要检查断路器是否为打开状态:
- 如果断路器是打开的,那么Hystrix不会执行命令,而是转接到fallback处理逻辑(第8步)
- 如果断路器是关闭的, 那么Hystrix跳到第5步,检查是否有可用资源来 执行命令。
5.线程池请求队列信号量是否占满:
如果与命令相关的线程池和请求队列,或者信号量(不使用线程池的时候)已经被占满, 那么Hystrix也不会执行命令,而是转接到fallback处理逻辑(第8步)。需要注意的是,这里Hystrix所判断的线程池并非容器的线程池,而是每个依赖服务的专有线程池。 Hystrix为了保证不会因为某个依赖服务的间题影响到其他依赖服务而采用了“ 舱壁模式" (Bulkhead Pattern)来 隔离每个依赖的服务。下面介绍一下依赖隔离
“舱壁模式”对于熟悉 Docker 的读者 一 定不陌生, Docker 通过“舱壁模式”实现进程的隔离, 使得容器与容器之间不会互相影响。 而 Hystrix 则使用该模式实现线程池的隔离,它会为每 一 个依赖服务创建 一 个独立的线程池, 这样就算某个依赖服务出现延迟过高的情况, 也只是对该依赖服务的调用产生影响, 而不会拖慢其他的依赖服务。通过实现对依赖服务的线程池隔离, 可以带来如下优势:
- 应用自身得到完全保护, 不会受不可控的依赖服务影响。 即便给依赖服务分配的线程池被填满, 也不会影响应用自身的其余部分。
- 可以有效降低接入新服务的风险。 如果新服务接入后运行不稳定或存在问题, 完全不会影响应用其他的请求。当依赖的服务从失效恢复正常后, 它的线程池会被清理并且能够马上恢复健康的服务, 相比之下, 容器级别的清理恢复速度要慢得多。
- 当依赖的服务出现配置错误的时候, 线程池会快速反映出此问题(通过失败次数、延迟、超时、拒绝等指标的增加情况)。 同时, 我们可以在不影响应用功能的情况下通过实时的动态属性刷新(后续会通过Spring Cloud Config与Spring Cloud Bus的联合使用来介绍) 来处理它。
- 当依赖的服务因实现机制调整等原因造成其性能出现很大变化的时候, 线程池的监控指标信息会反映出这样的变化。 同时, 我们也可以通过实时动态刷新自身应用对依赖服务的阙值进行调整以适应依赖方的改变。
- 除了上面通过线程池隔离服务发挥的优点之外, 每个专有线程池都提供了内置的并发实现, 可以利用它为同步的依赖服务构建异步访问。
总之, 通过对依赖服务实现线程池隔离, 可让我们的应用更加健壮, 不会因为个别依赖服务出现问题而引起非相关服务的异常。 同时, 也使得我们的应用变得更加灵活, 可以在不停止服务的情况下, 配合动态配置刷新实现性能配置上的调整。
6.HystrixObservableCommand.construct()或HystrixCommand.run():
Hystrix会根据我们编写的方法来决定采取什么样的方式去请求依赖服务。
- HystrixCommand.run(): 返回 一 个单 一 的结果,或者抛出异常。
- Hys 巨 ixObservableCommand.construct(): 返回 一 个Observable对象来发射多个结果,或通过onError发送错误通知。
如果run()或construet()方法的执行时间超过了命令设置的超时阙值,当前处理线程将会抛出 一 个TimeoutException (如果该命令不在其自身的线程中执行,则会通过单独的计时线程来 抛出)。在这种情况下,Hystrix会转接到fallback处理逻辑(第8步)。同时,如果当前命令没有被取消或中断, 那么它最终会忽略run()或者construct ()方法的返回。如果命令没有抛出异常并返回了结果,那么Hystrix在记录 一 些日志并采集监控报告之后将该结果返回。在使用run()的情况下,Hystrix会返回 一 个Observable, 它发射单个结果并产生onCompleted的结束通知; 而在使用construct ()的情况下,Hystrix会直接返回该方法产生的Observable对象。
7. 计算断路器的健康度:
Hystrix会将“成功” 、 “失败” 、 “ 拒绝 ” 、 “超时”等信息报告给断路器,而断路器会维护 一 组计数器来统计这些数据。断路器会使用这些统计数据来决定是否要将断路器打开,来对某个依赖服务的请求进行“熔断/短路” ,直到恢复期结束。 若在恢复期结束后,根据统计数据判断如果还是未达到健康指标,就再次“熔断/短路” 。
8. fallback处理:
当命令执行失败的时候, Hystrix会进入fallback尝试回退处理, 我们通常也称该操作为“服务降级 ” 。而能够引起服务降级处理的清况有下面几种:
- 第4步, 当前命令处于“熔断I短路”状态, 断路器是打开的时候。
- 第5步, 当前命令的线程池、 请求队列或者信号量被占满的时候。
- 第6步,HystrixObservableCommand.construct()或HystrixCommand.run()抛出异常的时候。
在服务降级逻辑中, 我们需要实现一 个通用的响应结果, 并且该结果的处理逻辑应当是从缓存或是根据 一 些静态逻辑来获取,而不是依赖网络请求获取。如果一 定要在降级逻辑中包含网络请求,那么该请求也必须被包装在HystrixCommand或是HystrixObservableCommand中, 从而形成级联的降级策略, 而最终的降级逻辑 一 定不是 一 个依赖网络请求的处理, 而是 一 个能够稳定地返回结果的处理逻辑。在HystrixCommand和HystrixObservableCommand中实现降级逻辑时还略有不同:
- 当使用HystrixCommand的时候, 通过实现HystrixCommand.getFallback()来实现服务降级逻辑。
- 当使用 HystrixObservableCommand 的时候, 通过 HystrixObservableCommand.resumeWithFallback()实现服务降级逻辑, 该方法会返回 一 个Observable对象来发射 一 个或多个降级结果。
当命令的降级逻辑返回 结果之后, Hystrix 就将该结果返回给调用者。 当使用HystrixCommand.getFallback()的时候, 它会返回一 个Observable对象, 该对象会 发 射 getFallback() 的 处 理 结 果 。 而使用 HystrixObservableCommand.resumeWithFallback ()实现的时候, 它会将Observable对象直接返回。如果我们没有为命令实现降级逻辑或者降级处理逻辑中抛出了异常, Hystrix依然会返回 一 个Observable对象, 但是它不会发射任何结果数据, 而是通过onError 方法通知命令立即中断请求,并通过onError()方法将引起命令失败的异常发送给调用者。实现 一个有可能失败的降级逻辑是 一 种非常糟糕的做法, 我们应该在实现降级策略时尽可能避免失败的情况。
当然完全不可能出现失败的完美策略是不存在的, 如果降级执行发现失败的时候,Hystrix会根据不同的执行方法做出不同的处理。
- execute(): 抛出异常。
- queue(): 正常返回Future对象,但是当 调用get()来获取结果的时候会抛出异常。
- observe () : 正常返回Observable对象, 当订阅它的时候, 将立即通过调用订阅者的onError方法来通知中止请求。
- toObservable(): 正常返回Observable对象, 当订阅它的时候, 将通过调用订阅者的onError方法来通知中止请求。
9. 返回成功的响应:
当Hystrix命令执行成功之后, 它会将处理结果直接返回或是以Observable 的形式返回。 而具体以哪种方式返回取决于之前第2步中我们所提到的对命令的4种不同执行方式, 下图中总结了这4种 调用方式之间的依赖关系。 我们可以将此图与在第2步中对前两者源码的分析联系起来 , 并且从源头toObservable()来开始分析。
- toObservable(): 返回最原始的 Observable, 必须通过订阅它才会真正触发命令的执行流程。
- observe () : 在toObservable()产生原始Observable 之后立即 订阅它, 让命令能够马上开始异步执行 , 并返回 一 个Observable 对象, 当调用它的subscribe 时, 将重新产生结果和通知给订阅者。
- queue (): 将 toObservable()产生的原始Observable通过toBlocking()方法转换成BlockingObservable对象, 并调用它的toFuture()方法 返回异步的Future对象。
- execute () : 在queue()产生异步结果Future对象之后,通过调用get()方法阻塞并等待结果的返回。
断路器原理:
断路器在 HystrixCommand 和 HystrixObservableCommand 执行过程中起到了举足轻重的作用,它是 Hystrix 的核心部件。 那么断路器是如何决策熔断和记录信息的呢?我们先来看看断路器 HystrixCircuitBreaker 的定义:
public interface HystrixCircuitBreaker { /**每个 Hystrix 命令的请求都通过它判断是否被执行。 * Every {@link HystrixCommand} requests asks this if it is allowed to proceed or not. * This takes into account the half-open logic which allows some requests through when determining if it should be closed again. * @return boolean whether a request should be permitted */ public boolean allowRequest(); /**返回当前断路器是否打开。 * Whether the circuit is currently open (tripped). * @return boolean state of circuit breaker */ public boolean isOpen(); /**用来闭合断路器。 * Invoked on successful executions from {@link HystrixCommand} as part of feedback mechanism when in a half-open state. */ void markSuccess(); /**静态类 Factory 中维护了 一 个 Hystrix 命令与 HystrixCircuitBreaker 的关系 * @ExcludeFromJavadoc * @ThreadSafe */ public static class Factory {...} /**断路器接口 HystrixCircuitBreaker的实现类 * The default production implementation of {@link HystrixCircuitBreaker}. * @ExcludeFromJavadoc * @ThreadSafe */ static class HystrixCircuitBreakerImpl implements HystrixCircuitBreaker {...} /**定义了一 个什么都不做的断路器实现,它允许所有请求,并且断路器状态始终闭合。 * An implementation of the circuit breaker that does nothing. * @ExcludeFromJavadoc */ static class NoOpCircuitBreaker implements HystrixCircuitBreaker {...} }
HystrixCircuitBreakerImpl 对 HystrixCircuitBreaker 接口的各个方法实现如下所示。
isOpen (): 判断断路器的打开/关闭状态。 详细逻辑如下所示。
- 如果断路器打开标识为true, 则直接返回true, 表示断路器处千打开状态。否则,就从度量指标对象 metrics 中获取 HealthCounts 统计对象做进 一 步判断(该对象记录了一 个滚动时间窗内的请求信息快照,默认时间窗为10秒)。
- 如果它的请求总数(QPS)在预设的阙值范围内就返回 false , 表示断路器处于未打开状态。该阙值的配置参数为 circuitBreakerRequestVolumeThreshold,默认值为20。
- 如果错误百分比在阑值范围内就返回 false, 表示断路器处于未打开状态。该阙值的配置参数为 circuitBreakerErrorThresholdPercentage, 默认值为50 。
- 如果上面的两个条件都不满足,则将断路器设置为打开状态 (熔断/短路)。 同时,如果是从关闭状态切换到打开状态的话,就将当前时间记录到上面提到的circuitOpenedOrLastTestedTirne 对象中。
@Override public boolean isOpen() { if (circuitOpen.get()) { // if we're open we immediately return true and don't bother attempting to 'close' ourself as that is left to allowSingleTest and a subsequent successful test to close return true; } // we're closed, so let's see if errors have made us so we should trip the circuit open HealthCounts health = metrics.getHealthCounts(); // check if we are past the statisticalWindowVolumeThreshold if (health.getTotalRequests() < properties.circuitBreakerRequestVolumeThreshold().get()) { // we are not past the minimum volume threshold for the statisticalWindow so we'll return false immediately and not calculate anything return false; } if (health.getErrorPercentage() < properties.circuitBreakerErrorThresholdPercentage().get()) { return false; } else { // our failure rate is too high, trip the circuit if (circuitOpen.compareAndSet(false, true)) { // if the previousValue was false then we want to set the currentTime circuitOpenedOrLastTestedTime.set(System.currentTimeMillis()); return true; } else { // How could previousValue be true? If another thread was going through this code at the same time a race-condition could have // caused another thread to set it to true already even though we were in the process of doing the same // In this case, we know the circuit is open, so let the other thread set the currentTime and report back that the circuit is open return true; } } }
allowRequest(): 判断请求是否被允许,这个实现非常简单。 先根据配置对象properties中的断路器判断强制打开或关闭属性是否被设置。 如果强制打开,就直接返回false, 拒绝请求。 如果强制关闭,它会允许所有请求,但是同时也会调用isOpen ()来执行断路器的计算逻辑, 用来模拟断路器打开/关闭的行为。 在默认情况下,断路器并不会进入这两个强制打开或关闭的分支中去,而是通过 !isOpen () II allowSingleTest ()来判断是否允许请求访问。 !isOpen()之前已经介绍过, 用来判断和计算当前断路器是否打开,如果是断开状态就允许请求。 那么allowSingleTest()是用来做什么的呢?
@Override public boolean allowRequest() { if (properties.circuitBreakerForceOpen().get()) { // properties have asked us to force the circuit open so we will allow NO requests return false; } if (properties.circuitBreakerForceClosed().get()) { // we still want to allow isOpen() to perform it's calculations so we simulate normal behavior isOpen(); // properties have asked us to ignore errors so we will ignore the results of isOpen and just allow all traffic through return true; } return !isOpen() || allowSingleTest(); }
从allowSingleTest()的实现中我们可以看到,这里使用了在isOpen()函数中当断路器从闭合到打开时候所记录的时间戳。 当断路器在打开状态的时候,这里会判断断开时的时间戳+配置中的circuitBreakerSleepWndowinMilliseconds时间是否小于当前时间,是的话,就将当前时间更新到记录断路器打开的时间对象 circuitOpenedOrLastTestedTime 中,并且允许此次请求。 简单地说, 通过 circuitBreakerSleepWindowinMilliseconds 属性设置了一 个断路器打开之后的休眠时间(默认为5秒),在该休眠时间到达之后,将再次允许请求尝试访问,此时断路器处于“半开”状态,若此时请求继续失败, 断路器又进入打开状态, 并继续等待下 一 个休眠窗口过去之后再次尝试;若请求成功, 则将断路器重新置于关闭状态。所以通过 allowSingleTest()与isOpen ()方法的配合,实现了断路器打开和关闭状态的切换。
public boolean allowSingleTest() { long timeCircuitOpenedOrWasLastTested = circuitOpenedOrLastTestedTime.get(); // 1) if the circuit is open // 2) and it's been longer than 'sleepWindow' since we opened the circuit if (circuitOpen.get() && System.currentTimeMillis() > timeCircuitOpenedOrWasLastTested + properties.circuitBreakerSleepWindowInMilliseconds().get()) { // We push the 'circuitOpenedTime' ahead by 'sleepWindow' since we have allowed one request to try. // If it succeeds the circuit will be closed, otherwise another singleTest will be allowed at the end of the 'sleepWindow'. if (circuitOpenedOrLastTestedTime.compareAndSet(timeCircuitOpenedOrWasLastTested, System.currentTimeMillis())) { // if this returns true that means we set the time so we'll return true to allow the singleTest // if it returned false it means another thread raced us and allowed the singleTest before we did return true; } } return false; }
markSuccess(): 该函数用来在“半开路”状态时使用。若 Hystrix 命令调用成功,通过调用它将打开的断路器关闭, 并重置度量指标对象。
public void markSuccess() { if (circuitOpen.get()) { if (circuitOpen.compareAndSet(true, false)) { //win the thread race to reset metrics //Unsubscribe from the current stream to reset the health counts stream. This only affects the health counts view, //and all other metric consumers are unaffected by the reset metrics.resetStream(); } } }
下图是 Netflix Hystrix 官方文档中关千断路器的详细执行逻辑,可以帮助我们理解上面的分析内容。