Rate-Limiter für API Gateway mit Redis und Spring Cloud Gateway
1. Überblick
Wenn mehrere Clients gleichzeitig eine API aufrufen, kann ein fehlerhafter oder absichtlich spammender Client das Backend für alle zerstören. Ein Rate-Limiter auf der API-Gateway-Ebene blockiert Anfragen, die das Limit überschreiten, bevor sie die dahinter liegenden Services erreichen.
Dieser Artikel führt Sie durch eine umfassende Fallstudie. Die Schritte umfassen die Angabe von Anforderungen, die Auswahl eines Algorithmus, die Gestaltung des Datenmodells und der API sowie die Implementierung mit Redis-Lua-Skripten in Spring Cloud Gateway. Danach werden Sie einen verteilten Rate-Limiter erstellen können und wissen, wie man mit Datenkonflikten, Uhrenabweichungen, Hot Keys und Redis-Ausfällen umgeht.
2. Anforderungen
2.1. Funktionale Anforderungen
- Begrenzen Sie nach API-Schlüssel des Mandanten und nach Route. Beispiel: Der Mandant
shop-adarfPOST /ordersmaximal 100 Anfragen pro Sekunde aufrufen, mit Burst bis zu 200. - Anfragen, die das Limit überschreiten, erhalten
429 Too Many Requestsmit dem HeaderRetry-After. - Jede Antwort hat einen Header
X-RateLimit-Remaining, damit Clients sich selbst regulieren können. - Regeln können zur Laufzeit geändert werden, ohne dass das Gateway neu bereitgestellt werden muss.
2.2. Skalierung und Einschränkungen
- Etwa 50.000 RPS in der Spitzenlast, 20 Gateway-Instanzen, 100.000 Mandanten.
- Die Zusatzverzögerung pro Anfrage muss unter 2ms bei p99 liegen.
- Das Limit muss über den gesamten Cluster korrekt sein, nicht pro Instanz. Wenn das Limit 100/s ist, sollte der gesamte Cluster insgesamt etwa 100/s durchlassen.
- Der Rate-Limiter darf kein Single Point of Failure sein. Wenn Redis fehlschlägt, muss das Gateway weiterhin bedient werden.
- Kleine Fehler (wenige %) werden akzeptiert, um Leistung zu erreichen.
Die dritte Einschränkung schließt die Möglichkeit aus, Zählungen im Speicher jeder Instanz zu speichern. Wir benötigen einen gemeinsamen Zustandsspeicher. Redis ist die natürliche Wahl, da es niedrige Latenzen und atomare Operationen über Lua bietet.
3. Algorithmusauswahl
| Algorithmus | Vorteile | Nachteile |
|---|---|---|
| Festes Fenster | Einfach, ein INCR |
Doppelter Burst an der Fensterkante |
| Schiebender Protokoll | Absolut genau | Speicherintensiv, speichert jeden Zeitstempel |
| Schiebender Fenster-Zähler | Annähernd, leicht | Schwierig, Burst darzustellen |
| Token Bucket | Unterstützt nativen Burst, nur 2 Felder | Erfordert Token-Auffüllungsberechnung |
Wir wählen Token Bucket. Jeder Bucket hat eine capacity (Burst-Größe) und eine Netzfrequenz refillPerSecond. Jede Anfrage verbraucht einen Token. Ohne Tokens wird die Anfrage abgelehnt. Der Zustand besteht nur aus zwei Zahlen: tokens und ts (letzte Aktualisierungszeit). Mit 100.000 Mandanten mal mehrere Routes beträgt der Gesamtspeicher nur wenige Dutzend MB.
4. Datenmodell
4.1. Regelkonfiguration in SQL
Regeln ändern sich selten und müssen verwaltet werden, daher speichern wir sie in PostgreSQL:
CREATE TABLE rate_limit_rule (
id BIGSERIAL PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL,
route_id VARCHAR(64) NOT NULL,
capacity INT NOT NULL CHECK (capacity > 0),
refill_per_second INT NOT NULL CHECK (refill_per_second > 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, route_id)
);
INSERT INTO rate_limit_rule (tenant_id, route_id, capacity, refill_per_second)
VALUES ('shop-a', 'create-order', 200, 100);
Das Gateway liest diese Tabelle nicht bei jeder Anfrage. Jede Instanz lädt alle Regeln in den Speicher und aktualisiert sie regelmäßig, z.B. alle 30 Sekunden. Eine Alternative wäre, auf rule-changed Events über Kafka zu hören. Der kritische Pfad (Hot Path) berührt die Datenbank nie.
4.2. Bucket-Status in Redis
Jedes Mandanten-Route-Paar ist ein Redis Hash:
Key: rl:{shop-a}:create-order
Fields: tokens = 153.4
ts = 1760256000123 (ms, nach Redis-Zeit)
TTL: verfällt automatisch, wenn Bucket lange nicht genutzt wird
Der Teil {shop-a} ist ein Hash-Tag. In Redis Cluster fallen alle Schlüssel eines Mandanten in einen einzigen Slot. Wenn Sie später mehrere Buckets in einem Skript überprüfen müssen, befinden sie sich auf demselben Knoten.
5. API und Verarbeitungsfluss
Clients sehen zwei Arten von Antworten:
HTTP/1.1 200 OK
X-RateLimit-Remaining: 152
HTTP/1.1 429 Too Many Requests
Retry-After: 1
X-RateLimit-Remaining: 0
Der Verarbeitungsfluss im Gateway:
- Rufen Sie
X-Api-Keyauf, um den Mandanten zu identifizieren und die Route-ID aus der übereinstimmenden Route abzurufen. - Nachschlagen der Regel im Speicher-Cache. Wenn keine Regel vorhanden ist, verwenden Sie die Standardregel.
- Rufen Sie ein Lua-Skript auf Redis auf, um Tokens zu laden und abzurufen in einer atomaren Round-Trip.
- Wenn erlaubt, Header anhängen und weiterleiten. Wenn verweigert, geben Sie 429 zurück.
- Bei Redis-Fehler oder Timeout, wechseln Sie zum lokalen Limiter (Abschnitt 7.3).
6. Implementierung
6.1. Abhängigkeiten
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2025.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
</dependencies>
6.2. Lua-Skript für Token Bucket
Speichern Sie die Datei unter src/main/resources/scripts/token_bucket.lua:
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillMs = tonumber(ARGV[2]) / 1000.0 -- Token pro ms
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
tokens = math.min(capacity, tokens + math.max(0, now - ts) * refillMs)
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call('HSET', key, 'tokens', tostring(tokens), 'ts', now)
redis.call('PEXPIRE', key, math.ceil(capacity / refillMs) * 2)
return { allowed, math.floor(tokens) }
Wichtige Punkte:
redis.call('TIME')ruft die Zeit von Redis ab, nicht die des Gateways. 20 Instanzen können Uhrenabweichungen von mehreren zehn ms haben, daher werden Tokens falsch gefüllt, wenn jede Instanz ihre eigenenowsendet. Seit Redis 7 replizieren Skripte standardmäßig nach Auswirkungen (Effects Replication), daher ist es sicher,TIMEvor dem Schreiben aufzurufen.- Der gesamte Ablauf von Lesen, Berechnen und Schreiben ist atomar, da Redis Skripte einfach-threaded ausführt. Dies ist die Lösung für Konflikte zwischen Instanzen.
PEXPIREist das Zweifache der Bucket-Auffüllungszeit. Ein ungenutzter Bucket verschwindet von selbst. Wenn er wieder auftaucht, beginnt er voll, genau wie beabsichtigt.- Das Skript gibt
[allowed, remaining]zurück. Lua konvertiert Gleitkommazahlen in Ganzzahlen beim Zurückgeben, daher müssen wir vorherfloorverwenden.
6.3. Skript von Java aufrufen
public record RateLimitRule(int capacity, int refillPerSecond) {}
public record Decision(boolean allowed, long remaining) {}
@Component
public class RedisTokenBucket {
private final ReactiveStringRedisTemplate redis;
private final RedisScript<List> script =
RedisScript.of(new ClassPathResource("scripts/token_bucket.lua"), List.class);
public RedisTokenBucket(ReactiveStringRedisTemplate redis) {
this.redis = redis;
}
@SuppressWarnings("unchecked")
public Mono<Decision> tryAcquire(String tenant, String route, RateLimitRule rule) {
String key = "rl:{" + tenant + "}:" + route;
return redis.execute(script, List.of(key),
List.of(String.valueOf(rule.capacity()),
String.valueOf(rule.refillPerSecond())))
.next()
.map(r -> {
List<Long> res = (List<Long>) r;
return new Decision(res.get(0) == 1L, res.get(1));
});
}
}
Spring berechnet automatisch die SHA1 des Skripts und verwendet EVALSHA. Wenn Redis das Skript noch nicht gecacht hat, wird Spring zu EVAL zurückfallen. Daher sendet nur der erste Aufruf den gesamten Skriptinhalt über das Netzwerk.
6.4. Global Filter im Gateway
@Component
public class RateLimitFilter implements GlobalFilter, Ordered {
private static final RateLimitRule DEFAULT_RULE = new RateLimitRule(20, 10);
private final RedisTokenBucket redisBucket;
private final LocalFallbackLimiter fallback;
private final RuleCache rules; // Map<String, RateLimitRule> nächste Lade aus der DB
public RateLimitFilter(RedisTokenBucket redisBucket,
LocalFallbackLimiter fallback, RuleCache rules) {
this.redisBucket = redisBucket;
this.fallback = fallback;
this.rules = rules;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String tenant = exchange.getRequest().getHeaders().getFirst("X-Api-Key");
if (tenant == null) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
String routeId = route != null ? route.getId() : "default";
RateLimitRule rule = rules.find(tenant, routeId).orElse(DEFAULT_RULE);
return redisBucket.tryAcquire(tenant, routeId, rule)
.timeout(Duration.ofMillis(5))
.onErrorResume(e -> Mono.just(fallback.tryAcquire(tenant, routeId, rule)))
.flatMap(decision -> {
var response = exchange.getResponse();
response.getHeaders().set("X-RateLimit-Remaining",
String.valueOf(decision.remaining()));
if (decision.allowed()) {
return chain.filter(exchange);
}
response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
response.getHeaders().set("Retry-After",
String.valueOf(Math.max(1, 1 / rule.refillPerSecond())));
return response.setComplete();
});
}
@Override
public int getOrder() {
return -100; // Ausführung nach Route-Matching, vor dem Routing-Filter
}
}
Der Filter läuft für alle Routes. Er setzt timeout(5ms), damit ein langsamer Redis-Knoten die Latenz des gesamten Gateways nicht beeinträchtigt. Bei Redis-Fehler wird onErrorResume zum lokalen Limiter ausgelöst. Schnell testen mit curl mit der Regel capacity=2:
for i in 1 2 3; do
curl -s -o /dev/null -w "%{http_code}\n" -H "X-Api-Key: shop-a" -X POST localhost:8080/orders
done
200
200
429
7. Schwierigkeiten und Lösungen
7.1. Race Conditions zwischen Instanzen
Der naive Ansatz ist, HGET aufzurufen, in Java zu berechnen und dann HSET aufzurufen. Zwei Instanzen könnten beide tokens=1 lesen und die Anfrage durchlassen. Lua-Skripte eliminieren diesen Race Condition vollständig. WATCH/MULTI ist auch korrekt, erfordert aber Wiederholungen bei Konflikten und mehr Round-Trips.
7.2. Hot Keys und Laststöße
Ein großer Mandant kann 20.000 RPS auf einen Schlüssel schießen. Ein Redis-Knoten kann etwa 100.000 ops/s verarbeiten, aber dieser Schlüssel wird zum Hot Spot. Es gibt zwei Lösungen:
- Token-Batch-Zuordnung (Local Pre-Fetch): Jede Instanz fordert vorab einen Token-Batch an, z.B. 50 Token pro Abruf, und verbraucht sie dann graduell im Speicher. Die Anzahl der Redis-Aufrufe wird um etwa 50 reduziert. Im Gegenzug kann das Gesamtlimit maximal um
Anzahl Instanzen × Batch-Größekurzzeitig überschritten werden. Mit der Anforderung “wenige % Fehler” in Abschnitt 2.2 ist dies akzeptabel. - Bucket-Sharding: Das Limit von 20.000/s auf 10 Sub-Buckets
rl:{shop-a}:create-order:0..9aufteilen, jeweils 2.000/s. Anfragen wählen den Sub-Bucket zufällig aus. Hash-Tags für diese Schlüssel weglassen, damit sie über mehrere Knoten verteilt werden.
Legitimer Burst wird bereits von capacity absorbiert. Das ist der Grund für die Wahl von Token Bucket über Fixed Window.
7.3. Redis nicht verfügbar: kontrolles Fail-Open
Fail-Closed, d.h. alle Anfragen ablehnen, wenn Redis ausfällt, verwandelt einen sekundären Infrastrukturfehler in einen Systemfehler. Vollständiges Fail-Open verliert dann die Schutzschicht. Die Zwischenlösung ist ein lokaler Limiter mit dem auf die Instanzen aufgeteilten Limit:
@Component
public class LocalFallbackLimiter {
private final int instanceCount;
private final LongSupplier clock;
private final ConcurrentHashMap<String, LocalBucket> buckets = new ConcurrentHashMap<>();
public LocalFallbackLimiter(@Value("${gateway.instances:20}") int instanceCount) {
this(instanceCount, System::currentTimeMillis);
}
LocalFallbackLimiter(int instanceCount, LongSupplier clock) {
this.instanceCount = instanceCount;
this.clock = clock;
}
public Decision tryAcquire(String tenant, String route, RateLimitRule rule) {
double cap = Math.max(1, rule.capacity() / (double) instanceCount);
double perMs = rule.refillPerSecond() / (double) instanceCount / 1000.0;
LocalBucket b = buckets.computeIfAbsent(tenant + ":" + route,
k -> new LocalBucket(cap, clock.getAsLong()));
return b.take(cap, perMs, clock.getAsLong());
}
static final class LocalBucket {
private double tokens;
private long ts;
LocalBucket(double tokens, long ts) {
this.tokens = tokens;
this.ts = ts;
}
synchronized Decision take(double cap, double perMs, long now) {
tokens = Math.min(cap, tokens + Math.max(0, now - ts) * perMs);
ts = now;
if (tokens >= 1) {
tokens -= 1;
return new Decision(true, (long) tokens);
}
return new Decision(false, 0);
}
}
}
Jede Instanz hält 1/instanceCount des Limits. Das Gesamtlimit des Clusters bleibt annähernd korrekt, ohne Redis zu benötigen. In der Produktionsumgebung sollten Sie den Redis-Aufruf mit einem Circuit Breaker einwickeln (z.B. Resilience4j). Wenn Redis ausfällt, verhindert der Circuit Breaker den 5ms-Timeout für jede Anfrage.
7.4. Multi-Region-Konsistenz
Wenn das Gateway in zwei Regionen ausgeführt wird, würde eine regionsübergreifende Redis-Synchronisierung bei jeder Anfrage die 2ms-Anforderung brechen. Der pragmatische Weg ist, das Limit nach Region nach Verkehrsanteil aufzuteilen, z.B. 70/30, und zu akzeptieren, dass ein Mandant in dieser Region früher blockiert werden kann, während in der anderen noch Platz vorhanden ist. Für weitere Optimierungen kann ein Background-Job die Anteile alle paar Minuten basierend auf tatsächlichen Metriken anpassen.
8. Tests
Die Token-Bucket-Logik sollte unabhängig von der echten Uhr getestet werden. Da LocalFallbackLimiter LongSupplier akzeptiert, können wir die Zeit mit AtomicLong vortäuschen:
class LocalFallbackLimiterTest {
@Test
void shouldRejectWhenBucketEmptyAndRefillOverTime() {
AtomicLong now = new AtomicLong(0);
var limiter = new LocalFallbackLimiter(1, now::get);
var rule = new RateLimitRule(2, 1); // Burst 2, 1 Token/Sekunde laden
assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isTrue();
assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isTrue();
assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isFalse();
now.addAndGet(1_000); // Nach 1 Sekunde ein weiterer Token
Decision d = limiter.tryAcquire("shop-a", "create-order", rule);
assertThat(d.allowed()).isTrue();
assertThat(d.remaining()).isZero();
}
}
Dieser Test bestätigt drei Verhaltensweisen: Burst bis zu capacity ist erlaubt, Anfragen über dem Limit werden abgelehnt, und Tokens werden korrekt nach Zeit gefüllt. Bei Lua-Skripten sollten Sie Integration Tests mit Testcontainers mit dem redis:7 Image schreiben. Senden Sie 1.000 Anfragen parallel und prüfen Sie, dass die Anzahl der zulässigen Anfragen nicht capacity überschreitet.
Hinweis: Häufige Fehler:
- Verwendung von
System.currentTimeMillis()des Gateways alsnowim Skript. Uhrenabweichungen zwischen Instanzen führen zu falscher Token-Auffüllung.- TTL für Schlüssel nicht setzen. Redis wird allmählich entsprechend der Anzahl der Mandanten anschwellen.
- Client nach NAT oder Load Balancer nach IP identifizieren. Ein ganzes Unternehmen teilt sich einen Bucket.
- Verwenden Sie den Rate-Limiter am Gateway nicht als Ersatz für Überlastungsschutz im Service selbst (Bulkhead, Load Shedding). Das Gateway kennt nur die Anzahl der Anfragen, nicht welche schwer sind.
9. Fazit
In diesem Artikel haben wir einen Rate-Limiter für ein API Gateway von den Anforderungen bis zur Implementierung entworfen. Token Bucket handhabt Burst elegant. Lua-Skripte auf Redis lösen Konflikte durch eine atomare Round-Trip. Die Zeit von Redis behebt Uhrenabweichungsprobleme zwischen Instanzen.
Die wirklichen Schwierigkeiten liegen außerhalb des Algorithmus. Hot Keys benötigen Token-Batch-Zuordnung oder Bucket-Sharding. Redis-Fehler benötigen ein kontrolliertes Fail-Open mit lokalem Limiter. Multi-Region-Systeme benötigen Limit-Sharding statt enger Synchronisierung. Bei jedem Punkt opfern wir etwas Genauigkeit für niedrige Latenzen und Verfügbarkeit, gemäß den anfangs festgelegten Anforderungen.