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 aufwindowSecondsSekunden. - 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.
isAllowedgibttruezurück, wenn die aktuelle Anzahl der Anfragen denlimitnoch 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:
ZREMRANGEBYSCORElöscht Einträge, deren Score (Timestamp) kleiner ist alsnow - windowMs, d. h. es entfernt Anfragen, die außerhalb des Zeitfensters liegen.ZCARDzä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 Scorenowhinzugefügt, die TTL des Keys wird aufwindowMsgesetzt (um sich selbst zu bereinigen, wenn kein Traffic mehr vorhanden ist), und1wird zurückgegeben (erlaubt). - Wenn das Limit bereits erreicht ist, wird
0zurü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
EXPIREvergisst, 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 (
GETgefolgt vonINCR): 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 atomarenINCR-Befehl. - Identische Keys für alle Clients: Wenn du vergisst,
clientIdin 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
limitgroß 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.