Rate limiting distribuido con Redis: por qué el limitador en memoria falla detrás de un load balancer
Un limitador de tasa en memoria es la primera solución que aparece cuando hay que proteger un endpoint de abuso, y funciona sin sorpresas mientras la API corra en un solo proceso. El problema aparece en cuanto esa misma API se escala horizontalmente detrás de un load balancer: cada réplica lleva su propio conteo, ninguna sabe lo que están contando las demás, y el límite que se pensó como "cien peticiones por minuto por cliente" se convierte en silencio en "cien peticiones por minuto por cliente, por cada réplica que le toque atender". Vimos este comportamiento en producción antes de entenderlo: alertas de abuso que no disparaban, seguidas de picos de carga que sí llegaban a la base de datos.
Cómo falla el token bucket local
Una implementación típica de token bucket en memoria guarda el estado en un Map dentro del propio proceso de Node.js:
const buckets = new Map();
function tomarToken(clienteId, capacidad = 100, ventanaMs = 60_000) {
const ahora = Date.now();
const estado = buckets.get(clienteId) ?? { tokens: capacidad, ultimaRecarga: ahora };
const transcurrido = ahora - estado.ultimaRecarga;
const recarga = (transcurrido / ventanaMs) * capacidad;
estado.tokens = Math.min(capacidad, estado.tokens + recarga);
estado.ultimaRecarga = ahora;
if (estado.tokens < 1) {
buckets.set(clienteId, estado);
return false;
}
estado.tokens -= 1;
buckets.set(clienteId, estado);
return true;
}
Esto es correcto y barato mientras haya un único proceso sirviendo tráfico. El Map vive en la memoria de ese proceso, así que no hay coordinación que resolver: cada petición lee y escribe el mismo estado sin competir con nadie más. En cuanto se despliega una segunda réplica detrás de un load balancer con reparto round robin, cada una arranca con su propio Map vacío, y un cliente que hace cien peticiones repartidas entre dos réplicas nunca ve el límite, porque desde el punto de vista de cada proceso solo llegaron cincuenta. El límite configurado en el código sigue siendo cien, pero el límite real que experimenta el sistema es cien multiplicado por el número de réplicas activas en ese momento, un número que además cambia cada vez que el autoescalado agrega o quita instancias.
INCR y TTL: un contador compartido y atómico
La solución no es sincronizar los Map de cada proceso entre sí -eso reintroduce el problema de coordinación distribuida que se quería evitar en primer lugar-, sino sacar el conteo del proceso y ponerlo en un almacén que todas las réplicas puedan leer y escribir por igual. Redis encaja bien aquí porque INCR es atómico a nivel del servidor: dos réplicas que ejecutan INCR sobre la misma clave al mismo tiempo nunca pisan el incremento de la otra, porque Redis procesa comandos de forma serializada internamente.
const redis = require('ioredis').createClient();
async function permitirPeticion(clienteId, limite = 100, ventanaSeg = 60) {
const clave = `ratelimit:${clienteId}:${Math.floor(Date.now() / 1000 / ventanaSeg)}`;
const conteo = await redis.incr(clave);
if (conteo === 1) {
await redis.expire(clave, ventanaSeg);
}
return conteo <= limite;
}
La clave incluye la ventana de tiempo truncada, así que cada minuto genera una clave nueva sin que haya que borrar nada manualmente: el EXPIRE que se aplica solo la primera vez que se crea la clave se encarga de que Redis la elimine sola cuando la ventana termina. Cualquier réplica que reciba una petición de ese cliente en ese minuto incrementa el mismo contador, sin importar cuál de ellas la atendió, porque el estado ya no vive en el proceso sino en un servidor compartido.
Este esquema de ventana fija tiene un efecto de borde conocido: un cliente puede agotar su límite en el último segundo de una ventana y volver a agotarlo en el primer segundo de la siguiente, logrando el doble del límite nominal en un lapso de dos segundos alrededor del corte de ventana. Para la mayoría de los casos de protección contra abuso esto es aceptable, porque el objetivo es evitar que un cliente sature el backend de forma sostenida, no aplicar un límite matemáticamente exacto en cualquier ventana deslizante arbitraria.
Ventana deslizante con sorted sets cuando el borde importa
Cuando el efecto de borde sí importa -por ejemplo, en un límite que protege una API de terceros facturada por petición, donde duplicar el límite nominal tiene un costo directo- un sorted set de Redis permite implementar una ventana deslizante real, guardando el timestamp de cada petición como score:
async function permitirPeticionDeslizante(clienteId, limite = 100, ventanaMs = 60_000) {
const clave = `ratelimit:sliding:${clienteId}`;
const ahora = Date.now();
const limiteInferior = ahora - ventanaMs;
const multi = redis.multi();
multi.zremrangebyscore(clave, 0, limiteInferior);
multi.zadd(clave, ahora, `${ahora}-${Math.random()}`);
multi.zcard(clave);
multi.pexpire(clave, ventanaMs);
const resultados = await multi.exec();
const totalEnVentana = resultados[2][1];
return totalEnVentana <= limite;
}
Cada petición se registra como un miembro del sorted set con su timestamp como score. Antes de contar, se eliminan los miembros cuyo score cae fuera de la ventana deslizante actual, así que ZCARD siempre refleja exactamente cuántas peticiones de ese cliente cayeron en los últimos ventanaMs milisegundos, sin importar en qué punto del reloj esté parada la ventana. El costo es mayor que un simple INCR -se guarda un miembro por petición en lugar de un único contador, y hay que limpiar periódicamente los miembros vencidos-, así que conviene reservarlo para los límites donde el borde de ventana fija realmente representa un problema, no aplicarlo por defecto a cualquier endpoint.
Lo que no funcionó: GET y SET separados desde el cliente
Antes de usar INCR, la primera versión que probamos leía el contador con GET, lo incrementaba en el propio código de Node.js, y lo volvía a escribir con SET:
// Version con condicion de carrera
async function permitirPeticionRota(clienteId, limite = 100) {
const clave = `ratelimit:${clienteId}`;
const valorActual = await redis.get(clave);
const conteo = (valorActual ? parseInt(valorActual, 10) : 0) + 1;
await redis.set(clave, conteo, 'EX', 60);
return conteo <= limite;
}
Esto parecía equivalente al INCR pero tenía una condición de carrera real: entre el GET y el SET hay dos viajes de red separados, y si dos réplicas ejecutan ese bloque para el mismo cliente casi al mismo tiempo, ambas pueden leer el mismo valor antes de que ninguna haya escrito el suyo, y ambas escriben el mismo conteo incrementado en uno, perdiendo un incremento. Bajo tráfico bajo esto casi nunca se manifestaba, lo que lo hacía peor: pasó pruebas manuales sin problema y solo empezó a filtrar peticiones de más cuando el tráfico concurrente por cliente subió lo suficiente como para que las ventanas de tiempo entre GET y SET de distintas réplicas empezaran a solaparse con frecuencia. La corrección no fue añadir reintentos ni bloqueos optimistas alrededor del GET/SET -eso solo movía el problema-, sino reemplazar por completo el patrón por INCR, que resuelve la lectura, el incremento y la escritura como una sola operación atómica en el servidor de Redis, sin dejar una ventana de tiempo entre pasos donde otra réplica pueda intercalarse.
Cuándo vale la pena el viaje de ida y vuelta a Redis
Meter una llamada de red a Redis en el camino crítico de cada petición tiene un costo que un Map en memoria no tiene, y para servicios internos de bajo riesgo con una sola réplica ese costo no se justifica. La pregunta que usamos para decidir es directa: ¿puede este endpoint recibir tráfico repartido entre más de un proceso al mismo tiempo? Si la respuesta es sí -y en cualquier API con autoescalado o con más de una instancia fija detrás de un load balancer, la respuesta casi siempre es sí- un contador en memoria no está limitando nada de forma confiable, solo está limitando lo que le toca ver a cada réplica por separado, que es una garantía bastante más débil que la que el límite prometía en el papel.