聊聊Asp.net Core中如何做服務的熔斷與降級

概念解析

啥是熔斷

而對於微服務來說,熔斷就是我們常說的“保險絲”,意爲當服務出現某些狀況時,切斷服務,從而防止應用程序不斷地嘗試執行可能會失敗的操作造成系統的“雪崩”;或者大量的超時等待導致系統卡死等情況,很多地方也將其成爲“過載保護”。

一個典型的應用場景:

雙11”高峯部分消費者反應阿里系手機App發生宕機

這個報錯的本質就是服務端流量過大,直接拒絕了部分請求;也就是“熔斷”了,像保險絲一樣;

啥是降級

降級的目的就是當某個服務提供者發生故障的時候,啓用的一套備用的邏輯;通常有兩種比較典型的做法:

1、是直接向調用方返回一個錯誤響應或者錯誤頁面

2、是執行備用/替代邏輯;

1比較容易理解;2的話,舉個例子你有個發送短信的服務非常重要,但你只接入了阿里雲短信服務,要是某天阿里雲掛了你怎麼辦?那我再接入個便宜點騰訊雲短信。沒錯這就是服務降級/回退;

可以看到降級主要做的是用戶體驗上的考慮,避免服務報錯時直接UI/js報錯卡住,點擊沒反應 等等功能/體驗降級;

如何實現

根據前面的概念,我們知道服務熔斷其實比較好做;

服務的降級是一個備用的邏輯,如果每個功能都實現一套備用邏輯成本是非常高的(要寫兩套代碼);所以服務降級我們比較常見到的是返回一個錯誤;

前端

  • 1、寫好請求攔截器,遇到各種後端未約定好的狀態碼;返回數據的格式;做到有對應的處理邏輯,該toast的toast;
  • 2、404、500等錯誤頁面要準備好,不能無端端空白頁;特別是不能報後端的堆棧信息出來;
  • 3、做好全局異常處理,最好配合做好異常埋點,做到故障有跡可循;故障體驗也要考慮,避免js報錯頁面操作直接沒反應;

後端

Net WebApi

1、寫好異常過濾器(實現IExceptionFilter),不要直接響應500或拋堆棧信息到前端;

示例:略

2、處理好模型驗證信息;

示例:

public static IServiceCollection ConfigureApiBehaviorOptions(this IServiceCollection services)
{
    services.Configure<ApiBehaviorOptions>(options =>
    {
        options.InvalidModelStateResponseFactory = (actionContext) =>
        {
            var firstInvalidMsg = actionContext.ModelState?.Values.SelectMany(c => c.Errors).Select(c => c.ErrorMessage)?.FirstOrDefault();

            return new JsonResult(new ApiResult<object>()
            {
                Code = EnumStatus.Fail,
                Message = firstInvalidMsg ?? "參數驗證失敗"
            });
        };
    });
    return services;
}

第三方庫Polly實現

Polly 是一個 .NET 彈性和瞬態故障處理庫,允許開發人員以 Fluent 和線程安全的方式來實現重試、斷路、超時、隔離、艙壁隔離、頻率限制和回退策略。

首先這裏的說的瞬態故障包含了程序發生的異常和出現不符合開發者預期的結果。所謂瞬態故障,就是說故障不是必然會發生的,而是偶然可能會發生的,比如網絡偶爾會突然出現不穩定或無法訪問這種故障。至於彈性,就是指應對故障 Polly 的處理策略具有多樣性和靈活性,它的各種策略可以靈活地定義和組合。

抽象,重試、斷路、超時、隔離、艙壁隔離、頻率限制就是Polly的策略,我們一一介紹下;

先安裝nuget

Install-Package Polly

項目地址:https://github.com/App-vNext/Polly

介紹

Polly 的異常處理策略的基本用法可以分爲三個步驟

   Policy
        // 1. 指定要處理什麼異常
        .Handle<HttpRequestException>()
        // 或者指定需要處理什麼樣的錯誤返回
        .OrResult<HttpResponseMessage>(r => r.StatusCode == HttpStatusCode.BadGateway)
        // 2. 指定重試次數和重試策略
        .Retry(3, (exception, retryCount, context) =>
        {
            Console.WriteLine($"開始第 {retryCount} 次重試:");
        })
        // 3. 執行具體任務
        .Execute(ExecuteMockRequest);

重試(Retry)

當我們服務依賴外部接口時,往往有接口瞬間故障問題,這個時刻就可以考慮重試策略;

// 重試一次
Policy
  .Handle<SomeExceptionType>()
  .Retry()

// 重試3次
Policy
  .Handle<SomeExceptionType>()
  .Retry(3)

// 重試3次,並在重試時執行邏輯
Policy
  .Handle<SomeExceptionType>()
  .Retry(3, onRetry: (exception, retryCount) =>
  {
      // Add log
  });

// 重試3次,並在重試時執行邏輯時攜帶上下文
Policy
  .Handle<SomeExceptionType>()
  .Retry(3, onRetry: (exception, retryCount, context) =>
  {
      // 每次重試時執行邏輯,比如寫日誌
  });

一直重試

// 一直重試
Policy
  .Handle<SomeExceptionType>()
  .RetryForever()

// 一直重試,同時執行邏輯
Policy
  .Handle<SomeExceptionType>()
  .RetryForever(onRetry: exception =>
  {
        // Add logic
  });

// 一直重試,同時執行邏輯(參數不一樣)
Policy
  .Handle<SomeExceptionType>()
  .RetryForever(onRetry: (exception, context) =>
  {
        // Add logic 
  });

更多...

超時(TimeOut)

當系統超過一定時間的等待,我們就幾乎可以判斷不可能會有成功的結果。比如平時一個網絡請求瞬間就完成了,如果有一次網絡請求超過了 30 秒還沒完成,我們就知道這次大概率是不會返回成功的結果了。因此,我們需要設置系統的超時時間,避免系統無限等待。

// 執行30秒後超時
Policy
  .Timeout(30)

//  timespan做超時時間.
Policy
  .Timeout(TimeSpan.FromMilliseconds(2500))

// 動態超時時間
Policy
  .Timeout(() => myTimeoutProvider)) // Func<TimeSpan> myTimeoutProvider

// 超時時,執行特定的邏輯
Policy
  .Timeout(30, onTimeout: (context, timespan, task) =>
    {
        // Add extra logic to be invoked when a timeout occurs, such as logging
    });

//示例: 超時時,記錄存在狀態
Policy
  .Timeout(30, onTimeout: (context, timespan, task) =>
    {
        logger.Warn($"{context.PolicyKey} at {context.OperationKey}: execution timed out after {timespan.TotalSeconds} seconds.");
    });

//  示例:當超時的任務完成時,捕獲來自超時任務的異常。
Policy
  .Timeout(30, onTimeout: (context, timespan, task) =>
    {
        task.ContinueWith(t => {
            if (t.IsFaulted) logger.Error($"{context.PolicyKey} at {context.OperationKey}: execution timed out after {timespan.TotalSeconds} seconds, with: {t.Exception}.");
        }); 
    });

更多...

回退(Fallback)

當出現故障,則進入降級動作。很常見的一個場景是,當用戶沒有上傳頭像時,我們就給他一個默認頭像。

// 當沒有用戶頭像時,用默認頭像
Policy
   .Handle<UserAvatar>()
   .OrResult(null)
   .Fallback<UserAvatar>(() => UserAvatar.GetDefaultAvatar())

// 當然,出現異常的時候也可以回退
Policy<UserAvatar>
   .Handle<FooException>()
   .OrResult(null)
   .Fallback<UserAvatar>(() => UserAvatar.GetDefaultAvatar()) 

// 執行回退策略的時候,執行邏輯(比如寫個日誌)
Policy<UserAvatar>
   .Handle<FooException>()
   .Fallback<UserAvatar>(UserAvatar.Blank, onFallback: (exception, context) =>
    {
        // Add logging
    });

斷路(Circuit-breaker)

我們服務也會依賴外部接口,有的時候外部接口負載很高的時候,響應很慢的時候。可以考慮使用斷路器,阻斷一定時間內對這個外部接口的調用邏輯;減輕第三方接口壓力,起短路器的作用;

//出現某個異常兩次時,斷路一分鐘
Policy
    .Handle<SomeExceptionType>()
    .CircuitBreaker(2, TimeSpan.FromMinutes(1));

//出現某個異常兩次時,斷路一分鐘;
//當觸發斷路,斷路恢復時,執行對應的邏輯;
Action<Exception, TimeSpan> onBreak = (exception, timespan) => { ... }; //斷路邏輯
Action onReset = () => { ... }; //斷路恢復邏輯
CircuitBreakerPolicy breaker = Policy
    .Handle<SomeExceptionType>()
    .CircuitBreaker(2, TimeSpan.FromMinutes(1), onBreak, onReset);

//出現某個異常兩次時,斷路一分鐘;
//當觸發斷路,斷路恢復時,攜帶上下文 執行對應的邏輯;
Action<Exception, TimeSpan, Context> onBreak = (exception, timespan, context) => { ... };
Action<Context> onReset = context => { ... };
CircuitBreakerPolicy breaker = Policy
    .Handle<SomeExceptionType>()
    .CircuitBreaker(2, TimeSpan.FromMinutes(1), onBreak, onReset);

//獲取迴路狀態
CircuitState state = breaker.CircuitState;

/*
*斷路器狀態釋義
CircuitState.Closed - 正常狀態,可以執行動作;
CircuitState.Open - 啓動斷路器,業務邏輯動作的執行被阻止.
CircuitState.HalfOpen - 當開啓狀態過期後,邏輯動作已經可以執行。這個時候接下來的狀態將會根據動作的執行爲開啓或關閉;
CircuitState.Isolated - 斷路器被獨立地設置爲開啓狀態,並保持開啓.,業務邏輯動作的執行被阻止.

//手動開啓一個斷路器,並保證開啓狀態;比如手動隔離下游服務
breaker.Isolate();
//重置斷路器到closed狀態,以便再次執行動作
breaker.Reset();

更多...

頻率限制(Rate-Limit)

限制一段代碼的執行頻率;

//每秒鐘執行不能超過20次
Policy.RateLimit(20, TimeSpan.FromSeconds(1));

// 每秒鐘執行不能超過20次,且不能連續執行超過10次
Policy.RateLimit(20, TimeSpan.FromSeconds(1), 10);

// 每秒鐘執行不能超過20次,如果超過之後執行一段邏輯,並設置下次重試時間
Policy.RateLimit(20, TimeSpan.FromSeconds(1), (retryAfter, context) =>
{
    return retryAfter.Add(TimeSpan.FromSeconds(2));
});

// 每秒鐘執行不能超過20次,且不能連續執行超過10次,如果超過之後執行一段邏輯,並設置下次重試時間
Policy.RateLimit(20, TimeSpan.FromSeconds(1), 10, (retryAfter, context) =>
{
    return retryAfter.Add(TimeSpan.FromSeconds(2));
});

更多...

艙壁隔離(Bulkhead Isolation)

當系統的一處出現故障時,可能促發多個失敗的調用,很容易耗盡主機的資源(如 CPU)。下游系統出現故障可能導致上游的故障的調用,甚至可能蔓延到導致系統崩潰。

所以要將可控的操作限制在一個固定大小的資源池中,以隔離有潛在可能相互影響的操作。

// 最多允許 12 個線程併發執行
Policy
  .Bulkhead(12)

// 最多允許 12 個線程併發執行
// 如果所有的線程都被佔用後,有兩個等待執行槽
Policy
  .Bulkhead(12, 2)

// 限制併發後,調用一個委託
Policy
  .Bulkhead(12, context =>
    {
        // 比如記日誌
    });

//監控隔離倉的可用資源
var bulkhead = Policy.Bulkhead(12, 2);
// ...
int freeExecutionSlots = bulkhead.BulkheadAvailableCount;
int freeQueueSlots     = bulkhead.QueueAvailableCount;

更多...

緩存(Cache)

一般我們會把頻繁使用且不會怎麼變化的資源緩存起來,以提高系統的響應速度。如果不對緩存資源的調用進行封裝,那麼我們調用的時候就要先判斷緩存中有沒有這個資源,有的話就從緩存返回,否則就從資源存儲的地方(比如數據庫)獲取後緩存起來,再返回,而且有時還要考慮緩存過期和如何更新緩存的問題。Polly 提供了緩存策略的支持,使得問題變得簡單。

var memoryCache = new MemoryCache(new MemoryCacheOptions());
var memoryCacheProvider = new MemoryCacheProvider(memoryCache);
var cachePolicy = Policy.Cache(memoryCacheProvider, TimeSpan.FromMinutes(5));

定義一個絕對緩存時間一天的緩存
var cachePolicy = Policy.Cache(memoryCacheProvider, new AbsoluteTtl(DateTimeOffset.Now.Date.AddDays(1));

一個滑動緩存時間5分鐘的緩存                               
var cachePolicy = Policy.Cache(memoryCacheProvider, new SlidingTtl(TimeSpan.FromMinutes(5));

定義一個緩存提供程序報錯後可以記錄日誌或執行邏輯的緩存
var cachePolicy = Policy.Cache(myCacheProvider, TimeSpan.FromMinutes(5),
   (context, key, ex) => {
       logger.Error($"Cache provider, for key {key}, threw exception: {ex}."); // (for example)
   }
);

// Execute through the cache as a read-through cache: check the cache first; if not found, execute underlying delegate and store the result in the cache.
// The key to use for caching, for a particular execution, is specified by setting the OperationKey (before v6: ExecutionKey) on a Context instance passed to the execution. Use an overload of the form shown below (or a richer overload including the same elements).
// Example: "FooKey" is the cache key that will be used in the below execution.
 
緩存到FooKey的key裏面,你
TResult result = cachePolicy.Execute(context => getFoo(), new Context("FooKey"));

更多...

AspectCore + Polly 的AOP實現

從上面來看,我們在代碼裏面使用Polly會產生很多重複代碼,影響可維護性;接下來我們藉助AspectCore + Polly 封裝了一個包,然後針對需要熔斷降級的函數,直接在函數上打標籤即可;

安裝包

Install-Package Hei.Hystrix

在program.cs裏面啓用

按不同需求配置啓用即可

//只啓用內存緩存
builder.Services.AddHeiHystrix();

//啓用內存緩存和redis緩存
builder.Services.AddHeiHystrix(o =>
{
    o.RedisConnectionString = AppSettings.GetConnectionString("Redis");
});

//啓用內存緩存和redis緩存,且要修改緩存數據的序列化配置
builder.Services.AddHeiHystrix(o =>
{
    o.RedisConnectionString = AppSettings.GetConnectionString("Redis");
    o.JsonSerializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
    {
        MaxDepth = 64,
        PropertyNameCaseInsensitive = false,
        DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
    };
});

啓用全局動態代理

builder.Host.UseServiceProviderFactory(new DynamicProxyServiceProviderFactory());

還有更多的動態代理配置,請參考:https://github.com/dotnetcore/AspectCore-Framework

使用

接口的話,標籤直接打到接口成員上;否則,標籤直接打到函數上;

//先寫個回退方法
Task<string> MyFallback();

/// <summary>
/// 增加回退邏輯
/// </summary>
/// <returns></returns>
[HeiHystrix(nameof(MyFallback))]
Task OnlyFallback();

/// <summary>
/// 熔斷處理
/// </summary>
/// <returns></returns>
[HeiHystrix(nameof(MyFallback), EnableCircuitBreaker = true, ExceptionsAllowedBeforeBreaking = 3, MillisecondsOfBreak = 10 * 1000)] //ExceptionsAllowedBeforeBreaking=熔斷前執行3次,每次熔斷10秒
Task CircuitBreaker();

/// <summary>
/// 超時處理
/// </summary>
/// <returns></returns>
//[HeiHystrix(nameof(MyFallback), TimeOutMilliseconds = 1*1000)]
[HeiHystrix(nameof(MyFallback), TimeOutMilliseconds = 2 * 1000)]
Task<string> TimeOut();

/// <summary>
/// 重試
/// </summary>
/// <returns></returns>
//[HeiHystrix(nameof(Retry), MaxRetryTimes = 1, RetryIntervalMilliseconds = 0)]
[HeiHystrix(nameof(MyFallback), MaxRetryTimes = 1, RetryIntervalMilliseconds = 4 * 1000)] //重試1次,重試間隔4秒
Task<string> Retry();

/// <summary>
/// 緩存
/// </summary>
/// <returns></returns>
// [HeiHystrix(nameof(MyFallback), CacheTTLSeconds = 5)]//內存緩存,有fallback邏輯,緩存5秒
// [HeiHystrix(CacheTTLMinutes = 2)] //內存緩存,緩存2分鐘
[HeiHystrix(CacheType = CacheTypeEnum.Redis, CacheTTLMinutes = 2)]//redis緩存,2分鐘
Task<List<string>> CacheDataAsync();

這是接口的實現

public async Task<string> MyFallback()
{
    var msg = "MyFallback Executed!!!!!!!!!!!!!!!!!!";
    Console.WriteLine(msg);
    return msg;
}

public async Task OnlyFallback()
{
    Console.WriteLine("執行熔斷方法 OnlyFallback");
    throw new Exception("fallback異常");
}

public async Task CircuitBreaker()
{
    Console.WriteLine("執行熔斷方法CircuitBreaker");
    throw new Exception("熔斷異常");
}

public async Task<string> TimeOut()
{
    Console.WriteLine("執行timeOut方法");
    await Task.Delay(2 * 1000);
    return "執行timeOut方法";
}

public async Task<string> Retry()
{
    Console.WriteLine("執行方法Retry");
    throw new Exception("重試異常");
    return "執行方法Retry";
}

public void CacheVoid()
{
    Console.WriteLine("執行緩存CacheVoid" + DateTime.Now.ToString());
}

public async Task CacheTask()
{
    Console.WriteLine("執行緩存CacheVoid" + DateTime.Now.ToString());
}

public async Task<List<string>> CacheDataAsync()
{
    var datatime = DateTime.Now.ToString();
    Console.WriteLine("執行緩存CacheData" + datatime);
    return new List<string>
    {
        datatime,
        new Random().Next(1,10000).ToString()
    };
}

總結

最後的nuget包其實總體上是基於楊老師的代碼簡單改了下,加上了比較常用的redis緩存;然後redis緩存序列化這塊也基本是“致敬”一念大佬的這個項目 ,大家可以點個星;

然後還有批量限制,艙壁隔離等,我目前需求不多 暫不加,後續看需要更新。

[參考]

https://github.com/App-vNext/Polly

https://github.com/dotnetcore/AspectCore-Framework

https://github.com/yangzhongke/RuPeng.HystrixCore

https://github.com/softlgl/NCache/blob/master/NCache/Aspect/CacheableAttribute.cs
https://github.com/softlgl/DotNetCoreRpc/blob/master/src/DotNetCoreRpc.Client/HttpRequestInterceptor.cs

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章