Rate Limiting con Redis en Spring Boot
1. Descripción general
Rate limiting te ayuda a bloquear clientes que llaman tu API demasiadas veces en un período determinado, evitando sobrecargar el sistema o que sea atacado. Este artículo presenta cómo usar Redis como contador centralizado, combinado con Lua script para garantizar operaciones atómicas cuando múltiples instancias de la aplicación acceden simultáneamente. Después de leer, podrás escribir tu propio RateLimiter usando los algoritmos fixed window y sliding window, integrarlo en Spring Boot a través de un interceptor, y escribir pruebas para verificar el comportamiento.
2. Por qué elegir Redis para rate limiting
Cuando la aplicación se ejecuta en múltiples instancias (horizontal scaling), el contador almacenado en memoria de cada instancia (como AtomicInteger) no se sincroniza. Redis resuelve este problema porque:
- Es un almacén de datos centralizado, todas las instancias leen/escriben en el mismo lugar.
- Soporta TTL (
EXPIRE) para limpiar automáticamente las claves antiguas, sin necesidad de cron jobs. - Soporta Lua script que se ejecutan de forma atómica en el servidor, evitando race conditions al incrementar el contador y verificar el umbral.
Nota: Si tu sistema solo tiene una única instancia y no necesita compartir estado, usar un limiter en memoria (por ejemplo,
RateLimiterde Guava) será más rápido y simple. Solo debes usar Redis cuando necesites rate limiting distribuido.
3. Configuración de dependencias
<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 configura automáticamente RedisConnectionFactory y StringRedisTemplate basándose en estas propiedades.
4. Algoritmo Fixed Window
Fixed window divide el tiempo en ventanas fijas (por ejemplo, cada 60 segundos) y cuenta el número de solicitudes en esa ventana. Si se excede el umbral, se rechaza.
4.1. Script Lua atómico
Usa Lua script para combinar INCR y EXPIRE en un comando atómico, evitando el caso donde otra solicitud se intercala entre los dos pasos.
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;
}
}
Explicación:
INCR KEYS[1]incrementa el contador de la clave, devuelve el nuevo valor.- Si
current == 1(primera vez que se crea la clave), establece el TTL awindowSecondssegundos. - Todo el script se ejecuta de forma atómica en el servidor Redis, sin ser intercalado por otra solicitud incluso si se ejecuta en múltiples hilos o múltiples instancias.
isAlloweddevuelvetruesi el número de solicitudes actuales no ha excedido ellimit.
4.2. Usar en 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;
}
}
El interceptor obtiene la IP del cliente como clientId, limitando a 100 solicitudes cada 60 segundos. Si se excede el umbral, devuelve HTTP 429 e impide que la solicitud continúe hacia el controlador.
Nota: Fixed window tiene la desventaja de “burst en los límites”: si un cliente envía 100 solicitudes al final del segundo 59 y 100 más al principio del segundo 60, en realidad hay 200 solicitudes en 1 segundo aunque cada ventana sea válida individualmente. Si necesitas más precisión, usa sliding window.
5. Algoritmo Sliding Window Log
Sliding window log almacena el timestamp de cada solicitud en un Sorted Set, eliminando los timestamps demasiado antiguos cada vez que verifica. Es más preciso que fixed window pero consume más memoria ya que almacena cada solicitud.
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;
}
}
Explicación de cada paso en Lua:
ZREMRANGEBYSCOREelimina las entradas con score (timestamp) menor anow - windowMs, es decir, elimina las solicitudes fuera de la ventana de tiempo.ZCARDcuenta el número de entradas restantes en el Sorted Set — este es el número de solicitudes en la ventana actual.- Si
count < limit, añade una nueva entrada con score igual anow, establece el TTL para la clave igual awindowMs(para limpiar automáticamente cuando no haya más tráfico), devuelve1(permitir). - Si ya está lleno, devuelve
0(rechazar), sin añadir la nueva entrada.
Resultado: la ventana se desliza continuamente con el tiempo real, sin el problema de burst en los límites como en fixed window.
6. Comparación de dos algoritmos
| Criterio | Fixed Window | Sliding Window Log |
|---|---|---|
| Precisión | Baja, con burst en los límites | Alta |
| Memoria | O(1) por clave | O(limit) por clave |
| Complejidad | Simple | Más complejo |
| Adecuado para | Límite aproximado, tráfico alto | APIs sensibles, requiere precisión |
Si no estás seguro de qué algoritmo elegir, comienza con fixed window — es simple, tiene buen rendimiento y es suficiente para la mayoría de APIs internas.
7. Escribir pruebas con Testcontainers
Para que las pruebas no dependan de Redis instalado en tu máquina, usa Testcontainers para ejecutar Redis en 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();
}
}
Esta prueba envía 4 solicitudes consecutivas con limit = 3. Las primeras tres solicitudes se aceptan (isTrue), la cuarta solicitud se rechaza (isFalse) porque ha excedido el umbral en la misma ventana. Testcontainers crea y destruye automáticamente el contenedor Redis, asegurando que las pruebas sean independientes del entorno de tu máquina de CI.
8. Errores comunes
- No establecer TTL para la clave: si olvidas
EXPIRE, la clave existe indefinidamente en Redis, causando fuga de memoria y el contador nunca se resetea. - Incrementar el contador y verificar el umbral con dos comandos separados (
GETluegoINCR): crea race condition cuando múltiples solicitudes llegan simultáneamente, resultando en superar el umbral en la práctica. Siempre combina en un script Lua o usaINCRatómico. - Usar una clave que no diferencie entre clientes: si olvidas incluir
clientIden la clave, todos los clientes comparten el mismo contador, bloqueando incorrectamente a otros usuarios. - Sliding window log con tráfico extremadamente alto: el Sorted Set crece si
limites grande y hay muchos clientes simultáneos, considera usar sliding window counter (combinando dos fixed windows) para ahorrar más memoria.
9. Conclusión
Redis combinado con Lua script es la opción popular para construir un rate limiter distribuido: es simple, atómico y fácil de escalar según el número de instancias de aplicación. Fixed window es adecuado para la mayoría de casos gracias a su simplicidad, mientras que sliding window log es adecuado cuando necesitas controlar con precisión en los límites de tiempo. Siempre prueba cuidadosamente el comportamiento en el umbral de límite (boundary) antes de poner en producción, ya que este es el lugar donde los errores lógicos son más propensos a ocurrir.