NET Core微服务之路:弹性和瞬态故障处理库Polly的介绍

时间:2022-03-11 12:37:38
原文: NET Core微服务之路:弹性和瞬态故障处理库Polly的介绍

前言

上一节中我们介绍了Ocelot的常见使用配置,通过json配置文件,实现API网关的请求处理。和一个使用DownStream扩展下游中间件,来实现Http转RPC的简单实现,功能不算强大,但可以作为一个思路,根据自己的RestFul或业务需求来规范下游中间件的处理功能,也有幸被张队收录,十分感谢。
我们知道,Consul、Etcd、Zookeeper等等这些注册中心都有健康检查的机制,用于检查服务节点的状态,是200,还是非200。但是,这种检测是粗粒度的,她只能检测节点的健康状态,却不能检测接口的健康状态,毕竟细粒度的控制太多由业务环境支配,无法统一化和标准化。本节我们介绍如何在接口(或方法)中如何实现健康状态的检测,其实也就是对某个接口的故障保护。
 
先来了解两个重要的名词定义。
 

瞬态故障:

一种仅短暂影响电气设备的介电性能,且可在短时间内自行恢复的故障;电力系统中90%以上的故障都是瞬态故障或由瞬态故障扩大的--来自百度百科。
NET Core微服务之路:弹性和瞬态故障处理库Polly的介绍
很难理解吧,没关系,我们换一个解释方法:
当你有一台服务器,服务上运行着一个“秒杀”系统,理论测试下承载的最大并发数是10W,而当实际运行时,瞬间涌入的请求量达到了1000W,如果你没做前置消峰处理(比如使用队列),服务器立马宕机,甚至烧毁的可能性。
再或者,这台服务器的电压是220V,运行电流是10A,如果服务器电源没做瞬时电流保护处理,那么电源第一个被烧坏,然后是CPU,接着是主板,再然后是硬盘,等等不可恢复的灾难。
这种故障是瞬间的,一般都在一秒以内,甚至微秒内发生,一旦出现,如果没有保护错误,那么必将造成重大灾难。
 

过载故障:

在我们的家用电箱中,都会有这么一个重要的器件,如下图所示(它叫空开,也叫断路器),当电流或电压超过断路器的额定工作范围,便会自动断开,从常闭状态改变为常开状态,阻断整个线路的流通情况(同样称之为过载保护)
NET Core微服务之路:弹性和瞬态故障处理库Polly的介绍
我们知道,我国规定的市电压是AC220V,50HZ,工业电压是AC380V,50HZ。当输入电压超过这个标准,断路器(比如额定输入AC220V,50HZ,60A,而实际输入AC300V,50HZ,100A)将会断开不合格的输入,达到保护的目的。
切换到我们的软件系统中,比如我们规定一台服务器的最大承载并发数是10W范围,超过这个范围服务器将会运行缓慢、甚至宕机。通过某个软件、或中间件、或物理设备控制着这个输入端,当输入量超过这个范围值,这个输入端将自动断开,达到保护服务的目的。
 
咋一看,瞬态故障和过载故障的意义都差不多,都是为了控制和防止过高或过大的输入,其实不然
从时间点:瞬态故障的动作范围极短,远远超过你眨眼睛的速度。而过载故障有可能是长时间的、超过这个额定范围的输入。
从发生点:瞬态故障发生在“秒杀”、“抢购”等瞬间存在的高并发系统中,而过载故障一般均是设计承载和实际承载不符造成的。
从输入量:瞬态故障的输入量一般是正常输入量的十几倍(电气中甚至上百倍),而过载故障一般是正常范围的几倍,最高也就是几十倍(这都很吓人了)。
 
好了,理解了上面两个主要故障,接下来我们看看有没有什么技术可以减少这种故障的出现的几率(或保护我们的整个系统)。
 

Polly介绍

我们来介绍一款强大的库Polly,Polly是一种.NET 弹性瞬态故障处理库,允许我们以非常顺畅和线程安全的方式来执诸如行重试,断路,超时,故障恢复等策略,并且已经被.NET基金会认可的弹性和瞬态处理库。支持目标.NET Framework 4.5+和.NET Core1.0+以上的基础框架库。
NET Core微服务之路:弹性和瞬态故障处理库Polly的介绍
 

安装最新的Polly库

目前最新的官方库是7.0.3,如下所示。
NET Core微服务之路:弹性和瞬态故障处理库Polly的介绍
(我喜欢在不同的操作系统上得到相同的开发体验,jetbrains帮我实现了)
 

Polly故障策略(措施)

该库目前按照官方的文档,已经实现了七种弹性策略。戳我查看原文( https://github.com/App-vNext/Polly#resilience-policies
重试策略:许多故障是暂时性的、并且可以在短时间延迟后可以自行纠正:Maybe it's just a blip。
断路器:断路器策略针对当系统繁忙时(或者系统出现故障),快速响应失败总比让用户一直等待更好:Stop doing it if it hurts。
超时:超时策略针对的条件是超过一定的等待时间,如果还未响应,视为超时,保证调用者不必等待到执行超时:Give that system a break。
隔离:隔离针对的条件是当进程出现故障时,多个失败一直在主机中对资源(例如线程/ CPU)一直占用,下游系统故障也可能导致上游失败,这些风险都将造成严重的后果:One fault shouldn't sink the whole ship。
缓存:缓存策略针对的条件是数据不会很频繁的进行更新,为了避免系统过载,首次加载数据时将响应数据进行缓存,如果缓存中存在则直接从缓存中读取:You've asked that one before
回退:不管重试多次,操作仍然会失败,也就是说,当发生这样的事情时我们打算做什么,撤销之前的操作等等都可以:Degrade gracefully。
策略包装:策略包装针对的条件是不同的故障需要不同的策略,也就意味着,可以弹性灵活的组合不同的策略来实现不同的故障保护措施:Defence in depth。
 

一个简单的异常

一般我们在处理异常的时候,都会习惯性用try.catch来处理,比如这样一个不能除以0的数学异常
try
{
    var z = 0;
    var r = 1 / z;
}
catch (DivideByZeroException ex)
{
    throw ex;
}

很友好,也很直观。但假如这不是一个除以0的数学异常,而只是一个其他异常,并且需要我们调用端通过重试或者等待一段时间后才能正常调用的处理呢,比如一个异步的操作方法,我不能将上面的代码加上个for循环,或者调个线程阻塞一下吧,不管理论上为了目的和结果是可以这样写,我们重试10次,可以写成这样。

for (int i = 0; i < 10; i++)
{
    try
    {
        var z = 0;
        var r = 1 / z;
    }
    catch (DivideByZeroException ex)
    {
        throw ex;
    }    
}
这样的代码,确实满足了我们对异常的友好处理,不过,有了Polly,我们还能更加友好,比如什么重试,超时,或者延时等等,都可以更好的实现。

 

Policy定义

故障定义

常见故障定义方式是指定委托执行过程中出现的特定异常
// 特定异常
Policy.Handle<DivideByZeroException>();
// 条件异常
Policy.Handle<ArgumentException>(ex => ex.HResult == 9999);
// 多重异常
Policy.Handle<DivideByZeroException>().Or<ArgumentException>();
// 聚合异常
Policy.Handle<ArgumentException>().Or<ArgumentException>();
通过Policy.Handle<TException>进行定义异常处理。
特定异常:正如上面“一个简单的异常”中抛出的DivideByZeroException的异常一样,自行指定一个异常类型。
注意:你也可以使用Exception来作为异常处理的类型,但这样的范围太大,所有.NET的异常都会经过这个处理,不推荐这样写。
条件异常:对某个异常指定一个过滤条件。
多重异常:对一个操作中出现多个不同异常的统一异常处理。
聚合异常:一个操作中,可能存在多个相同的异常,把他们合并为一个异常进行处理。
 

结果定义

指定要处理的返回结果
// 处理带条件的返回值
Policy.HandleResult<HttpResponseMessage>(r => r.StatusCode == HttpStatusCode.NotFound);
// 处理多个条件的返回值
Policy.HandleResult<HttpResponseMessage>(r => r.StatusCode == HttpStatusCode.InternalServerError)
    .OrResult(r => r.StatusCode == HttpStatusCode.BadGateway);
// 结果判断
Policy.HandleResult<int>(ret => ret <= 0);
通过Policy.HandleResult<TResult>进行定义结果处理。
上面这段代码不用过多的解释,相信都懂,将符合指定条件的记过过滤掉并返回。
 

故障处理策略定义

重试定义
指定策略应如何处理这些错误,常见的处理策略是重试
// 重试1次
Policy.Handle<TimeoutException>().Retry();
// 重试3次
Policy.Handle<TimeoutException>().Retry(3);
// 无限重试
Policy.Handle<TimeoutException>().RetryForever();
// 重试多次,每次重试都调用一个操作
Policy.Handle<TimeoutException>().Retry(3, (exception, retryCount) =>
{
    // do something
});
// 重试固定时间间隔 (1)
Policy.Handle<TimeoutException>().WaitAndRetry(3, _ => TimeSpan.FromSeconds(3));
// 重试指定时间时间 (2)
Policy.Handle<TimeoutException>().WaitAndRetry(new[]
{
    TimeSpan.FromSeconds(1),
    TimeSpan.FromSeconds(2),
    TimeSpan.FromSeconds(3)
}, (exception, timeSpan, retryCount, context) =>
{
    // do something
});
通过Policy.HandleResult<TResult>().Retry()进行定义重试处理次数和间隔时间。
(1):指定重试次数为3次,且每次重试的时间间隔是3秒
(2):指定重试次数为3次(数组大小),且每次重试间隔时间是1秒,2秒,3秒。
 

回退定义

Fallback策略是在遇到故障是指定一个默认的返回值,也是俗称的降级操作。
// 返回一个值
Policy<int>.Handle<TimeoutException>().Fallback(99);
Policy<int>.Handle<TimeoutException>().Fallback(() => 99);
// 或将返回值定义为一个方法
Policy.Handle<TimeoutException>().Fallback(() => { });

 

断路保护定义

Circuit Breaker也是一种比较常见的处理策略,它可以指定一定时间内最大的故障发生次数,当超过了该故障次数时,在该时间段内,不再执行Policy内的委托操作。
// 在指定的连续异常数后断开,并在指定的持续时间内保持断开。
Policy.Handle<TimeoutException>().CircuitBreaker(2, TimeSpan.FromMinutes(1));

// 在指定的连续异常数后断开,并在规定的时间内保持电路断开,且调用一个改变状态的操作。
var circuitBreaker = Policy.Handle<TimeoutException>().CircuitBreaker(2, TimeSpan.FromMinutes(1),
    (exception, timespan) =>
    {
        // On Break
    },
    () =>
    {
        // On Reset
    });

// 获取当前断路器的状态 (1)
var circuitState = circuitBreaker.CircuitState;

// 除了超时和策略执行失败的这种自动方式外,也可以手动控制它的状态:
// 手动打开(且保持)一个断路器,例如手动隔离downstream服务
circuitBreaker.Isolate();

// 重置一个断路器回closed的状态,可再次接受actions的执行
circuitBreaker.Reset();
通过Policy.HandleResult<TResult>().CircuitBreaker()进行定义重试处理次数后的断开操作处理。
Closed - 断路器的常态,可执行后续Action,如同线路上的开关处于关闭状态。
Open - 断路器已打开,不允许执行Action,如果线路上的开关处于打开状态。
HalfOpen - 在自动断路时间到时,从断开的状态复原。
Isolated - 在断开的状态时手动hold住,不允许执行Action。
 

策略定义(弹性策略)

我们可以通过PWrap的方式(Polly的策略的封装属于弹性策略),封装出一个更加强大的自定义策略。
// 一个简单混合(弹性)策略:当重试2次后,自动回退100
var fallback = Policy<int>.Handle<TimeoutException>().Fallback(100);
var retry = Policy<int>.Handle<TimeoutException>().Retry(2);
var policyWrap = Policy.Wrap(fallback, retry);
policyWrap.Execute(() => { return 0; });

// 超时策略用于控制委托的运行时间,如果达到指定时间还没有运行,则触发超时异常。
Policy.Timeout(TimeSpan.FromSeconds(3), TimeoutStrategy.Pessimistic); (1// 无操作策略(NoOp),啥也不不干
Policy.NoOp();

// 舱壁隔离(Bulkhead Isolation)
// 舱壁隔离是一种并发控制的行为,并发控制是一个比较常见的模式,Polly也提供了这方面的支持
Policy.Bulkhead(12);

// 超过了并发数的任务会抛BulkheadRejectedException,如果要放在队列中等待
// 这种方式下,有12个并发任务,每个任务维持着一个并发队列,每个队列可以自持最大100个任务。
Policy.Bulkhead(12, 100);
通过Policy.Wrap()可以混合多种策略进行弹性处理。
(1):Polly支持两种超时策略:
Pessimistic: 悲观模式
当委托到达指定时间没有返回时,不继续等待委托完成,并抛超时TimeoutRejectedException异常。
Optimistic:乐观模式
这个模式依赖于CancellationToken,需要等待委托自行终止操作。
其中悲观模式比较容易使用,因为它不需要在委托额外的操作,但由于它本身无法控制委托的运行,函数本身并不知道自己被外围策略取消了,也无法在超时的时候中断后续行为,因此用起来反而还不是那么实用。
 

重试操作

public static void Retry()
{
    var tick = 0;
    const int maxRetry = 6;
    var retry = Policy.Handle<Exception>().Retry(maxRetry);

    try
    {
        retry.Execute(() =>
        {
            Console.WriteLine($@"try {++tick}");
            if (tick >= 1)
                // 出现故障,开始重试Execute
                throw new Exception("throw the exception");
        });
    }
    catch (Exception ex)
    {
        Console.WriteLine(@"exception : " + ex.Message);
    }
}

 我们定义一个最大重试次数为6的常亮,和一个为Retry的Policy委托,通过委托执行retry.Execute()方法中,强制抛出一个异常,每抛出一次异常,将计数器+1(实际重试了7次),执行结果如下:

try 1
try 2
try 3
try 4
try 5
try 6
try 7
exception : throw the exception

 

回退操作

public static void Fallback()
{
    Policy.Handle<ArgumentException>().Fallback(() => { Console.WriteLine(@"error occured"); })
        .Execute(() =>
        {
            Console.WriteLine(@"try");
            // 出现故障,进行降级处理Fallback
            throw new ArgumentException(@"throw the exception");
        });
}

以上代码解释:当委托方法中出现异常,在回退操作的控制台中输出一句话“error occured”,执行结果如下:

try
error occured

 

缓存操作

public static void Cache()
{
    const int ttl = 60;
    var policy = Policy.Cache(new MemoryCacheProvider(new MemoryCache(new MemoryCacheOptions())), TimeSpan.FromSeconds(ttl));
    var context = new Context(operationKey: "cache_key");
    for (var i = 0; i < 3; i++)
    {
        var cache = policy.Execute(_ =>
        {
            Console.WriteLine(@"get value");
            return 3;
        }, context);
        Console.WriteLine(cache);
    }
}

Polly支持Cache的操作,你可以使用Memory、Redis等缓存提供器来支持Polly的缓存处理。代码中,我们使用Context来设置一个Key,通过委托执行3次,每次都通过这个缓存来获取值,执行结果如下:

get value
3
3
3

 

断路操作

public static void CircuitBreaker()
{
    var tick = 0;
    const int interval = 10;
    const int maxRetry = 6;
    var circuitBreaker = Policy.Handle<Exception>().CircuitBreaker(maxRetry, TimeSpan.FromSeconds(interval));

    while (true)
    {
        try
        {
            circuitBreaker.Execute(() =>
            {
                Console.WriteLine($@"try {++tick}");
                throw new Exception("throw the exception");
            });
        }
        catch (Exception ex)
        {
            Console.WriteLine(@"exception : " + ex.Message);

            // 当重试次数达到断路器指定的次数时,Polly会抛出The circuit is now open and is not allowing calls. 断路器已打开,不允许访问
            // 为了演示,故意将下面语句写上,可退出while循环
            // 实际环境中视情况,断开绝不等于退出,或许20-30秒后,服务维护后变得可用了
            if (ex.Message.Contains("The circuit is now open and is not allowing calls"))
            {
                break;
            }
        }

        Thread.Sleep(300);
    }
}

这段代码稍微有点多,我们先看看执行结果:

try 1
exception : throw the exception
try 2
exception : throw the exception
try 3
exception : throw the exception
try 4
exception : throw the exception
try 5
exception : throw the exception
try 6
exception : throw the exception
exception : The circuit is now open and is not allowing calls.
从执行结果中我们可以看到,一共尝试重试了6次,超过6次后,直接返回The circuit is now open and is not allowing calls(断路器已打开,不允许调用)。
 

弹性操作

我们用一个超时来处理策略组合的弹性操作。
public static void Timeout()
{
    const int timeoutSecond = 3;

    try
    {
        Policy.Wrap(
            Policy.Timeout(timeoutSecond, TimeoutStrategy.Pessimistic),
            Policy.Handle<TimeoutRejectedException>().Fallback(() => { })
        ).Execute(() =>
        {
            Console.WriteLine(@"try");
            Thread.Sleep(5000);
        });
    }
    catch (Exception ex)
    {
        // 当超时时间到,会抛出The delegate executed through TimeoutPolicy did not complete within the timeout.
        // 委托执行未在指定时间内完成
        Console.WriteLine($@"exception : {ex.GetType()} : {ex.Message}");
    }
}

 以上策略很好理解,组合一个超时和回退的策略组合,当超时已到,执行回退操作,执行结果如下:

try
exception : Polly.Timeout.TimeoutRejectedException : The delegate executed through TimeoutPolicy did not complete within the timeout.
策略组合是Polly中强大的弹性操作核心,我们可以结合实际业务场景,来组合多个策略以满足我们对故障保护的需求,以下是Polly中Warp的方法源码:
NET Core微服务之路:弹性和瞬态故障处理库Polly的介绍NET Core微服务之路:弹性和瞬态故障处理库Polly的介绍
 

总结

本节只是简单的介绍了一下Polly这个弹性框架库的使用范围,我们可以通过它的故障定义和委托来实现熔断,降级,隔离,重试等操作,实现方式也非常简单(.Net的语法糖确实很甜),更强大的是她的弹性策略,结合到我们实际的业务中,可以保护(或处理)很多实际业务场景。比如一个支付失败的操作,可以尝试指定次数后回滚之前的支付操作,保护支付合规性。再或者,服务节点中某个接口存在异常,立马阻断与调用端的连接,并隔离这个异常的接口,等等业务场景。并且,不仅在Ocelot中也集成了这强大的弹性库,其他非常多的.Net开源框架也默认集成了Polly。

 

感谢阅读!