在本教程中,我们将学习如何使用 Redis 为 ASP.NET Core 构建滑动窗口限流器。
我们在此实现的模式是滑动窗口限流器。滑动窗口限流器与固定窗口不同,它限制的是当前请求评估前的离散窗口内的请求数量。而固定窗口限流器则根据一个非常确定的时间窗口将请求分组到桶中。例如,如果您有一个每分钟 10 个请求的固定窗口限流器,您可能会遇到一分钟内允许 20 个请求通过的情况。这是因为如果前 10 个请求位于当前窗口的左侧,而接下来的 20 个请求位于窗口的右侧,它们各自的桶中都有足够的空间允许通过。另一方面,如果您通过滑动窗口限流器发送同样的 20 个请求,如果它们都在彼此的 60 秒内发送,则只有 10 个会通过。使用有序集合(Sorted Sets)和 Lua 脚本,实现这种限流器非常简单。
开始之前,请启动 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
{
}
}
要使用 Redis,我们将初始化 StackExchange.Redis 中的 ConnectionMultiplexer 实例。为此,请转到 Startup.cs 中的 ConfigureServices 方法并添加以下行:
services.AddSingleton<IConnectionMultiplexer>(ConnectionMultiplexer.Connect("localhost"));
在 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 foobar
和 password password
,您将获得 200 OK 响应。
您可以使用此 cURL 请求来获取该响应
curl -X POST -H "Content-Length: 0" --user "foobar:password" http://localhost:5000/api/RateLimited/single
为了实现这种模式,我们需要执行以下步骤
route:apikey
这里的关键在于所有操作都需要原子性地发生,我们希望能够修剪集合、检查其基数、向其中添加项并设置其过期时间,所有这些操作在期间不会发生任何变化。幸运的是,这里非常适合使用 Lua 脚本。具体来说,我们将使用 StackExchange 脚本准备引擎来驱动我们的 Lua 脚本,这意味着我们可以在脚本中使用 @variable_name
代替 ARGV
或 KEYS
中的特定位置。我们的 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