程序员L札记

V1

2022/06/27阅读:15主题:橙心

Histrix工作原理

Histrix工作原理

流程图

下图显示了当您通过 Hystrix 向服务依赖项发出请求时会发生什么:

以下部分将更详细地解释此流程:

  1. 构造一个 HystrixCommand 或 HystrixObservableCommand 对象
  2. 执行命令
  3. 响应是否缓存?
  4. 断路器是否打开?
  5. 线程池/队列/信号量是否已满?
  6. HystrixObservableCommand.construct() 或 HystrixCommand.run()
  7. 计算断路器健康状态
  8. 获取后备
  9. 返回成功的响应

构造一个 HystrixCommand 或 HystrixObservableCommand 对象

第一步是构造一个 HystrixCommand 或 HystrixObservableCommand 对象来表示您对依赖项发出的请求。向构造函数传递发出请求时需要的任何参数。

如果期望依赖项返回单个响应,则构造一个 HystrixCommand 对象。例如:

HystrixCommand command = new HystrixCommand(arg1, arg2);

如果期望依赖项返回一个发出响应的 Observable,则构造一个 HystrixObservableCommand 对象。例如:

HystrixObservableCommand command = new HystrixObservableCommand(arg1, arg2);

执行命令

有四种方法可以执行命令,通过使用 Hystrix 命令对象的以下四种方法之一(前两种仅适用于简单的 HystrixCommand 对象,不适用于 HystrixObservableCommand):

  • execute() — 阻塞,然后返回从依赖项接收到的单个响应(或在发生错误时抛出异常)
  • queue() — 返回一个 Future,您可以使用它从依赖项中获取单个响应
  • observe() — 订阅代表该依赖响应的Observable,并返回一个复制源Observable的Observable
  • toObservable() — 返回一个Observable,当你订阅它时,它会执行Hystrix命令并发出响应
K             value   = command.execute();
Future<K>     fValue  = command.queue();
Observable<K> ohValue = command.observe();         //hot observable
Observable<K> ocValue = command.toObservable();    //cold observable

同步调用 execute() 调用 queue().get()。 queue() 依次调用 toObservable().toBlocking().toFuture()。也就是说,最终每个 HystrixCommand 都由 Observable 实现支持,即使是那些旨在返回单个简单值的命令。

响应是否缓存

如果这个命令开启了请求缓存,并且缓存中有请求的响应,那么这个缓存的响应会立即以 Observable 的形式返回。 (请参阅下面的“请求缓存”。)

断路器是否打开

当您执行命令时,Hystrix 会检查断路器以查看电路是否打开。

如果电路打开(或“跳闸”),则 Hystrix 不会执行命令,但会将流程路由到 (8) Get the Fallback。

如果电路闭合,则流程进行到 (5) 以检查是否有容量可用于运行命令。

线程池/队列/信号量是否已满

如果与命令关联的线程池和队列(或信号量,如果未在线程中运行)已满,则 Hystrix 将不执行命令,但会立即将流路由到 (8) Get the Fallback。

HystrixObservableCommand.construct() 或 HystrixCommand.run()

在这里,Hystrix通过你为此目的编写的方法调用对依赖项的请求,方法如下:

如果run()或construct()方法超过了命令的超时值,线程将抛出一个TimeoutException(如果命令本身没有在自己的线程中运行,则单独的计时器线程将抛出该异常)。在这种情况下,Hystrix将响应路由到8.Get the Fallback,它将丢弃最终返回值run()或construct()方法,如果该方法没有取消/中断。

请注意,没有办法强制潜在线程停止工作—Hystrix在JVM上能做的最好的事情是抛出InterruptedException异常。如果被Hystrix包装的工作不重视InterruptedExceptions, Hystrix线程池中的线程将继续其工作,尽管客户端已经收到一个TimeoutException。这种行为会使Hystrix线程池饱和,尽管负载被“正确地释放”。大多数Java HTTP客户端库不解释InterruptedExceptions。因此,请确保正确配置HTTP客户端的链接和读写超时。

如果命令没有抛出任何异常,并且返回了一个响应,Hystrix在执行一些日志记录和指标报告之后返回这个响应。在run()的情况下,Hystrix返回一个Observable对象,它会发出单个响应,然后发出onCompleted通知;对于construct(), Hystrix返回相同的Observable。

计算断路器健康状态

Hystrix 向断路器报告成功、失败、拒绝和超时,断路器维护一组滚动的计数器来计算统计信息。

它使用这些统计数据来确定电路何时应该“跳闸”,在这一点上,它短路任何后续请求,直到恢复期间过去,在恢复期间,它在第一次检查某些健康检查后再次关闭电路。

获取后备

每当命令执行失败时,Hystrix 都会尝试恢复到回退:当construct() 或run() (6.) 抛出异常时,当由于电路打开而导致命令短路时(4.),当命令的线程池和队列或信号量达到容量 (5.),或者当命令超过其超时长度时。

编写您的回退以从内存缓存或其他静态逻辑提供通用响应,而无需任何网络依赖。如果你必须在回退中使用网络调用,你应该通过另一个 HystrixCommand 或 HystrixObservableCommand来实现。

对于HystrixCommand,要提供回退逻辑,需要实现HystrixCommand.getFallback(),它返回单个回退值。

对于HystrixObservableCommand ,要提供回退逻辑,您需要实现 HystrixObservableCommand.resumeWithFallback() ,它返回一个可能发出一个或多个回退值的 Observable。

如果 fallback 方法返回一个响应,那么 Hystrix 将这个响应返回给调用者。在 HystrixCommand.getFallback() 的情况下,它将返回一个 Observable,该 Observable 发出从该方法返回的值。在 HystrixObservableCommand.resumeWithFallback() 的情况下,它将返回从该方法返回的相同 Observable。

如果你没有为你的 Hystrix 命令实现回退方法,或者如果回退本身抛出异常,Hystrix 仍然返回一个 Observable,但它什么也不发出,并立即以 onError 通知终止。正是通过这个 onError 通知,导致命令失败的异常被传回给调用者。 (实现可能失败的回退实现是一种糟糕的做法。您应该实现回退,使其不执行任何可能失败的逻辑。)

回退失败或不存在的结果将根据您调用Hystrix命令的方式而不同:

  • execute() — 抛出异常
  • queue() — 成功返回一个 Future,但是如果调用它的 get() 方法,这个 Future 会抛出一个异常
  • observe() — 返回一个 Observable,当你订阅它时,它会通过调用订阅者的 onError 方法立即终止
  • toObservable() — 返回一个 Observable,当你订阅它时,它将通过调用订阅者的 onError 方法终止

返回成功的响应

如果 Hystrix 命令成功,它将以 Observable 的形式向调用者返回一个或多个响应。根据您在上面第 2 步中调用命令的方式,此 Observable 可能会在返回给您之前进行转换:

  • execute() — 以与 .queue() 相同的方式获取 Future,然后在此 Future 上调用 get() 以获取 Observable 发出的单个值
  • queue() — 将 Observable 转换为 BlockingObservable 以便可以将其转换为 Future,然后返回此 Future
  • observe() — 立即订阅 Observable 并开始执行命令的流程;返回一个 Observable,当你订阅它时,它会重放发射和通知
  • toObservable() — 不变的返回 Observable;您必须订阅它才能真正开始导致执行命令的流程

时序图

断路器

下图显示了 HystrixCommand 或 HystrixObservableCommand 如何与 HystrixCircuitBreaker 交互及其逻辑和决策流程,包括计数器在断路器中的行为方式。

断路器开闭发生的具体方式如下:

  1. 假设整个电路的音量达到某个阈值(HystrixCommandProperties.circuitBreakerRequestVolumeThreshold())...
  2. 并假设错误百分比超过阈值错误百分比(HystrixCommandProperties.circuitBreakerErrorThresholdPercentage())...
  3. 然后断路器从 CLOSED 转变为 OPEN。
  4. 当它打开时,它会将针对该断路器的所有请求短路。
  5. 一段时间后(HystrixCommandProperties.circuitBreakerSleepWindowInMilliseconds()),下一个请求被允许通过(这是半开状态)。如果请求失败,断路器将在睡眠窗口期间返回 OPEN 状态。如果请求成功,断路器将转换为 CLOSED,并且 1. 中的逻辑再次接管。

隔离

Hystrix 使用舱壁模式来隔离彼此的依赖关系并限制对其中任何一个的并发访问。

线程和线程池

客户端(库、网络调用等)在不同的线程上执行。这将它们与调用线程(Tomcat 线程池)隔离开来,以便调用者可以“离开”耗时过长的依赖调用。

Hystrix使用单独的、每个依赖项的线程池作为一种约束任何给定依赖项的方式,因此底层执行的延迟将只会使该池中的可用线程饱和。

您可以在不使用线程池的情况下防止失败,但这需要受信任的客户端非常快速地失败(网络连接/读取超时和重试配置)并且始终表现良好。

Netflix 在其 Hystrix 的设计中,出于多种原因选择使用线程和线程池来实现隔离,包括:

  • 许多应用程序针对由许多不同团队开发的数十种不同服务执行数十种(有时甚至超过 100 次)不同的后端服务调用。
  • 每个服务都提供自己的客户端库。
  • 客户端库一直在变化。
  • 客户端库逻辑可以更改以添加新的网络调用。
  • 客户端库可以包含诸如重试、数据解析、缓存(内存中或跨网络)和其他此类行为之类的逻辑。
  • 客户端库往往是“黑匣子”——对于用户来说,实现细节、网络访问模式、配置默认值等都是不透明的。
  • 即使客户端本身没有改变,服务本身也会改变,这会影响性能特征,进而导致客户端配置无效。
  • 传递依赖可能会引入其他未预期且可能未正确配置的客户端库。
  • 大多数网络访问是同步执行的。
  • 失败和延迟也可能发生在客户端代码中,而不仅仅是网络调用。

线程池的好处

通过线程在自己的线程池中进行隔离的好处是:

  • 该应用程序完全不受失控客户端库的影响。给定依赖库的池可以填满,而不会影响应用程序的其余部分。
  • 该应用程序可以接受风险低得多的新客户端库。如果出现问题,它会与库隔离,不会影响其他所有内容。
  • 当失败的客户端再次恢复健康时,线程池将清理干净,应用程序立即恢复健康的性能,而不是整个 Tomcat 容器不堪重负时的长时间恢复。
  • 如果客户端库配置错误,线程池的健康状况将很快证明这一点(通过增加的错误、延迟、超时、拒绝等),您可以在不影响应用程序功能的情况下处理它(通常通过动态属性实时处理) .
  • 如果客户端服务更改了性能特征(这种情况经常发生,足以成为一个问题),进而导致需要调整属性(增加/减少超时、更改重试等),这再次通过线程池指标(错误、延迟)变得可见、超时、拒绝),并且可以在不影响其他客户端、请求或用户的情况下进行处理。
  • 除了隔离优势之外,拥有专用线程池还提供了内置的并发性,可用于在同步客户端库之上构建异步外观(类似于 Netflix API 如何在 Hystrix 命令之上构建反应式、完全异步的 Java API)。

简而言之,线程池提供的隔离允许客户端库和子系统性能特征的不断变化和动态组合得到妥善处理,而不会导致中断。

注意:尽管单独的线程提供了隔离,但您的底层客户端代码也应该有超时和/或响应线程中断,因此它不会无限期地阻塞并使 Hystrix 线程池饱和。

线程池的缺点

线程池的主要缺点是它们增加了计算开销。每个命令执行都涉及在单独线程上运行命令所涉及的排队、调度和上下文切换。

Netflix 在设计这个系统时,决定接受这种开销的成本以换取它提供的好处,并认为它足够小,不会对成本或性能产生重大影响。

线程的成本

Hystrix 测量子线程上执行construct() 或run() 方法时的延迟以及父线程上的总端到端时间。通过这种方式,您可以看到 Hystrix 开销(线程、指标、日志记录、断路器等)的成本。

Netflix API 每天使用线程隔离处理 10+ 亿次 Hystrix 命令执行。每个 API 实例有 40 多个线程池,每个线程池有 5-20 个线程(大多数设置为 10)。

下图表示一个 HystrixCommand 在单个 API 实例上以每秒 60 个请求的速度执行(每台服务器每秒约 350 个总线程执行):

在中位数(或更低),拥有一个单独的线程是没有成本的。

在第 90 个百分位,拥有一个单独的线程需要 3 毫秒的成本。

在第 99 个百分位,有一个单独的线程需要 9 毫秒。但是请注意,成本的增加远小于单独线程(网络请求)的执行时间增加,后者从 2 跳到 28,而成本从 0 跳到 9。

对于大多数 Netflix 用例而言,此类电路的 90% 或更高的开销已被认为是可以接受的,因为可以实现弹性优势。

对于封装非常低延迟请求的电路(例如那些主要命中内存缓存的请求),开销可能太高,在这种情况下,您可以使用另一种方法,例如可尝试的信号量,虽然它们不允许超时,无需开销即可提供大部分弹性优势。然而,一般来说,开销足够小,以至于 Netflix 在实践中通常更喜欢单独线程的隔离优势而不是此类技术。

信号量

您可以使用信号量(或计数器)来限制对任何给定依赖项的并发调用数,而不是使用线程池/队列大小。这允许 Hystrix 在不使用线程池的情况下减轻负载,但它不允许超时和走开。如果您信任客户端并且只想要减载,则可以使用这种方法。

HystrixCommand 和 HystrixObservableCommand 在两个地方支持信号量:

  • Fallback:当 Hystrix 检索后备时,它总是在调用 Tomcat 线程上这样做。
  • Execution:如果将属性 execution.isolation.strategy 设置为 SEMAPHORE,那么 Hystrix 将使用信号量而不是线程来限制调用命令的并发父线程的数量。

您可以通过定义可以执行多少并发线程的动态属性来配置信号量的这两种用途。您应该使用与调整线程池大小时使用的类似计算来调整它们的大小(在亚毫秒时间内返回的内存调用可以在信号量仅为 1 或 2 的情况下执行超过 5000rps ......但默认值为 10)。

注意:如果一个依赖被信号量隔离然后变成潜在的,父线程将保持阻塞,直到底层网络调用超时。

一旦达到限制,信号量拒绝将开始,但填充信号量的线程不能走开。

请求合并

您可以在 HystrixCommand 前面使用请求折叠器(HystrixCollapser 是抽象父级),您可以使用该请求折叠器将多个请求折叠到单个后端依赖项调用中。

下图显示了两种情况下的线程数和网络连接数:首先没有请求折叠,然后请求折叠(假设所有连接在很短的时间窗口内“并发”,在本例中为 10 毫秒)。

为什么使用请求合并

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

全局上下文(跨所有Tomcat线程)

理想的折叠类型是在全局应用程序级别完成的,因此来自任何 Tomcat 线程上的任何用户的请求都可以折叠在一起。

例如,如果您配置 HystrixCommand 以支持任何用户对检索电影评分的依赖项的请求进行批处理,那么当同一 JVM 中的任何用户线程发出此类请求时,Hystrix 会将其请求与任何其他请求一起添加到同一崩溃的网络通话。

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

用户请求上下文(单Tomcat线程)

如果您将 HystrixCommand 配置为仅处理单个用户的批处理请求,则 Hystrix 可以折叠来自单个 Tomcat 线程(请求)的请求。

例如,如果用户想要为 300 个视频对象加载书签,而不是执行 300 个网络调用,Hystrix 可以将它们全部合并为一个。

对象建模和代码复杂性

有时,当您创建对对象的消费者具有逻辑意义的对象模型时,这与对象生产者的有效资源利用不匹配。

例如,给定一个包含 300 个视频对象的列表,遍历它们并在每个对象上调用 getSomeAttribute() 是一个明显的对象模型,但如果实施得天真,可能会导致 300 个网络调用都在毫秒内进行(并且很可能饱和资源)。

您可以使用手动方式来处理此问题,例如在允许用户调用 getSomeAttribute() 之前,要求他们声明他们想要获取属性的视频对象,以便它们都可以被预取。

或者,您可以划分对象模型,以便用户必须从一个地方获取视频列表,然后从其他地方请求该视频列表的属性。

这些方法可能会导致笨拙的 API 和对象模型与心智模型和使用模式不匹配。当多个开发人员在代码库上工作时,它们还可能导致简单的错误和效率低下,因为为一个用例完成的优化可能会被另一个用例的实现和代码中的新路径破坏。

通过将折叠逻辑下推到 Hystrix 层,您如何创建对象模型、以什么顺序进行调用、或者不同的开发人员是否知道正在完成甚至需要完成的优化都无关紧要。

getSomeAttribute() 方法可以放在最适合的地方,并以适合使用模式的任何方式调用,并且折叠器将自动批量调用时间窗口。

请求合并的代价是什么

启用请求折叠的代价是在执行实际命令之前增加了延迟。最大成本是批处理窗口的大小。

如果您有一个执行中位数为 5 毫秒的命令,以及一个 10 毫秒的批处理窗口,那么在最坏的情况下执行时间可能会变为 15 毫秒。通常,请求不会恰好在窗口打开时被提交到窗口,因此中值惩罚是窗口时间的一半,在本例中为 5 毫秒。

该成本是否值得的确定取决于正在执行的命令。高延迟命令不会因少量额外的平均延迟而受到太大影响。此外,给定命令的并发量是关键:如果要批处理的请求很少超过 1 或 2 个,那么付出代价是没有意义的。事实上,在单线程顺序迭代中,崩溃将是一个主要的性能瓶颈,因为每次迭代都会等待 10 毫秒的批处理窗口时间。

然而,如果一个特定的命令被大量并发使用并且可以同时批处理数十甚至数百个调用,那么成本通常远远超过所获得的吞吐量增加,因为 Hystrix 减少了它所需的线程数和网络连接数依赖关系。

合并器流程

请求缓存

HystrixCommand 和 HystrixObservableCommand 实现可以定义一个缓存键,然后用于以并发感知的方式对请求上下文中的重复调用进行重复数据删除。

这是一个示例流程,涉及 HTTP 请求生命周期和在该请求中工作的两个线程:

请求缓存的好处是:

  • 不同的代码路径可以执行 Hystrix 命令,而不用担心重复工作。

这在许多开发人员正在实现不同功能的大型代码库中特别有用。

例如,通过代码的多个路径都需要获取用户的 Account 对象,每个路径都可以像这样请求它:

Account account = new UserGetAccount(accountId).execute();

//or

Observable<Account> accountObservable = new UserGetAccount(accountId).observe();

Hystrix RequestCache 将执行底层 run() 方法一次且仅一次,并且执行 HystrixCommand 的两个线程将接收相同的数据,尽管实例化了不同的实例。

  • 数据检索在整个请求中是一致的。

不是每次执行命令时都可能返回不同的值(或回退),而是缓存第一个响应并为同一请求中的所有后续调用返回。

  • 消除重复的线程执行。

由于请求缓存位于construct() 或run() 方法调用的前面,因此Hystrix 可以在调用导致线程执行之前对其进行重复数据删除。

如果 Hystrix 没有实现请求缓存功能,那么每个命令都需要在构造或运行方法中自己实现它,这将把它放在线程排队并执行之后。

欢迎关注我的公众号:程序员L札记

更多原创文章,请扫码关注我的微信公众号
更多原创文章,请扫码关注我的微信公众号

分类:

后端

标签:

Java

作者介绍

程序员L札记
V1