学习

如何使用 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 请求将使用 Basic auth - 这意味着每个请求都将预期一个形式为 Authorization: Basic <base64encoded> 的头部,其中 base64encoded 将是形式为 apiKey:apiSecret 的 base64 编码字符串,例如 Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==。此路由将从头部解析出 key 并返回一个 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://:5001/api/RateLimited/simple 发出 POST 请求 - 使用 apiKey foobar 和 password password,您将获得一个 200 OK 响应。

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

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

固定窗口限流 Lua 脚本#

我们将构建一个固定窗口限流脚本。固定窗口限流器将限制特定时间窗口内的请求数量。在我们的示例中,我们将限制特定 API Key 对特定路由的请求数量。因此,例如,如果 apiKey foobar 在 12:00:05 访问我们的路由 api/ratelimited/simple,并且我们有一个 60 秒的窗口,在此窗口内您最多只能发送十个请求,我们需要执行以下操作:

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

这里我们需要解决的问题是,这种限流要求所有命令都具有原子性(例如,在获取和增加 key 的值之间,我们不希望有人进来访问它)。因此,我们将通过一个 Lua 脚本 在服务器上运行所有操作。现在有两种方法来编写这个 Lua 脚本。传统方法是完全基于 keys 和 arguments 来驱动,如下所示

 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 支持一种 更易读的脚本模式,它允许您为脚本的 arguments 命名,库将在执行时负责填充相应的值。我们在此将使用的脚本模式将生成以下脚本:

 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
            ";
    }
 }

执行脚本#

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

 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

资源#