1 概览
随着微服务时代的到来,我们越来越少的编写一个大型单体应用程序,而是将应用的各个功能拆分成不同的服务,每个服务之间通过网络来相互调用。那么这样就会带来一个问题:
网络是不可靠的
构建一个容错性高的服务成为开发人员的目标,一个弹性的服务可以很好的帮助我们应对各个故障问题。
Polly 就是这样一个 .Net 库, 它提供了若干种策略(Policy)来让我们的服务在应对各种情况下该如何处理。在 Java 世界里也有相同的库:Hystrix.
Polly 对业务代码的侵入量非常小,简单来讲主要分为两步
- 创建策略 (Policy) : 这一步根据需要的场景来创建相应的策略, 比如重试,熔断器等等;还有可以将多个策略组装在一起,形成一个复合的策略。
- 执行操作:将业务逻辑封装成一个委托(Action),然后调用策略的
Execute
或者ExecuteAsync
方法来执行委托。
接下来我们就依次分析这些策略和使用场景。
2 重试
重试是一个非常 Straightforward
的策略,就连我们的应用程序奔溃,操作系统死机,大部分情况下都是重启一下就好。在微服务中也是同样如此,由于网络的抖动,下游的服务在这次请求中没有给出相应,再试一下可能就没有问题了。
Polly 重试包含了四种策略,其实就两个不同维度的组合:重试次数和等待时间。
- 重试若干次 (Retry)
RetryPolicy policy = Policy.Handle<Exception>().Retry(2);
- 永远重试 (RetryForever)
RetryPolicy policy = Policy.Handle<Exception>().RetryForever((exp, count, context) =>{Console.WriteLine("retry");});
- 等待并且重试若干次 (WaitAndRetry)
RetryPolicy policy = Policy.Handle<Exception>().WaitAndRetry(3, (count) =>{return TimeSpan.FromMilliseconds(count * 1000);});
- 等待并且永远重试 (WaitAndRetryForever)
RetryPolicy policy = Policy.Handle<Exception>().WaitAndRetryForever( (count) =>{return TimeSpan.FromMilliseconds(count * 1000);});
在重试等待的测率中,可以借鉴 Exponential backoff ,在每一次重试指数增加等待的时间。
只需要调用 policy
的 Execute
就可以完成重试的逻辑
policy.Execute(() => { CallSomething(); });
整个执行的流程如下:
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) 如果异常的InnerException
是ArgumentNullException
。 - 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 掉。
得到的结果是这样的
doing something
doing something
doing something
doing something
doing something
reject
reject
reject
doing something
doing something
由于我们设计的 buckhead 的大小是 5 + 2, 在并发执行 10 次请求中,有 3 个请求被 reject 掉了,只有 7 次请求被执行,其中 5 次先执行,2 次后执行。
那么问题来了,该如何设计 maxParallelization
和 maxQueuingActions
的值呢?
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
方法只会调用两次,第一次调用和第三次调用。
calling DoSomething
result is Hello world
result is Hello world
calling DoSomething
result is Hello world
8 策略组合
前面我们了解了 Polly 策略,那么有没有可能将多个策略组合在一起?举例来讲,假设我们使用了 Circuit-Breaker
策略,如果 Circuit-Breaker
的状态是 Open
, 此刻调用的执行就会抛出 BrokenCircuitException
的异常,我想在捕获这个异常的时候,希望执行 Fallback
这个策略。Polly 当然支持这种组合的策略模式,而且组合的形式非常灵活。
下面的组合形式都是等效的
fallback.Execute(()=> waitAndRetry.Execute(()=> breaker.Execute(action)));fallback.Warp(waitAndRetry).Warp(breaker).Execute(action);fallback.Warp(waitAndRetry.Warp(breaker)).Execute(action);Policy.Wrap(fallback, waitAndRetry, breaker).Execute(action);
记住两条规则
- 最外面的策略执行内部的策略,直到最终的策略
- 异常会从最里面传递到最外面
那么一般该如何组合这些 Policy 呢?
- FallbackPolicy: 通常最为最外面的策略,也可以作为中间策略
- CachePolicy: 可以作为外面的策略,但是不要作为 Fallback 策略的外面
- TimeoutPolicy: 可以作为 RetryPolicy, CircuitBreaker 或者 BulkheadPolicy 的外面
- RetryPolicy/CircuitBreaker: 要不 Retry 包含 CircuitBreaker 或者 CircuitBreaker 包含 Retry
- BulkheadPolicy: 通常作为最里面的策略,也可以包含 TimeoutPolicy 策略
- TimeoutPolicy: 最靠近执行的方法