学习

如何使用 ASP.NET Core & Redis 实现滑动窗口限流应用

在本教程中,我们将学习如何使用 Redis 为 ASP.NET Core 构建滑动窗口限流器。

什么是滑动窗口限流器#

我们在此实现的模式是滑动窗口限流器。滑动窗口限流器与固定窗口不同,它限制的是当前请求评估前的离散窗口内的请求数量。而固定窗口限流器则根据一个非常确定的时间窗口将请求分组到桶中。例如,如果您有一个每分钟 10 个请求的固定窗口限流器,您可能会遇到一分钟内允许 20 个请求通过的情况。这是因为如果前 10 个请求位于当前窗口的左侧,而接下来的 20 个请求位于窗口的右侧,它们各自的桶中都有足够的空间允许通过。另一方面,如果您通过滑动窗口限流器发送同样的 20 个请求,如果它们都在彼此的 60 秒内发送,则只有 10 个会通过。使用有序集合(Sorted Sets)和 Lua 脚本,实现这种限流器非常简单。

先决条件#

启动 Redis#

开始之前,请启动 Redis。在本例中,我们将使用 Redis docker 镜像

docker run -p 6379:6379 redis

创建项目#

在您的终端中,导航到您希望应用所在的目录并运行

dotnet new webapi -n SlidingWindowRateLimiter --no-https

cd 进入 SlidingWindowRateLimiter 文件夹并运行命令 dotnet add package StackExchange.Redis

在 Rider、Visual Studio 中打开 SlidingWindowRateLimiter.csproj,或在 VS Code 中打开该文件夹。在 Controllers 文件夹中,添加一个名为 RateLimitedController 的 API 控制器。完成后,RateLimitedController.cs 应如下所示:

namespace SlidingWindowRateLimiter.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class RateLimitedController : ControllerBase
    {
    }
}

初始化 Multiplexer#

要使用 Redis,我们将初始化 StackExchange.Redis 中的 ConnectionMultiplexer 实例。为此,请转到 Startup.cs 中的 ConfigureServices 方法并添加以下行:

services.AddSingleton<IConnectionMultiplexer>(ConnectionMultiplexer.Connect("localhost"));

注入 ConnectionMultiplexer#

RateLimitedController.cs 中,将 ConnectionMultiplexer 注入到控制器中,并从中取出 IDatabase 对象,如下所示:

private readonly IDatabase _db;
public RateLimitedController(IConnectionMultiplexer mux)
{
    _db = mux.GetDatabase();
}

添加一个简单路由#

我们将添加一个将进行限流的简单路由;它将是我们控制器上的一个 POST 请求路由。此 POST 请求将使用 Basic auth - 这意味着每个请求都将期望一个格式为 Authorization: Basic <base64encoded> 的标头,其中 base64encoded 将是 apiKey:apiSecret 经过 Base64 编码的字符串,例如 Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==。此路由将从标头中解析出密钥并返回 OK 结果。

[HttpPost]
[HttpGet]
[Route("sliding")]
public async Task<IActionResult> Sliding([FromHeader]string authorization)
{
    var encoded = string.Empty;
    if(!string.IsNullOrEmpty(authorization)) encoded = AuthenticationHeaderValue.Parse(authorization).Parameter;
    if (string.IsNullOrEmpty(encoded)) return new UnauthorizedResult();
    var apiKey = Encoding.UTF8.GetString(Convert.FromBase64String(encoded)).Split(':')[0];
    return Ok();
}

完成上述设置后,您应该使用 dotnet run 运行项目。如果您向 https://localhost:5001/api/RateLimited/sliding 发出 POST 请求 - 使用 apiKey foobarpassword password,您将获得 200 OK 响应。

您可以使用此 cURL 请求来获取该响应

curl -X POST -H "Content-Length: 0" --user "foobar:password" http://localhost:5000/api/RateLimited/single

滑动窗口限流器 Lua 脚本#

为了实现这种模式,我们需要执行以下步骤

  1. 1.客户端将创建一个供服务器检查的键,此键的格式为 route:apikey
  2. 2.该键将映射到 Redis 中的一个有序集合(sorted set)。我们将检查当前时间,并移除有序集合中超出我们窗口的任何请求。
  3. 3.然后我们将检查有序集合的基数(cardinality)
  4. 4.如果基数小于我们的限制,我们将
  5. 5.向我们的有序集合添加一个新成员,其分数为当前时间的秒数,成员为当前时间的微秒数
  6. 6.将我们的有序集合的过期时间设置为窗口长度
  7. 7.返回 0
  8. 8.如果基数大于或等于我们的限制,我们将返回 1

这里的关键在于所有操作都需要原子性地发生,我们希望能够修剪集合、检查其基数、向其中添加项并设置其过期时间,所有这些操作在期间不会发生任何变化。幸运的是,这里非常适合使用 Lua 脚本。具体来说,我们将使用 StackExchange 脚本准备引擎来驱动我们的 Lua 脚本,这意味着我们可以在脚本中使用 @variable_name 代替 ARGVKEYS 中的特定位置。我们的 Lua 脚本如下所示:

local current_time = redis.call('TIME')
local trim_time = tonumber(current_time[1]) - @window
redis.call('ZREMRANGEBYSCORE', @key, 0, trim_time)
local request_count = redis.call('ZCARD',@key)

if request_count < tonumber(@max_requests) then
    redis.call('ZADD', @key, current_time[1], current_time[1] .. current_time[2])
    redis.call('EXPIRE', @key, @window)
    return 0
end
return 1

为了在我们的应用中使用它,我们将创建一个名为 Scripts 的新静态类,该类将保存脚本文本,并准备使用 StackExchange.Redis 运行脚本。创建一个名为 Scripts.cs 的新文件并添加以下内容。

using StackExchange.Redis;

namespace SlidingWindowRateLimiter
{
    public static class Scripts
    {
        public static LuaScript SlidingRateLimiterScript => LuaScript.Prepare(SlidingRateLimiter);
        private const string SlidingRateLimiter = @"
            local current_time = redis.call('TIME')
            local trim_time = tonumber(current_time[1]) - @window
            redis.call('ZREMRANGEBYSCORE', @key, 0, trim_time)
            local request_count = redis.call('ZCARD',@key)

            if request_count < tonumber(@max_requests) then
                redis.call('ZADD', @key, current_time[1], current_time[1] .. current_time[2])
                redis.call('EXPIRE', @key, @window)
                return 0
            end
            return 1
            ";
    }
}

更新控制器以实现限流#

回到我们的 RateLimitedControllerSliding 方法中,我们将添加几行代码来检查是否应该限制 API 请求,将 return 语句替换为以下内容:

var limited = ((int) await _db.ScriptEvaluateAsync(Scripts.SlidingRateLimiterScript,
    new {key = new RedisKey($"{Request.Path}:{apiKey}"), window = 30, max_requests = 10})) == 1;
return limited ? new StatusCodeResult(429) : Ok();

现在整个方法应该如下所示

[HttpPost]
[HttpGet]
[Route("sliding")]
public async Task<IActionResult> Sliding([FromHeader] string authorization)
{
    var encoded = string.Empty;
    if(!string.IsNullOrEmpty(authorization)) encoded = AuthenticationHeaderValue.Parse(authorization).Parameter;
    if (string.IsNullOrEmpty(encoded)) return new UnauthorizedResult();
    var apiKey = Encoding.UTF8.GetString(Convert.FromBase64String(encoded)).Split(':')[0];
    var limited = ((int) await _db.ScriptEvaluateAsync(Scripts.SlidingRateLimiterScript,
        new {key = new RedisKey($"{Request.Path}:{apiKey}"), window = 30, max_requests = 10})) == 1;
    return limited ? new StatusCodeResult(429) : Ok();
}

现在,如果我们使用 dotnet run 重新启动服务器并尝试运行以下命令:

for n in {1..20}; do echo $(curl -s -w " HTTP %{http_code}, %{time_total} s" -X POST -H "Content-Length: 0" --user "foobar:password" http://localhost:5000/api/ratelimited/sliding); sleep 0.5; done

您会看到一些请求返回 200,而有 10 个请求返回 429。如果您等待一段时间并再次运行上述命令,您可能会看到每隔一个请求才能通过的行为。这是因为窗口每秒滑动,并且在确定是否限制请求时,仅考虑前 30 秒的请求。上述命令第一次运行会产生如下所示的输出:

HTTP 200, 0.081806 s
HTTP 200, 0.003170 s
HTTP 200, 0.002217 s
HTTP 200, 0.001632 s
HTTP 200, 0.001508 s
HTTP 200, 0.001928 s
HTTP 200, 0.001647 s
HTTP 200, 0.001656 s
HTTP 200, 0.001699 s
HTTP 200, 0.001667 s
{"status":429,"traceId":"00-4af32d651483394292e35258d94ec4be-6c174cc42ca1164c-00"} HTTP 429, 0.012612 s
{"status":429,"traceId":"00-7b24da2422f5b144a1345769e210b78a-75cc1deb1f260f46-00"} HTTP 429, 0.001688 s
{"status":429,"traceId":"00-0462c9d489ce4740860ae4798e6c4869-2382f37f7e112741-00"} HTTP 429, 0.001578 s
{"status":429,"traceId":"00-127f5493caf8e044a9f29757fbf91f0a-62187f6cf2833640-00"} HTTP 429, 0.001722 s
{"status":429,"traceId":"00-89a4c2f7e2021a4d90264f9d040d250c-34443a5fdb2cff4f-00"} HTTP 429, 0.001718 s
{"status":429,"traceId":"00-f1505b800f30da4b993bebb89f902401-dfbadcb1bc3b8e45-00"} HTTP 429, 0.001663 s
{"status":429,"traceId":"00-621cf2b2f32c184fb08d0d483788897d-1c01af67cf88d440-00"} HTTP 429, 0.001601 s
{"status":429,"traceId":"00-e310ba5214d7874dbd653a8565f38df4-216f1a4b8c4b574a-00"} HTTP 429, 0.001456 s
{"status":429,"traceId":"00-52a7074239a5e84c9ded96166c0ef042-4dfedf1d60e3fd46-00"} HTTP 429, 0.001550 s
{"status":429,"traceId":"00-5e03e785895f2f459c85ade852664703-c9ad961397284643-00"} HTTP 429, 0.001535 s
{"status":429,"traceId":"00-ba2ac0f8fd902947a4789786b0f683a8-be89b14fa88d954c-00"} HTTP 429, 0.001451 s

资源#

  • 您可以在以下位置找到本教程使用的代码