学习

如何使用 ASP.NET Core 和 Redis 实现滑动窗口速率限制器应用程序

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

什么是滑动窗口速率限制器#

我们在此实现的模式是滑动窗口速率限制器。滑动窗口速率限制器与固定窗口不同,它限制了对当前正在评估的请求之前的一个离散窗口的请求。与固定窗口速率限制器不同,固定窗口速率限制器将请求分组到一个基于非常明确的时间窗口的桶中。例如,如果您有一个 10 req/分钟的速率限制器,在固定窗口上,您可能会遇到速率限制器在一分钟内允许 20 个请求的情况。这是因为,如果前 10 个请求位于当前窗口的左侧,而接下来的 20 个请求位于窗口的右侧,则两者在各自的桶中都有足够的空间被允许通过。另一方面,如果您通过滑动窗口受限速率限制器发送了相同的 20 个请求,如果它们都在 60 秒内发送,则只有 10 个请求会通过。使用排序集和 Lua 脚本,实现这些速率限制器之一非常容易。

先决条件#

启动 Redis#

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

docker run -p 6379:6379 redis

创建项目#

在您的终端中,导航到您希望应用程序所在的位置并运行

dotnet new webapi -n SlidingWindowRateLimiter --no-https

进入 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
    {
    }
}

初始化多路复用器#

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

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

注入 ConnectionMultiplexer#

在 RateLimitedController.cs 中,将 ConnectionMultiplexer 注入控制器,并使用以下内容从中提取 IDatabase 对象:

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

添加一个简单的路由#

我们将添加一个简单的路由,我们将对其进行速率限制;它将是我们控制器上的 POST 请求路由。此 POST 请求将使用 基本身份验证 - 这意味着每个请求都将期望一个名为 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 为 foobar 且密码为 password,您将收到一个 200 OK 响应。

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

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

滑动窗口速率限制器 Lua 脚本#

要实现此模式,我们需要执行以下操作

  1. 1.客户端将为服务器创建一个要检查的密钥,此密钥的格式为 route:apikey
  2. 2.该密钥将映射到 Redis 中的一个排序集,我们将检查当前时间,并剔除排序集中超出我们窗口范围的任何请求
  3. 3.然后我们将检查排序集的基数
  4. 4.如果基数小于我们的限制,我们将
  5. 5.使用当前时间的秒数作为分数,使用当前时间的微秒数作为成员,向排序集中添加一个新成员
  6. 6.将排序集的过期时间设置为窗口长度
  7. 7.返回 0
  8. 8.如果基数大于或等于我们的限制,我们将返回 1

这里的诀窍是,所有事情都需要原子化地发生,我们希望能够修剪集合、检查其基数、向其中添加项目并设置其过期时间,所有这些都无需在期间内发生任何变化。幸运的是,这是一个使用 Lua 脚本 的绝佳位置。具体来说,我们将使用 StackExchange 脚本准备引擎来驱动我们的 lua 脚本,这意味着我们可以在脚本中的 ARGV 或 KEYS 中的特定位置使用 @variable_name。我们的 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
            ";
    }
}

更新控制器以进行速率限制#

回到我们的 RateLimitedController Sliding 方法中,我们将添加几行代码来检查我们是否应该限制 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

资源#

  • 您可以在