Rate Limiting avec Redis dans Spring Boot
1. Aperçu
Le rate limiting vous aide à bloquer les clients qui appellent l’API trop de fois dans une période donnée, pour éviter de surcharger le système ou d’être abusé. Cet article explique comment utiliser Redis comme un compteur centralisé, combiné avec Lua script pour assurer l’atomicité quand plusieurs instances de l’application y accèdent simultanément. Une fois que vous l’aurez lu, vous serez capable d’écrire vous-même un RateLimiter utilisant les algorithmes fixed window et sliding window, l’intégrer dans Spring Boot via un interceptor, et écrire des tests pour vérifier le comportement.
2. Pourquoi choisir Redis pour le rate limiting
Quand l’application s’exécute sur plusieurs instances (horizontal scaling), les compteurs stockés en mémoire de chaque instance (par exemple AtomicInteger) ne sont pas synchronisés. Redis résout ce problème car :
- C’est un magasin de données centralisé, toutes les instances lisent/écrivent au même endroit.
- Il supporte TTL (
EXPIRE) pour nettoyer automatiquement les anciennes clés, sans avoir besoin d’un cron job. - Il supporte Lua script qui s’exécutent atomiquement sur le serveur, évitant les race conditions lors de l’incrémentation du compteur et de la vérification du seuil.
Note : Si votre système ne dispose que d’une seule instance et n’a pas besoin de partager l’état, l’utilisation d’un limiter en mémoire (par exemple Guava
RateLimiter) sera plus rapide et plus simple. Vous ne devriez utiliser Redis que si vous avez besoin d’un rate limit distribué.
3. Installation des dépendances
<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 configure automatiquement RedisConnectionFactory et StringRedisTemplate en fonction de ces propriétés.
4. Algorithme Fixed Window
Fixed window divise le temps en fenêtres fixes (par exemple chaque 60 secondes), en comptant le nombre de requêtes dans chaque fenêtre. Si le seuil est dépassé, la requête est rejetée.
4.1. Script Lua atomique
Utilisez un Lua script pour combiner INCR et EXPIRE en une seule commande atomique, évitant qu’une autre requête n’intervienne entre les deux étapes.
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;
}
}
Explication :
INCR KEYS[1]incrémente le compteur de la clé, retourne la nouvelle valeur.- Si
current == 1(première fois que la clé est créée), définissez TTL égal àwindowSecondssecondes. - Le script entier s’exécute atomiquement sur le serveur Redis, ne pouvant pas être entrelacé par une autre requête même avec du multi-threading ou plusieurs instances.
isAllowedretournetruesi le nombre de requêtes actuel ne dépasse paslimit.
4.2. Utilisation dans un interceptor Spring Boot
@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;
}
}
L’interceptor récupère l’adresse IP du client comme clientId, limite 100 requêtes par 60 secondes. Si le seuil est dépassé, retourner HTTP 429 et bloquer la requête d’accéder au contrôleur.
Note : Fixed window a le désavantage du « burst à la limite » : si un client envoie 100 requêtes à la fin de la 59e seconde et 100 autres requêtes au début de la 60e seconde, il y a en réalité 200 requêtes en 1 seconde, bien que chaque fenêtre soit conforme aux limites. Si vous avez besoin de plus de précision, utilisez sliding window.
5. Algorithme Sliding Window Log
Sliding window log stocke le timestamp de chaque requête dans un Sorted Set, supprimant les timestamps obsolètes à chaque vérification. C’est plus précis que fixed window mais consomme plus de mémoire car chaque requête est enregistrée.
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;
}
}
Explication de chaque étape dans Lua :
ZREMRANGEBYSCOREsupprime les entrées avec un score (timestamp) inférieur ànow - windowMs, c’est-à-dire supprime les requêtes sorties de la fenêtre de temps.ZCARDcompte le nombre d’entrées restantes dans le Sorted Set — c’est exactement le nombre de requêtes dans la fenêtre actuelle.- Si
count < limit, ajouter une nouvelle entrée avec le scorenow, définissez TTL pour la clé égal àwindowMs(pour nettoyer automatiquement quand il n’y a plus de trafic), retourner1(autorisé). - Si elle est pleine, retourner
0(rejeté), ne pas ajouter de nouvelle entrée.
Résultat : la fenêtre glisse continuellement en temps réel, sans phénomène de burst à la limite comme avec fixed window.
6. Comparaison des deux algorithmes
| Critères | Fixed Window | Sliding Window Log |
|---|---|---|
| Précision | Faible, avec burst à la limite | Haute |
| Mémoire | O(1) par clé | O(limit) par clé |
| Complexité | Simple | Plus complexe |
| Cas d’usage | Limites grossières, trafic élevé | API sensibles, nécessitant de la précision |
Si vous n’êtes pas sûr quel algorithme choisir, commencez par fixed window — c’est simple, performant, et suffisant pour la plupart des API internes.
7. Écrire des tests avec Testcontainers
Pour que les tests ne dépendent pas de Redis pré-installé sur la machine, utilisez Testcontainers pour démarrer Redis dans Docker.
<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();
}
}
Ce test envoie 4 requêtes successives avec limit = 3. Les trois premières requêtes sont acceptées (isTrue), la quatrième est rejetée (isFalse) car elle dépasse le seuil dans la même fenêtre. Testcontainers démarre et arrête automatiquement le conteneur Redis, garantissant que le test est indépendant de l’environnement de la machine exécutant la CI.
8. Erreurs courantes
- Ne pas définir TTL pour la clé : si vous oubliez
EXPIRE, la clé persiste indéfiniment dans Redis, causant une fuite mémoire et le compteur ne réinitialisant jamais. - Incrémenter le compteur et vérifier le seuil avec deux commandes séparées (
GETpuisINCR) : crée une race condition quand plusieurs requêtes arrivent simultanément, causant un dépassement réel du seuil. Combinez toujours en un seul Lua script ou utilisezINCRatomique. - Utiliser une clé qui ne distingue pas les clients : si vous oubliez d’intégrer
clientIddans la clé, tous les clients partageront le même compteur, ce qui bloquerait par erreur d’autres utilisateurs. - Sliding window log avec un trafic extrêmement élevé : le Sorted Set peut grossir énormément si
limitest grand et que beaucoup de clients accèdent simultanément, envisagez d’utiliser sliding window counter (combinant deux fixed windows) pour économiser la mémoire.
9. Conclusion
Redis combiné avec Lua script est le choix populaire pour construire un rate limiter distribué : simple, atomique, et facile à scaler en fonction du nombre d’instances d’application. Fixed window convient à la plupart des cas grâce à sa simplicité, tandis que sliding window log est adapté quand vous avez besoin d’un contrôle précis à la limite du temps. Testez toujours soigneusement le comportement aux seuils (boundaries) avant de le mettre en production, car c’est là que les erreurs logiques sont les plus susceptibles de se produire.