.NET 7 提供的速率限制

目录

原文作者: Brennan Conroy
原文地址: Announcing Rate Limiting for .NET
译文地址: https://www.cnblogs.com/MikeTheMike/articles/RateLimitingforDotNET.html
文章分类: .NET, .NET Core, ASP.NET, ASP.NET Core
文章翻译: MikeTheMike
未经译者许可, 禁止搬运本文至其他平台

很高兴宣布内置速率限制(Rate Limiting)将作为.NET 7的一部分发布。速率限制提供一种可以保护资源的解决方案,可以帮你避免应用程序被淹没并将流量保持在安全水平。

什么是速率限制?

速率限制指限制资源在一定时间内的可访问次数。例如,你明知道你的应用程序访问的数据库每分钟可以安全处理1000个请求,但它可不敢说它能不能处理更多的请求。你可以在应用程序中设置一个速率限制器(rate limiter),每分钟允许1000个请求,并在那些超额请求访问数据库之前拒绝更多请求。显然,限制数据库的速率,只允许应用程序处理安全数量的请求,可以避免数据库出现严重故障。
有许多速率限制算法可控制请求流,本文将介绍.NET 7中提供的其中4种。

并发限制 Concurrency limit

并发限制器可以控制访问资源的并发请求数。如果你的限制是10,那么可以允许10个请求同时访问一个资源,第11个请求将不被允许。一旦一个请求完成,允许请求数将增加到1,当第二个请求完成时,允许请求刷领将增加到2,如此等等。这是通过处理 RateLimitLease 来实现的,稍后本文将对此进行讨论。

令牌桶限制 Token bucket limit

令牌桶是一种算法,其名称正是对其工作方式的描述。想象一下,有一个装满令牌(token)的桶。当请求传入时,它会拿走一个令牌并将其永久保存。经过一段稳定的时间后,有人将预先确定数量的令牌添加到桶中,但永远不会加的超过桶的容量。如果桶为空,则请求传入时将被拒绝访问资源。
举一个更具体的例子,假设桶可以容纳10个令牌,每分钟向桶中添加2个令牌。当1个请求进来时会拿走一个令牌,因此我们剩下9个,另外3个请求进来,每个请求都需要1个令牌,剩下6个令牌,1分钟后,我们得到了2个新的令牌,这使我们达到了8个。8个请求进入并获取剩余令牌,剩下0个。如果出现另一个请求,则在我们获得更多令牌之前,不允许它访问该资源,这每分钟都会发生。在5分钟无请求后,存储桶将再次拥有所有10个令牌,并且在随后的几分钟内不会再添加任何令牌,除非来了请求拿走了一些令牌。

固定窗口限制 Fixed window limit

固定窗口算法使用了窗口的概念,这在下一个要介绍的算法中也有使用。固定窗口限流的思路是,将某一个时间段当做一个窗口,在这个窗口内设置计数器记录该窗口接收请求的次数,每接收一次请求便让计数器的值加一。如果计数器的值大于你设置的请求阈值的时候,即执行限流。当这个时间段(窗口期)结束后,会初始化窗口的计数器数据,相当于重新开了一个窗口重新监控请求次数。

让我们想象一下,有一家电影院(处理器/服务/...),有一个放映厅,可以容纳100人(单次最大处理量/服务容量/..., 也即请求限制数),电影播放时间长达2小时(处理时长, 窗口时间)。当电影开始时,我们让放映厅外的人们开始排队等待2小时后的下一场放映(阻挡这些请求)。2小时的电影结束后,0~99号人组成的队伍可以进入电影院,然后再次重新开始排队。这与在固定窗口算法中移动窗口相同。

滑动窗口限制

滑动窗口算法类似于固定窗口算法,但增加了片段的概念。一个片段是一个窗口的一部分,如果我们将之前长度2小时的观影窗口拆分为4个片段,那么我们现在有4个长度为30分钟的片段。还有一个当前段索引(current segment),它将始终指向窗口中的最新段。30分钟内的请求进入当前段,这30分钟结束后窗口向后滑动一段。如果在窗口滑过的这半小时期间有任何请求,现在将刷新这些请求,我们的限额修改相应的数量。如果没有任何请求,限制保持不变。
让我们使用如下场景示例, 把一个窗口分为3个10分钟段, 100个总请求限制量的滑动窗口算法。我们的初始状态是3个段,三段的请求计数都是0,当前段索引指向第3个段。

在最初的10分钟内,我们收到了50个请求(总限制量100减去本段用掉的50, 100-50, 剩50),所有这些请求都在第三段(我们当前的段索引)中进行了跟踪。

10分钟过去后,我们将窗口滑动1段,同时将当前段索引移动到第4段。第1段中任何请求占用的数量现在都要加回我们的总限制数(50+0)。由于第1段没有收到请求,我们的总请求限制仍保持50个。

在接下来的10分钟里,我们又收到了20个请求(总限制量50减去本段用掉的20, 50-20, 剩30),所以现在有50个在片段3,20个在片段4。

同样,我们在10分钟后滑动窗口,因此当前的段索引指向5,我们将来自片段2的任何请求添回到我们的总限制数(30+0)。由于第2段没有收到请求,我们的总请求限制仍保持30个。

在这段的十分钟里我们没有收到任何请求, 总限制数仍保持20.

10分钟后,我们再次滑动窗口,这一次当窗口滑动时,当前段索引为6,段3(收到50个请求的段)现在位于窗口之外。因此,我们取回50个请求,并将它们加回到我们的总限制数上(30+50),现在总限制数是80个(还有第4段拿走的20个没还回来, 第四段在窗口内部)。

速率限制器API

.NET7中引入了新的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 包含AcquireWaitAsync作为尝试获取受保护资源许可的核心方法。根据应用程序的不同,受保护的资源可能需要获取1个以上的许可,因此acquireWaitAsync都接受permitCount作为参数(默认值都是1)。Acquire是同步方法,它将检查是否有足够的许可可用,并返回RateLimitLease,它包含那些有关你是否成功获取许可证的信息。WaitAsyncAcquire类似,不同之处在于它支持排队许可请求,当许可变得可用时,可以在将来某个时间点取消排队,这就是为什么它是异步的,并接受CancellationToken作为可选参数以允许取消排队的请求。

RateLimitLease有一个IsAcquired属性,用于查看是否获得了许可证。此外,RateLimitLease可能包含元数据,例如,如果租约失败,建议在一段时间后重试(将在后面的示例中显示)。最后,RateLimitLease一次性(disposable) 的,应该在使用受保护的资源完成代码时进行处理(should be disposed)。它(RateLimitLease)的释放(the disposal)将使RateLimiter根据它拿走的许可数量来更新自己的当前限额数量。下面是一个使用RateLimiter尝试获取需要1个许可证的资源的示例。

//此行伪代码, 获取到一个RateLimiter
RateLimiter limiter = GetLimiter();
//注意前面说过, RateLimitLease用完要释放
using RateLimitLease lease = limiter.Acquire(permitCount: 1);
if (lease.IsAcquired)
{
    // Do action that is protected by limiter
}
else
{
    // Error handling or add retry logic
}

在上面的示例中,我们尝试使用同步获取方法获取1个许可。我们还使用using来确保在处理完资源后处理租约。然后检查租约以查看我们请求的许可是否已获得. 如果成功获得,我们可以使用受保护的资源,否则我们可能需要进行一些日志记录或错误处理,以通知用户或应用程序由于达到速率限制而未使用资源。
也可以用WaitAsync获取许可证, 这种方法允许排队获取许可证, 如果许可证不可用,则等待许可证可用。让我们展示另一个示例来解释排队概念。

//获取到一个并发RateLimiter
RateLimiter limiter = new ConcurrencyLimiter
(
    new ConcurrencyLimiterOptions
    (
        //最多同时两个许可
        permitLimit: 2, 
        //队列处理顺序: FIFO. 谁先发起谁优先
        queueProcessingOrder: QueueProcessingOrder.OldestFirst, 
        //队列中最多两个请求
        queueLimit: 2
    )
);

// 在线程1中(可视为请求1), 线程相关的部分代码省略
// 以Acquire方式获取两个许可
using RateLimitLease lease = limiter.Acquire(permitCount: 2);
if (lease.IsAcquired) { }

// 在线程2中(可视为请求2), 线程相关的部分代码省略
// 以WaitAsync异步方式也获取两个许可
using RateLimitLease lease = await limiter.以WaitAsync方式WaitAsync(permitCount: 2);
if (lease.IsAcquired) { }

这里我们展示了使用内置速率限制实现之一ConcurrencyLimiter的第一个示例。我们创建了最大许可限制为2、队列限制为2的限制器。这意味着在任何时候最多可以获得2个许可证,我们允许排队等待异步调用,最多可以有两个对WaitAsync的调用请求排队。

枚举类型参数queueProcessingOrder参数用于确定队列内各项的处理顺序,它的枚举值有: QueueProcessingOrder.OldestFirst(FIFO)和QueueProcessing order.NewstFirst(LIFO)。需要注意的一个有趣(?)的行为是,使用QueueProcessingOrder.NewestFirst时, 如果用着用着队列满了, 将直接塞给最早进来(最老的)WaitAsync调用排队项一个失败的RateLimitLease(人家老年人一大早排队买鸡蛋, 你让谁来得晚谁先买不说, 鸡蛋不够分你还直接塞给人家一个空盒子让人家滚蛋, 就这么糊弄老年人啊, 老年人得不到一点尊重啊),就这么(一直欺负来得早的老年人)直到队列中有空间容纳最新的队列项了为止。

上面的示例中,有2个线程试图获取许可。如果线程1首先运行,它将成功获取2个许可,线程2中的WaitAnc将排队等待直至线程1中的RateLimitLease被释放。此外,如果又来了第3个线程尝试使用AcquireWaitAsync获取许可,它将立即收到一个RateLimitLease,其IsAcquired属性等于false,因为permitLimitqueueLimit已经被那俩线程分别用完了。
如果线程2首先运行,它将立即获得一个RateLimitLeaseIsAcquired等于true,而当线程1接下来运行时(假设线程2中的租约尚未释放),它将同步获得一个RateLimitLease,其IsAcquireed属性等于false,因为Acquire不排队(queueLimit对它无效),permitLimit刚才已经被WaitAsync调用用完还没释放。

上文介绍了ConcurrencyLimiter,此外还提供了另外3个限制器:

  • TokenBucketRateLimiter 令牌桶速率限制器
  • FixedWindowRateLimiter 固定窗口速率限制器
  • SlidingWindowRateLimiter 滑动窗口速率限制器

它们都实现了抽象类ReplementingRateLimiter(补充速率限制器),该抽象类本身实现了RateLimiterReplenishingRateLimiter引入了TryReplenish(尝试补充)方法以及机个用于观察限制器常用设置的属性。在下文展示这些限速器的一些使用示例后,你将更容易理解TryReplenish的作用。

//创建令牌桶速率限制器
RateLimiter limiter = new TokenBucketRateLimiter
(
    //传入设置
    new TokenBucketRateLimiterOptions
    (
        //总许可数, 即令牌总量, 最大值
        tokenLimit: 5,
        //队列处理顺序. FIFO
        queueProcessingOrder: QueueProcessingOrder.OldestFirst,
        //队列中最多两个请求
        queueLimit: 1, 
        //补充令牌的周期. 此处指定5秒补充一个
        replenishmentPeriod: TimeSpan.FromSeconds(5), 
        //每个周期生成的令牌数量
        tokensPerPeriod: 1, 
        //自开启动补充
        autoReplenishment: true
    )
);

//以WaitAsync异步方式获取5个许可(拿完了)
using RateLimitLease lease = await limiter.WaitAsync(5);

//
// will complete after ~5 seconds
using RateLimitLease lease2 = await limiter.WaitAsync();

这里我们展示了TokenBucketRateLimiter,它比ConcurrencyLimiter有更多的配置项。replenishmentPeriod(补充周期)是指新令牌(与许可的概念相同,这么叫只是在令牌桶上下文中的一个更合适的名字)添加回限制的频率。在本例中,tokensPerPeriod为1,补充周期为5秒,因此每5秒将向tokenLimit补充1个令牌,最大值为5。最后,autoReplenishment设置为true,这意味着限制器将在内部创建一个Timer(计时器),以每5秒1次的速率检测是否补充。
如果autoSupplement设置为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, 
        //窗口重置/移动时间. 每隔10s重置一次
        window: TimeSpan.FromSeconds(10), 
        autoReplenishment: true
    )
);

SlidingWindowRateLimiter滑动窗口限制器除了窗口之外还有一个segmentsPerWindow配置项,它指定有多少段(segment)以及窗口滑动的频率。

new SlidingWindowRateLimiter
(
    new SlidingWindowRateLimiterOptions
    (
        permitLimit: 2,
    	queueProcessingOrder: QueueProcessingOrder.OldestFirst,
        queueLimit: 1, 
        //窗口重置/移动时间. 10s
        window: TimeSpan.FromSeconds(10), 
        //每窗口片段数.
        //意味着10s/5, 每2s窗口滑动一次
        segmentsPerWindow: 5, 
        autoReplenishment: true
    )
);

回到前面提到的元数据(metadata),让我们展示一个元数据可能有用的示例:

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);
        }
        //获取不到. 先生成一个429响应
        var response = new HttpResponseMessage(System.Net.HttpStatusCode.TooManyRequests);
        //如果元数据中指定要稍后重试, 就给response的header里加上重试信息
        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(Too Many Requests),而不是还向下游资源发出http请求。此外,429响应的header里可以包含"Retry-After"信息(多久后重试),让消费者(调用者)知道何时重试可能成功。我们通过使用TryGetMetadata方法和MetadataName.RetryAfter(元数据名)在RateLimitLease上查找元数据来实现这一点。这里用了TokenBucketRateLimiter,因为它知道令牌的补充频率, 能够计算出所请求令牌的数量何时可用的估计值。而ConcurrencyLimiter无法知道许可证何时可用,因此它不会提供任何RetryAfter元数据。
MetadataName是一个静态类,它提供了两个预先创建好的MetadataName<T>实例. 例如我们刚才看到的MetadataName.RetryAfter, 它就是个MetadataName<TimeSpan>类型的静态属性, 另一个静态属性是MetadataName<string>类型的MetadataName.ReasonPhrase. 此外还有可以让你创建自己的强类型命名元数据键的MetadataName.Create<T>(string name)方法可供使用。RateLimitLease.TryGetMetadata方法有两个重载,一个重载用于具有out T参数的强类型MetadataName<T>,另一个重载接受表示元数据名称的字符串参数, 另一个参数是out object

现在让我们来看看另一个API,它被引入来帮助处理更复杂的场景,PartitionedRateLimiter(分区/分段速率限制器).

分区速率限制器

System.Thread.RateLimiting nuget包中还包含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
}

//资源类型为string
PartitionedRateLimiter<string> limiter = PartitionedRateLimiter.Create<string, MyPolicyEnum>(
    resource =>
    {
        if (resource == "Policy1")
        {
            //具有Policy1标识符的资源都有该速率限制器关联
            return RateLimitPartition.Create
                   (
                        //分区键
                        MyPolicyEnum.One, 
                        //应用的配置
                        //使用自定义速率限制器
                        key => new MyCustomLimiter()
            		);
        }
        //so on...
        else if (resource == "Policy2")
        {
            return RateLimitPartition.CreateConcurrencyLimiter
                    (
                        MyPolicyEnum.Two, 
                        key => new ConcurrencyLimiterOptions
                        (
                            permitLimit: 2, 
                            queueProcessingOrder: QueueProcessingOrder.OldestFirst, 
                            queueLimit: 2
                        )
                    );
        }
        //so on...
        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<TResource, TPartitionKey>有2个泛型类型参数,第一个参数TResource表示的是资源类型,它也将是返回的PartitionedRateLimiter<TResource>中的TResource。第二个参数TPartitionKey是分区键类型,在这个示例中我们使用MyPolicyEnum作为键类型。该键用于区分具有相同速率限制器的一组TResource实例,这就是我们所称的分区。PartitionedRateLimiter.Create接受一个Func<TResource, RateLimitPartition<TPartitionKey>>参数,我们称之为partitioner分区器。每次通过AcquireWaitAsyncPartitionedRateLimiter交互并且从函数返回RateLimitPartition<TKey>时,都会调用此Func委托。RateLimitPartition<TKey>包含一个Create方法,该方法是用户指定分区将具有的标识符以及将与该标识符与哪个速率限制器关联。

在上面的第一个代码块中,我们先检查资源是否与"Policy1"相等,如果它们匹配,我们将创建一个具有关键字MyPolicyEnum.One的分区,并返回一个用以创建自定义RateLimiter的工厂。该工厂被调用一次,然后存储该速率限制器,以便将来以MyPolicyEnum.One为key访问时使用同一个速率限制器实例。

查看第一个else if条件,当资源等于"Policy2"时,我们类似地创建了一个分区,这次我们直接使用内置方法CreateConcurrencyLimiter来创建ConcurrencyLimiter。我们为此分区使用一个新分区键: MyPolicyEnum.Two,并为将生成的ConcurrencyLimiter指定配置项。现在,"Policy2"的每个AcquireWaitAsync都将使用ConcurrencyLimiter的相同实例。

我们的第三个条件是我们的"Admin"资源,我们不想限制我们的管理员,所以我们用CreateNoLimiter,它不会进行任何限制。我们还为此分区使用MyPolicyEnum.Admin作为分区密钥。

最后,我们让所有其他资源都用TokenBucketLimiter实例,并将MyPolicyEnum.Default的密钥分配给该分区。对我们的if条件未涵盖的资源的任何请求都将使用此TokenBucketLimiter。如果你没有涵盖所有条件或在将来向应用程序添加新行为,那么最好用个 non-noop (我也不知道怎么翻)速率限制器做个保底(就像这里用了个TokenBucketLimiter)。

在下一个示例中,让我们将PartitionedRateLimiter与前面定制的HttpClient结合起来。我们将使用HttpRequestMessage作为PartitionedRateLimiter的资源类型,这是我们在DelegatingHandlerSendAsync方法中获得的类型。以及一个字符串作为分区键,因为我们将根据url路径进行分区。

PartitionedRateLimiter<HttpRequestMessage> limiter = PartitionedRateLimiter
    .Create<HttpRequestMessage, string>(
    	resource =>
        {
            //回环地址无限制
            if (resource.RequestUri?.IsLoopback)
            {
                return RateLimitPartition.CreateNoLimiter("loopback");
            }

            //如果请求地址是"api/"路径下的
            string[]? segments = resource.RequestUri?.Segments;
            if (segments?.Length >= 2 && segments[1] == "api/")
            {
                //url片段就像 [] { "/", "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,我们的第一个检查是本机回环地址,我们决定,如果用户在本地进行操作就不限制它们,他们就不会使用我们想保护的上游资源。下一个检查更有趣,我们先检查url路径,判断是否是对"/api/<something>"端点下的请求。如果请求匹配,我们获取路径的<something>部分,并为该特定路径(以该路径为键)创建分区。这意味着,对"/api/apple/*"的任何请求都将使用同一个ConcurrencyLimiter的实例,而对"/api/oorange/*"的所有请求都会使用我们的另一个ConcurrencyLimiter实例。这是因为我们对这些请求使用了不同的分区键(前一个是"apple", 后一个是"orange"),因此我们的限制器工厂为不同的分区生成了一个新的限制器。最后,对于不属于localhost"/api/*"端点的任何请求,默认用一个自定义速率限制器。

此处介绍了更新的RateLimitedHandler,它现在接受PartitionedRateLimiter而不是RateLimiter,并将请求传递给WaitAsync调用,其余代码保持不变。
在这个例子中有几点值得指出。如果发出大量独特的"/api/*"请求(例如近乎随机的请求"/api/666/...", "/api/xyz/...", "/api/abc/..."),我们可能会创建许多分区,这将导致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);
//因为他们使用不同的资源id, 他们都会成功获取许可 
RateLimitLease lease = limiter.Acquire(resourceID: 1, permitCount: 10);
RateLimitLease lease2 = limiter.Acquire(resourceID: 2, permitCount: 7);

此实现确实存在一些问题,例如从不删除字典中的条目,不支持排队,以及在访问元数据时抛异常. 因此请仅将其作为实现自定义PartitionedRateLimiter<T>的参考,不要在没有修改的情况下复制到你的生产代码中。
现在我们已经介绍了主要的API,让我们看看ASP.NET Core中使用这些原语的RateLimit中间件。

RateLimiting 中间件

此中间件由Microsoft.AspNetCore.RateLimiting NuGet包提供。主要的使用模式是通过配置一些速率限制策略,然后将这些策略附加到指定端点。所谓策略的类型就是Func<HttpContext, RateLimitPartition<TPartitionKey>>委托,这与PartitionedRateLimiter.Create方法所采用的策略参数相同. 不过, 其中的TResource现在是HttpContext,TPartitionKey则仍然是你可以任意定义的键。如果你想为不需要不同分区的策略配置一个速率限制器, 有几个扩展方法可以提供4种内置的速率限制器. 在Program.cs中的示例如下

//...

var app = WebApplication.Create(args);

//...

//添加速率限制中间件
app.UseRateLimiter(
    //填入配置
    new RateLimiterOptions()
    //添加第一个策略
    .AddConcurrencyLimiter
    (
        //策略名: get (不是指http的get方法)
        policyName: "get", 
        new ConcurrencyLimiterOptions
        (
            permitLimit: 2, 
            queueProcessingOrder: QueueProcessingOrder.OldestFirst, 
            queueLimit: 2
        )
    )
    //对admin不用任何限制, 也就不加任何策略. 
    .AddNoLimiter(policyName: "admin")
    //再添加一个策略
    .AddPolicy(
        //策略名: post 
        policyName: "post", 
        //即前面说的策略委托, TResource参数类型是是HttpContext
        partitioner: httpContext =>
        {
            //如果请求头中包含了token
            if (!StringValues.IsNullOrEmpty(httpContext.Request.Headers["token"]))
            {
                
                return RateLimitPartition.CreateTokenBucketLimiter(
                    //应用分区键为token
                    "token", 
                    //创建TokenBucketLimiter时要应用的配置
                    key => new TokenBucketRateLimiterOptions
                    (
                        tokenLimit: 5, 
                        queueProcessingOrder: QueueProcessingOrder.OldestFirst,
                        queueLimit: 1,
                        replenishmentPeriod: TimeSpan.FromSeconds(5), 
                        tokensPerPeriod: 1, 
                        autoReplenishment: true
                    )
                );
            }
            //如果访问post端点时请求头未携带token
            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();

这个示例显示了如何添加中间件、配置一些策略,以及将不同的策略应用于不同的端点。从顶部开始,我们使用UseRateLimit将中间件添加到中间件管道中。接下来,我们使用方便的方法AddConcurrencyLimiterAddNoLimiter为其中两个策略添加一些策略,分别命名为"get"和"admin"。然后我们使用AddPolicy方法,该方法允许基于传入的资源(中间件中使用的是HttpContext)配置不同的分区。最后,我们在各种端点上使用RequireRateLimiting方法,让RateLimitiing中间件知道在哪个端点上运行什么策略。(注意"/admin"端点上的RequireAuthorization用法在这个小例子里没有任何作用,并假设你已经配置了身份验证和授权)

AddPolicy方法还有两个可以用IRateLimiterPolicy<TPartitionKey>做参数的重载。此接口公开了一个OnRejected回调(与下面描述的RateLimiterOptions相同), 以及一个GetPartition方法,该方法接收一个HttpContext参数并返回RateLimitPartition<TPartitionKey>结果。AddPolicy的第一个重载使用IRateLimiterPolicy的一个实例,第二个重载将IRateLimitterPolicy的实现作为泛型参数。泛型参数logger将通过依赖注入调用构造函数并为你实例化一个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)
    {
        //header里有token时
        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()
    //策略名"a", 应刚才的自定义策略
    .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允许通过RateLimiteerOptions.GlobalLimiter配置一个全局的PartitionedRateLimiter<HttpContext>。如果提供了GlobalLimiteer,它将在端点上指定的任何策略之前运行。例如,如果你希望限制应用程序(这个整体)最多处理1000个并发请求,无论各端点自己应用了哪种策略,都可以针对该要求配置一个PartitionedRateLimiter并设置到GlobalLimiter属性。

总结

请尝试速率限制并让这波开发者知道你的想法!对于System.Threading.RateLimiting命名空间中的RateLimiting API,请使用nuget包System.Threading.RateLimiting,并在Runtime GitHub repo中提供反馈。对于RateLimiting中间件,请使用nuget包Microsoft.AspNetCore.RateLimiting,并在AspNetCore GitHub repo中提供反馈。

本文脉络清晰易懂, 有时间我会把自己的代码改一改放上来供读者参考.
好文当赏啊, 请帮一帮博客园

posted on 2023-03-22 17:49  MikeTheMike  阅读(152)  评论(0)    收藏  举报