Rate-Limiting mit Redis sauber implementieren
AI generated
SET
TTL
Redis · API-Sicherheit · Lua-Scripting · Backend
Rate-Limiting mit Redis sauber implementieren
von INCR bis zum atomaren Token Bucket

Ein API-Endpunkt ohne Rate-Limiting ist eine offene Einladung fuer Missbrauch, egal ob durch fehlerhafte Clients, aggressive Scraper oder gezielte Angriffe. Redis liefert mit INCR, EXPIRE und Lua-Scripts die Bausteine, um Fixed-Window-, Sliding-Window- und Token-Bucket-Limiter zu bauen, die auch unter Last korrekt und atomar bleiben.

18 Min. Lesezeit INCR · EXPIRE · Lua · Sorted Sets Redis 6.x · 7.x

1. Warum Rate-Limiting in Redis gehoert

Rate-Limiting begrenzt, wie oft ein Client innerhalb eines Zeitfensters eine Aktion ausfuehren darf, und ist damit eine der wenigen Massnahmen, die sowohl Missbrauch als auch versehentliche Ueberlastung gleichzeitig adressieren. Die Herausforderung liegt nicht in der Idee, sondern in der Umsetzung: Zaehler muessen ueber mehrere Applikationsserver hinweg konsistent sein, dabei nebenlaeufige Zugriffe korrekt zaehlen und trotzdem in Mikrosekunden antworten, weil jede Anfrage durch den Limiter muss, bevor die eigentliche Logik startet.

Redis eignet sich fuer Rate-Limiting besser als eine relationale Datenbank, weil Operationen wie INCR atomar sind, weil TTLs nativ unterstuetzt werden und weil die typische Latenz im Sub-Millisekunden-Bereich liegt. Ein Zaehler in Postgres braucht eine Transaktion mit Row-Lock, ein Zaehler in Redis braucht einen einzigen Roundtrip. Bei tausenden Requests pro Sekunde macht dieser Unterschied den Unterschied zwischen einem Limiter, der mitschwimmt, und einem, der selbst zum Flaschenhals wird.

In den folgenden Abschnitten werden drei etablierte Algorithmen fuer Rate-Limiting mit Redis aufgebaut: Fixed Window, Sliding Window und Token Bucket. Jeder hat unterschiedliche Trade-offs zwischen Implementierungsaufwand, Speicherbedarf und Genauigkeit an den Fenstergrenzen, und die Wahl sollte bewusst auf Basis des tatsaechlichen Traffic-Musters getroffen werden.

2. Fixed Window Counter mit INCR und EXPIRE

Der einfachste Ansatz fuer Rate-Limiting ist der Fixed Window Counter: pro Client und Zeitfenster wird ein Schluessel inkrementiert, und beim ersten INCR eines neuen Fensters wird eine EXPIRE-Zeit gesetzt, die dem Fenster entspricht. Ueberschreitet der Zaehler das Limit, wird die Anfrage abgelehnt. Der Schluessel traegt typischerweise den gerundeten Zeitstempel im Namen, etwa ratelimit:api-key-42:2026072314 fuer ein Ein-Stunden-Fenster, sodass jedes Fenster automatisch einen neuen Schluessel erzeugt.

Der Vorteil dieses Rate-Limiting-Musters liegt in seiner Einfachheit: zwei Redis-Befehle, ein Schluessel pro Client und Fenster, minimaler Speicherbedarf, da abgelaufene Fenster automatisch per TTL entfernt werden. Fuer viele interne APIs und einfache Public APIs reicht dieser Ansatz vollkommen aus, insbesondere wenn ein gewisses Mass an Ungenauigkeit an den Fenstergrenzen toleriert werden kann.


# Fixed Window Rate-Limiting per redis-cli
# Window: 60 seconds, limit: 100 requests

redis-cli> INCR ratelimit:client-42:window:29140805
(integer) 1
redis-cli> EXPIRE ratelimit:client-42:window:29140805 60 NX
(integer) 1
redis-cli> INCR ratelimit:client-42:window:29140805
(integer) 2

# Application logic: reject when counter exceeds limit
# window_id = floor(unix_timestamp / 60)
# key = "ratelimit:" + client_id + ":window:" + window_id

3. Das Boundary-Problem des Fixed Window

Der entscheidende Nachteil des Fixed Window Counters zeigt sich an der Fenstergrenze: ein Client kann kurz vor Ende eines Fensters das volle Limit ausschoepfen und direkt nach Beginn des naechsten Fensters erneut das volle Limit ausschoepfen. Bei einem Limit von 100 Requests pro Minute koennen so in einem Zwei-Sekunden-Fenster um die Grenze herum theoretisch 200 Requests durchgehen, obwohl das Rate-Limiting pro Minute eigentlich nur 100 erlauben sollte. Fuer viele Anwendungsfaelle ist das akzeptabel, fuer Systeme mit knappen Kapazitaeten oder Abrechnungsrelevanz nicht.

Dieses Verhalten ist kein Implementierungsfehler, sondern eine strukturelle Eigenschaft fester Zeitfenster. Wer diese Schwaeche im Rate-Limiting nicht tolerieren kann, muss zu einem Algorithmus wechseln, der keine harten Fenstergrenzen kennt, sondern kontinuierlich ueber einen gleitenden Zeitraum zaehlt. Genau das leisten Sliding-Window-Ansaetze, die im naechsten Abschnitt aufgebaut werden.

4. Sliding Window Log mit Sorted Sets

Der Sliding Window Log speichert fuer jeden Client jede einzelne Anfrage als Eintrag in einem Sorted Set, wobei der Score der Unix-Zeitstempel der Anfrage ist. Vor jeder neuen Anfrage entfernt ZREMRANGEBYSCORE alle Eintraege, die aelter sind als das Zeitfenster, und ZCARD liefert die Anzahl der verbleibenden Anfragen. Ist diese Zahl kleiner als das Limit, wird die aktuelle Anfrage per ZADD hinzugefuegt, sonst wird sie abgelehnt. Dieses Rate-Limiting-Verfahren ist exakt, weil es tatsaechlich jede Anfrage einzeln nachverfolgt statt in Fenstern zu runden.

Der Preis fuer diese Exaktheit ist Speicherverbrauch: bei einem Limit von 1000 Anfragen pro Stunde muss pro aktivem Client ein Sorted Set mit bis zu 1000 Eintraegen vorgehalten werden. Bei Millionen aktiver Clients summiert sich das schnell zu erheblichem RAM-Bedarf, weshalb der Sliding Window Log vor allem fuer Endpunkte mit niedrigem Limit oder kleiner Clientzahl geeignet ist, etwa Login-Versuche oder teure Schreiboperationen.


-- sliding_window_log.lua
-- KEYS[1] = rate limit key, ARGV[1] = now (ms), ARGV[2] = window (ms), ARGV[3] = limit
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])

-- Drop entries outside the sliding window
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

local count = redis.call('ZCARD', key)
if count < limit then
  redis.call('ZADD', key, now, now .. '-' .. math.random())
  redis.call('PEXPIRE', key, window)
  return 1 -- allowed
else
  return 0 -- rejected
end

5. Sliding Window Counter als Kompromiss

Der Sliding Window Counter kombiniert die Genauigkeit des Logs mit dem geringen Speicherbedarf des Fixed Window Counters, indem er zwei benachbarte Fixed-Window-Zaehler gewichtet zusammenrechnet. Die Formel lautet: aktuelle Anzahl plus vorherige Anzahl multipliziert mit dem Anteil des vorherigen Fensters, der noch in das gleitende Fenster faellt. Bei einem Ein-Minuten-Fenster und einer Anfrage 15 Sekunden nach Fensterbeginn fliesst das vorherige Fenster mit 75 Prozent seines Gewichts ein, weil 45 der 60 Sekunden noch relevant sind. Dieses Rate-Limiting-Verfahren approximiert damit ein echtes Sliding Window bei nur zwei Zaehlern pro Client.

Die Approximation ist in der Praxis ausreichend genau, weil Traffic selten exakt gleichverteilt ist und die Abweichung zur echten Sliding-Window-Berechnung meist im niedrigen einstelligen Prozentbereich bleibt. Fuer die meisten Public APIs ist der Sliding Window Counter der beste Kompromiss zwischen Genauigkeit an den Fenstergrenzen und Speichereffizienz, und er wird deshalb von vielen kommerziellen API-Gateways als Standardalgorithmus verwendet.

6. Token Bucket fuer Burst-Toleranz

Der Token Bucket unterscheidet sich konzeptionell von den bisherigen Ansaetzen, weil er nicht Anfragen in Fenstern zaehlt, sondern ein Guthaben an Tokens verwaltet, das mit konstanter Rate aufgefuellt wird. Jeder Bucket hat eine maximale Kapazitaet und eine Fuellrate, etwa zehn Tokens pro Sekunde bei einer Kapazitaet von 50. Jede Anfrage verbraucht einen Token, und ist der Bucket leer, wird die Anfrage abgelehnt oder verzoegert. Weil ungenutzte Kapazitaet sich bis zum Maximum ansammelt, erlaubt der Token Bucket kurze Bursts, waehrend die langfristige Durchschnittsrate trotzdem begrenzt bleibt.

In Redis wird der Token Bucket ueber einen Hash mit den Feldern tokens und last_refill realisiert. Bei jeder Anfrage berechnet das Skript, wie viele Tokens seit dem letzten Refill haetten nachfliessen muessen, addiert diese bis zur Kapazitaetsgrenze und zieht dann einen Token ab. Dieses Rate-Limiting-Modell eignet sich besonders fuer APIs, bei denen kurzfristige Spitzen legitim sind, etwa wenn ein Nutzer mehrere Ressourcen in schneller Folge laedt, die durchschnittliche Nutzung aber begrenzt bleiben soll.


-- token_bucket.lua
-- KEYS[1] = bucket key
-- ARGV[1] = capacity, ARGV[2] = refill_rate (tokens/sec), ARGV[3] = now (sec), ARGV[4] = requested tokens
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now

local elapsed = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + elapsed * refill_rate)

if tokens >= requested then
  tokens = tokens - requested
  redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
  redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) * 2)
  return 1 -- allowed
else
  redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
  redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) * 2)
  return 0 -- rejected
end

7. Atomaritaet mit Lua-Scripts erzwingen

Alle bisherigen Beispiele mit mehreren Befehlen haben ein gemeinsames Problem, wenn sie ohne Lua ausgefuehrt werden: zwischen dem Lesen des Zaehlers und dem Erhoehen kann ein anderer Prozess denselben Schluessel veraendern, was zu Race Conditions fuehrt. Redis fuehrt Lua-Scripts atomar aus, das heisst kein anderer Client kann Befehle zwischen die Zeilen eines Scripts schieben. Fuer korrektes Rate-Limiting unter echter Nebenlaeufigkeit sind Lua-Scripts deshalb keine Optimierung, sondern eine Notwendigkeit, sobald mehr als ein Redis-Befehl fuer die Entscheidung noetig ist.

Ein haeufiger Fehler ist, INCR und EXPIRE als zwei getrennte Roundtrips vom Applikationsserver auszufuehren. Zwischen beiden Aufrufen kann der Prozess abstuerzen oder die Verbindung unterbrochen werden, sodass der Schluessel ohne TTL im Speicher verbleibt und nie mehr abglaeuft. Die robuste Loesung setzt EXPIRE mit der NX-Option nur beim ersten INCR eines Fensters, entweder ueber ein kleines Lua-Script oder ueber eine MULTI-Transaktion mit WATCH, wobei das Lua-Script wegen des geringeren Overheads meist die bessere Wahl ist.


-- fixed_window_atomic.lua
-- KEYS[1] = rate limit key, ARGV[1] = window seconds, ARGV[2] = limit
local current = redis.call('INCR', KEYS[1])
if current == 1 then
  redis.call('EXPIRE', KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
  return 0 -- rejected
end
return 1 -- allowed

8. Per-IP und Per-API-Key Limiting

Die Wahl des Rate-Limit-Schluessels entscheidet, welche Ebene des Rate-Limiting tatsaechlich geschuetzt wird. Per-IP-Limiting mit dem Schluesselmuster ratelimit:ip:203.0.113.42:60 schuetzt vor volumetrischen Angriffen von einer einzelnen Quelle, versagt aber bei verteilten Angriffen und trifft legitime Nutzer hinter einem gemeinsamen NAT-Gateway zu hart. Per-API-Key-Limiting mit ratelimit:key:sk_live_abc123:60 ist praeziser, weil es an eine authentifizierte Identitaet gebunden ist, und erlaubt zusaetzlich unterschiedliche Limits je nach Vertragsstufe eines Kunden.

In der Praxis kombiniert man beide Ebenen: ein grosszuegiges Per-IP-Limit als grobe Verteidigungslinie gegen Bots ohne gueltigen Key, und ein enges Per-API-Key-Limit fuer authentifizierte Anfragen, das die eigentliche Geschaeftslogik des Rate-Limiting abbildet. PHP-Implementierungen binden diese Logik meist als Middleware vor die Route, sodass die eigentliche Anwendungslogik das Limiting nicht kennen muss.


<?php
declare(strict_types=1);

final class RedisRateLimiter
{
    public function __construct(
        private readonly \Redis $redis,
        private readonly string $luaScript
    ) {
    }

    /**
     * Checks whether the request identified by $key is allowed
     * under the given window and limit using an atomic Lua script.
     */
    public function isAllowed(string $key, int $windowSeconds, int $limit): bool
    {
        $result = $this->redis->eval(
            $this->luaScript,
            ["ratelimit:{$key}:{$windowSeconds}"],
            1
        );

        return (bool) $result;
    }
}

// Usage: per-IP and per-API-key combined
$limiter = new RedisRateLimiter($redis, $fixedWindowScript);
if (!$limiter->isAllowed("ip:{$clientIp}", 60, 300)) {
    http_response_code(429);
    exit;
}
if (!$limiter->isAllowed("key:{$apiKey}", 60, 100)) {
    http_response_code(429);
    exit;
}
Algorithmus Speicherbedarf Genauigkeit an Fenstergrenze Einsatzbereich
Fixed Window Sehr niedrig Schwach, bis zu 2x Burst Interne APIs, grobe Limits
Sliding Window Log Hoch, ein Eintrag pro Request Exakt Login-Versuche, teure Writes
Sliding Window Counter Niedrig, zwei Zaehler Sehr gut, approximiert Public APIs, Standardfall
Token Bucket Niedrig, ein Hash Gut, erlaubt Bursts Burst-tolerante APIs

9. Skalierung, Failure Modes und Header-Design

Ein produktives Rate-Limiting-System muss auch dann eine Entscheidung treffen, wenn Redis selbst nicht erreichbar ist. Die Wahl zwischen Fail-open, also Anfragen bei Redis-Ausfall durchlassen, und Fail-closed, also Anfragen ablehnen, ist eine bewusste Architekturentscheidung. Fail-open schuetzt die Verfuegbarkeit auf Kosten der Sicherheit, Fail-closed schuetzt vor Missbrauch auf Kosten der Verfuegbarkeit. Fuer die meisten oeffentlichen APIs ist Fail-open mit kurzem Timeout, etwa 20 Millisekunden, die bessere Wahl, weil ein Redis-Ausfall selten laenger als wenige Sekunden dauert und ein komplett blockierter Traffic groesseren Schaden verursacht.

In Redis-Cluster-Umgebungen sollte der Rate-Limit-Schluessel die Client-Identitaet als Hash-Tag enthalten, etwa ratelimit:{ip:203.0.113.42}:60, damit alle Schluessel eines Clients auf demselben Shard landen und Lua-Scripts nicht ueber Shard-Grenzen hinweg scheitern. Fuer die Kommunikation mit dem Client gehoeren zum Rate-Limiting immer die Standard-Header X-RateLimit-Limit, X-RateLimit-Remaining und Retry-After bei einer 429-Antwort, damit Client-Bibliotheken automatisch mit exponentiellem Backoff reagieren koennen, statt den Endpunkt sofort erneut zu bombardieren.

10. Zusammenfassung

Rate-Limiting mit Redis ist kein einzelner Algorithmus, sondern eine Familie von Loesungen mit unterschiedlichen Trade-offs. Der Fixed Window Counter ist am einfachsten, toleriert aber Bursts an Fenstergrenzen. Der Sliding Window Log ist exakt, kostet aber Speicher proportional zur Anzahl der Anfragen. Der Sliding Window Counter approximiert die Genauigkeit bei minimalem Speicherbedarf und ist fuer die meisten Public APIs die richtige Wahl. Der Token Bucket erlaubt kontrollierte Bursts bei begrenzter Durchschnittsrate und passt zu Anwendungsfaellen mit natuerlicherweise ungleichmaessigem Traffic.

Entscheidend fuer korrektes Rate-Limiting unter Last ist in jedem Fall die Atomaritaet der Operationen: Lua-Scripts vermeiden Race Conditions zwischen Lesen und Schreiben und reduzieren die Anzahl der Netzwerk-Roundtrips auf einen einzigen. Kombiniert mit durchdachtem Schluesseldesign fuer Per-IP- und Per-API-Key-Limits sowie einer klaren Failure-Mode-Strategie entsteht ein Limiter, der sowohl unter normalem Betrieb als auch bei Redis-Ausfaellen vorhersagbar reagiert.

Rate-Limiting mit Redis, das Wichtigste auf einen Blick

Algorithmus waehlen

Fixed Window fuer einfache Faelle, Sliding Window Counter als Standard, Token Bucket fuer Burst-Toleranz.

Atomaritaet erzwingen

Lua-Scripts fuer INCR plus EXPIRE oder Token-Berechnung, sonst drohen Race Conditions unter Last.

Schluesseldesign

Per-IP als grobe Verteidigung, Per-API-Key fuer praezise Geschaeftslogik, Hash-Tags im Cluster.

Failure Mode klaeren

Fail-open mit kurzem Timeout fuer die meisten Public APIs, Retry-After Header bei 429.

11. FAQ: Rate-Limiting mit Redis

1Welcher Algorithmus ist am besten geeignet?
Der Sliding Window Counter ist fuer die meisten Public APIs der beste Kompromiss aus Genauigkeit und Speicherbedarf.
2Warum sind Lua-Scripts wichtig?
Atomare Ausfuehrung verhindert Race Conditions zwischen Lesen und Schreiben des Zaehlers unter Last.
3Was ist das Boundary-Problem?
An Fenstergrenzen kann bis zu doppelt so viel Traffic durchgehen wie eigentlich erlaubt ist.
4Wie viel Speicher braucht der Sliding Window Log?
Proportional zur Anzahl erlaubter Anfragen pro Client, da jede Anfrage einzeln gespeichert wird.
5Fail-open oder Fail-closed bei Redis-Ausfall?
Fuer die meisten Public APIs empfiehlt sich Fail-open mit kurzem Timeout.
6Wie kombiniere ich Per-IP und Per-API-Key?
Grosszuegiges IP-Limit als grobe Verteidigung, engeres Key-Limit fuer die Geschaeftslogik.
7Was macht den Token Bucket besonders?
Ungenutzte Kapazitaet sammelt sich an und erlaubt kurzfristige Bursts bei begrenzter Durchschnittsrate.
8Welche HTTP-Header gehoeren dazu?
X-RateLimit-Limit, X-RateLimit-Remaining und Retry-After bei einer 429-Antwort.
9Wie funktioniert Limiting im Redis-Cluster?
Hash-Tags im Schluessel sorgen dafuer, dass zugehoerige Keys auf demselben Shard liegen.
10Warum reicht INCR ohne EXPIRE-NX nicht?
Ein Absturz zwischen beiden Befehlen kann einen Schluessel ohne TTL hinterlassen, der nie ablaeuft.