Polly 库 — 构建弹性微服务

1 概览

随着微服务时代的到来,我们越来越少的编写一个大型单体应用程序,而是将应用的各个功能拆分成不同的服务,每个服务之间通过网络来相互调用。那么这样就会带来一个问题:

网络是不可靠的

构建一个容错性高的服务成为开发人员的目标,一个弹性的服务可以很好的帮助我们应对各个故障问题。

Polly 就是这样一个 .Net 库, 它提供了若干种策略(Policy)来让我们的服务在应对各种情况下该如何处理。在 Java 世界里也有相同的库:Hystrix.

Polly 对业务代码的侵入量非常小,简单来讲主要分为两步

  • 创建策略 (Policy) : 这一步根据需要的场景来创建相应的策略, 比如重试,熔断器等等;还有可以将多个策略组装在一起,形成一个复合的策略。
  • 执行操作:将业务逻辑封装成一个委托(Action),然后调用策略的 Execute 或者 ExecuteAsync 方法来执行委托。

接下来我们就依次分析这些策略和使用场景。

2 重试

重试是一个非常 Straightforward 的策略,就连我们的应用程序奔溃,操作系统死机,大部分情况下都是重启一下就好。在微服务中也是同样如此,由于网络的抖动,下游的服务在这次请求中没有给出相应,再试一下可能就没有问题了。

Polly 重试包含了四种策略,其实就两个不同维度的组合:重试次数和等待时间。

  • 重试若干次 (Retry)
  • 永远重试 (RetryForever)
  • 等待并且重试若干次 (WaitAndRetry)
  • 等待并且永远重试 (WaitAndRetryForever)

在重试等待的测率中,可以借鉴 Exponential backoff ,在每一次重试指数增加等待的时间。

只需要调用 policyExecute 就可以完成重试的逻辑

整个执行的流程如下:

3 熔断器

如果下游的服务已经宕机了,之前我们重试的那种策略就好像不行了。 不停的重试只会增加下游服务的压力,并且在调用方积压请求。我们需要一种方式可以让调用方感知到当前调用的服务出现了问题

能不能早点失败(Fail Early)

熔断器(Circuit Breaker) 就是针对这种情况设计的策略,它的灵感来自于在家庭中使用的保险丝。假设通过它的电流异常的大,那么保险丝就自动熔断。

但是 Polly 中的 Circuit Breaker 策略在发生熔断之后,在经过特定的时间段还能再次尝试连接,如果连接成功就表明下游的服务已经恢复,如果还是失败,则下游服务仍然处于宕机状态。因此 Polly 的 Circuit Breaker 策略中包含了一个状态机。

  • Closed: 此刻状态机处于关闭状态,所有的调用都会执行。如果发生异常的次数超出了一个阈值,则进入 Open 状态。注意调用的异常任然会被抛出。
  • Open: 处于这种状态下的 Circuit Breaker, 所有的请求操作都会被 Fail Early,也就是说并不会被执行,而是抛出一个 BrokenCircuitException 这个异常。在经过一段时间后,会进入下一个 Half Open 这个状态
  • Half Open: 在这个状态,会执行相关的操作,如果成功,则进入 Closed 状态;如果任然之前的错误, 则进入 Open 状态;如果是未知的错误,则保持 Half Open 这个状态。

这里是 Polly 中 Circuit Breaker 的一个使用案例

  • 12–13 行定义了这个 policy,其中 1 表示只要发生一个异常, 就进入 Open 状态;TimeSpan.FromSecond(1) 表示在 Open 之后 1 秒进入 Half Open;OnBreak 和 OnReset 分别是进入 Open 和 Closed 状态的委托
  • 18–29 行是使用这个 policy 来执行一些操作,因为第一次操作肯定能抛出 NullReferenceException 这个异常,所以可以捕获这个异常;如果 Circuit Breaker 处于 Open 转台,则会抛出 BorkenCircuitException , 而且并不会在执行 DoSomething 这个方法
  • 39–42 行,在后续的操作中,并不会抛出 NullReferenceException 这个异常,服务的下游恢复正常,OnReset 委托就会被执行。

整个执行的流程如下

4 Fallback

Fallback 这个策略就比较 StraightForward 了,当调用的下游服务发生异常的时候,我们就直接返回默认值。

  • 2–4 行定了了三种 Fallback 的条件:1)发生了 NullReferenceException 的异常;2)如果返回值是 String.Empty ; 3) 如果异常的 InnerExceptionArgumentNullException
  • 5 行定义了我们 Fallback 的值是 Hello world
  • 9, 16, 23 行是我们发生三种发生异常的条件。

5 超时

在前面讨论的策略都是一种被动策略,什么叫被动策略呢?就是说我们的策略是根据下游的服务的情况来决定需要做什么。接下来我们讨论一下主动的策略。

第一个就是超时(Timeout) ,这是一个非常容易理解的一个策略。假设我们限定某个操作在 10 秒钟内没有返回结果,那么我们认为这个服务发生了故障,哪怕是调用的服务由于网络延迟的问题没有在 10 秒钟内返回结果。

超时有两种方式:1) 乐观超时(Optimistic);2)悲观超时(Pessimistic)它的区别在哪呢?

  • 乐观超时是执行的方法会接受一个 CancellationToken 的参数,在超时发生的时候,token 会发出 Cancel 的请求,而执行的方法也会在适当的地方检查 cancellationToken.ThrowIfCancellationRequested()
  • 悲观超时则不接受一个 CancellationToken 这个方法,而是将执行的方法封装成一个 Task 并且由 TreadPool 中的一个线程来执行。而且即使超时发生,也不会主动停止掉线程以避免破坏应用程序的状态。

下面是一个 Pessimistic 类型的 timeout.

  • 4 行创建一个 Pessimistic 的timeout
  • 14到 17 行是需要执行的操作,注意对于 Pessimistic 的类型,采取的是 Walk-Away 的模式,在 timeout 之后,并不会等待结果的返回,而是抛出一个 TimeoutRejectedException 异常,所以 Result 的结果仍然是 null
  • 5 到 9 行是执行的操作完成后, 就会触发 onTimeout 的事件委托,并且执行这次操作,通常是一些清理工作

接下来我们再看看 Optimistic 类型的 timeout

  • 4 行创建了一个 Optimistic 类型的 timeout, 而且没有 onTimeout 的委托,因为再这种情况下,task 都为 null
  • 8 到 19 行,我们将 token 传递给执行的方法,再必要的地方调用 ThrowIfCancellationRequested 或者 IsCancellationRequested 来判断是否发生了取消操作(比如 timeout, 用户手动取消)
  • 24 行的结果输出是 result: failed work , 因为在这里并没有采用 walk away 的方式。

6 隔断

隔断这个策略来自于船的设计

在船设计的过程中,会将船划分为不同的隔断,这样哪怕其中一个隔断进水,也不会影响整个船的正常航行。我估计当初泰坦尼克号就是没有采用隔断的设计。

那么在 Polly 中该如何实现隔断这种设计呢?我们需要一个策略相当于船舶中的一个个隔断,每个同一个服务的调用都选择相同的策略执行,在每个策略限制好所需要的资源。

Polly 中的隔断策略是这样设计的

  • maxParallelization 是可以并发执行的操作
  • maxQueuingActions 是可以排队的执行的个数

如果请求量超出了 maxParalleization + maxQueuingActions 之和,那么后续的请求就会被 reject 掉。

得到的结果是这样的

由于我们设计的 buckhead 的大小是 5 + 2, 在并发执行 10 次请求中,有 3 个请求被 reject 掉了,只有 7 次请求被执行,其中 5 次先执行,2 次后执行。

那么问题来了,该如何设计 maxParallelizationmaxQueuingActions 的值呢?

maxParallelization

  • 计算密集型应用(CPU bound work): 应该和计算核数相同,因为这样可以避免上下文切换
  • I/O 密集型应用(I/O bound work): 应当比线程数量稍微大一些

maxQueuingActions

  • 计算密集型应用(CPU bound work): 对于同步的操作,设置为 0/1 就可以了。
  • I/O 密集型应用(I/O bound work): 应当由实验测试决定

7 缓存

缓存(Cache) 也是一种策略,也就是说将结果缓存起来,如果缓存过期,再调用服务来获取最新的值。Polly 采用的是 Cache-Aside模式。

也就是说,第一步是先去缓存中查找,如果没有再去外部服务查询,然后再将结果保存到缓存中。

第二个问题是 Polly 是怎么设计缓存的呢?缓存要实现 ISyncCacheProvider 或者 IAsyncCacheProvider 两个接口。 Polly 已经提供了两类具体实现

  • Polly.Caching.Memory: 是 Microsoft.Extension.Cache.Memory 的封装,提供了内存的缓存方式
  • Polly.Caching.Distributed: 可以支持 Redis 或者数据库 ( Sql-Server ) 等数据库的支持。
  • 3 到 4 行创建了一个 MemoryCacheProvider 对象
  • 5 行创建的创建了一个 CachePolicy , 注意这里的 cache 过期事件是 1 秒。Policy 为 Cache 提供了多种缓存失效的选项
  • 7 到 16 行多次调用 cache 的执行方法,按照预期 DoSoemthing 方法只会调用两次,第一次调用和第三次调用。

8 策略组合

前面我们了解了 Polly 策略,那么有没有可能将多个策略组合在一起?举例来讲,假设我们使用了 Circuit-Breaker 策略,如果 Circuit-Breaker 的状态是 Open , 此刻调用的执行就会抛出 BrokenCircuitException 的异常,我想在捕获这个异常的时候,希望执行 Fallback 这个策略。Polly 当然支持这种组合的策略模式,而且组合的形式非常灵活。

下面的组合形式都是等效的

记住两条规则

  • 最外面的策略执行内部的策略,直到最终的策略
  • 异常会从最里面传递到最外面

那么一般该如何组合这些 Policy 呢?

  • FallbackPolicy: 通常最为最外面的策略,也可以作为中间策略
  • CachePolicy: 可以作为外面的策略,但是不要作为 Fallback 策略的外面
  • TimeoutPolicy: 可以作为 RetryPolicy, CircuitBreaker 或者 BulkheadPolicy 的外面
  • RetryPolicy/CircuitBreaker: 要不 Retry 包含 CircuitBreaker 或者 CircuitBreaker 包含 Retry
  • BulkheadPolicy: 通常作为最里面的策略,也可以包含 TimeoutPolicy 策略
  • TimeoutPolicy: 最靠近执行的方法

参考

A software developer in Microsoft at Suzhou. Most articles spoken language is Chinese. I will try with English when I’m ready

A software developer in Microsoft at Suzhou. Most articles spoken language is Chinese. I will try with English when I’m ready