RedisSlidingWindowLimiter.cs 1.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
  1. using Application.Abstractions.RateLimit;
  2. using StackExchange.Redis;
  3. namespace Infrastructure.RateLimit;
  4. /// <summary>
  5. /// Redis sorted-set 기반 sliding window rate limiter.
  6. /// Lua script 로 원자성 보장 (read-modify-write 한 번에).
  7. /// </summary>
  8. public sealed class RedisSlidingWindowLimiter(IConnectionMultiplexer redis) : IRateLimiter
  9. {
  10. private readonly IDatabase _db = redis.GetDatabase();
  11. private const string LuaScript = @"
  12. local key = KEYS[1]
  13. local now = tonumber(ARGV[1])
  14. local minScore = tonumber(ARGV[2])
  15. local windowSeconds = tonumber(ARGV[3])
  16. local limit = tonumber(ARGV[4])
  17. local member = ARGV[5]
  18. redis.call('ZREMRANGEBYSCORE', key, '-inf', minScore)
  19. local count = redis.call('ZCARD', key)
  20. if count < limit then
  21. redis.call('ZADD', key, now, member)
  22. redis.call('EXPIRE', key, windowSeconds)
  23. return { 1, limit - count - 1 }
  24. else
  25. return { 0, 0 }
  26. end
  27. ";
  28. public async Task<RateLimitResult> CheckAsync(string key, int limit, TimeSpan window, CancellationToken ct = default)
  29. {
  30. var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
  31. var windowMs = (long)window.TotalMilliseconds;
  32. var minScore = now - windowMs;
  33. var fullKey = $"ratelimit:{key}";
  34. var uniqueMember = $"{now}:{Guid.NewGuid():N}";
  35. var raw = await _db.ScriptEvaluateAsync(
  36. LuaScript,
  37. keys: [fullKey],
  38. values: [now, minScore, (long)window.TotalSeconds, limit, uniqueMember]
  39. );
  40. var array = (RedisResult[])raw!;
  41. var allowed = (long)array[0] == 1;
  42. var remaining = (int)(long)array[1];
  43. var resetAt = DateTimeOffset.FromUnixTimeMilliseconds(now + windowMs).UtcDateTime;
  44. return new RateLimitResult(allowed, limit, remaining, resetAt);
  45. }
  46. }