我们很高兴地宣布,作为.NET 7的一部分,内置速率限制支持。速率限制提供了一种保护资源的方法,以避免你的应用程序不堪重负,并将流量保持在一个安全水平。
什么是速率限制?
速率限制是指限制一个资源的访问量的概念。例如,你知道你的应用程序访问的数据库可以安全地处理每分钟1000个请求,但你不相信它可以处理比这多得多的请求。你可以在你的应用程序中放置一个速率限制器,允许每分钟有1000个请求,并在访问数据库之前拒绝任何更多的请求。因此,速率限制你的数据库,允许你的应用程序处理安全数量的请求,而不可能有来自你的数据库的不良故障。
有多种不同的速率限制算法来控制请求的流量。我们将讨论其中的4种,它们将在.NET 7中提供。
并发限制
并发限制器限制了多少个并发请求可以访问一个资源。如果你的限制是10,那么10个请求可以同时访问一个资源,第11个请求将不被允许。一旦一个请求完成,允许的请求数就会增加到1,当第二个请求完成时,这个数字会增加到2,等等。这是通过处置一个RateLimitLease来完成的,我们稍后会讨论这个问题。
Token bucket限制
Token bucket是一种算法,它的名字来源于描述它的工作方式。想象一下,有一个装满了代币的桶。当一个请求进来的时候,它取走一个令牌并永远保留它。在一段稳定的时间后,有人将预先确定的代币数量添加到桶中,永远不会超过桶所能容纳的数量。如果桶是空的,当一个请求进来时,该请求就会被拒绝访问该资源。
举个更具体的例子,假设水桶可以容纳10个代币,每分钟有2个代币被添加到水桶中。当一个请求进来时,它拿走了一个代币,所以我们还剩下9个,又有3个请求进来,每个都拿走了一个代币,我们还剩下6个代币,一分钟后,我们得到了2个新的代币,使我们有8个。在5分钟内没有请求之后,这个桶将再次拥有所有的10个代币,并且在随后的几分钟内不会再增加任何代币,除非请求占用更多的代币。
固定窗口限制
固定窗口算法使用了窗口的概念,这在下一个算法中也会用到。窗口是我们的限制在移动到下一个窗口之前应用的时间量。在固定窗口的情况下,移动到下一个窗口意味着将限制重新设置到其起点。让我们想象一下,有一个电影院,有一个可以容纳100人的单间,播放的电影是2小时的。当电影开始时,我们让人们开始排队等候2小时后的下一场放映,在我们开始告诉他们改日再来之前,最多允许100人排队。一旦2小时的电影结束,0到100人的队伍可以进入电影院,我们重新开始排队。这与固定窗口算法中的移动窗口是一样的。
滑动窗口限制
滑动窗口算法与固定窗口算法类似,但增加了分段。一个段是一个窗口的一部分,如果我们把之前的2小时窗口分成4个段,我们现在有4个30分钟的段。还有一个当前段的索引,它总是指向一个窗口中最新的段。30分钟内的请求会进入当前段,每30分钟窗口就会滑过一个段。如果在窗口滑过的段期间有任何请求,这些请求现在就会被刷新,我们的限额就会增加这个数量。如果没有任何请求,我们的限额就保持不变。
例如,让我们使用滑动窗口算法,有3个10分钟的片段和100个请求限制。我们的初始状态是3个段的计数都为0,我们当前的段索引指向第3个段。

在最初的10分钟里,我们收到了50个请求,所有这些请求都被追踪到第三段(我们当前的段索引)。一旦10分钟过后,我们将窗口滑动1段,同时将我们当前的段索引移到第4段。第一段中的任何已使用的请求现在都被添加到我们的限制中。由于没有任何请求,我们的限制是50个(因为50个已经在第三段中使用了)。

在接下来的10分钟里,我们又收到了20个请求,所以我们在第三段有50个,在第四段有20个。同样,我们在10分钟过后滑动窗口,所以我们当前的分段索引指向5,并且我们将来自第2段的任何请求添加到我们的限制中。

10分钟后,我们再次滑动窗口,这次当窗口滑动时,当前段的索引是6,第3段(有50个请求的那段)现在在窗口之外。所以我们把这50个请求拿回来,并把它们加到我们的限制中,现在是80个,因为还有20个被段4使用。

速率限制器API
介绍一下.NET 7中新的nuget包System.Threading.RateLimiting!
这个包提供了编写速率限制器的基元,同时也提供了一些常用的内置算法。主要的类型是抽象的基类RateLimiter :
public abstract class RateLimiter : IAsyncDisposable, IDisposable
{
public abstract int GetAvailablePermits();
public abstract TimeSpan? IdleDuration { get; }
public RateLimitLease Acquire(int permitCount = 1);
public ValueTask<RateLimitLease> WaitAsync(int permitCount = 1, CancellationToken cancellationToken = default);
public void Dispose();
public ValueTask DisposeAsync();
}
RateLimiter 包含 和 作为核心方法,试图为一个被保护的资源获得许可。根据不同的应用,被保护的资源可能需要获得超过1个许可,所以 和 都接受一个可选的 参数。 是一个同步方法,它将检查是否有足够的许可,并返回一个 ,其中包含关于你是否成功获得许可的信息。 与 类似,除了它可以支持排队的许可证请求,当许可证可用时,可以在未来的某个时间点取消排队,这就是为什么它是异步的,并接受一个可选的 ,以允许取消排队的请求。Acquire WaitAsync Acquire WaitAsync permitCount Acquire RateLimitLease WaitAsync Acquire CancellationToken
RateLimitLease 有一个 属性,用于查看是否获得了许可证。此外, 可能包含元数据,例如,如果租赁失败,建议重试后的时间(将在后面的例子中展示)。最后, 是一次性的,当代码使用完受保护的资源后,应该被处理掉。弃置将让 知道根据获得的许可数量来更新其限制。下面是一个使用 ,试图用1个许可来获取资源的例子。IsAcquired RateLimitLease RateLimitLease RateLimiter RateLimiter:
RateLimiter limiter = GetLimiter();
using RateLimitLease lease = limiter.Acquire(permitCount: 1);
if (lease.IsAcquired)
{
// Do action that is protected by limiter
}
else
{
// Error handling or add retry logic
}
在上面的例子中,我们尝试使用同步的Acquire 方法来获取1个许可证。我们还使用了using ,以确保我们在使用完资源后,将租约处理掉。然后检查租约,看我们请求的许可是否被获得,如果是,我们就可以使用被保护的资源,否则我们可能需要一些日志或错误处理来通知用户或应用程序,由于遇到了速率限制,资源没有被使用。
另一种尝试获取许可的方法是WaitAsync 。这种方法允许排队获取许可,如果没有的话,就等待许可的到来。让我们展示另一个例子来解释排队的概念:
RateLimiter limiter = new ConcurrencyLimiter(
new ConcurrencyLimiterOptions(permitLimit: 2, queueProcessingOrder: QueueProcessingOrder.OldestFirst, queueLimit: 2));
// thread 1:
using RateLimitLease lease = limiter.Acquire(permitCount: 2);
if (lease.IsAcquired) { }
// thread 2:
using RateLimitLease lease = await limiter.WaitAsync(permitCount: 2);
if (lease.IsAcquired) { }
这里我们展示第一个使用内置速率限制实现的例子,ConcurrencyLimiter 。我们创建的限制器的最大许可限制为2,队列限制为2。这意味着在任何时候最多可以获得2个许可,我们允许排队WaitAsync ,总的许可请求最多为2。
queueProcessingOrder 参数决定了队列中项目的处理顺序,它可以是QueueProcessingOrder.OldestFirst (FIFO) 或QueueProcessingOrder.NewestFirst (LIFO) 的值。需要注意的一个有趣的行为是,当队列已满时,使用QueueProcessingOrder.NewestFirst ,将以失败的RateLimitLease ,完成最古老的队列WaitAsync 的调用,直到队列中有空间给最新的队列项目。
在这个例子中,有2个线程试图获取许可证。如果线程1先运行,它将成功获得2个许可证,线程2中的WaitAsync ,等待线程1中的RateLimitLease 被处理掉。IsAcquired 此外,如果另一个线程试图使用Acquire 或WaitAsync 来获取许可证,它将立即收到一个RateLimitLease ,其属性等于false,因为permitLimit 和queueLimit 已经用完了。
如果线程2先运行,它将立即得到一个RateLimitLease ,其中IsAcquired 等于true,而当线程1接下来运行时(假设线程2中的租约还没有被处置),它将同步得到一个RateLimitLease ,其中IsAcquired 属性等于false,因为Acquire 不排队,而且permitLimit 已经被WaitAsync 调用用完。
到目前为止,我们已经看到了ConcurrencyLimiter ,还有其他3个限制器,我们提供了in-box。TokenBucketRateLimiter FixedWindowRateLimiter 和SlidingWindowRateLimiter 都实现了抽象类ReplenishingRateLimiter ,它本身实现了RateLimiter 。ReplenishingRateLimiter 介绍了TryReplenish 方法以及观察限制器上常见设置的几个属性。TryReplenish 将在展示这些速率限制器的一些例子后解释:
RateLimiter limiter = new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions(tokenLimit: 5, queueProcessingOrder: QueueProcessingOrder.OldestFirst,
queueLimit: 1, replenishmentPeriod: TimeSpan.FromSeconds(5), tokensPerPeriod: 1, autoReplenishment: true));
using RateLimitLease lease = await limiter.WaitAsync(5);
// will complete after ~5 seconds
using RateLimitLease lease2 = await limiter.WaitAsync();
这里我们展示了TokenBucketRateLimiter ,它比ConcurrencyLimiter 有更多的选项。replenishmentPeriod 是新的令牌(与许可证的概念相同,只是在令牌桶的背景下有一个更好的名字)被添加回限制的频率。在这个例子中,tokensPerPeriod 是1,replenishmentPeriod 是5秒,所以每5秒就有1个令牌被加回tokenLimit ,最多是5。最后,autoReplenishment 被设置为true,这意味着限制器将在内部创建一个Timer ,以处理每5秒补充代币的问题。
如果autoReplenishment 被设置为false,那么将由开发者在限制器上调用TryReplenish 。当管理多个ReplenishingRateLimiter 实例,并希望通过创建一个单一的Timer 实例和管理补充调用来降低开销,而不是让每个限制器创建一个Timer ,这是很有用的:
ReplenishingRateLimiter[] limiters = GetLimiters();
Timer rateLimitTimer = new Timer(static state =>
{
var replenishingLimiters = (ReplenishingRateLimiter[])state;
foreach (var limiter in replenishingLimiters)
{
limiter.TryReplenish();
}
}, limiters, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
FixedWindowRateLimiter 有一个选项window,定义了窗口更新的时间。:
new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(permitLimit: 2,
queueProcessingOrder: QueueProcessingOrder.OldestFirst, queueLimit: 1, window: TimeSpan.FromSeconds(10), autoReplenishment: true));
而SlidingWindowRateLimiter ,除了window ,还有一个segmentsPerWindow 选项,指定有多少段,以及窗口的滑动频率:
new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(permitLimit: 2,
queueProcessingOrder: QueueProcessingOrder.OldestFirst, queueLimit: 1, window: TimeSpan.FromSeconds(10), segmentsPerWindow: 5, autoReplenishment: true));
回到前面提到的元数据,让我们展示一个元数据可能有用的例子:
class RateLimitedHandler : DelegatingHandler
{
private readonly RateLimiter _rateLimiter;
public RateLimitedHandler(RateLimiter limiter) : base(new HttpClientHandler())
{
_rateLimiter = limiter;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
using RateLimitLease lease = await _rateLimiter.WaitAsync(1, cancellationToken);
if (lease.IsAcquired)
{
return await base.SendAsync(request, cancellationToken);
}
var response = new HttpResponseMessage(System.Net.HttpStatusCode.TooManyRequests);
if (lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
response.Headers.Add(HeaderNames.RetryAfter, ((int)retryAfter.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo));
}
return response;
}
}
RateLimiter limiter = new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions(tokenLimit: 5, queueProcessingOrder: QueueProcessingOrder.OldestFirst,
queueLimit: 1, replenishmentPeriod: TimeSpan.FromSeconds(5), tokensPerPeriod: 1, autoReplenishment: true));;
HttpClient client = new HttpClient(new RateLimitedHandler(limiter));
await client.GetAsync("https://example.com");
在这个例子中,我们正在做一个速率有限的HttpClient ,如果我们不能获得所要求的许可,我们想用429状态码(Too Many Requests)返回一个失败的http请求,而不是向我们的下游资源发出http请求。此外,429响应可以包含一个 "Retry-After "头,让消费者知道什么时候重试可能会成功。我们通过使用TryGetMetadata 和MetadataName.RetryAfter 在RateLimitLease 上寻找元数据来实现这一目标。我们还使用TokenBucketRateLimiter ,因为它能够计算出所请求的代币数量何时可用的估计值,因为它知道它补充代币的频率。而ConcurrencyLimiter 将没有办法知道许可证何时可用,所以它不会提供任何RetryAfter 元数据。
MetadataName 是一个静态类,它提供了几个预先创建的 实例,就是我们刚才看到的 ,它的类型是 ,还有 ,它的类型是 。还有一个静态的 方法,用于创建你自己的强类型的命名元数据键。 有两个重载,一个用于强类型的 ,有一个 参数,另一个接受一个字符串作为元数据名称,有一个 参数。MetadataName<T> MetadataName.RetryAfter MetadataName<TimeSpan> MetadataName.ReasonPhrase MetadataName<string> MetadataName.Create<T>(string name) RateLimitLease.TryGetMetadata MetadataName<T> out T out object
现在让我们看看正在引入的另一个API,以帮助处理更复杂的情况,PartitionedRateLimiter!
分区速率限制器(PartitionedRateLimiter)
同样包含在System.Threading.RateLimitingnuget包中的是PartitionedRateLimiter<TResource> 。这是一个与RateLimiter 类非常相似的抽象,只是它接受一个TResource 实例作为它的方法的参数。例如,Acquire 现在是:Acquire(TResource resourceID, int permitCount = 1) 。这对于你可能想根据传入的TResource 来改变速率限制行为的情况很有用。这可以是不同的TResource,或者更复杂的情况,如将X和Y归入同一并发限制,但将W和Z归入一个代币桶限制。
为了帮助常见的用法,我们包括了一种通过PartitionedRateLimiter.Create<TResource, TPartitionKey>(...) 来构建PartitionedRateLimiter<TResource> 的方法:
enum MyPolicyEnum
{
One,
Two,
Admin,
Default
}
PartitionedRateLimiter<string> limiter = PartitionedRateLimiter.Create<string, MyPolicyEnum>(resource =>
{
if (resource == "Policy1")
{
return RateLimitPartition.Create(MyPolicyEnum.One, key => new MyCustomLimiter());
}
else if (resource == "Policy2")
{
return RateLimitPartition.CreateConcurrencyLimiter(MyPolicyEnum.Two, key =>
new ConcurrencyLimiterOptions(permitLimit: 2, queueProcessingOrder: QueueProcessingOrder.OldestFirst, queueLimit: 2));
}
else if (resource == "Admin")
{
return RateLimitPartition.CreateNoLimiter(MyPolicyEnum.Admin);
}
else
{
return RateLimitPartition.CreateTokenBucketLimiter(MyPolicyEnum.Default, key =>
new TokenBucketRateLimiterOptions(tokenLimit: 5, queueProcessingOrder: QueueProcessingOrder.OldestFirst,
queueLimit: 1, replenishmentPeriod: TimeSpan.FromSeconds(5), tokensPerPeriod: 1, autoReplenishment: true));
}
});
RateLimitLease lease = limiter.Acquire(resourceID: "Policy1", permitCount: 1);
// ...
RateLimitLease lease = limiter.Acquire(resourceID: "Policy2", permitCount: 1);
// ...
RateLimitLease lease = limiter.Acquire(resourceID: "Admin", permitCount: 12345678);
// ...
RateLimitLease lease = limiter.Acquire(resourceID: "other value", permitCount: 1);
PartitionedRateLimiter.Create 有2个通用的类型参数,第一个代表资源类型,这也将是返回的PartitionedRateLimiter<TResource> 中的TResource 。第二个通用类型是分区的关键类型,在上面的例子中,我们使用MyPolicyEnum作为我们的关键类型。键是用来区分一组具有相同限制器的 TResource实例,这就是我们所说的分区。 接受一个 PartitionedRateLimiter.Create,我们称之为分区器。每次通过Acquire或 WaitAsync与 Func<TResource, RateLimitPartition<TPartitionKey>> 进行交互时,都会调用这个函数,并从该函数中返回一个 PartitionedRateLimiter 。 RateLimitPartition<TKey> 包含一个 Create方法,这是用户指定分区将具有什么标识符以及什么限制器将与该标识符相关。
在我们上面的第一个代码块中,我们正在检查资源是否与 "Policy1 "相等,如果它们匹配,我们就用键MyPolicyEnum.One 创建一个分区,并返回一个用于创建自定义RateLimiter 的工厂。该工厂被调用一次,然后速率限制器被缓存,因此未来对键MyPolicyEnum.One 的访问将使用同一个速率限制器实例。
看一下第一个else if 条件,当资源等于 "Policy2 "时,我们同样创建一个分区,这次我们使用方便的方法CreateConcurrencyLimiter 来创建一个ConcurrencyLimiter 。我们为这个分区使用一个新的分区键MyPolicyEnum.Two ,并指定将生成的ConcurrencyLimiter 的选项。现在,"Policy2 "的每一个Acquire 或WaitAsync 将使用相同的ConcurrencyLimiter 实例。
我们的第三个条件是 "管理员 "资源,我们不想限制我们的管理员,所以我们使用CreateNoLimiter ,这将没有限制。我们还为这个分区分配了分区键MyPolicyEnum.Admin 。
最后,我们有一个后备方案,让所有其他资源使用TokenBucketLimiter 实例,我们为这个分区分配了MyPolicyEnum.Default 的密钥。任何对未被我们的if 条件覆盖的资源的请求将使用这个TokenBucketLimiter 。一般来说,如果你没有覆盖所有的条件或在未来为你的应用程序添加新的行为,拥有一个非noop的回退限制器是一个好的做法。
在下一个例子中,让我们把PartitionedRateLimiter 和我们先前定制的HttpClient 结合起来。我们将使用HttpRequestMessage 作为我们的资源类型,用于PartitionedRateLimiter ,这是我们在SendAsync 方法中得到的类型DelegatingHandler 。还有一个string 作为我们的分区键,因为我们将根据url路径来进行分区。
PartitionedRateLimiter<HttpRequestMessage> limiter = PartitionedRateLimiter.Create<HttpRequestMessage, string>(resource =>
{
if (resource.RequestUri?.IsLoopback)
{
return RateLimitPartition.CreateNoLimiter("loopback");
}
string[]? segments = resource.RequestUri?.Segments;
if (segments?.Length >= 2 && segments[1] == "api/")
{
// segments will be [] { "/", "api/", "next_path_segment", etc.. }
return RateLimitPartition.CreateConcurrencyLimiter(segments[2].Trim('/'), key =>
new ConcurrencyLimiterOptions(permitLimit: 2, queueProcessingOrder: QueueProcessingOrder.OldestFirst, queueLimit: 2));
}
return RateLimitPartition.Create("default", key => new MyCustomLimiter());
});
class RateLimitedHandler : DelegatingHandler
{
private readonly PartitionedRateLimiter<HttpRequestMessage> _rateLimiter;
public RateLimitedHandler(PartitionedRateLimiter<HttpRequestMessage> limiter) : base(new HttpClientHandler())
{
_rateLimiter = limiter;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
using RateLimitLease lease = await _rateLimiter.WaitAsync(request, 1, cancellationToken);
if (lease.IsAcquired)
{
return await base.SendAsync(request, cancellationToken);
}
var response = new HttpResponseMessage(System.Net.HttpStatusCode.TooManyRequests);
if (lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
response.Headers.Add(HeaderNames.RetryAfter, ((int)retryAfter.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo));
}
return response;
}
}
仔细观察上述例子中的PartitionedRateLimiter ,我们的第一个检查是针对localhost的,我们已经决定,如果用户在本地做事情,我们不想限制他们,他们不会使用我们试图保护的上游资源。接下来的检查更有意思,我们正在查看url路径,寻找任何对/api/<something> 端点的请求。如果请求匹配,我们就抓取路径的<something> 部分,并为该特定路径创建一个分区。这意味着任何对/api/apple/* 的请求将使用我们的ConcurrencyLimiter 的一个实例,而任何对/api/orange/* 的请求将使用我们的ConcurrencyLimiter 的另一个实例。这是因为我们为这些请求使用了不同的分区密钥,所以我们的限制器工厂为不同的分区生成了一个新的限制器。最后,我们有一个后备限制,用于任何不是针对localhost或/api/* 端点的请求。
此外,还显示了更新后的RateLimitedHandler ,它现在接受一个PartitionedRateLimiter<HttpRequestMessage> ,而不是RateLimiter ,并将request 传递给WaitAsync ,其他的代码保持不变。
在这个例子中,有几件事值得指出。如果有很多独特的/api/* 请求,我们可能会创建很多分区,这将导致我们的PartitionedRateLimiter 的内存使用量增加。从PartitionedRateLimiter.Create 返回的PartitionedRateLimiter 确实有一些逻辑,一旦它们有一段时间没有被使用,就会移除限制器,以帮助缓解这种情况,但应用程序开发人员也应该注意创建无界的分区,并尽可能地避免这种情况。此外,我们的分区键有segments[2].Trim('/') ,调用Trim 是为了避免在/api/apple 和/api/apple/ 的情况下使用不同的限制器,因为这些限制器在使用Uri.Segments 时产生不同的段。
也可以不使用PartitionedRateLimiter.Create 方法来编写自定义的PartitionedRateLimiter<T> 实现。下面是一个自定义实现的例子,为每个int 资源使用一个并发量限制。所以资源1 有自己的限制,2 有自己的限制,等等。这样做的好处是更灵活,可能更有效,但代价是更高的维护。
public sealed class PartitionedConcurrencyLimiter : PartitionedRateLimiter<int>
{
private ConcurrentDictionary<int, int> _keyLimits = new();
private int _permitLimit;
private static readonly RateLimitLease FailedLease = new Lease(null, 0, 0);
public PartitionedConcurrencyLimiter(int permitLimit)
{
_permitLimit = permitLimit;
}
public override int GetAvailablePermits(int resourceID)
{
if (_keyLimits.TryGetValue(resourceID, out int value))
{
return value;
}
return 0;
}
protected override RateLimitLease AcquireCore(int resourceID, int permitCount)
{
if (_permitLimit < permitCount)
{
return FailedLease;
}
bool wasUpdated = false;
_keyLimits.AddOrUpdate(resourceID, (key) =>
{
wasUpdated = true;
return _permitLimit - permitCount;
}, (key, currentValue) =>
{
if (currentValue >= permitCount)
{
wasUpdated = true;
currentValue -= permitCount;
}
return currentValue;
});
if (wasUpdated)
{
return new Lease(this, resourceID, permitCount);
}
return FailedLease;
}
protected override ValueTask<RateLimitLease> WaitAsyncCore(int resourceID, int permitCount, CancellationToken cancellationToken)
{
return new ValueTask<RateLimitLease>(AcquireCore(resourceID, permitCount));
}
private void Release(int resourceID, int permitCount)
{
_keyLimits.AddOrUpdate(resourceID, _permitLimit, (key, currentValue) =>
{
currentValue += permitCount;
return currentValue;
});
}
private sealed class Lease : RateLimitLease
{
private readonly int _permitCount;
private readonly int _resourceId;
private PartitionedConcurrencyLimiter? _limiter;
public Lease(PartitionedConcurrencyLimiter? limiter, int resourceId, int permitCount)
{
_limiter = limiter;
_resourceId = resourceId;
_permitCount = permitCount;
}
public override bool IsAcquired => _limiter is not null;
public override IEnumerable<string> MetadataNames => throw new NotImplementedException();
public override bool TryGetMetadata(string metadataName, out object? metadata)
{
throw new NotImplementedException();
}
protected override void Dispose(bool disposing)
{
if (_limiter is null)
{
return;
}
_limiter.Release(_resourceId, _permitCount);
_limiter = null;
}
}
}
PartitionedRateLimiter<int> limiter = new PartitionedConcurrencyLimiter(permitLimit: 10);
// both will be successful acquisitions as they use different resource IDs
RateLimitLease lease = limiter.Acquire(resourceID: 1, permitCount: 10);
RateLimitLease lease2 = limiter.Acquire(resourceID: 2, permitCount: 7);
这个实现确实有一些问题,比如永远不会删除字典中的条目,不支持排队,以及在访问元数据时抛出,所以请把它作为实现自定义PartitionedRateLimiter<T> 的灵感,不要不加修改地复制到你的代码中。
现在我们已经了解了主要的API,让我们看看ASP.NET Core中的RateLimiting中间件,它利用了这些基元。
RateLimiting中间件
这个中间件是通过Microsoft.AspNetCore.RateLimitingNuGet包提供的。主要的使用模式是配置一些速率限制策略,然后将这些策略附加到你的端点。一个策略是一个名为Func<HttpContext, RateLimitPartition<TPartitionKey>> ,这与PartitionedRateLimiter.Create 方法所采取的相同,其中TResource 现在是HttpContext ,TPartitionKey 仍然是一个用户定义的键。当你想为一个策略配置一个限制器而不需要不同的分区时,也有4个内置速率限制器的扩展方法。
var app = WebApplication.Create(args);
app.UseRateLimiter(new RateLimiterOptions()
.AddConcurrencyLimiter(policyName: "get", new ConcurrencyLimiterOptions(permitLimit: 2, queueProcessingOrder: QueueProcessingOrder.OldestFirst, queueLimit: 2))
.AddNoLimiter(policyName: "admin")
.AddPolicy(policyName: "post", partitioner: httpContext =>
{
if (!StringValues.IsNullOrEmpty(httpContext.Request.Headers["token"]))
{
return RateLimitPartition.CreateTokenBucketLimiter("token", key =>
new TokenBucketRateLimiterOptions(tokenLimit: 5, queueProcessingOrder: QueueProcessingOrder.OldestFirst,
queueLimit: 1, replenishmentPeriod: TimeSpan.FromSeconds(5), tokensPerPeriod: 1, autoReplenishment: true));
}
else
{
return RateLimitPartition.Create("default", key => new MyCustomLimiter());
}
}));
app.MapGet("/get", context => context.Response.WriteAsync("get")).RequireRateLimiting("get");
app.MapGet("/admin", context => context.Response.WriteAsync("admin")).RequireRateLimiting("admin").RequireAuthorization("admin");
app.MapPost("/post", context => context.Response.WriteAsync("post")).RequireRateLimiting("post");
app.Run();
这个例子展示了如何添加中间件,配置一些策略,并将不同的策略应用于不同的端点。从顶部开始,我们使用UseRateLimiter ,将中间件添加到我们的中间件管道。接下来,我们使用方便的方法AddConcurrencyLimiter 和AddNoLimiter 为我们的选项添加一些策略,其中两个策略分别命名为"get" 和"admin" 。然后,我们使用AddPolicy 方法,允许根据传入的资源配置不同的分区(中间件为HttpContext )。最后,我们在不同的端点上使用RequireRateLimiting 方法,让速率限制中间件知道在哪个端点上运行什么策略。(请注意,在这个最小的例子中,/admin 端点上的RequireAuthorization 方法并没有做任何事情,想象一下,认证和授权已经配置好了。)
AddPolicy 方法还有2个重载,使用IRateLimiterPolicy<TPartitionKey> 。这个接口暴露了一个OnRejected 回调,与我将在下面描述的RateLimiterOptions 相同,还有一个GetPartition 方法,它接受HttpContext 作为参数,并返回一个RateLimitPartition<TPartitionKey> 。AddPolicy 的第一个重载接受一个IRateLimiterPolicy 的实例,第二个接受一个IRateLimiterPolicy 的实现作为一个通用参数。通用参数之一将使用依赖注入来调用构造函数并为你实例化IRateLimiterPolicy 。
public class CustomRateLimiterPolicy<string> : IRateLimiterPolicy<string>
{
private readonly ILogger _logger;
public CustomRateLimiterPolicy(ILogger<CustomRateLimiterPolicy<string>> logger)
{
_logger = logger;
}
public Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected
{
get => (context, lease) =>
{
context.HttpContext.Response.StatusCode = 429;
_logger.LogDebug("Request rejected");
return new ValueTask();
};
}
public RateLimitPartition<string> GetPartition(HttpContext context)
{
if (!StringValues.IsNullOrEmpty(httpContext.Request.Headers["token"]))
{
return RateLimitPartition.CreateTokenBucketLimiter("token", key =>
new TokenBucketRateLimiterOptions(tokenLimit: 5, queueProcessingOrder: QueueProcessingOrder.OldestFirst,
queueLimit: 1, replenishmentPeriod: TimeSpan.FromSeconds(5), tokensPerPeriod: 1, autoReplenishment: true));
}
else
{
return RateLimitPartition.Create("default", key => new MyCustomLimiter());
}
}
}
var app = WebApplication.Create(args);
var logger = app.Services.GetRequiredService<ILogger<CustomRateLimiterPolicy<string>>>();
app.UseRateLimiter(new RateLimitOptions()
.AddPolicy("a", new CustomRateLimiterPolicy<string>(logger))
.AddPolicy<CustomRateLimiterPolicy<string>>("b"));
在RateLimiterOptions 上的其他配置包括:RejectionStatusCode ,它是在租赁失败时返回的状态代码,默认情况下,返回503。对于更高级的使用,还有一个OnRejected 函数,它将在使用RejectionStatusCode 后被调用,并接收OnRejectedContext 作为参数。
new RateLimiterOptions()
{
OnRejected = (context, cancellationToken) =>
{
context.HttpContext.StatusCode = StatusCodes.Status429TooManyRequests;
return new ValueTask();
}
};
最后但并非最不重要的是,RateLimiterOptions 允许通过RateLimiterOptions.GlobalLimiter 配置一个全局的PartitionedRateLimiter<HttpContext> 。如果提供一个GlobalLimiter ,它将在端点上指定的任何策略之前运行。例如,如果你想限制你的应用程序处理1000个并发请求,无论指定什么端点策略,你可以用这些设置配置一个PartitionedRateLimiter ,并设置GlobalLimiter 属性。
总结
请尝试一下速率限制,让我们知道你的想法。对于System.Threading.RateLimiting命名空间中的RateLimiting APIs,请使用nuget包System.Threading.RateLimiting,并在RuntimeGitHub repo中提供反馈。对于RateLimiting中间件,请使用nuget包Microsoft.AspNetCore.RateLimiting,并在AspNetCoreGitHub repo中提供反馈。