ASP.NET Core-限流(Rate Limiting)
一、应用场景
微服务架构中,限流功能一般由网关提供。而对于很多非微服务化的系统,可能并没有网关[无论是因为成本还是复杂度],在这种场景下,为了实现限流,.NET 7中提供了限流中间件 Rate Liniting。
二、实现
首先,SDK版本 >= 7。
然后添加代码注册。
微软为我们提供了4中常用的限流算法:
- 固定窗口限流器【FixedWindowLimiter】
- 滑动窗口限流器【SlidingWindowLimiter】
- 令牌桶限流器【TokenBucketLimiter】
- 并发限流器【ConcurrencyLimiter】
通常我们会注册一个命名限流策略,并在该策略内指定限流算法,以及其他限流逻辑。需要注意的是,UseRateLimiter的位置,若限流行为作用于特定路由,这限流中间件必须放到UseRouting之后。
三、详情
3.1、固定窗口限流器
固定窗口限流器比较简单,限流方式如下:
【原理】:使用固定的时间长度来限制请求数量。如固定窗口长度为10s,则每10s就会切换(销毁并创建)一个新窗口,在每个单独的窗口内,限制请求数量。
【特点】:
优点:实现简单,内存占用低。
缺点:
1、当窗口QPS到达阈值,流量会被瞬间切断,不能平滑处理突发流量(实际应用中理想效果是让流量平滑的进入系统)
2、窗口切换时可能出现2倍QPS。如窗口大小为1s,阈值为100,窗口1在后500ms内处理100个请求,窗口2在前500ms内也处理100个请求,这样就导致实际在1s内处理了200个请求。
#region 注册限流中间件 builder.Services.AddRateLimiter(options => { //1、固定窗口限流策略 //配置说明:该固定窗口60s时间内,可以最多有100+50=150个请求,100个会被处理,50个会被排队,其他则会在一定时间后拒绝返回 RejectionStatusCode options.AddFixedWindowLimiter(policyName:"fixed",fixedOptions => { fixedOptions.PermitLimit = 100;//每个窗口时间范围内,允许100个请求被处理 fixedOptions.Window = TimeSpan.FromSeconds(60); //窗口大小。即窗口时间长度60s。必须>TimeSpan.Zero fixedOptions.QueueLimit = 50;//窗口阈值。即每个窗口时间范围内,最多允许的请求个数。该值必须>0。当窗口请求达到最大值,后续请求会进入排队。该值用于设置对垒大小(即允许几个请求在排队队列等待) fixedOptions.QueueProcessingOrder = System.Threading.RateLimiting.QueueProcessingOrder.OldestFirst;//排队请求的处理顺序。这里设置为有限处理先来的请求 fixedOptions.AutoReplenishment = true;//开启新窗口时是否自动重置请求限制,默认true。如果是false,则需要手动调佣 FixedWindowRateLimiter.TryReplenish来重置 }); }); #endregion //使用限流器 app.UseRateLimiter();
3.2、滑动窗口限流器
滑动窗口限流器是固定窗口限流器的升级版。
【原理】:在固定窗口限流器的基础上,它将每个窗口划分为多个段,每经过一个段的时间间隔(=窗口时间/窗口段的个数),窗口就会向后滑动一段,所以称为滑动窗口(窗口大小仍是固定的)。当窗口滑动后,会“吃进”一个段(称为当前段),并“吐出”一个段(称为过期端),过期段会被回收,回收的请求数可以用于当前段。
【特点】:
优点:按段滑动处理,相对于固定窗口,可以对流量进行更精准的控制,更平滑的处理突发流量,并且段划分的越多,移动更平滑。
缺点:对时间精度要求高,比固定窗口实现复杂,占用内存更高。
//2、滑动窗口限流则略 //配置说明:窗口时间长度为30s,每个窗口内,最多允许100个请求,窗口段数3,每个段的时间间隔为30/3=10s,即窗口每10s滑动一段。 options.AddSlidingWindowLimiter(policyName:"sliding", slidingOptions => { slidingOptions.PermitLimit = 100; slidingOptions.Window = TimeSpan.FromSeconds(30); slidingOptions.QueueLimit = 2; slidingOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; slidingOptions.AutoReplenishment = true;//开启新窗口时是否自动重置请求限制,默认true slidingOptions.SegmentsPerWindow = 3; });
【理解】:滑动窗口就是为了解决固定窗口2n的问题。
计算公式:估计数 = 前一窗口技术 * (1 - 当前窗口经过时间/单位时间)+ 当前窗口计数
举个例子:窗口为每分钟最大处理10个请求。这实际计算后数据为:估计数=9 * (1-25%) + 5 = 11.75 > 10,则最后的一次请求会被阻止。虽然在单个窗口内,请求数都没有超过最大限制,但是超过了加权计算结果。
另外,滑动窗口的段,可以理解为每次加权计算移动的时间距离。如果最近的2次请求相距两个时间窗口,则可以认为前一窗口计数为零,重新开始计数。
3.3、令牌桶限流器
令牌桶限流器是一种限制数据平均传输速率的限流算法。
【原理】:想象有一个桶,每个固定时间段会向桶内放入固定数量的令牌(token),当桶内令牌装满时,新的令牌将会被丢弃。当请求流量进入时,会先从桶内拿 1 个令牌,拿到了则该请求会被处理,没拿到则会在队列中等待,若队列已满,则会被限流拒绝处理。
【特点】:可以限制数据的平均传输速率,还可以一次性耗尽令牌应对突发流量,并平滑地处理后续流量,是一种通用的算法。
//策略说明:桶最多装4个令牌,每10秒发放一次令牌,每次发放2个令牌,所以在一个发放周期,最多可以处理4个请求,至少可以处理2个请求。 options.AddTokenBucketLimiter(policyName:"token_bucket", tokenBucketOptions => { tokenBucketOptions.TokenLimit = 4;//桶最多可以装的令牌数,发放的多余令牌会被丢弃 tokenBucketOptions.ReplenishmentPeriod = TimeSpan.FromSeconds(10);//令牌发放周期 tokenBucketOptions.TokensPerPeriod = 2;//每个周期发放令牌数 tokenBucketOptions.QueueLimit = 2;//当桶内的令牌全部被拿完(token=0)时,后续请求会进入排队 tokenBucketOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; tokenBucketOptions.AutoReplenishment = true;//进入新令牌发放周期,是否自动发放令牌。如果设置为false,则需要手动调用 TokenBucketRateLimiter.TryReplenish来发放 });
3.4、并发限流器
并发限流器不是限制一段时间内的最大请求数,而是限制并发数。
【原理】:限制同一时刻并发请求的数量。
【特点】:可以充分利用服务器的性能,当出现突发流量时,服务器负载可能会持续过高。
//策略说明:最大并发请求4,超过最大并发请求,则后续最多2个请求进入排队队列。 options.AddConcurrencyLimiter(policyName:"concurrency", concurrencyOptions => { concurrencyOptions.PermitLimit = 4;//最大并发请求数 concurrencyOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; concurrencyOptions.QueueLimit = 2;//当并发请求数达到最大,后续请求进入排队,该参数用于配置队列大小 });
四、扩展
4.1、全局限流器
通过GlobalLimiter
,我们可以设置全局限流器,更准确的说法是全局分区限流器,该限流器会应用于所有请求。执行顺序为先执行全局限流器,再执行特定于路由终结点的限流器(如果存在的话)。
需要注意的是,相对于上面注册的限流策略来说,GlobalLimiter
已经是一个限流器实例了,所以需要分配给他一个分区限流器实例,通过PartitionedRateLimiter.Create
来创建。
builder.Services.AddRateLimiter(limiterOptions => { limiterOptions.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, IPAddress>(context => { IPAddress? remoteIpAddress = context.Connection.RemoteIpAddress; // 针对非回环地址限流 if (!IPAddress.IsLoopback(remoteIpAddress!)) { return RateLimitPartition.GetTokenBucketLimiter (remoteIpAddress!, _ => new TokenBucketRateLimiterOptions { TokenLimit = 4, QueueProcessingOrder = QueueProcessingOrder.OldestFirst, QueueLimit = 2, ReplenishmentPeriod = TimeSpan.FromSeconds(10), TokensPerPeriod = 10, AutoReplenishment = true }); } // 若为回环地址,则不限流 return RateLimitPartition.GetNoLimiter(IPAddress.Loopback); }); });
4.2、链式组合限流器
它并不是一个新类型的限流器,而是可以将我们上面提到的分区限流器进行组合而得到一个新的分区限流器。
例如我可以将包含固定窗口限流逻辑的分区限流器和将包含并发限流逻辑的分区限流器组合进行组合,那么应用该限流器的请求就会先被固定窗口限流器处理,再被并发限流器处理,任意一个被限流,就会被拒绝。
var chainedLimiter = PartitionedRateLimiter.CreateChained( PartitionedRateLimiter.Create<HttpContext, string>(httpContext => { var userAgent = httpContext.Request.Headers.UserAgent.ToString(); return RateLimitPartition.GetFixedWindowLimiter (userAgent, _ => new FixedWindowRateLimiterOptions { AutoReplenishment = true, PermitLimit = 4, Window = TimeSpan.FromSeconds(2) }); }), PartitionedRateLimiter.Create<HttpContext, string>(httpContext => { var userAgent = httpContext.Request.Headers.UserAgent.ToString(); return RateLimitPartition.GetConcurrencyLimiter (userAgent, _ => new ConcurrencyLimiterOptions { PermitLimit = 4, QueueProcessingOrder = QueueProcessingOrder.OldestFirst, QueueLimit = 2 }); })
目前链式组合的限流器只能用于全局限流器,而不能用于终结点限流器。
另外,响应码也可以被修改。具体参考代码。
4.3、自定义限流策略
需要认识到:
- 上述使用
AddXXXLimiter
添加的限流策略,内部实际上调用了AddPolicy。
- 述使用
AddXXXLimiter
添加的限流策略,每种策略只有一个分区,即使用了该限流策略的路由共享一个分区。例如通过AddFixedWindowLimiter
添加了限流策略“fixed”,窗口阈值为 10,并有 10 个路由使用了该策略,那么在一个窗口内,这 10 个路由总的请求数达到 10,那这 10 个路由后续的请求都会被限流。
- 上述使用
下面我们就借助AddPolicy
,分别使用两种方式添加一个自定义策略“my_policy”:一个用户一个分区,匿名用户共享一个分区。
4.3.1、通过委托创建自定义限流策略
builder.Services.AddRateLimiter(limiterOptions => { limiterOptions.AddPolicy(policyName: "my_policy", httpcontext => { var userId = "anonymous user"; if (httpcontext.User.Identity?.IsAuthenticated is true) { userId = httpcontext.User.Claims.First(c => c.Type == "id").Value; } return RateLimitPartition.GetFixedWindowLimiter(partitionKey: userId, _ => new FixedWindowRateLimiterOptions { PermitLimit = 3, Window = TimeSpan.FromSeconds(60), QueueProcessingOrder = QueueProcessingOrder.OldestFirst, QueueLimit = 0 }); }); });
4.3.2、通过IRateLimiterPolicy创建自定义限流策略
public interface IRateLimiterPolicy<TPartitionKey> { // 若不为空,则执行它(不会执行全局的),如果它为空,则执行全局的 Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected { get; } // 获取限流分区 RateLimitPartition<TPartitionKey> GetPartition(HttpContext httpContext); } public class MyRateLimiterPolicy : IRateLimiterPolicy<string> { // 可以通过依赖注入参数 public MyRateLimiterPolicy(ILogger<MyRateLimiterPolicy> logger) { // 可以设置自己的限流拒绝回调逻辑,而不使用上面全局设置的 limiterOptions.OnRejected OnRejected = (ctx, token) => { ctx.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; logger.LogWarning($"Request rejected by {nameof(MyRateLimiterPolicy)}"); return ValueTask.CompletedTask; }; } public Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected { get; } public RateLimitPartition<string> GetPartition(HttpContext httpContext) { var userId = "anonymous user"; if (httpContext.User.Identity?.IsAuthenticated is true) { userId = httpContext.User.Claims.First(c => c.Type == "id").Value; } return RateLimitPartition.GetFixedWindowLimiter(partitionKey: userId, _ => new FixedWindowRateLimiterOptions { PermitLimit = 3, Window = TimeSpan.FromSeconds(60), QueueProcessingOrder = QueueProcessingOrder.OldestFirst, QueueLimit = 0 }); } } // 记得注册它 builder.Services.AddRateLimiter(limiterOptions => { limiterOptions.AddPolicy<string, MyRateLimiterPolicy>(policyName: "my_policy"); }
五、应用限流策略
5.1、RequireRateLimiting & DisableRateLimiting
可以一次性为所有controller应用限流策略
app.MapControllers().RequireRateLimiting("fixed");
实质上,RequireRateLimiting
和DisableRateLimiting
是通过向终结点元数据中EnableRateLimiting
和DisableRateLimiting
两个特性来实现的。
public static class RateLimiterEndpointConventionBuilderExtensions { public static TBuilder RequireRateLimiting<TBuilder>(this TBuilder builder, string policyName) where TBuilder : IEndpointConventionBuilder { builder.Add(endpointBuilder => endpointBuilder.Metadata.Add(new EnableRateLimitingAttribute(policyName))); return builder; } public static TBuilder RequireRateLimiting<TBuilder, TPartitionKey>(this TBuilder builder, IRateLimiterPolicy<TPartitionKey> policy) where TBuilder : IEndpointConventionBuilder { builder.Add(endpointBuilder => { endpointBuilder.Metadata.Add(new EnableRateLimitingAttribute(new DefaultRateLimiterPolicy( RateLimiterOptions.ConvertPartitioner<TPartitionKey>(null, policy.GetPartition), policy.OnRejected))); }); return builder; } public static TBuilder DisableRateLimiting<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder { builder.Add(endpointBuilder => endpointBuilder.Metadata.Add(DisableRateLimitingAttribute.Instance)); return builder; } }
5.2、EnableRateLimitingAttribute & DisableRateLimitingAttribute
在Controller
层面,我们可以方便的使用特性来标注使用或禁用限流策略。这两个特性可以标注在Controller
类上,也可以标注在类的方法上。
但需要注意的时,如果前面使用了RequireRateLimiting
或DisableRateLimiting
扩展方法,由于它们在元数据中添加特性比直接使用特性标注要晚,所以它们的优先级很高,会覆盖掉这里使用的策略。建议不要针对所有 Controller 使用RequireRateLimiting
或DisableRateLimiting
。
[EnableRateLimiting("fixed")] // 针对整个 Controller 使用限流策略 fixed public class WeatherForecastController : ControllerBase { // 会使用 Controller 类上标注的 fixed 限流策略 [HttpGet(Name = "GetWeatherForecast")] public string Get() => "Get"; [HttpGet("Hello")] [EnableRateLimiting("my_policy")] // 会使用 my_policy 限流策略,而不会使用 fixed public string Hello() => "Hello"; [HttpGet("disable")] [DisableRateLimiting] // 禁用任何限流策略 public string Disable() => "Disable"; }
六、参考文章
1、理解ASP.NET Core - 限流(Rate Limiting) - xiaoxiaotank - 博客园 (cnblogs.com)
七、Demo下载