当我们从单体架构迁移到微服务模式时,其中一个比较大的变化就是模块(业务,服务等)间的调用方式。在以前,一个业务流程的执行在一个进程中就完成了,但是在微服务模式下可能会分散到2到10个,甚至更多的机器(微服务)上,这必然就要使用网络进行通信。而网络本身就是不可靠的,并随着每个服务都根据自身的情况进行的动态扩容,以及机器漂移等等。可以说,在微服务中,网络连接缓慢,资源繁忙,暂时不可用,服务脱机等异常情况已然变成了一种常态。因此我们必须要有一种机制来保证服务整体的稳定性,而本文要介绍的熔断降级就是一种很好的应对方案。
服务熔断
在介绍熔断之前,我们先来谈谈微服务中的雪崩效应。在微服务中,服务A调用服务B,服务B可能会调用服务C,服务C又可能调用服务D等等,这种情况非常常见。如果服务D出现不可用或响应时间过长,就会导致服务C原来越多的线程处于网络调用等待状态,进而影响到服务B,再到服务A等,最后会耗尽整个系统的资源,导致整体的崩溃,这就是微服务中的“雪崩效应”。
而熔断机制就是应对雪崩效应的一种链路保护机制。其实,对于熔断这个词我们并不陌生,在日常生活中经常会接触到,比如:家用电力过载保护器,一旦电压过高(发生漏电等),就会立即断电,有些还会自动重试,以便在电压正常时恢复供电。再比如:股票交易中,如果股票指数过高,也会采用熔断机制,暂停股票的交易。同样,在微服务中,熔断机制就是对超时的服务进行短路,直接返回错误的响应信息,而不再浪费时间去等待不可用的服务,防止故障扩展到整个系统,并在检测到该服务正常时恢复调用链路。
服务降级
当我们谈到服务熔断时,经常会提到服务降级,它可以看成是熔断器的一部分,因为在熔断器框架中,通常也会包含服务降级功能。
降级的目的是当某个服务提供者发生故障的时候,向调用方返回一个错误响应或者替代响应。从整体负荷来考虑,某个服务熔断后,服务器将不再被调用,此时客户端可以自己准备一个本地的fallback回调,这样,虽然服务水平下降,但总比直接挂掉的要好。比如:调用联通接口服务器发送短信失败之后,改用移动短信服务器发送,如果移动短信服务器也失败,则改用电信短信服务器,如果还失败,则返回“失败”响应;再比如:在从推荐商品服务器加载数据的时候,如果失败,则改用从缓存中加载,如果缓存也加载失败,则返回一些本地替代数据。
在某些情况下,我们也会采取主动降级的机制,比如双十一活动等,由于资源的有限,我们也可以把少部分不重要的服务进行降级,以保证重要服务的稳定,待度过难关,再重新开启。
Polly基本使用
在.Net Core中有一个被.Net基金会认可的库Polly,它一种弹性和瞬态故障处理库,可以用来简化对服务熔断降级的处理。主要包含以下功能:重试(Retry),断路器(Circuit-breaker),超时检测(Timeout),舱壁隔离(Bulkhead Isolation), 缓存(Cache),回退(FallBack)。
该项目作者现已成为.NET基金会一员,一直在不停的迭代和更新,项目地址: https://github.com/App-vNext/Polly。
策略
在Polly中,有一个重要的概念:Policy,策略有“故障定义”和“故障恢复”两部分组成。故障是指异常、非预期的返回值等情况,而动作则包括重试(Retry)、熔断(Circuit-Breaker)、Fallback(降级)等。
故障定义
故障也可以说是触发条件,它使用Handle<T>
来定义,表示在什么情况下,才对其进行处理(熔断,降级,重试等)。
一个简单的异常故障定义如下:
Policy.Handle<HttpRequestException>()
如上,表示当我们的代码触发HttpRequestException
异常时,才进行处理。
我们也可以对异常的信息进行过滤:
Policy.Handle<SqlException>(ex => ex.Number == 1205)
如上,只有触发SqlException
异常,并且其异常号为1205
的时候才进行处理。
如果我们希望同时处理多种异常,可以使用Or<T>
来实现:
Policy.Handle<HttpRequestException>().Or<OperationCanceledException>()
Policy.Handle<SqlException>(ex => ex.Number == 1205).Or<ArgumentException>(ex => ex.ParamName == "example")
除此之外,我们还可以根据返回结果进行故障定义:
Policy.HandleResult<HttpResponseMessage>(r => r.StatusCode == HttpStatusCode.NotFound)
如上,当返回值为HttpResponseMessage
,并且其StatusCode
为NotFound
时,才对其进行处理。更多用法参考:usage--fault-handling-policies。
故障恢复
当定义了故障后,要考虑便是如何对故障进行恢复了,Polly中常用的有以下几种恢复策略:
重试(Retry)策略
重试就是指Polly在调用失败时捕获我们指定的异常,并重新发起调用,如果重试成功,那么对于调用者来说,就像没有发生过异常一样。在网络调用中经常出现瞬时故障,那么重试机制就非常重要。
一个简单的重试策略定义如下:
// 当发生HttpRequestException异常时,重试3次
var retryPolicy = Policy.Handle<HttpRequestException>().Retry(3);
有些情况下,如果故障恢复的太慢,我们重试的过快是没有任何任何意义的,这时可以指定重试的时间间隔:
Policy.Handle<HttpRequestException>().WaitAndRetryAsync(5, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt - 1)));
如上,重试五次,并且重试时间指数级增加。
超时(Timeout)策略
超时是我们比较常见的,比如HttpClient
就可以设置超时时间,如果在指定的时间内还没有返回,就触发一个TimeoutException
异常,而Polly的超时机制与其类似,只不过超时时触发的是一个TimeoutRejectedException
异常。
// 如果30秒种内没有执行完成,就触发`TimeoutRejectedException`异常
Policy.TimeoutAsync(30);
// 设置超时回调
Policy.TimeoutAsync(30, onTimeout: (context, timespan, task) =>
{
// do something
});
由于超时策略本身就是抛出一个超时异常,所以不需要设置触发条件。
回退(FallBack)策略
回退也称服务降级,用来指定发生故障时的备用方案。
Policy<string>.Handle<HttpRequestException>().FallbackAsync("substitute data", (exception, context) =>
{
// do something
});
如上,如果触发HttpRequestException
异常时,就返回固定的substitute data
。
熔断(Circuit-breaker)策略
断路器用于在服务多次不可用时,快速响应失败,保护系统故障免受过载。
Policy.Handle<HttpRequestException>().Or<TimeoutException>()
.CircuitBreakerAsync(
// 熔断前允许出现几次错误
exceptionsAllowedBeforeBreaking: 3,
// 熔断时间
durationOfBreak: TimeSpan.FromSeconds(100),
// 熔断时触发
onBreak: (ex, breakDelay) =>
{
// do something
},
// 熔断恢复时触发
onReset: () =>
{
// do something
},
// 在熔断时间到了之后触发
onHalfOpen: () =>
{
// do something
}
);
如上,如果我们的业务代码连续失败3次,就触发熔断(onBreak),就不会再调用我们的业务代码,而是直接抛出BrokenCircuitException
异常。当熔断时间(100s)过后,切换为HalfOpen
状态,触发onHalfOpen
事件,此时会再调用一次我们的业务代码,如果调用成功,则触发onReset
事件,并解除熔断,恢复初始状态,否则立即切回熔断状态。
更多策略的用法查看:usage--general-resilience-policie。
执行
在上面的示例中,我们熟悉了各种策略的定义,那么接下来就是执行它。也就是使用Polly
包裹我们的业务代码,Polly
会拦截业务代码中的故障,并根据指定的策略进行恢复。
最简单的策略执行方式如下:
var policy = /*策略定义*/;
var res = await policy.ExecuteAsync(/*业务代码*/);
如果需要同时指定多个策略,可以使用Policy.Wrap
来完成:
Policy.Wrap(retry, breaker, timeout).ExecuteAsync(/*业务代码*/);
其实Warp
本质就是多个策略的嵌套执行,使用如下写法效果是一样的:
fallback.Execute(() => waitAndRetry.Execute(() => breaker.Execute(action)));
关于Polly
更详细的用法可以查看Polly Github上的https://github.com/App-vNext/Polly/wiki,本文就不再过多介绍。
Polly熔断降级实战
场景:轮询调用服务A和服务B,单次调用时间不得超过1s,调用失败时自动切换到另外一个服务重试一次,如果都失败,进行优雅的降级,返回模拟数据,并在2个服务都多次失败后进行熔断。
首先创建一个ASP.NET Core Console程序,命名为PollyDemo。
然后引入Polly的官方Nuge包:
dotnet add package Polly
在我们首先定义一个超时策略:
var timeoutPolicy = Policy.TimeoutAsync(1, (context, timespan, task) =>
{
Console.WriteLine("It's Timeout, throw TimeoutRejectedException.");
return Task.CompletedTask;
});
可以根据实际情况来设置超时时间,我这里为了方便测试,就设置为1s。
然后定义重试策略:
var retryPolicy = Policy.Handle<HttpRequestException>().Or<TimeoutException>().Or<TimeoutRejectedException>()
.WaitAndRetryAsync(
retryCount: 2,
sleepDurationProvider: retryAttempt =>
{
var waitSeconds = TimeSpan.FromSeconds(Math.Pow(2, retryAttempt - 1));
Console.WriteLine(DateTime.Now.ToString() + "-Retry:[" + retryAttempt + "], wait " + waitSeconds + "s!");
return waitSeconds;
});
再定义一个熔断策略:
var circuitBreakerPolicy = Policy.Handle<HttpRequestException>().Or<TimeoutException>().Or<TimeoutRejectedException>()
.CircuitBreakerAsync(
// 熔断前允许出现几次错误
exceptionsAllowedBeforeBreaking: 2,
// 熔断时间
durationOfBreak: TimeSpan.FromSeconds(3),
// 熔断时触发
onBreak: (ex, breakDelay) =>
{
Console.WriteLine(DateTime.Now.ToString() + "Breaker->Breaking the circuit for " + breakDelay.TotalMilliseconds + "ms! Exception: ", ex.Message);
},
// 熔断恢复时触发
onReset: () =>
{
Console.WriteLine(DateTime.Now.ToString() + "Breaker->Call ok! Closed the circuit again.");
},
// 在熔断时间到了之后触发
onHalfOpen: () =>
{
Console.WriteLine(DateTime.Now.ToString() + "Breaker->Half-open, next call is a trial.");
}
);
如上,连续错误2次就熔断3秒。
最后,再定义一个回退策略:
var fallbackPolicy = Policy<string>.Handle<Exception>()
.FallbackAsync(
fallbackValue: "substitute data",
onFallbackAsync: (exception, context) =>
{
Console.WriteLine("It's Fallback, Exception->" + exception.Exception.Message + ", return substitute data.");
return Task.CompletedTask;
});
我们的业务代码如下:
private List<string> services = new List<string> { "localhost:5001", "localhost:5002" };
private int serviceIndex = 0;
private HttpClient client = new HttpClient();
private Task<string> HttpInvokeAsync()
{
if (serviceIndex >= services.Count)
{
serviceIndex = 0;
}
var service = services[serviceIndex++];
Console.WriteLine(DateTime.Now.ToString() + "-Begin Http Invoke->" + service);
return client.GetStringAsync("http://" + service + "/api/values");
}
这里方便测试,直接写死了两个服务,对其轮询调用,在生产环境中可以参考上一篇《服务发现之Consul》来实现服务发现和负载均衡。
现在,我们组合这些策略来调用我们的业务代码:
for (int i = 0; i < 100; i++)
{
Console.WriteLine(DateTime.Now.ToString() + "-Run[" + i + "]-----------------------------");
var res = await fallbackPolicy.WrapAsync(Policy.WrapAsync(circuitBreakerPolicy, retryPolicy, timeoutPolicy)).ExecuteAsync(HttpInvokeAsync);
Console.WriteLine(DateTime.Now.ToString() + "-Run[" + i + "]->Response" + ": Ok->" + res);
await Task.Delay(1000);
Console.WriteLine("--------------------------------------------------------------------------------------------------------------------");
}
如上,循环执行100次,策略的执行是非常简单的,唯一需要注意的就是调用的顺序:如上是依次从右到左进行调用,首先是进行超时的判断,一旦超时就触发TimeoutRejectedException
异常,然后就进入到了重试策略中,如果重试了一次就成功了,那就直接返回,不再触发其他策略,否则就进入到熔断策略中:
如上图,服务A(localhost:5001)和服务B(localhost:5001),都没有启动,所以会一直调用失败,最后熔断器开启,并最终被降级策略拦截,返回substitute data
。
现在我们启动服务A,可以看到服务会自动恢复,解除熔断状态:
总结
本篇首先讲解了一下微服务中熔断、降级的基本概念,然后对.Net Core中的Polly
框架做了一个基本介绍,最后基于Polly
演示了如何在.NET Core中实现熔断降级来提高服务质量。而熔断本质上只是一个保护壳,在周围出现异常的时候保全自身,从长远来看,平时定期做好压力测试才能防范于未然,降低触发熔断的次数。如果清楚的知道每个服务的承载量,并做好服务限流的控制,就能将“高压”下触发熔断的概率降到最低了。那下一篇就来介绍一下速率限制(Rate Limiting),敬请期待!