| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455 |
- using Application.Abstractions.RateLimit;
- using StackExchange.Redis;
- namespace Infrastructure.RateLimit;
- /// <summary>
- /// Redis sorted-set 기반 sliding window rate limiter.
- /// Lua script 로 원자성 보장 (read-modify-write 한 번에).
- /// </summary>
- public sealed class RedisSlidingWindowLimiter(IConnectionMultiplexer redis) : IRateLimiter
- {
- private readonly IDatabase _db = redis.GetDatabase();
- private const string LuaScript = @"
- local key = KEYS[1]
- local now = tonumber(ARGV[1])
- local minScore = tonumber(ARGV[2])
- local windowSeconds = tonumber(ARGV[3])
- local limit = tonumber(ARGV[4])
- local member = ARGV[5]
- redis.call('ZREMRANGEBYSCORE', key, '-inf', minScore)
- local count = redis.call('ZCARD', key)
- if count < limit then
- redis.call('ZADD', key, now, member)
- redis.call('EXPIRE', key, windowSeconds)
- return { 1, limit - count - 1 }
- else
- return { 0, 0 }
- end
- ";
- public async Task<RateLimitResult> CheckAsync(string key, int limit, TimeSpan window, CancellationToken ct = default)
- {
- var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
- var windowMs = (long)window.TotalMilliseconds;
- var minScore = now - windowMs;
- var fullKey = $"ratelimit:{key}";
- var uniqueMember = $"{now}:{Guid.NewGuid():N}";
- var raw = await _db.ScriptEvaluateAsync(
- LuaScript,
- keys: [fullKey],
- values: [now, minScore, (long)window.TotalSeconds, limit, uniqueMember]
- );
- var array = (RedisResult[])raw!;
- var allowed = (long)array[0] == 1;
- var remaining = (int)(long)array[1];
- var resetAt = DateTimeOffset.FromUnixTimeMilliseconds(now + windowMs).UtcDateTime;
- return new RateLimitResult(allowed, limit, remaining, resetAt);
- }
- }
|