学习

如何使用 ASP.NET Core 和 Redis 实现一个固定窗口速率限制应用程序

Steve Lorello
作者
Steve Lorello Redis 高级现场工程师

在本教程中,我们将构建一个实现基本固定窗口速率限制的应用程序,使用 Redis 和 ASP.NET Core。

先决条件#

启动 Redis#

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

docker run -dp 6379:6379 redis

创建项目#

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

dotnet new webapi -n FixedRateLimiter --no-https

更改目录到 FixedRateLimiter 并运行以下命令

dotnet add package StackExchange.Redis

在 Visual Studio 或 Rider 中打开 FixedRateLimiter.csproj 文件(或在 VS Code 中打开文件夹),并在 Controllers 文件夹中添加一个名为 RateLimitedController 的 API 控制器,当所有这些完成时, RateLimitedController.cs 应该看起来像下面这样

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

    }
 }

初始化多路复用器#

要使用 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 请求将使用 基本身份验证 - 这意味着每个请求都将期望一个形式为 Authorization: Basic <base64encoded> 的标头,其中 base64encoded 将是一个形式为 apiKey:apiSecret 的字符串,经过 Base64 编码,例如 Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==。此路由将解析标头中的密钥,并返回一个 OK 结果。

 [HttpPost("simple")]
 public async Task<IActionResult> Simple([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/simple 发出 POST 请求 - 使用 apiKey foobar 和密码 password,您将获得 200 OK 响应。

您可以使用此 cURL 请求来引发该响应

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

固定窗口速率限制 Lua 脚本#

我们将构建一个固定窗口速率限制脚本。固定窗口速率限制器将限制特定时间窗口内的请求数量。在我们的示例中,我们将限制特定 API 密钥对特定路由的请求数量。例如,如果我们有 apiKey foobar 在 12:00:05 访问我们的路由 api/ratelimited/simple ,并且我们有一个 60 秒的窗口,在该窗口内您可以发送不超过 10 个请求,那么我们需要

  1. 1.根据我们的信息格式化一个密钥,例如 Route:ApiKey:time-window - 在我们的例子中,这将是 api/ratelimited/simple:foobar:12:00
  2. 2.增加该密钥的当前值
  3. 3.为该密钥设置 60 秒的过期时间
  4. 4.如果该密钥的当前值小于或等于允许的最大请求数,则增加该密钥并返回 false(未受速率限制)
  5. 5.如果该密钥的当前值大于或等于允许的最大请求数,则返回 true(受速率限制)

我们需要解决的问题是,这种速率限制需要我们所有命令的原子性(例如,在我们获取和增加密钥之间,我们不希望任何人进来并点击它)。因此,我们将通过 Lua 脚本 在服务器上运行所有内容。现在,有两种方法可以编写此 Lua 脚本。传统方式,您将所有内容都从密钥和参数中驱动,以下

 local key = KEYS[1]
 local max_requests = tonumber(ARGV[1])
 local expiry = tonumber(ARGV[2])
 local requests = redis.call('INCR',key)
 redis.call('EXPIRE', key, expiry)
 if requests < max_requests then
     return 0
 else
    return 1
 end

或者,StackExchange.Redis 包含对 更易读的脚本模式 的支持,它们将允许您为您的脚本命名参数,并且库将负责在执行时填写适当的项目。我们将在此处使用的脚本模式将产生以下脚本:

 local requests = redis.call('INCR',@key)
 redis.call('EXPIRE', @key, @expiry)
 if requests < tonumber(@maxRequests) then
     return 0
 else
     return 1
 end

加载脚本#

要使用 StackExchange.Redis 运行 Lua 脚本,您需要准备一个脚本并运行它。因此,相应地在项目中添加一个名为 Scripts.cs 的新文件,并在该文件中添加一个名为 Scripts 的新静态类;这将包含一个包含我们的脚本的常量字符串和一个 getter 属性,用于准备脚本以供执行。

 using StackExchange.Redis;
 namespace FixedRateLimiter
 {
     public static class Scripts
     {
         public static LuaScript RateLimitScript => LuaScript.Prepare(RATE_LIMITER);

        private const string RATE_LIMITER = @"
            local requests = redis.call('INCR',@key)
            redis.call('EXPIRE', @key, @expiry)
            if requests < tonumber(@maxRequests) then
                return 0
            else
                return 1
            end
            ";
    }
 }

执行脚本#

设置完脚本后,剩下的就是构建我们的密钥、运行脚本并检查结果。我们之前已经提取了 apiKey ,因此;我们将使用它、请求路径和当前时间来创建我们的密钥。然后,我们将运行 ScriptEvaluateAsync 来执行脚本,我们将使用其结果来确定是否返回 429 或我们的 JSON 结果。在我们的 Simple 方法中,在返回之前添加以下内容:

 var script = Scripts.RateLimitScript;
 var key = $"{Request.Path.Value}:{apiKey}:{DateTime.Now:hh:mm}";
 var res = await _db.ScriptEvaluateAsync(script, new {key = new RedisKey(key), expiry = 60, maxRequests = 10});
 if ((int) res == 1)
     return new StatusCodeResult(429);

我们的 Simple 路由的代码应如下所示:

 [HttpPost("simple")]
 public async Task<IActionResult> Simple([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 script = Scripts.RateLimitScript;
    var key = $"{Request.Path.Value}:{apiKey}:{DateTime.UtcNow:hh:mm}";
    var res = await _db.ScriptEvaluateAsync(script, new {key = new RedisKey(key), expiry = 60, maxRequests = 10});
    if ((int) res == 1)
        return new StatusCodeResult(429);
    return new JsonResult(new {key});
 }

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

for n in {1..21}; 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/simple); sleep 0.5; done

你会看到一些请求返回 200,至少有一个请求返回 429。具体多少取决于你开始发送请求的时间。请记住,请求在单分钟窗口内进行时间限制,所以如果你在 21 个请求的中间切换到下一分钟,计数器将重置。因此,你应该预计收到大约 10 到 20 个 OK 结果,以及 1 到 11 个 429 结果。响应应该类似于以下内容:

 HTTP 200, 0.002680 s
 HTTP 200, 0.001535 s
 HTTP 200, 0.001653 s
 HTTP 200, 0.001449 s
 HTTP 200, 0.001604 s
 HTTP 200, 0.001423 s
 HTTP 200, 0.001492 s
 HTTP 200, 0.001449 s
 HTTP 200, 0.001551 s
 {"status":429,"traceId":"00-16e9da63f77c994db719acff5333c509-f79ac0c862c5a04c-00"} HTTP 429, 0.001803 s
 {"status":429,"traceId":"00-3d2e4e8af851024db121935705d5425f-0e23eb80eae0d549-00"} HTTP 429, 0.001521 s
 {"status":429,"traceId":"00-b5e824c9ebc4f94aa0bda2a414afa936-8020a7b8f2845544-00"} HTTP 429, 0.001475 s
 {"status":429,"traceId":"00-bd6237c5d0362a409c436dcffd0d4a7a-87b544534f397247-00"} HTTP 429, 0.001549 s
 {"status":429,"traceId":"00-532d64033c54a148a98d8efe1f9f53b2-b1dbdc7d8fbbf048-00"} HTTP 429, 0.001476 s
 {"status":429,"traceId":"00-8c210b1c1178554fb10aa6a7540d3488-0fedba48e38fdd4b-00"} HTTP 429, 0.001606 s
 {"status":429,"traceId":"00-633178f569dc8c46badb937c0363cda8-ab1d1214b791644d-00"} HTTP 429, 0.001661 s
 {"status":429,"traceId":"00-12f01e448216c64b8bfe674f242a226f-d90ff362926aa14e-00"} HTTP 429, 0.001858 s
 {"status":429,"traceId":"00-63ef51cee3bcb6488b04395f09d94def-be9e4d6d6057754a-00"} HTTP 429, 0.001622 s
 {"status":429,"traceId":"00-80a971db60fdf543941e2457e35ac2fe-3555f5cb9c907e4c-00"} HTTP 429, 0.001710 s
 {"status":429,"traceId":"00-f718734ae0285343ac927df617eeef92-91a49e127f2e4245-00"} HTTP 429, 0.001582 s
 {"status":429,"traceId":"00-9da2569cce4d714480dd4f0edc0506d2-8a1ce375b1a9504f-00"} HTTP 429, 0.001629 s

资源#