Rate limiter distribuido para API gateway con Redis y Spring Cloud
1. Descripción general
Cuando múltiples clientes llaman a la API, un cliente con errores o que hace spam intencionalmente puede derribar el backend de todos. Un rate limiter en la capa API gateway bloquea las solicitudes que exceden el límite antes de que lleguen al servicio detrás.
Este artículo revisa un estudio de caso completo. Los pasos incluyen especificar requisitos, elegir un algoritmo, diseñar datos y API, e implementar con script Lua en Redis junto con Spring Cloud Gateway. Después de leer, sabrás cómo construir un rate limiter distribuido y manejar condiciones de carrera, desviación de reloj, hot keys y fallos de Redis.
2. Requisitos del problema
2.1. Requisitos funcionales
- Limitar según API key del tenant y según ruta. Por ejemplo: el tenant
shop-apuede llamar aPOST /ordersmáximo 100 solicitudes/segundo, permitiendo ráfagas de hasta 200. - Las solicitudes que exceden el límite reciben
429 Too Many Requestscon el headerRetry-After. - Cada respuesta incluye el header
X-RateLimit-Remainingpara que el cliente se autorregule. - Las reglas se pueden cambiar en tiempo de ejecución sin necesidad de redesploy del gateway.
2.2. Escala y restricciones
- Aproximadamente 50.000 RPS en horas pico, 20 instancias de gateway, 100.000 tenants.
- La latencia añadida por solicitud debe ser inferior a 2ms en p99.
- El límite debe ser correcto en todo el cluster, no por instancia. Si el límite es 100/s, el cluster en total permite aproximadamente 100/s.
- El rate limiter no debe convertirse en un punto único de fallo. Cuando Redis falla, el gateway sigue sirviendo solicitudes.
- Aceptamos errores pequeños (unos pocos %) para ganar rendimiento.
La tercera restricción elimina el enfoque de conteo en memoria de cada instancia. Necesitamos un estado compartido. Redis es la opción natural por su baja latencia y operaciones atómicas a través de Lua.
3. Selección del algoritmo
| Algoritmo | Ventajas | Desventajas |
|---|---|---|
| Ventana fija | Simple, un INCR |
Ráfaga doble en los límites de la ventana |
| Registro deslizante | Absolutamente preciso | Uso alto de memoria, almacena cada timestamp |
| Contador de ventana deslizante | Aproximadamente preciso, ligero | Difícil de expresar ráfagas |
| Token bucket | Soporta ráfagas naturalmente, solo 2 campos | Requiere cálculo de recarga |
Elegimos token bucket. Cada bucket tiene capacity (tamaño de ráfaga) y una tasa de recarga refillPerSecond. Cada solicitud toma un token. Sin tokens, la solicitud se rechaza. El estado consta de solo dos números: tokens y ts (última actualización). Con 100.000 tenants por varias rutas, el uso total de memoria es solo decenas de MB.
4. Modelo de datos
4.1. Reglas de configuración en SQL
Las reglas cambian raramente y necesitan ser administradas, así que las almacenamos en 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);
El gateway no lee esta tabla en cada solicitud. Cada instancia carga todas las reglas en memoria y las actualiza periódicamente, por ejemplo cada 30 segundos. Otra forma es escuchar eventos rule-changed a través de Kafka. La ruta crítica (hot path) nunca accede a la base de datos.
4.2. Estado del bucket en Redis
Cada pareja tenant–route es un hash de Redis:
Key: rl:{shop-a}:create-order
Fields: tokens = 153.4
ts = 1760256000123 (ms, según el reloj de Redis)
TTL: se expira automáticamente cuando el bucket está lleno y no se usa
La parte {shop-a} es una hash tag. En Redis Cluster, todas las claves del mismo tenant caen en el mismo slot. Si después necesitas inspeccionar múltiples buckets en un script, estarán en el mismo nodo.
5. API y flujo de procesamiento
El cliente ve dos tipos de respuesta:
HTTP/1.1 200 OK
X-RateLimit-Remaining: 152
HTTP/1.1 429 Too Many Requests
Retry-After: 1
X-RateLimit-Remaining: 0
El flujo de procesamiento en el gateway:
- Obtener
X-Api-Keypara identificar el tenant y obtener el id de ruta de la ruta coincidente. - Buscar la regla en el caché en memoria. Si no hay regla, usar la regla por defecto.
- Llamar a un script Lua en Redis para recargar el token y obtener un token en un viaje redondo atómico.
- Si está permitido, adjuntar headers y reenviar. Si se rechaza, devolver 429.
- Si Redis falla o expira el tiempo, cambiar al limitador local (sección 7.3).
6. Implementación
6.1. Dependencias
<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. Script Lua token bucket
Coloca el archivo en src/main/resources/scripts/token_bucket.lua:
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillMs = tonumber(ARGV[2]) / 1000.0 -- tokens por 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) }
Puntos principales:
redis.call('TIME')obtiene la hora de Redis en lugar de la hora del gateway. Las 20 instancias tienen relojes desviados por varias decenas de ms, así que si cada instancia envía su propionow, los tokens se cargarán incorrectamente. Desde Redis 7, los scripts se replican por defecto según efectos (effects replication), así que llamar aTIMEantes de escribir es seguro.- Toda la secuencia de lectura, cálculo y escritura se ejecuta de forma atómica, porque Redis ejecuta scripts de un solo hilo. Esta es la solución para las condiciones de carrera entre instancias.
PEXPIREes dos veces el tiempo de recarga completa del bucket. Un bucket sin usar desaparece automáticamente. Cuando vuelve a aparecer, comienza lleno, como se espera.- El script devuelve
[allowed, remaining]. Lua convierte números reales a enteros al devolver, así que usamosfloorantes para mayor claridad.
6.3. Llamar al script desde Java
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 calcula automáticamente el SHA1 del script y usa EVALSHA. Si Redis aún no ha cacheado el script, Spring fallback a EVAL. Por lo tanto, solo la primera llamada envía el contenido del script completo a través de la red.
6.4. Global filter en el 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> cargadas de DB periódicamente
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; // se ejecuta después de que la ruta coincida, antes del routing filter
}
}
El filter se ejecuta para todas las rutas. Establece timeout(5ms) para que un nodo Redis lento no arrastre la latencia de todo el gateway. Cuando Redis falla, onErrorResume cambia al limitador local. Prueba rápidamente con curl y regla 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. Puntos difíciles y soluciones
7.1. Condiciones de carrera entre instancias
El enfoque ingenuo es llamar a HGET, calcular en Java y luego llamar a HSET. Dos instancias pueden leer simultáneamente tokens=1 y permitir que ambas solicitudes pasen. El script Lua elimina completamente esta condición de carrera. El enfoque WATCH/MULTI también es correcto, pero requiere reintentos en caso de conflicto y consume más viajes redondos.
7.2. Hot keys y ráfagas de carga
Un tenant grande puede enviar 20.000 RPS a una sola clave. Un nodo Redis puede manejar aproximadamente 100.000 ops/s, pero esa clave se convertirá en un cuello de botella. Hay dos formas de resolverlo:
- Asignación de tokens en lotes (pre-fetch local): cada instancia solicita un lote de tokens por adelantado, por ejemplo 50 tokens a la vez, luego los consume localmente. El número de llamadas a Redis se reduce aproximadamente 50 veces. En cambio, el límite total podría excederse en
número de instancias × tamaño del loteen un intervalo corto. Con el requisito “error de unos pocos %” en la sección 2.2, esto es aceptable. - Sharding de bucket: divide el límite de 20.000/s en 10 sub-buckets
rl:{shop-a}:create-order:0..9, cada uno con 2.000/s. Las solicitudes se distribuyen aleatoriamente entre los sub-buckets. Omite el hash tag para estas claves para que se distribuyan entre múltiples nodos.
El burst legítimo ya está contenido por capacity. Esta es la razón por la que elegimos token bucket en lugar de fixed window.
7.3. Redis no disponible: fail-open controlado
Fail-closed, es decir rechazar todas las solicitudes cuando Redis muere, convierte una falla de infraestructura secundaria en una falla de todo el sistema. Fail-open completamente pierde la capa de protección. Una solución intermedia es un limitador local con límites divididos equitativamente entre instancias:
@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);
}
}
}
Cada instancia mantiene una parte de 1/instanceCount del límite. El total del cluster sigue siendo aproximadamente correcto sin necesidad de Redis. En producción, debes envolver la llamada a Redis con un circuit breaker (por ejemplo, Resilience4j). Cuando Redis está caído, el circuit breaker evita gastar 5ms en timeout para cada solicitud.
7.4. Consistencia multi-región
Si el gateway se ejecuta en dos regiones, sincronizar Redis entre regiones en cada solicitud romperá el requisito de 2ms. El enfoque práctico es dividir el límite por región según la proporción de tráfico, por ejemplo 70/30, y aceptar que un tenant podría ser limitado en una región aunque otra tenga capacidad disponible. Si se necesita optimizar más, un trabajo en segundo plano puede ajustar la proporción cada pocos minutos según métricas reales.
8. Pruebas
La lógica del token bucket debe probarse independientemente del reloj del sistema. Como LocalFallbackLimiter recibe LongSupplier, podemos avanzar el tiempo con AtomicLong:
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, carga 1 token/segundo
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); // después de 1 segundo hay 1 token más
Decision d = limiter.tryAcquire("shop-a", "create-order", rule);
assertThat(d.allowed()).isTrue();
assertThat(d.remaining()).isZero();
}
}
Esta prueba confirma tres comportamientos: la ráfaga se permite hasta capacity, las solicitudes que exceden el límite se rechazan y los tokens se recargan correctamente con el tiempo. Para el script Lua, deberías escribir una prueba de integración con Testcontainers usando la imagen redis:7. Dispara 1.000 solicitudes en paralelo y verifica que el número de solicitudes permitidas no exceda capacity.
Nota: Algunos errores comunes:
- Usar
System.currentTimeMillis()del gateway comonowen el script. La desviación del reloj entre instancias hará que los tokens se carguen incorrectamente.- Olvidar establecer TTL para la clave. Redis crecerá gradualmente según el número de tenants que aparezcan.
- Identificar cliente por IP detrás de NAT o load balancer. Toda una empresa compartirá un bucket.
- No debes usar rate limiter en el gateway en lugar de mecanismos de sobrecarga en el servicio en sí (bulkhead, load shedding). El gateway solo sabe el número de solicitudes, no cuál es pesada.
9. Conclusión
En este artículo, diseñamos un rate limiter para API gateway desde los requisitos hasta la implementación. Token bucket maneja ráfagas limpiamente. El script Lua en Redis resuelve las condiciones de carrera con un viaje redondo atómico. Obtener la hora de Redis elimina el problema de desviación de reloj entre instancias.
Las dificultades reales están fuera del algoritmo. Los hot keys necesitan asignación de tokens en lotes o sharding de bucket. Las fallas de Redis necesitan fail-open controlado con limitador local. Los sistemas de varias regiones necesitan dividir límites en lugar de sincronización ajustada. En cada punto, intercambiamos un poco de precisión por baja latencia y disponibilidad, de acuerdo con las restricciones establecidas desde el principio.