Rate Limiting mit Redis in Spring Boot

1. Übersicht

Rate Limiting hilft dir, Clients zu blockieren, wenn sie die API zu oft aufrufen, und verhindert damit eine Überlastung oder einen Missbrauch des Systems. Dieser Artikel zeigt, wie man Redis als zentralen Zähler nutzt und Lua-Skripte kombiniert, um Atomarität zu gewährleisten, wenn mehrere Anwendungsinstanzen gleichzeitig zugreifen. Nach der Lektüre wirst du in der Lage sein, deinen eigenen RateLimiter mithilfe der Algorithmen fixed window und sliding window zu schreiben, ihn über einen Interceptor in Spring Boot zu integrieren und das Verhalten mit Tests zu überprüfen.

2. Warum Redis für Rate Limiting wählen

Wenn eine Anwendung in mehreren Instanzen läuft (horizontal scaling), sind Zähler, die im Speicher einzelner Instanzen gespeichert sind (z. B. AtomicInteger), nicht synchronisiert. Redis löst dieses Problem, weil:

  • Es ein zentraler Datenspeicher ist; alle Instanzen lesen/schreiben an einem zentralen Ort.
  • Es TTL (EXPIRE) unterstützt, um alte Keys automatisch zu bereinigen, ohne dass ein Cron-Job notwendig ist.
  • Es Lua-Skripte atomar auf dem Server ausführt und damit Race Conditions vermeidet, wenn der Zähler erhöht und der Schwellenwert überprüft wird.

Hinweis: Wenn dein System nur eine einzelne Instanz hat und keinen verteilten Status brauchst, ist ein In-Memory-Limiter (z. B. Guavas RateLimiter) schneller und einfacher. Redis solltest du nur verwenden, wenn du verteiltes Rate Limiting brauchst.

3. Abhängigkeitseinrichtung

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
spring.data.redis.host=localhost
spring.data.redis.port=6379

Spring Boot konfiguriert RedisConnectionFactory und StringRedisTemplate automatisch basierend auf diesen Eigenschaften.

4. Fixed-Window-Algorithmus

Fixed Window unterteilt die Zeit in feste Intervalle (z. B. jeweils 60 Sekunden) und zählt die Anzahl der Anfragen in diesem Intervall. Wenn der Schwellenwert überschritten wird, wird die Anfrage abgelehnt.

4.1. Lua-Skript für Atomarität

Verwende ein Lua-Skript, um INCR und EXPIRE in einem atomaren Befehl zu kombinieren und zu verhindern, dass andere Anfragen dazwischengehen.

public class FixedWindowRateLimiter {

    private static final String SCRIPT = """
        local current = redis.call('INCR', KEYS[1])
        if current == 1 then
            redis.call('EXPIRE', KEYS[1], ARGV[1])
        end
        return current
        """;

    private final StringRedisTemplate redisTemplate;
    private final RedisScript<Long> redisScript;

    public FixedWindowRateLimiter(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.redisScript = RedisScript.of(SCRIPT, Long.class);
    }

    public boolean isAllowed(String clientId, int limit, int windowSeconds) {
        String key = "rate:fixed:" + clientId;
        Long current = redisTemplate.execute(
                redisScript,
                List.of(key),
                String.valueOf(windowSeconds));
        return current != null && current <= limit;
    }
}

Erklärung:

  • INCR KEYS[1] erhöht den Zähler des Keys und gibt den neuen Wert zurück.
  • Wenn current == 1 (das erste Mal, dass der Key erstellt wird), setzt es die TTL auf windowSeconds Sekunden.
  • Das gesamte Skript wird atomar auf dem Redis-Server ausgeführt und kann nicht durch andere Anfragen unterbrochen werden, auch nicht bei Multi-Threading oder mehreren Instanzen.
  • isAllowed gibt true zurück, wenn die aktuelle Anzahl der Anfragen den limit noch nicht überschritten hat.

4.2. Verwendung in einem Spring-Boot-Interceptor

@Component
public class RateLimitInterceptor implements HandlerInterceptor {

    private final FixedWindowRateLimiter rateLimiter;

    public RateLimitInterceptor(FixedWindowRateLimiter rateLimiter) {
        this.rateLimiter = rateLimiter;
    }

    @Override
    public boolean preHandle(HttpServletRequest request,
                              HttpServletResponse response,
                              Object handler) throws IOException {
        String clientId = request.getRemoteAddr();
        boolean allowed = rateLimiter.isAllowed(clientId, 100, 60);
        if (!allowed) {
            response.setStatus(429);
            response.getWriter().write("Too many requests");
            return false;
        }
        return true;
    }
}

Der Interceptor ermittelt die Client-IP als clientId und begrenzt auf 100 Anfragen pro 60 Sekunden. Wenn der Schwellenwert überschritten wird, wird der HTTP-Status 429 zurückgegeben und die Anfrage wird blockiert, bevor sie den Controller erreicht.

Hinweis: Fixed Window hat einen Nachteil am Fensterrand: Wenn ein Client 100 Anfragen am Ende der 59. Sekunde und 100 weitere Anfragen am Anfang der 60. Sekunde sendet, gibt es tatsächlich 200 Anfragen in 1 Sekunde, obwohl jedes Fenster einzeln gültig ist. Wenn du höhere Präzision brauchst, verwende sliding window.

5. Sliding-Window-Log-Algorithmus

Sliding Window Log speichert den Timestamp jeder Anfrage in einem Sorted Set und entfernt alte Timestamps bei jeder Überprüfung. Es ist genauer als Fixed Window, verbraucht aber mehr Speicher, da jede Anfrage gespeichert wird.

public class SlidingWindowRateLimiter {

    private static final String SCRIPT = """
        local key = KEYS[1]
        local now = tonumber(ARGV[1])
        local windowMs = tonumber(ARGV[2])
        local limit = tonumber(ARGV[3])

        redis.call('ZREMRANGEBYSCORE', key, 0, now - windowMs)
        local count = redis.call('ZCARD', key)
        if count < limit then
            redis.call('ZADD', key, now, now)
            redis.call('PEXPIRE', key, windowMs)
            return 1
        end
        return 0
        """;

    private final StringRedisTemplate redisTemplate;
    private final RedisScript<Long> redisScript;

    public SlidingWindowRateLimiter(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.redisScript = RedisScript.of(SCRIPT, Long.class);
    }

    public boolean isAllowed(String clientId, int limit, int windowMs) {
        String key = "rate:sliding:" + clientId;
        long now = System.currentTimeMillis();
        Long result = redisTemplate.execute(
                redisScript,
                List.of(key),
                String.valueOf(now),
                String.valueOf(windowMs),
                String.valueOf(limit));
        return result != null && result == 1;
    }
}

Erklärung der einzelnen Schritte im Lua-Skript:

  • ZREMRANGEBYSCORE löscht Einträge, deren Score (Timestamp) kleiner ist als now - windowMs, d. h. es entfernt Anfragen, die außerhalb des Zeitfensters liegen.
  • ZCARD zählt die verbleibenden Einträge im Sorted Set — das ist die Anzahl der Anfragen im aktuellen Fenster.
  • Wenn count < limit, wird ein neuer Eintrag mit Score now hinzugefügt, die TTL des Keys wird auf windowMs gesetzt (um sich selbst zu bereinigen, wenn kein Traffic mehr vorhanden ist), und 1 wird zurückgegeben (erlaubt).
  • Wenn das Limit bereits erreicht ist, wird 0 zurückgegeben (abgelehnt), und kein neuer Eintrag wird hinzugefügt.

Das Ergebnis: Das Fenster gleitet kontinuierlich mit der Zeit, ohne dass es zu Bursts am Fensterrand kommt, wie es bei Fixed Window der Fall ist.

6. Vergleich der beiden Algorithmen

Kriterium Fixed Window Sliding Window Log
Genauigkeit Niedrig, Burst am Rand möglich Hoch
Speicher O(1) pro Key O(limit) pro Key
Komplexität Einfach Komplexer
Geeignet für Grobe Begrenzung, hoher Traffic Empfindliche APIs, hohe Genauigkeit erforderlich

Wenn du dir unsicher bist, welcher Algorithmus zu verwenden ist, beginne mit Fixed Window — es ist einfach, leistungsoptimiert und ausreichend für die meisten APIs.

7. Testen mit Testcontainers

Um Tests unabhängig von einer vorkonfigurierten Redis-Installation zu halten, verwende Testcontainers, um Redis in Docker zu starten.

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@Testcontainers
class FixedWindowRateLimiterTest {

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
            .withExposedPorts(6379);

    FixedWindowRateLimiter rateLimiter;

    @BeforeEach
    void setUp() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(
                redis.getHost(), redis.getMappedPort(6379));
        LettuceConnectionFactory factory = new LettuceConnectionFactory(config);
        factory.afterPropertiesSet();

        StringRedisTemplate template = new StringRedisTemplate(factory);
        template.afterPropertiesSet();

        rateLimiter = new FixedWindowRateLimiter(template);
    }

    @Test
    void shouldBlockRequestsExceedingLimit() {
        String clientId = "client-A";
        int limit = 3;

        boolean r1 = rateLimiter.isAllowed(clientId, limit, 60);
        boolean r2 = rateLimiter.isAllowed(clientId, limit, 60);
        boolean r3 = rateLimiter.isAllowed(clientId, limit, 60);
        boolean r4 = rateLimiter.isAllowed(clientId, limit, 60);

        assertThat(r1).isTrue();
        assertThat(r2).isTrue();
        assertThat(r3).isTrue();
        assertThat(r4).isFalse();
    }
}

Dieser Test sendet 4 aufeinanderfolgende Anfragen mit limit = 3. Die ersten drei Anfragen werden akzeptiert (isTrue), die vierte Anfrage wird abgelehnt (isFalse), da das Limit innerhalb desselben Fensters überschritten wurde. Testcontainers startet den Redis-Container automatisch und bereinigt ihn danach, wodurch sichergestellt wird, dass Tests unabhängig von der CI-Umgebung sind.

8. Häufige Fehler

  • Keine TTL für Keys setzen: Wenn du EXPIRE vergisst, bleibt der Key unbegrenzt in Redis bestehen, was zu Speicherlecks führt und der Zähler setzt sich niemals zurück.
  • Zähler erhöhen und Schwellenwert überprüfen mit zwei separaten Befehlen (GET gefolgt von INCR): Das erzeugt Race Conditions, wenn mehrere Anfragen gleichzeitig ankommen, was dazu führt, dass das Limit in der Praxis überschritten wird. Kombiniere sie immer in einem Lua-Skript oder verwende den atomaren INCR-Befehl.
  • Identische Keys für alle Clients: Wenn du vergisst, clientId in den Key einzubinden, verwenden alle Clients denselben Zähler, was versehentlich andere Benutzer blockiert.
  • Sliding Window Log bei extrem hohem Traffic: Das Sorted Set wird größer, wenn limit groß ist und viele Clients gleichzeitig zugreifen. Erwäge die Verwendung von Sliding Window Counter (Kombination aus zwei Fixed Windows), um Speicher zu sparen.

9. Fazit

Redis in Kombination mit Lua-Skripten ist eine verbreitete Wahl für den Aufbau verteilter Rate Limiter: einfach, atomar und leicht skalierbar, je nach Anzahl der Anwendungsinstanzen. Fixed Window eignet sich für die meisten Fälle aufgrund der Einfachheit, während Sliding Window Log geeignet ist, wenn du eine genaue Kontrolle am Fensterrand brauchst. Teste immer gründlich das Verhalten an den Grenzen des Limits (Boundary-Tests) vor dem Produktiveinsatz, da dies der Ort ist, an dem logische Fehler am wahrscheinlichsten auftreten.

redisrate-limitingspring-bootlua-scriptjava