Hystrix 是如何工作的(Hystrix设计原理)

流程图

  下面的流程图展示了,如果你通过 Hystrix 来向某个依赖服务发送请求的时候,会发生什么事情:

图1. Hystrix 运行流程图

  下面的分段将向大家详细说明每一个步骤(序号对应流程图中的节点编号):

  1. Construct a HystrixCommand or HystrixObservableCommand Object(构造 HystrixCommandHystrixObservableCommand 对象)
  2. Execute the Command(执行命令 command
  3. Is the Response Cached?(判断响应是否已缓存)
  4. Is the Circuit Open?(判断断路器是否已打开)
  5. Is the Thread Pool/Queue/Semaphore Full?(判断资源 - 线程池/队列/信号量 - 是否耗尽)
  6. HystrixObservableCommand.construct() or HystrixCommand.run()(执行具体的命令操作)
  7. Calculate Circuit Health(计算电路的健康值)
  8. Get the Fallback(获取执行回滚的方法)
  9. Return the Successful Response(返回成功的响应)

1. 构造 HystrixCommandHystrixObservableCommand 对象

  第一步是创建 HystrixCommandHystrixObservableCommand 对象,这两种对象用来封装向依赖服务发送请求的动作;其中 HystrixCommand 用来构造传统的同步命令式请求,而 HystrixObservableCommand 用来构造异步的响应式请求,HystrixObservableCommand 对象是一个可观察的对象;构造的方法很简单,直接通过构造器进行创建:

HystrixCommand command = new HystrixCommand(arg1, arg2);
HystrixObservableCommand command = new HystrixObservableCommand(arg1, arg2);

2. 执行命令

  执行命令有四种方式:

  • execute() —— 同步阻塞式,只能用于执行 HystrixCommand 类型的命令,立即执行命令,并返回从依赖服务获取的响应,或者抛出错误异常;
  • queue() —— 异步提交式,只能用于执行 HystrixCommand 类型的命令,这种方式不立即执行命令,而是先将命令对象提交到队列并获取一个 Future 对象,从 Future 中获取最终的执行结果,而命令由线程池异步执行;
  • observe() ——异步响应式,返回一个 Observable 来表示响应结果,通过订阅 Observable 并注册回调函数来消费最终的响应结果;这种方法两种命令都支持,对于 HystrixCommand 类型的命令,实际上是先执行 toObservable() 将自身转换成 HystrixObservableCommand 类型的命令,然后再执行异步响应式命令;
  • toObservable()——延迟异步响应式,返回一个 Observable 来表示响应结果,与 observe() 的区别在于,toObservable() 返回的 Observable 是还未执行的,需要手动调用订阅类方法后才执行具体的命令,而 observe() 返回的 Observable 是已经开始执行的,只需要订阅最终的响应就可以
K             value   = command.execute();
Future     fValue  = command.queue();
Observable ohValue = command.observe();         //hot observable
Observable ocValue = command.toObservable();    //cold observable

3. 判断响应是否已缓存

  当以 Observable 这种形式来发送请求时,如果请求缓存对于命令可用,并且某个请求的响应存在于缓存中,那么缓存的响应会立刻返回。

4. 判断断路器是否打开

  当执行 command 时,Hystrix 会检查断路器以查看电路是否开路。如果电路打开(或“跳闸”),那么 Hystrix 将不会执行命令,而是转而去执行回退动作。如果电路关闭,那么 Hystrix 将按照流程去检测是否有可用的资源。

5. 判断资源 - 线程池/队列/信号量 - 是否耗尽

  如果关联到 command 的线程池和队列(或者信号量,如果不在一个线程内执行)等资源已经耗尽(比如队列已满,线程池没有空闲的线程,或者信号量消耗完毕),Hystrix 将不会执行命令,而是立刻转而去执行回退动作。

6. 执行具体的命令操作

  如果电路是关闭的,并且有足够的可用资源,那么 Hystrix 就会开始执行具体的命令操作(也就是向依赖服务发送请求并等待获取响应的代码,这部分的逻辑是由用户自己实现),具体执行的是如下方法:

  • HystrixCommand.run() —— 返回用户定义的响应对象或者抛出异常;
  • HystrixObservableCommand.construct() —— 返回一个 Observable 对象,在执行完用户请求代码后,将用户自定义的响应对象发送给 Observable 对象中注册的订阅者,如果发生错误,会通过 onError 来通知用户;

如果 run() 或者 construct() 方法的执行时间超过了命令配置的 timeout 临界值,执行线程会抛出 TimeoutException,在这种情况下,Hystrix 会转而去执行回退逻辑,如果 run() 或者 construct() 方法无法取消或者中断,那么最终返回的响应会被放弃。

注意:我们没有办法强制正在执行的线程停止工作 —— Hystrix 在 JVM 上可以做的最好的事情就是抛出一个InterruptedException;如果 Hystrix 封装的任务无法响应 InterruptedException,那么 Hystrix 线程池中的线程就无法中断,需要继续执行任务,直到用于发送请求的网络客户端代码收到 TimeoutException。这种情况可能会导致 Hystrix 线程池饱和,因为等待网络客户端直到超时期间,线程无法中断去执行其他任务,只能等待。大多数 Java 实现的 Http Client 库都无法响应中断异常,因此在配置网络客户端的超时时间时,最好能和 Hystrix 的 timeout 临界值保持一致;如果网络客户端的超时临界值大于 Hystrix 自身的超时临界值,同时网络客户端又无法响应中断,则会导致 Hystrix 线程池发生长时间阻塞等待,导致其过快饱和。

   如果命令没有发生任何异常并且成功返回了响应,Hystrix 会在执行一些日志记录和度量报告,将响应返回给调用方。如果是通过 run(),直接返回响应,如果是通过 construct(),响应会被发布到Observable 对象,并且通过 onCompleted 来通知调用方。

7. 计算电路的健康值

  Hystrix 将成功、失败、拒绝和超时报告给断路器,断路器维护了一组滚动的计数器进行统计计算;Hystrix 使用这些统计值来决定何时将电路开闸,而在这个时间点之后的所有请求都将被短路直到恢复期结束,在健康检查首次通过后,会关闭电路;

8. 执行回退动作

  当 Hystrix 执行命令失败时(当电路短路、资源不足、执行异常、发生超时等),都会尝试执行用户定义的回退操作(fallback);回退操作是由用户自行编写,HystrixCommand.getFallback() 会返回 fallback 对象,回退操作一般执行一些服务降级或者业务回滚等操作,尽量不要在 fallback 中访问其他依赖服务,如果要进行网络访问,应当通过 HystrixCommand 或者 HystrixObservableCommand 来执行。对于 HystrixCommand,可以通过 HystrixCommand.getFallback() 来实现回退逻辑;对于 HystrixObservableCommand 可以通过 HystrixObservableCommand.resumeWithFallback() 来获取一个可以发布回退的 Observable 对象。如果用户没有实现 fallback 逻辑,Hystrix 会选择抛出异常或者通过 onError 来将异常信息传送给调用者。

9. 返回成功的响应

  如果命令执行成功,Hystrix 将响应返回给调用者,返回响应的形式取决于你以哪种方式来执行命令。

断路器(Circuit Breaker)

  下图展示了HystrixCommandHystrixObservableCommand 是如何与 HystrixCircuitBreaker 进行交互的,以及 HystrixCircuitBreaker 的相关逻辑流程、如何进行决策以及计数器如何运作等:

图2. 断路器

电路开闭的确切方式如下:

  1. 当电路上的 volume 值超过阈值时(HystrixCommandProperties.circuitBreakerRequestVolumeThreshold())...
  2. 当电路上的错误率超过阈值时(HystrixCommandProperties.circuitBreakerErrorThresholdPercentage())...
  3. 然后断路器(circuit-breaker)会从 CLOSED 转换为 OPEN
  4. 当断路器打开时,所有的请求都会被短路,不允许被通过
  5. 一段时间后(HystrixCommandProperties.circuitBreakerSleepWindowInMilliseconds()),下一个请求被允许通过(这是 HALF-OPEN 状态); 如果请求失败,断路器将在休眠窗口期间返回 OPEN 状态;如果请求成功,断路器将转换为 CLOSED,并且 1. 中的逻辑再次接管。

资源隔离

  Hystrix 采用了隔板模式来隔离每个依赖服务所使用的资源和限制并发访问的数量


图3. 资源隔离

线程和线程池

  客户端(库、网络调用等)对依赖的访问在不同的线程中执行,将对依赖的访问同调用线程分离开来(比如 Tomcat 的线程池),因此调用者就可以离开一个耗时较长的依赖操作。
  Hystrix 采用每个依赖使用隔离的独立线程池的方式来保证一旦发生延迟,那么就只会使该依赖的线程池内的线程饱和,而不会影响到其他的线程。


图4. 隔离线程池

  如果不使用这种隔离线程池,就需要访问依赖服务的客户端能够非常快速地失败(比如网络连接、请求超时和重试等都能快速失败),并且这些客户端都能稳定的正确执行。Netflix 在设计 Hystrix 时,为什么要选择隔离线程池呢?原因有很多,包括:

  • 许多的应用程序会远程调用大量不同的后台服务(有些时候可能会超过100个),这些服务通常由许多不同的团队开发和部署
  • 每个服务可能都会提供自己的客户端 SDK
  • 这些客户端 SDK 随时都会更新
  • 这些客户端 SDK 可能会因为添加新的网络调用而发生逻辑上的更改
  • 这些客户端 SDK 会包含诸如重试、数据解析、缓存等其他行为
  • 这些客户端 SDK 都是黑盒子 —— 它的实现细节、网络访问模式、默认配置等对用户都是透明的
  • 即使客户端 SDK 本身没有变化,但服务本身也会发生变化,这会影响性能特征,进而导致客户端配置无效
  • 依赖链的某个节点因为客户端 SDK 的不正确配置可能会引起无法预期的错误,而这些错误会沿着调用链向上传导
  • 绝大多数网络访问都是同步的
  • 失败和延迟也可能发生在客户端代码中,而不仅仅是在网络调用中


    图4. 资源隔离

隔离线程池的优点

  使用隔离线程池来访问依赖服务比在应用线程中进行访问有下面这些好处:

  • 应用程序完全不受失控的客户端 SDK 的影响,当分配给依赖项的线程池饱和时不会影响到应用程序的其余部分
  • 该应用程序可以接受风险较低的新客户端 SDK;如果出现问题,因为隔离机制,不会影响到应用程序
  • 当失败的依赖服务再次恢复健康时,分配给依赖服务的线程池将被清理,应用程序能够立即恢复健康的性能,而不是整个应用容器不堪重负时的长时间恢复
  • 如果客户端库 SDK 配置错误,或者依赖服务发生故障,线程池的健康状况将迅速证明这一点(通过增加的错误、延迟、超时、拒绝等),并且用户可以及时进行处理(通常通过动态属性实时)而不影响应用程序功能
  • 除了隔离的好处之外,专用的线程池还提供了内置的并发性,可以利用它在同步客户端库之上构建异步调用(类似于 Netflix API 如何在 Hystrix 命令之上构建反应式、完全异步的 Java API)

  简而言之,这种隔离的线程池,可以在依赖服务发生故障或者变动时,使用户能够优雅地来处理,而不会导致应用程序的中断。

注意:尽管 Hystrix 提供了隔离的线程池,但是底层的客户端代码也应该具有访问超时或响应线程中断的功能,来避免无限期地阻塞 Hystrix 线程。

隔离线程池的缺点

  使用隔离线程池的主要缺点是它们增加了计算开销,由于每个命令都在单独地线程上执行,因此每执行一个命令都会涉及到排队、调度和上下文切换。但在设计这个系统时,Netflix 决定接受这种开销的成本以换取它提供的好处,并认为它足够小,不会对成本或性能产生重大影响。

信号量(Semaphores)

  可以使用信号量(或计数器)来限制对某个依赖服务的并发调用数量,而不是使用线程池/队列;这允许 Hystrix 在不使用线程池的情况下降低负载,但它不允许超时和阻塞等待;如果您信任客户端并且只想要降低负载,则可以使用该方法。
  HystrixCommandHystrixObservableCommand 在两个地方支持使用信号量:

  • Fallback:当 Hystrix 要执行回退(fallback)动作时,通常都是使用应用程序线程来执行
  • Execution:如果将 Hystrix 属性 execution.isolation.strategy 的值设置为 SEMAPHORE 时,Hystrix 将使用信号量代替线程池来限制应用程序线程并发执行命令的数量

用户可以通过配置同时在这两个地方使用信号量,具体的并发量(信号量的数量)可以通过属性进行动态配置,信号量数量的计算方法和线程池大小的计算方法以及队列大小的计算方法类似。

注意:如果使用信号量来限制对依赖服务的并发访问数量,一旦底层的网络调用阻塞,那么应用程序线程也会同时阻塞,直到底层网络调用超时返回

如果信号量达到了最大限制,会拒绝无法获取信号量的请求,但是填充信号量的线程不能停止(信号量应该是采用了令牌桶算法的限流)

请求折叠

  你可以使用请求折叠器 HystrixCollapser(抽象父类)在 HystrixCommand 前面,通过它可以将多个请求折叠到单个后端服务调用中;请求折叠技术可以将多个重复的网络请求合并成为一个,从而降低负载。
  下面的图表展示了两种场景下使用的线程和网络连接的数量:第一个图表没有使用请求折叠,而第二个使用了请求折叠(假设所有连接在很短的时间窗口内“并发”,这种情况下为 10ms);

图5. 使用请求折叠和不使用请求折叠的区别

为什么要使用请求折叠

  使用请求折叠可以降低并发执行 HystrixCommand 所使用的线程和网络连接数。请求折叠以自动方式执行此操作,不会强制开发人员编写代码来手动协调批处理请求。

全局上下文 —— 基于所有应用容器(比如Tomcat/Jetty/Undertow)线程

  理想的请求折叠类型是全局应用程序级的,来自任何应用容器线程上的任何用户的请求都可以折叠在一起。例如:如果将 HystrixCommand 配置为支持对任何用户检索电影评级的请求进行批处理,那么当同一个 JVM 中的任何用户线程发出此类请求时,Hystrix 都会将这个请求与其他相同请求一起折叠到同一个网络调用中。

注意:折叠器会将单个 HystrixRequestContext 对象传递给折叠的网络调用,因此下游系统必须需要处理这种情况才能使其成为有效选项。

用户请求上下文 —— 基于单个应用容器线程

  如果将 HystrixCommand 配置为仅处理单个用户的批处理请求,则 Hystrix 只会在单个应用容器线程(请求)内折叠请求。例如:如果用户想要为 300 个视频对象加载书签,而不是执行 300 个网络调用,Hystrix 可以将它们全部合并为一个。

对象建模和代码复杂性

  有时,你需要创建一个对用户具有逻辑含义的对象模型,但却无法高效利用对象生产者的有效资源。例如:给定一个包含 300 个视频对象的列表,遍历它们并在每个对象上调用 getSomeAttribute(),这种简单的实现可能会导致 300 个网络调用在毫秒间隔内进行(并且很可能会饱和资源),这样就浪费了大量的计算资源。有一些手动方法可以处理这个问题,例如在用户调用 getSomeAttribute() 之前,要求他们声明他们想要获取哪些视频对象的属性,以便可以进行批量预取;或者可以划分对象模型,以便用户必须从一个地方获取视频列表,然后从其他地方请求该视频列表的属性。这些方法可能导致笨重的 API 和对象模型,并且和心智模型以及使用模式不匹配;而且当多个开发人员在一个代码库上工作时,它们还可能导致简单的错误和低效率,因为为一个用例完成的优化可能会被另一个用例的实现和代码中的新路径破坏。而通过将折叠逻辑下推到 Hystrix 层,您如何创建对象模型、以什么顺序进行调用,或者不同的开发人员是否知道正在完成甚至需要完成的优化都无关紧要。getSomeAttribute() 方法可以放在最适合的地方,并以适合使用模式的任何方式调用,折叠器将自动批量调用时间窗口。

请求折叠花费的代价

  启用请求折叠的代价是在执行实际命令之前增加了延迟;最大成本是批处理窗口的大小。如果您有一个中位数执行时间为 5 毫秒的命令和一个 10 毫秒的批处理窗口,那么在最坏的情况下,执行时间可能会变成 15 毫秒。一般情况下请求不会恰好在窗口打开时提交到窗口,因此中值惩罚是窗口时间的一半,在这种情况下为 5 毫秒。确定此成本是否值得取决于正在执行的命令。高延迟命令不会受到少量额外平均延迟的影响。此外,给定命令的并发量是关键:如果要一起批处理的请求很少超过 1 或 2 个,那么付出代价是没有意义的。事实上,在单线程顺序迭代中,崩溃将是一个主要的性能瓶颈,因为每次迭代将等待 10 毫秒的批处理窗口时间。然而,如果一个特定的命令被大量并发使用,并且可以将数十个甚至数百个调用一起批处理,那么随着 Hystrix 减少它所需的线程数量和网络连接数量而实现的吞吐量增加通常远远超过成本。


图6. 请求折叠流程图

请求缓存

  HystrixCommandHystrixObservableCommand 实现了缓存键,使用缓存键可以通过并发感知的方式在请求上下文中删除重复的调用。下图是一个示例流程,涉及 HTTP 请求的生命周期以及该请求中执行工作的两个线程:

图7. 请求缓存的流程

请求缓存的优点:

  • 不同的代码路径都可以执行 Hystrix 命令而无需担心重复工作;这在许多开发人员正在实现不同功能的大型代码库中尤其有用。例如:通过代码的多个路径都需要获取用户的 Account 对象,每个路径都可以像这样请求它:
Account account = new UserGetAccount(accountId).execute();
//or
Observable accountObservable = new UserGetAccount(accountId).observe();

Hystrix 的 RequestCache 将执行底层的 run() 方法仅一次,并且执行 HystrixCommand 的两个线程将收到相同的数据,虽然创建了不同的实例。

  • 数据检索在整个请求中是一致的;不是每次执行命令都访问依赖服务并返回不同的值(或回退),而是缓存第一个响应并为同一请求中的所有后续调用返回。
  • 消除重复的线程执行;由于请求缓存位于 construct()run() 方法调用之前,Hystrix 可以在线程执行该调用之前进行重复数据删除。如果 Hystrix 没有实现请求缓存功能,那么每个命令都需要在构造或运行方法中自己实现它,这将把它放在线程排队并执行之后。

你可能感兴趣的:(Hystrix 是如何工作的(Hystrix设计原理))