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-a puede llamar a POST /orders máximo 100 solicitudes/segundo, permitiendo ráfagas de hasta 200.
  • Las solicitudes que exceden el límite reciben 429 Too Many Requests con el header Retry-After.
  • Cada respuesta incluye el header X-RateLimit-Remaining para 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:

  1. Obtener X-Api-Key para identificar el tenant y obtener el id de ruta de la ruta coincidente.
  2. Buscar la regla en el caché en memoria. Si no hay regla, usar la regla por defecto.
  3. Llamar a un script Lua en Redis para recargar el token y obtener un token en un viaje redondo atómico.
  4. Si está permitido, adjuntar headers y reenviar. Si se rechaza, devolver 429.
  5. 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 propio now, los tokens se cargarán incorrectamente. Desde Redis 7, los scripts se replican por defecto según efectos (effects replication), así que llamar a TIME antes 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.
  • PEXPIRE es 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 usamos floor antes 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 lote en 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 como now en 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.

system-designrate-limitingredisspring-cloud-gatewayjava