using Application.Abstractions.RateLimit; using StackExchange.Redis; namespace Infrastructure.RateLimit; /// /// Redis sorted-set 기반 sliding window rate limiter. /// Lua script 로 원자성 보장 (read-modify-write 한 번에). /// 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 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); } }