PHP Rate Limiting: Token Bucket vs. Sliding Window in Redis
Der meiste PHP-Rate-Limiting-Code beginnt sein Leben als kopiertes Snippet. Jemand musste einen Scraper stoppen, fand ein INCR mit angehängtem EXPIRE, klebte es in eine Middleware und machte weiter. Es funktioniert, ungefähr, bis zu dem Tag, an dem ein Kunde an einer Minutengrenze blockiert wird oder zwei App-Server gegeneinander racen und doppelt so viel Traffic durchlassen. PHP Rate Limiting mit Redis verdient einen Nachmittag echten Nachdenkens, weil sich die beiden Hauptalgorithmen genau in den Situationen unterschiedlich verhalten, in denen ein Limiter zählt.
Dieser Beitrag vergleicht Token Bucket und Sliding Window Log, zeigt die Redis-Lua-Skripte, die jeden davon atomar machen, und geht die Symfony-Verdrahtung durch: Key-Struktur, Response-Header, Overrides pro Route und die Fehlerfälle, die nur unter Produktions-Traffic auftauchen.
Wie der Token Bucket funktioniert
Stell dir einen Eimer vor, der 60 Tokens fasst. Jeder Request nimmt ein Token. Der Eimer füllt sich mit konstanter Rate nach, hier ein Token pro Sekunde, und hält nie mehr als seine Kapazität. Ein Request, der auf einen leeren Eimer trifft, wird abgelehnt.
Zwei Eigenschaften ergeben sich aus diesem Design. Der dauerhafte Durchsatz ist auf die Nachfüllrate begrenzt, in diesem Beispiel 60 Requests pro Minute. Und ein Client, der eine Weile ruhig war, kann seine angesparten Tokens auf einmal ausgeben: Ein Burst von 60 Requests in einer Sekunde ist erlaubt, wenn der Eimer voll ist.
Für eine öffentliche API ist diese Burst-Toleranz meist genau das, was du willst. Echte Clients sind burstig. Ein Dashboard lädt und feuert acht Requests gleichzeitig ab, ein Sync-Job wacht auf und schiebt einen Rückstau. Der Token Bucket fängt diese Spitzen ab und hält trotzdem alle über die Zeit an der Durchschnittsrate. Er ist außerdem billig: Der gesamte Zustand besteht aus zwei Zahlen, dem aktuellen Token-Stand und dem Zeitstempel der letzten Nachfüllung.
Die Schwäche ist die Präzision. "60 pro Minute", wie ein Token Bucket es durchsetzt, bedeutet im Schnitt einen pro Sekunde mit Bursts bis 60. Bis zu 120 Requests können in einem einzigen 60-Sekunden-Fenster landen: der volle Eimer plus eine Minute Nachfüllung. Wenn ein Vertrag oder ein fragiles nachgelagertes System die Obergrenze als hartes Maximum in jedem Fenster definiert, ist dieser Überschuss ein echtes Problem.
Wie das Sliding Window Log funktioniert
Das Sliding Window Log führt eine Aufzeichnung jedes akzeptierten Requests mit Zeitstempel, üblicherweise in einem Redis Sorted Set. Kommt ein neuer Request an, löscht der Limiter alle Einträge, die älter als das Fenster sind, zählt den Rest und akzeptiert nur, wenn die Anzahl unter dem Limit liegt.
Das liefert eine exakte Antwort. Bei einem Limit von 60 pro Minute wird kein rückwärts laufendes 60-Sekunden-Fenster jemals mehr als 60 akzeptierte Requests enthalten. Es gibt keinen Burst-Multiplikator und keine der Grenzartefakte eines Fixed-Window-Zählers, bei dem 60 Requests um 11:59:59 gefolgt von 60 weiteren um 12:00:01 völlig legal sind.
Der Preis ist Speicher und Arbeit pro Request. Das Sorted Set hält ein Element pro akzeptiertem Request, ein Tenant mit 10.000 erlaubten Requests pro Stunde kann also 10.000 Einträge unter einem einzigen Key halten. ZREMRANGEBYSCORE plus ZCARD plus ZADD ist immer noch schnell, aber mehr als zwei Hash-Felder zu aktualisieren. Bei hohen Limits wechseln viele Teams zu einem Sliding Window Counter, der das Log annähert, indem er das vorherige feste Fenster gewichtet. Ein fairer Kompromiss, aber nicht mehr exakt.
PHP Rate Limiting in Redis: die Lua-Skripte
Egal welchen Algorithmus du wählst, das Read-Modify-Write muss atomar sein. Zwei PHP-FPM-Worker, die gleichzeitig denselben Zähler prüfen, sehen beide ein verbleibendes Token und akzeptieren beide. Dafür brauchst du keinen verteilten Lock. Redis führt Lua-Skripte atomar aus, Prüfung und Update passieren also als eine Einheit.
Der Token Bucket:
-- KEYS[1] = bucket key
-- ARGV = capacity, refill rate per second, now in ms, cost
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1])
local ts = tonumber(state[2])
if tokens == nil then
tokens = capacity
ts = now
end
tokens = math.min(capacity, tokens + (math.max(0, now - ts) / 1000) * rate)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / rate) * 2000)
return {allowed, math.floor(tokens)}
Das Sliding Window Log:
-- KEYS[1] = zset key
-- ARGV = window in ms, limit, now in ms, unique request id
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)
local count = redis.call('ZCARD', KEYS[1])
if count < limit then
redis.call('ZADD', KEYS[1], now, ARGV[4])
redis.call('PEXPIRE', KEYS[1], window)
return {1, limit - count - 1}
end
return {0, 0}
Lade jedes Skript einmal mit SCRIPT LOAD und rufe es per SHA mit EVALSHA auf; sowohl phpredis als auch Predis machen das in wenigen Zeilen. Die Request-ID im zweiten Skript muss nur pro Request eindeutig sein. Eine UUID funktioniert, genauso die Request-ID, die du ohnehin in Tracing-Headern mitführst.
Keys für Limits pro Tenant und pro Endpoint
Ein Rate-Limit-Key sollte kodieren, welche Policy gilt, wer limitiert wird und was aufgerufen wird:
rl:{policy}:{tenant}:{scope}
Das ergibt rl:api-default:tenant_8231:global für das kontoweite Budget und rl:export:tenant_8231:POST_/v1/exports für einen Endpoint, der ein eigenes braucht. Weil der Policy-Name Teil des Keys ist, startet eine Policy unter neuem Namen mit sauberem Zustand, statt Zählerstände der alten Zahlen zu erben.
Zwei Dinge solltest du vermeiden. Baue Keys nicht aus rohem Client-Input wie dem API-Key-String selbst; hashe ihn oder nutze deine interne Tenant-ID, sonst kann ein feindlicher Client deinen Keyspace mit Müll fluten. Und auf Redis Cluster: Widerstehe dem Drang, alle Keys eines Tenants in einen Hash-Slot zu zwingen, es sei denn, du brauchst wirklich Multi-Key-Operationen. Diese Skripte berühren je einen Key, einfache Keys verteilen die Last also gleichmäßig.
Die Response-Header, die Clients tatsächlich lesen
Gib genug Information zurück, damit ein gut erzogener Client zurückweichen kann, ohne zu raten. X-RateLimit-Limit trägt die Obergrenze für das aktuelle Fenster, X-RateLimit-Remaining den Rest und X-RateLimit-Reset den Zeitpunkt, an dem sich das Budget erneuert. Nutze für Reset einen Unix-Zeitstempel statt eines relativen Sekundenwerts und dokumentiere diese Entscheidung, weil Clients das in beide Richtungen falsch machen. Bei einem 429 füge Retry-After in Sekunden hinzu. Es ist der einzige Header der Gruppe mit einem RFC dahinter, und mehrere HTTP-Clients beachten ihn automatisch.
Beim Token Bucket ist Remaining der abgerundete Token-Stand und Reset jetzt plus die Zeit bis zum nächsten Token. Beim Sliding Window Log ist Reset der älteste Eintrag im Set plus die Fensterlänge. Wenn du diese Werte exakt statt angenähert willst, berechne sie im selben Lua-Skript und gib sie neben dem Verdikt zurück.
Die Verdrahtung in Symfony
Symfony bringt eine Rate-Limiter-Komponente mit, und für einen einzelnen Service mit moderatem Traffic erledigt sie den Job. Ihr Storage bleibt konsistent, indem er die Prüfung in einen Lock wickelt, statt sie als eine atomare Operation nach Redis zu schieben, und unter starker Nebenläufigkeit wird dieser Lock zum eigenen Flaschenhals. Sobald du Policies pro Tenant, exakte Header und mehr als ein Fenster pro Client brauchst, ist ein kleiner dedizierter Service hinter einem Event Subscriber am Ende weniger Code als die Komponente zu verbiegen.
final class RateLimitSubscriber implements EventSubscriberInterface
{
public function __construct(
private RedisRateLimiter $limiter,
private PolicyRegistry $policies,
) {}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onRequest', 7],
KernelEvents::RESPONSE => ['onResponse', 0],
];
}
public function onRequest(RequestEvent $event): void
{
$request = $event->getRequest();
$policy = $this->policies->forRoute($request->attributes->get('_route'));
if ($policy === null) {
return;
}
$tenant = $request->attributes->get('tenant_id')
?? 'ip:' . $request->getClientIp();
$result = $this->limiter->consume($policy, $tenant, $request);
$request->attributes->set('_rate_limit', $result);
if (!$result->allowed) {
$event->setResponse(
new JsonResponse(['error' => 'rate_limited'], 429)
);
}
}
public function onResponse(ResponseEvent $event): void
{
$result = $event->getRequest()->attributes->get('_rate_limit');
if ($result !== null) {
$event->getResponse()->headers->add($result->toHeaders());
}
}
}
Die Registry löst eine Policy pro Route auf: ein globaler Default mit Overrides dort, wo eine Route strengere Zahlen oder gar keine braucht. Ein PHP-Attribut am Controller liest sich gut, eine schlichte Config-Map erledigt denselben Job. Die Request-Priorität von 7 platziert den Subscriber nach dem Routing (Priorität 32) und nach der Firewall (Priorität 8), sodass der Tenant bereits authentifiziert ist, wenn der Limiter läuft, aber bevor Controller-Arbeit für einen Request verschwendet wird, der ohnehin abgelehnt wird.
Fehlerfälle, die du vorher entscheiden solltest
Clock Skew. Wenn jeder App-Server seine eigene microtime() ins Skript gibt, setzen Server mit driftenden Uhren leicht unterschiedliche Fenster durch, und ein stark abweichender füllt Eimer zu früh nach. Die saubere Lösung ist, TIME innerhalb des Lua-Skripts aufzurufen, sodass Redis die einzige Uhr für alle ist. Verwandt: Skripte laufen auf dem Primary, und die Replikation ist asynchron, ein Failover kann also die letzten paar hundert Millisekunden Limiter-Zustand verlieren. Der Token Bucket degradiert dort sanft; das Log lässt kurz zu viel durch. Keins von beidem rechtfertigt ein Konsenssystem.
Redis down. Entscheide Fail Open versus Fail Closed pro Policy, vor dem Ausfall. Für normalen API-Traffic ist Fail Open mit Alarm meist richtig, denn niemand will, dass ein Cache-Ausfall zum kompletten API-Ausfall eskaliert. Wo der Limiter ein Sicherheitskontrollmechanismus ist, bei Login-Versuchen, Passwort-Resets und teuren Export-Endpoints, Fail Closed. In beiden Fällen: Wickle den Redis-Aufruf in ein Timeout von einigen zehn Millisekunden, damit eine hängende Verbindung nicht jeden Request der Flotte blockiert, während sie sich entscheidet.
Limits über mehrere Fenster. "100 pro Minute und 5.000 pro Tag" heißt zwei Keys mit zwei TTLs, idealerweise in einem einzigen Lua-Skript geprüft, sodass ein Roundtrip beides klärt und eine Ablehnung durch das Tagesfenster das Minutenfenster zurückerstattet. Gib jedem Fenster seinen eigenen Key und lass PEXPIRE zu seiner Spanne passen. Einen Key für beide Fenster wiederzuverwenden ist der Weg, auf dem Tageslimits zu seltsamen Uhrzeiten zurückgesetzt werden, die niemand erklären kann.
Die Wahl
Nimm standardmäßig den Token Bucket. Er ist billiger, er passt zum Verhalten von API-Clients, und seine Burst-Toleranz ist für fast allen kommerziellen Traffic akzeptabel. Greif zum Sliding Window Log, wenn die Obergrenze vertraglich ist oder ein nachgelagertes System schützt, das keine Bursts verkraftet, und halte diese Limits niedrig genug, dass die Speicherkosten vernünftig bleiben. Viele Produktionssysteme fahren beides: Buckets für die allgemeine API, ein Log auf der Handvoll Endpoints, wo sich Präzision bezahlt macht.
Der Algorithmus ist nur die halbe Arbeit. Wie du Quotas pro Plan festlegst und sie Kunden kommunizierst, zählt mindestens genauso, und diese Seite haben wir in Rate Limiting für eine Multi-Tenant-API behandelt.
Wenn dein Limiter immer noch dieses kopierte Snippet ist, das seit dem Deployment niemand angeschaut hat, ist das ein häufiger Fund in wachsenden Codebasen und genau die Art von Ding, die wir in einem Code-Audit markieren. Wenn du eine API-Plattform entwirfst und Rate Limiting von Anfang an eingebaut statt später angeschraubt haben willst, ist das individuelle Entwicklung, die wir regelmäßig machen. Schreib an hello@wolf-tech.io oder schau auf wolf-tech.io, wie wir arbeiten.

