65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Polly 构建弹性 .NET 应用程序

starIconstarIconstarIconstarIconstarIcon

5.00/5 (9投票s)

2024 年 1 月 22 日

CPOL

12分钟阅读

viewsIcon

14616

downloadIcon

127

如何编写弹性 .NET 应用程序?

引言

微服务是一种将软件应用程序构建为一组小型、独立且松耦合服务的架构风格。在微服务架构中,应用程序被分解为一组可独立部署的服务,每个服务代表一个特定的业务功能。这些服务可以独立开发、部署和扩展,从而实现更大的灵活性、可维护性和可伸缩性。

尽管如此,这种新兴的范式带来了一些挑战,特别是在管理分布式架构和在微服务遇到性能问题时制定有效响应方面。通常,这些场景在项目初期就被忽视了,有时甚至在应用程序迁移到生产环境时也被忽略。然后,它们被仓促解决,通常是在用户投诉引发的紧急或深夜情况下。

在本系列中,我们将探讨如何使用一个专门为此类挑战设计的强大库来熟练处理这些问题。我们将深入研究 C# 与 Azure Functions 的实际用例实现,并观察它们在遇到问题时的响应。Polly 应运而生。

以下关于此主题的教科书是一部经久不衰的经典之作,值得每个开发者的书架上拥有。它不仅涵盖弹性模式,还包含大量广泛且实用的通用用例。

这篇文章最初发布在此处:here

什么是分布式架构?

分布式架构是指软件应用程序的组件或模块分布在多台计算机或服务器上,通常是地理上分散的系统设计。与所有组件紧密互连并驻留在单个平台上的传统单体架构不同,分布式架构将工作负载和功能分布在网络中的各个节点上。

分布式架构的常见示例包括微服务架构,其主要目标是通过利用多个互连系统的功能来增强可伸缩性、提高性能和增加可靠性。要更深入地探讨实施微服务无服务器架构的所有挑战和复杂性,我们鼓励读者参考我们专门关于此主题的文章(如何在 Azure 上实现无服务器架构)。

下图展示了分布式架构的一个示例。

如果其中一个组件变得无响应,会发生什么?

我可能会显得重复,但我再说一遍:要预料到故障。
Nygard Release It!: Design and Deploy Production-Ready Software

继续前面的例子,如果 Account 服务出现延迟,可能会产生什么后果?

  • 用户尝试访问 StoreFront 应用程序中的某个页面。

  • 随后,应用程序分配一个新的线程来处理此请求,并尝试与包括 Account 服务在内的各种服务建立通信。

  • Account 服务无响应,因此负责处理请求的线程被挂起,拼命等待服务器响应。遗憾的是,在此延迟期间,它无法执行任何其他任务。

  • 当另一位用户访问该网站时,应用程序会分配一个线程来管理新请求。但是,由于 Account 服务无响应,此线程也出现了类似的情况,被挂起。因此,连续的请求受到了服务停机时间的影响。

  • 这个循环会反复进行,直到没有更多线程可用于处理新请求为止。在某些云平台上,可能会部署 StoreFront 应用程序的其他实例作为一种变通方法。但是,它们会遇到类似的问题,导致许多服务器都在等待响应,所有服务器的线程都被阻塞。更糟糕的是,这些服务器会向云平台收费,而这一切都源于一个服务的不可用。

即使 Account 服务不是主要诱因,也可能出现此类问题,例如 Account 数据库无响应。

每次 Account 服务需要从该数据库获取信息时,它都会分配一个线程,而该线程又会因数据库故障而被挂起。因此,Account 服务中的所有线程都会被占用,等待响应,从而导致前面提到的 Account 服务开始挂起的场景。

这些例子说明了连锁反应和级联故障,即一个层的中断会引发调用层中相应的 au.

一个明显的例子是数据库故障。如果整个数据库集群宕机,那么调用该数据库的任何应用程序都会遇到某种问题。接下来发生什么取决于调用者是如何编写的。如果调用者处理不当,那么调用者也会开始失败,从而导致级联故障。(就像我们把树倒着画,根朝向天空一样,我们的问题会通过各层向上级联。)
Nygard Release It!: Design and Deploy Production-Ready Software

具体问题是什么?

在我们的场景中,问题出在某个组件出现故障时。但是,从根本上说,这种情况发生在我们需要访问平台或服务器场内的另一个资源时。当多个服务器参与该过程时,正如分布式架构中常见的,遇到并发症的风险会增加。这些潜在的故障点被称为**集成点**:集成点是两台计算机之间的无数连接。

  • 集成点可以包括 Web 应用程序与服务之间的连接。
  • 它可以包括服务与数据库之间的连接。
  • 它可以包括应用程序内部发生的任何 HTTP 请求。

因此,分布式系统在通信、数据一致性、容错能力和整体系统复杂性方面带来了独特的挑战。有效的設計和實施對於利用分佈式的優勢並減輕相關的潛在挑戰至關重要。

幸运的是,有一些成熟的模式和最佳实践可以缓解此类问题。可以使用常见的数据结构来规避挑战,更有利的是存在一个所有内容都已预先实现的库,从而节省了手动实现的需要。下一篇文章将深入介绍该库。

安装环境

部署有缺陷的服务

  • 创建一个新的解决方案,并在其中添加一个名为 EOCS.Polly.FaultyService 的新 Azure Function 项目。

  • 例如,添加一个名为 _FaultyService.cs_ 的新类,并将以下代码添加到其中。

    public class FaultyService
    {
        public FaultyService()
        {
        }
    
        [FunctionName(nameof(GetNeverResponding))]
        public async Task<IActionResult> GetNeverResponding
         ([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)] 
           HttpRequest req, 
           ILogger log)
        {
            while (true)
            {
    
            }
    
            return new OkResult();
        }
    }

信息

确实,这段代码缺乏显着的复杂性,其唯一值得注意之处在于请求从未结束。

  • 添加一个名为 _StartUp.cs_ 的新类,并将以下代码添加到其中。
    [assembly: WebJobsStartup(typeof(StartUp))]
    namespace EOCS.Polly.FaultyService
    {
        public class StartUp : FunctionsStartup
        {
            public override void Configure(IFunctionsHostBuilder builder)
            {            
            }
        }
    }
  • 运行程序并记下 URL。

部署调用服务

  • 在同一个解决方案中,添加一个名为 EOCS.Polly.CallingService 的新 Azure Function 项目。

  • 例如,添加一个名为 _CallingService.cs_ 的新类,并将以下代码添加到其中。

    public class CallingService
    {
        public CallingService()
        {
        }
    
        [FunctionName(nameof(GetAccountById))]
        public async Task<IActionResult> GetAccountById
        ([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)] 
          HttpRequest req, 
          ILogger log)
        {
            var client = new HttpClient();
            var response = await client.GetAsync
                           ("https://:7271/api/GetNeverResponding");
    
            return new OkResult();
        }
    }

信息 1

在实际场景中,URL 应该存储在配置文件中,并通过依赖注入获取。

信息 2

上面的代码只是启动对我们的有缺陷服务的 HTTP 请求。目标是检查服务无响应时的情况。

  • 添加一个名为 _StartUp.cs_ 的新类,并将以下代码添加到其中。
    [assembly: WebJobsStartup(typeof(StartUp))]
    namespace EOCS.Polly.CallingService
    {
        public class StartUp : FunctionsStartup
        {
            public override void Configure(IFunctionsHostBuilder builder)
            {            
            }
        }
    }

最终配置应与下图相似。

重要

别忘了正确配置启动项目。

运行应用程序

我们现在将通过 Fiddler(或 Postman)执行一个 GET 请求来测试我们的应用程序,并观察随之而来的结果。

  • 启动应用程序。

  • 执行以下请求。

显然,此请求似乎没有响应。

发生的情况与我们前面所描述的完全一致:由于下游服务出现延迟,调用线程被挂起。如何解决这种情况?Polly 来帮忙!

什么是 Polly?

Polly 是一个弹性与瞬态故障处理库,旨在通过提供策略来定义和实现故障处理逻辑,帮助开发人员处理应用程序中的故障。Polly 允许开发人员为各种场景定义**策略**,例如处理瞬态故障、重试、超时和断路器(后续文章将详细介绍)。

Polly 在微服务方面尤其有益,现在我们将探讨其实际实现。

之前场景中的问题是什么?

问题出在一个从未响应的服务上。由于调用代码没有强制执行超时,线程被挂起。游戏结束。

超时是一种简单的机制,它允许您在认为不会收到答案时停止等待。(...)至关重要的是,任何阻塞线程的资源池都必须有超时机制,以确保调用线程最终能够解除阻塞,无论资源是否可用。
Nygard Release It!: Design and Deploy Production-Ready Software

安装 Polly

  • 将 Polly Nuget 包添加到 EOCS.Polly.CallingService 项目。

重要

在本系列中,我们使用 Polly 8 版本。

配置 Polly

有多种方法可以解决网络问题或集成点故障带来的挑战。在 Polly 中,这些方法被称为**弹性策略**(以前称为策略)。这些策略也可以组合使用;例如,我们可以实现一个超时策略**并在**失败时回退到默认值。这引入了**弹性管道**的概念,它是管理请求的多个策略的组合。

信息

更多详细信息可在文档(here)中找到。

  • 编辑 GetAccountById 方法中的代码。
    [FunctionName(nameof(GetAccountById))]
    public async Task<IActionResult> GetAccountById
      ([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)] HttpRequest req, 
        ILogger log)
    {
        var pipeline = new ResiliencePipelineBuilder().AddTimeout
                       (TimeSpan.FromSeconds(5)).Build();
                    
        // Execute the pipeline asynchronously
        var response = await pipeline.ExecuteAsync(async token => 
        {
            var client = new HttpClient();
            return await client.GetAsync
            ("https://:7271/api/GetNeverResponding", token).ConfigureAwait(false);
        });
    
        return response.IsSuccessStatusCode ? new OkResult() : new BadRequestResult();
    }

通过 Fiddler 执行请求后,我们现在收到一个响应。

信息 1

现在,我们确实收到了响应,即使它可能是 500 错误。重要的是要注意,Polly 并没有神奇地解决有缺陷的服务问题,而是阻止了该问题传播到整个系统:**这就是所谓的弹性**。在这种情况下,响应可能不是预期的,但至关重要的是,调用线程不会被阻塞。需要承认的是,我们仍然有责任及时解决当前存在的问题。

信息 2

在实际场景中,我们很可能宁愿使用依赖注入,而不是为每个请求创建管道。

处理故障

在之前的代码中,我们接受返回 500 错误,但这样的错误非常通用,无法提供太多关于根本问题的见解。为开发人员提供额外信息会更有益,特别是当我们确定发生了超时时。

  • 编辑 GetAccountById 方法中的代码。
    [FunctionName(nameof(GetAccountById))]
    public async Task<IActionResult> GetAccountById
        ([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)] 
          HttpRequest req, 
          ILogger log)
    {
        var pipeline = new ResiliencePipelineBuilder().AddTimeout
                       (TimeSpan.FromSeconds(5)).Build();
    
        try
        {
            // Execute the pipeline asynchronously
            var response = await pipeline.ExecuteAsync(async token =>
            {
                var client = new HttpClient();
                return await client.GetAsync
                       ("https://:7271/api/GetNeverResponding", token);
            });
    
            return new OkResult();
        }
        catch (TimeoutRejectedException)
        {
            return new BadRequestObjectResult
                   ("A timeout has occurred while processing the request.");
        }
    }

通过 Fiddler 执行请求后,我们现在收到一个 400 错误。

因此,我们探索了在一个非常基本的情况下安装和配置 Polly。但是,这种情况有点琐碎(严格来说,对于简单的超时不需要使用 Polly)。接下来,我们将把重点放在检查更复杂的场景。

什么是瞬态故障?

瞬态故障是系统中发生的临时且通常短暂的错误或问题,但它们并不表示永久性问题。这些故障通常是瞬态的,这意味着它们可能在短暂延迟后或在后续尝试后自行解决。瞬态故障的常见示例包括临时网络问题、间歇性服务不可用或短暂的资源限制。

在分布式系统中,其中各个组件通过网络进行通信,瞬态故障可能更为普遍。这些故障通常是不可预测的,并且可能由于网络拥塞、临时服务器不可用或资源使用率短暂激增等因素而发生。

什么是重试策略?

重试策略是一种用于自动重试最初失败的操作的机制。这种方法包括对同一操作进行多次连续尝试,期望后续尝试可能会成功,特别是在故障是瞬态的或由于间歇性问题的情况下。重试策略旨在通过提供一种机制来从瞬态故障中恢复,而无需手动干预,从而提高应用程序的弹性和可靠性。

在 Polly 的上下文中,重试策略涉及定义一个策略,该策略指定应发生重试的条件、最大重试次数以及连续重试尝试之间的时间间隔。这在处理瞬态故障、网络故障或其他可能导致操作暂时失败的间歇性问题方面特别有用。

模拟瞬态错误

  • 编辑 FaultyService 类。
    public class FaultyService
    {
        // ...
    
        [FunctionName(nameof(GetWithTransientFailures))]
        public async Task<IActionResult> GetWithTransientFailures
        ([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)] 
          HttpRequest req, 
          ILogger log)
        {
            var counter = CounterSingleton.Instance.Increment();
    
            if (counter % 3 == 1) return new InternalServerErrorResult();
            else return new OkResult();
        }
    }
    
    public class CounterSingleton
    {
        private static CounterSingleton _instance;
        private int _index = 0;
    
        private CounterSingleton() { }
    
        public static CounterSingleton Instance
        {
            get
            {
                if (_instance == null) _instance = new CounterSingleton();
                return _instance;
            }
        }
    
        public int Increment()
        {
            _index++;
            return _index;
        }
    }

    在这里,我们通过在三次尝试中故意触发一次错误请求来模拟瞬态故障。此特定操作使用作为单例实现的计数器执行。

  • 编辑 CallingService 类以调用此方法。
    public class CallingService
    {
        public CallingService()
        {
        }
    
        [FunctionName(nameof(GetAccountById02))]
        public async Task<IActionResult> GetAccountById02([HttpTrigger
        (AuthorizationLevel.Anonymous, "get", Route = null)] HttpRequest req, ILogger log)
        {
            var client = new HttpClient();
            var response = await client.GetAsync
            ("https://:7271/api/GetWithTransientFailures").ConfigureAwait(false);
    
            return response.IsSuccessStatusCode ? new OkResult() : 
                                                  new InternalServerErrorResult();
        }
    }

此请求在出错时返回 500 错误,在一切正常进行时返回典型的 200 错误。

通过 Fiddler 执行此请求后,我们可以实际观察到每三次尝试中发生一次错误。

信息

在我们这个特定的案例中,我们的代码是确定性的,这意味着错误不是真正瞬态的。在现实场景中,此类错误通常会随机出现。但是,我们出于说明目的使用了此模拟。

使用 Polly 实现重试策略

等待和重试策略通常用于处理瞬态错误,Polly 提供了一种方便的方法来轻松实现此类策略。

  • 编辑 CallingService 类以实现重试策略。
    [FunctionName(nameof(GetAccountById02))]
    public async Task<IActionResult> GetAccountById02([HttpTrigger
     (AuthorizationLevel.Anonymous, "get", Route = null)] HttpRequest req, ILogger log)
    {
        var options = new RetryStrategyOptions<HttpResponseMessage>()
        {
            Delay = TimeSpan.Zero,
            MaxRetryAttempts = 3,
            ShouldHandle = new PredicateBuilder<HttpResponseMessage>().HandleResult
            (response => response.StatusCode == HttpStatusCode.InternalServerError),
        };
    
        var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>().AddRetry
                       (options).Build();
    
        // Execute the pipeline asynchronously
        var response = await pipeline.ExecuteAsync(async token =>
        {
            var client = new HttpClient();
            return await client.GetAsync
            ("https://:7271/api/GetWithTransientFailures", token);
        });
    
        return response.IsSuccessStatusCode ? new OkResult() : new BadRequestResult();
    }

在这里,我们实现了一个最多重试 3 次的重试策略。这意味着如果发生瞬态故障,调用服务不会立即返回错误;相反,它将至少重试处理请求 3 次。

通过 Fiddler 执行此请求后,我们现在可以看到这些瞬态故障得到了有效处理。

信息

还有其他配置选项可用于自定义重试策略,包括设置最大尝试次数和两次重试尝试之间的延迟。有关更全面的详细信息,请参阅文档。

重试策略确实有一个缺点:如果远程服务完全宕机,我们仍然会尝试访问它,从而消耗调用函数中的线程。一个具有弹性的策略必须考虑这一点并实现断路器。我们将探讨如何使用 Polly 快速实现它。但是,为了避免使本文过于冗长,有兴趣了解此实现的读者可以在此处找到续篇:here

历史

  • 2024 年 1 月 22 日:初始版本
© . All rights reserved.