冠军

导航

.NET 7 中的限流

.NET 中的限流

https://devblogs.microsoft.com/dotnet/announcing-rate-limiting-for-dotnet/

这里我们要宣布的是集成为 .NET 一部分的内建限流支持。限流是保护资源的一种方式,以便于避免无法承担的对应用的访问,并保持流量在安全的水平上。

什么是限流?

限流是关于限制多少资源可以被访问的概念。例如,你可能知道你的应用所使用的数据库可以安全处理每分钟 1000 个请求,但是不能确保对超过该限制的请求进行处理。那么,你可以设置一个只允许每分钟 1000 个请求的限制器,在更多的请求访问数据库之前就拒绝它们。这样,限制对数据库的访问并支持你的应用可以安全处理一定数量的请求,而不会潜在地导致数据库访问的失败。

有多种限流算法来控制请求的流量,我们将介绍其中的 4 种,它们将在 .NET 7 种提供支持。

并发限制

并发限制器限制有多少个并发的请求可以访问资源。如果你设置为 10,那么 10 个请求可以同时访问资源,而第 11 个请求将不会被允许访问。一旦某个请求完成,那么支持的请求数量可以增加到 1 个,当第 2 个请求处理完成,这个数字增加到 2 个,依次类推。该方式通过 RateLimitLease 支持,随后我们将会讨论它。

令牌桶限制

令牌桶算法则可以通过它的名称知道它是如何工作的。想象一下这里有一个填满了令牌的桶,当请求到来的时候,从其中取走一个令牌并一直持有它。在一段连续时间之后,再重新填充一些预先决定数量的令牌到桶种。但是永远不会填充超过桶的容量。如果桶变空了,此时如果有请求到达,就会拒绝对资源的访问。

举个更具体的例子,比如说桶种可以容纳 10 个令牌,每分钟有 2 个令牌将添加到桶种,当第一个请求到达的时候,它取走一个令牌,现在桶中剩下 9 个,随后又有 3 个请求到达,每个取走 1 个令牌,这样我们剩下 6 个,在 1 分钟之后,我们又得到 2 个新的令牌,这样我们回到 8 个。随后的 8 个请求取走剩下的令牌,我们剩下 0 个。如果又有请求到达,对资源的请求就不会被支持,直到每分钟后新的令牌被填充到桶中,在 5 分钟没有请求之后,桶中又回到 10 个令牌,而且随后的时间也不能再增加新的令牌,直到有请求取走令牌为止。

固定时间窗口限制

固定时间窗口算法与下一个算法都使用了时间窗口的概念。时间窗口是在移动到下一个时间窗口之前,限制所生效的一段时间。在固定时间窗口中,移动到下一个时间窗口意味着限制将重置回到开始点。我们可以想象一个电影院,它只有一个可以容纳 100 人的放映室,放映电影需要 2 个小时,在下一场之前,只能有 100 个人可以进入。一旦 2 个小时的放映结束,下一个排队的 100 人可以进入放映室,重新开始排队。这与固定时间窗口算法是相同的。

滑动时间窗口限制

滑动时间窗口算法与固定时间窗口算法类似,但增加了段。段是窗口的一部分,如果我们继续使用前面的 2 个小时的概念,并再分割为 4 个段,我们就拥有了 4 个 30 分钟的段。还需要一个当前段的索引,它总是窗口中下一个新的段。在 30 分钟内的请求进入当前段,每 30 分钟,时间窗口滑动到下一个段。如果在上一个段中的请求,它们会被刷新,限制增加该数量。如果没有任何请求,限制保持不变。

例如,考虑我们使用滑动时间窗口支持 100 个请求限制,使用 3 个 10 分钟的段。初始 3 个段中都只有 0 个请求,当前段指向第 3 个段。

在第 1 个 10 分钟我们收到 50 个请求,这些都被第 3 个段处理 ( 前端段索引指向它 ),一旦 10 分钟过去,滑动一个段,当前段的索引成为第 4 个段。任何在上一段中的请求被添加到限制中,现在的段为空,所以目前限制为 50 ( 50 个在第 3 个段中使用 )。

在这个 10 分钟内,我们收到 20 个新请求,这样我们有第 3 段中的 50 个请求和现在第 4 个段中的 20 个请求。重复一下,每 10 分钟滑动窗口,现在段索引是 5,我们会将上一段中的请求加入到限制中。

10 分钟之后我们再次滑动窗口,现在的窗口索引是 6,而第 3 段 ( 有 50 个请求的段 ) 现在滑出了窗口。所以我们可以有 50 个限额回收回来,这样当前限额成为 80,此时我们还有 20 个可用。

限流 API

新的限流 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 包含 Acquire() 和 WaitAsync() 方法作为尝试获得访问受到保护的资源许可的核心方法。基于应用程序保护的资源,可能需要申请多于 1 个许可。所以 Acquire() 和 WaitAsync() 方法都接收一个可选的 permitCount 参数。Acquire() 方法是同步方法,它检查是否有足够的许可可用,并返回一个包含是否成功获得需要的 RateLimitLease 对象。而 WaitAsync() 方法类似于 Acquire(),除了它支持排队的许可请求,当许可重新变得可用的时候,它可以在未来某个时间出队。这就是为什么它是异步的,并接收可选的 CancellationToken 来支持取消排队的原因。

RateLimitLease 有一个 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 关键字来保证一旦完成对资源的使用就丢弃租约。申请之后需要检查申请的许可是否被接受。如果是,我们就可以使用受保护的资源,否则我们可能需要记录日志或者进行错误处理以通知用户或者 app,该资源由于限流而不能被使用。

另一个申请许可的方法是 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 的时候,如果队列是满的将导致最后排队的 WaitAsync() 调用得到失败的 RateLimitLease,直到有针对最新的排队空间为止。

在示例中,有 2 个线程尝试获得许可。如果线程 1 先执行,它将成功获得 2 个许可,而线程 2 中的 WaitAsync() 将被排队,直到线程 1 中的 RateLimitLease 被丢弃。另外,如果其它线程尝试使用 Acquire() 或者 WaitAsync() 申请许可,将会立即返回一个 IsAcquired 属性为 false 的 RateLimitLease 对象,因为 permitLimit 和 queueLimit 都已经满了。

如果线程 2 先执行,它将立即获得 IsAcquired 为 true 的 RateLimitLease。在线程 1 后继执行的时候 (假设线程 2 还没有丢弃租约),它将同步得到一个 IsAcquired 为 false 的 RateLimitLease,因为 Acquire() 方法没有队列,而 permitLimit 已经被 WaitAsync() 使用了。

目前为止,我们使用了 ConcurrencyLimiter,还有另外 3 种开箱即用的限流器:

  • 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,意味着限流器将内部创建一个定时器来处理每 5 秒钟的补充。

如果 autoReplenishment 设置为 false,那么需要由开发者来调用限流器上的 TryReplenish() 方法。在管理多个 ReplenishingRateLimiter 实例的时候,这种方式很有用,可以通过创建单个的定时器来降低负载,并自己管理补充,而不是每个限流器都创建一个定时器。

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 拥有一个 segmentsPerWindow 设置,来进一步定义 window 中段的数量,以及窗口滑动的时间间隔。

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,如果申请许可失败的话,我们希望返回失败的 HTTP 429 响应代码 (请求过多),而不将请求进一步处理。另外,429 响应可以包含一个 "Retry-After" 响应头,以便消费者知道何时重试可能成功。我们通过使用 RateLimitLease 中的 TryGetMetadata() 和 MetadataName.RetryAfter 来完成。我们还使用了 TokenBucketRateLimiter,这是因为它知道补充令牌的频率,所以它可以计算一个预估的何时令牌可用的时间。而 ConcurrencyLimiter 就没有办法知道何时许可可能可用,所以它没有提供 RetryAfter 元数据。

MetadataName 是静态类,提供了一组预先创建的 MetadataName<T> 实例。我们刚看到的是 MetadataName.RetryAfter,它的类型是 MetadataName<TimeSpan>,还有 MetadataName.ReasonPhrase,它的类型是 MetadataName<string>。这里还提供了静态的 MetadataName.Create<T>(string name) 方法用来创建自定义的强类型的元数据键。RateLimitLease.TryGetMetadata() 方法有 2 个重载,一个用于强类型的 MetadataName<T>,它有一个 out T 参数,另一个接受一个元数据的名字字符串,它有一个 out object 的参数。

现在我们看一下另一个引入的 API,它用于更加复杂的场景,就是 PartitionedRateLimiter。

分区限流器 PartitionedRateLimiter

包含在 System.Threading.RateLimiting 中的还有 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() 方法有两个泛型参数,第一个表示资源的类型,它将成为返回类型 PartitionedRateLimiter<TResource> 中的 TResource。第二个泛型参数分区键的类型。在上面的例子中,我们使用 MyPolicyEnum 作为分区键的类型。该键用于对同一个限流器区分不同的资源组 TResource。这就是为什么被成为分区的原因。PartitionedRateLimiter.Create() 接受一个我们称为分区器的 Func\<TResource, RateLimitPartition\<TPartitionKey>>。在 PartitionedRateLimiter 被通过 Acquire() 或者 WaitAsync() 每次使用的时候都将被调用,该函数返回 RateLimitPartition<TKey>。RateLimitPartition<TKey> 包含 Create() 方法,它是用户如何指定分区标识,以及那个限流器将关联到该标识上。

在上面代码的第一段,我们检查资源是否为 "Policy1",如果匹配,我们创建使用键 MyPolicyEnum.One 来创建分区,并返回一个用来创建自定义 RateLimiter 的工厂。该工厂被调用一次,限流器将被缓存起来,所以以后访问 MyPolicyEnum.One 的键将使用同一个限流器实例。

观察第一个 else if 条件,当资源等于 "Policy2" 的时候,我们简单地创建一个分区,这一次我们使用便捷方法 CreateConcurrencyLimiter 来创建 ConcurrencyLimiter 限流器。我们使用了新的分区键 MyPolicyEnum.Two 用于此分区,并指定将用来生成 ConcurrencyLimiter 的配置。现在,对 "Policy2" 的每次 Acquire 或者 WaitAsync() 都将使用相同的 ConcurrencyLimiter 实例。

第 3 个条件用于 "Admin" 资源,我们并不希望限制管理操作,所以这里使用了 CreateNoLimiter,它没有应用限制。我们还对该分区赋予了 MyPolicyEnum.Admin 键。

最后,我们针对其它所有资源提供回退处理,使用 TokenBucketLimiter 实例,并赋予 MyPolicy.Default 给该分区。任何没有匹配 if 条件的请求都将使用这个 TokenBucketLimiter。通常,最好使用非 noop 回退限制器,以防将来未涵盖所有条件或向应用程序添加新行为。

在下一个示例中,让我们合并 PartitionedRateLimiter 到前面介绍的定制 HttpClient 中。我们将使用 HttpRequestMessage 作为 PartitionedRateLimiter 的资源类型,该类型通过 DelegatingHandler 的 SendAsync() 获得。并使用 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<T> 实现也可以不使用 PartitionedRateLimiter.Create() 方法来完成。下面的示例中针对 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 的限流中间件,它使用了这些技术。

限流器中间件

该中间件通过 NuGet 包 Microsoft.AspNetCore.RateLimiting 提供。主要的使用模式是配置某些限流策略,然后将策略应用于端点。策略是命名的 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() 定义 2 种策略。分别命名为 "get" 和 "admin"。然后使用 AddPolicy() 方法来基于传入的资源 (对于中间件就是 HttpContext) 配置不同的分区。最后,使用 RequireRateLimiting() 方法应用于不同的端点来让限流中间件知道何种策略应用于何种端点。( 注意应用于 /admin 端点的 RequireAuthorization 在这个最小化示例种没有做任何工作,想象一下已经配置了认证和授权 )

AddPolicy() 方法还有 2 个使用 IRateLimiterPolicy<TPartitionKey> 的重载,该接口暴露一个 OnRejected 回调,与下面的 RateLimiterOptions 相同,以及一个接受 HttpContext 作为参数并返回 RateLimitPartition<TPartitionKey> 的 GetPartition() 方法。第一个 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 属性。

小结

这些 RateLimiting APIs 定义在命名空间 System.Threading.RateLimiting 中,NuGet 包是 System.Threading.RateLimiting,欢迎反馈使用建议。

posted on 2022-10-08 16:08  冠军  阅读(917)  评论(0编辑  收藏  举报