承载ASP.NET应用的服务器资源总是有限的,短时间内涌入过多的请求可能会瞬间耗尽可用资源并导致宕机。为了解决这个问题,我们需要在服务端设置一个阀门将并发处理的请求数量限制在一个可控的范围,即使会导致请求的延迟响应,在极端的情况会还不得不放弃一些请求。ASP.NET应用的流量限制是通过ConcurrencyLimiterMiddleware中间件实现的。(本文提供的示例演示已经同步到《ASP.NET Core 6框架揭秘-实例演示版》)
[S2601]设置并发和等待请求阈值 (源代码)
[S2602]基于队列的限流策略(源代码)
[S2603]基于栈的限流策略(源代码)
[S2604]处理被拒绝的请求(源代码)
[S2601]设置并发和等待请求阈值
由于各种Web服务器、反向代理和负载均衡器都提供了限流的能力,我们很少会在应用层面进行流量控制。ConcurrencyLimiterMiddleware中间件由“Microsoft.AspNetCore.ConcurrencyLimiter”这个NuGet包提供,ASP.NET应用采用的SDK(“Microsoft.NET.Sdk.Web”)并没有将该包作为默认的引用,所以我们需要手工添加该NuGet包的引用。
当请求并发量超过设定的阈值,ConcurrencyLimiterMiddleware中间件会将请求放到等待队列中,整个限流工作都是围绕这个这个队列进行的,采用怎样的策略管理这个等待队列是整个限流模型的核心。不论采用何种策略,我们都需要设置两个阈值,一个是当前允许的最大并发请求量,另一个是等待队列的最大容量。如代码片段所示,我们通过调用IServiceCollection接口的AddQueuePolicy扩展方法注册了一个基于队列(“Queue”)的策略,并将上述的两个阈值设置为2。
using App;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Services
.AddHostedService<ConsumerHostedService>()
.AddQueuePolicy(options =>
{
options.MaxConcurrentRequests = 2;
options.RequestQueueLimit = 2;
});
var app = builder.Build();
app
.UseConcurrencyLimiter()
.Run(httpContext => Task.Delay(1000).ContinueWith(_ => httpContext.Response.StatusCode = 200));
app.Run();
ConcurrencyLimiterMiddleware中间件是通过调用IApplicationBuilder的UseConcurrencyLimiter扩展方法进行注册的。后续通过调用Run扩展方法提供的RequestDelegate委托模拟了一秒钟的处理耗时。我们演示的程序还注册了一个ConsumerHostedService类型的承载服务来模拟消费API的客户端。如下面的代码片段所示,ConsumerHostedService利用注入的IConfiguration对象来提供并发量配置。当此承载服务启动之后,它会根据配置创建相应数量的并发任务持续地对我们的应用发起请求。public class ConsumerHostedService : BackgroundService { private readonly HttpClient[] _httpClients; public ConsumerHostedService(IConfiguration configuration) { var concurrency = configuration.GetValue<int>("Concurrency"); _httpClients = Enumerable .Range(1, concurrency) .Select(_ => new HttpClient()) .ToArray(); } protected override Task ExecuteAsync(CancellationToken stoppingToken) { var tasks = _httpClients.Select(async client => { while (true) { var start = DateTimeOffset.UtcNow; var response = await client.GetAsync("http://localhost:5000"); var duration = DateTimeOffset.UtcNow - start; var status = $"{(int)response.StatusCode},{response.StatusCode}"; Console.WriteLine($"{status} [{(int)duration.TotalSeconds}s]"); if (!response.IsSuccessStatusCode) { await Task.Delay(1000); } } }); return Task.WhenAll(tasks); } public override Task StopAsync(CancellationToken cancellationToken) { Array.ForEach(_httpClients, it => it.Dispose()); return Task.CompletedTask; } }
对于发送的每个请求,ConsumerHostedService都会在控制台上记录下响应的状态和耗时。为了避免控制台“刷屏”,我们在接收到错误响应后模拟一秒钟的等待。由于并发量是由配置系统提供的,所以我们可以利用命令行参数(“Concurrency”)的方式来对并发量进行设置。如图1所示,我们以命令行的方式启动了程序,并通过命令行参数将并发量设置为2。由于并发量并没有超出阈值,所以每个请求均得到正常的响应。
图1 并发量未超出阈值
由于并发量的阈值和等待队列的容量均设置为2,从外部来看,我们的演示程序所能承受的最大并发量为4。所以当我们以此并发量启动程序之后,并发的请求能够接收到成功的响应,但是除了前两个请求能够得到及时处理之外,后续请求都会在等待队列中呆上一段时间,所以整个耗时会延长。如果将并发量提升到5,这显然超出了服务端的极限,所以部分请求会得到状态码为“503, Service Unavailable”的响应。
图2 并发量超出阈值
ASP.NET应用的并发处理的请求量可以通过dotnet-counters工具提供的性能计数器进行查看。具体的性能计数器名称为“Microsoft.AspNetCore.Hosting”,我们现在通过这种方式来看看应用程序真正的并发处理指标是否和我们的预期一致。我们还是以并发量为5启动演示程序,然后以图26-3所示的方式执行“dotnet-coutners ps”命令查看演示程序的进程,并针对进程ID执行“dotnet-counters monitor”命令查看名为“Microsoft.AspNetCore.Hosting”的性能指标。
图3 使用dotnet-counters monitor查看并发量
如图3所示,dotnet-counters显示的并发请求为4,这和我们的设置是吻合的,因为对于应用的中间件管道来说,并发处理的请求包含ConcurrencyLimiterMiddleware中间件的等待队列的两个和后续中间件真正处理的两个。我们还看到了每秒处理的请求数量为3,并有约1/3的请求失败率,这些指标和我们的设置都是吻合的。
[S2602]基于队列的限流策略
通过前面的示例演示我们知道,当ConcurrencyLimiterMiddleware中间件维护的等待队列被填满并且后续中间件管道正在“满负荷运行(并发处理的请求达到设定的阈值)”的情况下,如果此时接收到一个新的请求,它只能放弃某个待处理的请求。具体来说,它具有两种选择,一种是放弃刚刚接收的请求,另一种就是将等待队列中的某个请求扔掉,其位置由新接收的请求占据。
前面演示实例采用的等待队列处理策略是通过调用IServiceCollection接口的AddQueuePolicy扩展方法注册的,这样一种基于“队列”的策略。我们知道队列的特点就是先进先出(FIFO),讲究“先来后到”,如果采用这种策略就会放弃刚刚接收到的请求。我们可以通过简单的实例证实这一点。如下面的演示程序所示,我们在ConcurrencyLimiterMiddleware中间件之前注册了一个通过DiagnosticMiddleware方法表示的中间件,它会对每个请求按照它接收到的时间顺序进行编号,我们利用它打印出每个请求对应的响应状态就知道ConcurrencyLimiterMiddleware中间件最终放弃的是那个请求了。
using App; var requestId = 1; var @lock = new object(); var builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); builder.Services .AddHostedService<ConsumerHostedService>() .AddQueuePolicy(options => { options.MaxConcurrentRequests = 2; options.RequestQueueLimit = 2; }); var app = builder.Build(); app .Use(InstrumentAsync) .UseConcurrencyLimiter() .Run(httpContext => Task.Delay(1000).ContinueWith(_ => httpContext.Response.StatusCode = 200)); await app.StartAsync(); var tasks = Enumerable.Range(1, 5) .Select(_ => new HttpClient().GetAsync("http://localhost:5000")); await Task.WhenAll(tasks); Console.Read(); async Task InstrumentAsync(HttpContext httpContext, RequestDelegate next) { Task task; int id; lock (@lock!) { id = requestId++; task = next(httpContext); } await task; Console.WriteLine($"Request {id}: {httpContext.Response.StatusCode}"); }
我们在 IServiceCollection接口的AddQueuePolicy扩展方法中提供的设置不变(最大并发量和等待队列大小都是2)。在应用启动之后,我们同时发送了5个请求,此时控制台上会呈现出如图4所示的输出结果,可以看出ConcurrencyLimiterMiddleware中间件在接收到第5个请求并不得不作出取舍的时候,它放弃的就是当前接收到的请求。
图4 基于队列的处理策略
[S2603]基于栈的限流策略
当ConcurrencyLimiterMiddleware中间件在接收到某个请求并需要决定放弃某个待处理请求时,它还可以采用另一种基于“栈”的策略。如果采用这种策略,它会先保全当前接收到的请求,并用它替换掉存储在等待队列时间最长的那个。也就是说它不再讲究先来后到,而主张后来居上。对于前面演示的程序来说,我们只需要按照如下的方式将针对AddQueuePolicy扩展方法的调用替换成AddStackPolicy方法就可以切换到这种策略。
... var builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); builder.Services .AddHostedService<ConsumerHostedService>() .AddStackPolicy(options => { options.MaxConcurrentRequests = 2; options.RequestQueueLimit = 2; }); var app = builder.Build(); ...
重新启动改动后的演示程序,我们将在控制台上得到如图5所示的输出结果。可以看出这次ConcurrencyLimiterMiddleware中间件在接收到第5个请求并不得不做出取舍的时候,它放弃的就是最先存储到等待队列的第3个请求。
图5 基于栈处理策略
[S2604]处理被拒绝的请求
从ConcurrencyLimiterMiddleware中间件的实现可以看出,在默认情况下因超出限流阈值而被拒绝处理的请求来说,应用最终会给与一个状态码为“503 Service Available”的响应。如果我们对这个默认的处理方式不满意,可以通过对配置选项ConcurrencyLimiterOptions的设置来提供一个自定义的处理器。举个典型的场景,集群部署的多台机器可能负载不均,所以如果将被某台机器拒绝的请求分发给另一台机器是可能被正常处理的。为了确保请求能够尽可能地被处理,我们可以针对相同的URL发起一个客户端重定向,具体的实现体现在如下所示的演示程序中。
using Microsoft.AspNetCore.ConcurrencyLimiter; using Microsoft.AspNetCore.Http.Extensions; var builder = WebApplication.CreateBuilder(args); builder.Logging.ClearProviders(); builder.Services .Configure<ConcurrencyLimiterOptions>(options => options.OnRejected = RejectAsync) .AddStackPolicy(options => { options.MaxConcurrentRequests = 2; options.RequestQueueLimit = 2; }); var app = builder.Build(); app .UseConcurrencyLimiter() .Run(httpContext => Task.Delay(1000).ContinueWith(_ => httpContext.Response.StatusCode = 200)); app.Run(); static Task RejectAsync(HttpContext httpContext) { var request = httpContext.Request; if (!request.Query.ContainsKey("reject")) { var response = httpContext.Response; response.StatusCode = 307; var queryString = request.QueryString.Add("reject", "true"); var newUrl = UriHelper.BuildAbsolute(request.Scheme, request.Host, request.PathBase, request.Path, queryString); response.Headers.Location = newUrl; } return Task.CompletedTask; }
如上面的代码片段所示,我们调用IServiceCollection接口的Configure<TOptions>扩展方法对ConcurrencyLimiterOptions进行了配置。具体来说,我们将RejectAsync方法表示的RequestDelegate委托作为拒绝请求处理器赋值给了ConcurrencyLimiterOptions配置选项的OnRejected属性。在RejectAsync方法中,我们针对当前请求的URL返回了一个状态码为307的临时重定向响应。为了避免重复的重定向操作,我们为重定向地址添加了一个名为“reject”的查询字符串来识别重定向请求。