¿Cómo implementas un rate limiting que sea correcto entre múltiples instancias de servidor, no solo en un proceso?
Los contadores en memoria mienten en cuanto escalás horizontalmente — un limitador correcto necesita estado compartido, atómico y un algoritmo de ventana bien elegido.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo implementas un rate limiting que sea correcto entre múltiples instancias de servidor, no solo en un proceso?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Cuando "Simplemente Contar en Memoria" Deja de Funcionar
Un limitador de tasa ingenuo mantiene un contador por clave de cliente en una estructura de datos local — un Map, un arreglo fijo de buckets, lo que sea — y lo resetea o lo desliza con el tiempo. Eso funciona perfectamente en un solo proceso. Se rompe en cuanto corrés más de una instancia de tu servicio, que en la práctica significa casi de inmediato: dos contenedores detrás de un load balancer, un Deployment de Kubernetes con replicas: 3, o incluso una sola instancia que se reinicia a mitad de una ventana.
El modo de falla es fácil de ver. Si tu límite es "100 requests por minuto por API key" y corrés 4 instancias detrás de un load balancer round-robin, cada instancia cree de forma independiente que es dueña de la cuota completa. Un cliente ahora puede hacer casi 400 requests por minuto — cien contra cada instancia — antes de que un solo proceso rechace una request. El limitador no está mal en ningún nodo individual; está mal respecto al sistema. Un rate limiting correcto necesita estado compartido entre todas las instancias que puedan ver el tráfico de un cliente dado, y ese estado tiene que actualizarse atómicamente, o reintroducís la misma carrera a menor escala.
#Elegir un Algoritmo
Antes de preocuparte por dónde vive el estado, decidí qué debería significar realmente "100 requests por minuto" en el borde de la ventana, porque los algoritmos no están de acuerdo.
| Algoritmo | Cómo funciona | Comportamiento en el borde | Memoria |
|---|---|---|---|
| Ventana fija (fixed window) | Incrementa un contador con clave clienteId:minutoBucket; se resetea cada minuto | Puede permitir ~2x el límite cruzando el borde de la ventana (ráfaga en 0:59 y 1:00) | O(1) por cliente |
| Log de ventana deslizante | Guarda un timestamp por request (por ejemplo en un sorted set); cuenta entradas dentro de la ventana móvil | Exacto — sin ráfaga de borde | O(límite) por cliente |
| Contador de ventana deslizante | Promedio ponderado del conteo de la ventana fija actual y la anterior | Aproximación cercana, error pequeño, barato | O(1) por cliente |
| Token bucket | El bucket se recarga a una tasa fija; cada request consume un token | Permite ráfagas controladas hasta el tamaño del bucket, suaviza la tasa sostenida | O(1) por cliente |
| Leaky bucket | Las requests se encolan y drenan a tasa fija | Suaviza completamente la salida, agrega latencia bajo ráfaga | O(cola) por cliente |
Para la mayoría de los rate limits de API, un log de ventana deslizante (exacto) o un token bucket (tolerante a ráfagas) es el trade-off correcto; las ventanas fijas son las más simples pero filtran el bug de ráfaga en el borde, que es una pregunta de seguimiento común en entrevistas.
#El Verdadero Peligro: Check-Then-Act No Es Atómico
Incluso después de mover el contador a Redis, una implementación ingenua sigue teniendo carrera:
- La instancia A ejecuta
GET count→ 99 (el límite es 100) - La instancia B ejecuta
GET count→ 99, casi en el mismo instante - Ambas ven
99 < 100y deciden permitir la request - Ambas ejecutan
INCR count→ el contador termina en 101, y se admitieron dos requests que no deberían haberse admitido las dos
Esta es una carrera clásica de check-then-act, la prima distribuida de un i++ no atómico entre threads. La solución tiene la misma forma que la solución dentro de un proceso: convertir la secuencia "leer estado actual, decidir, mutar estado" en una única operación atómica, para que ninguna otra instancia pueda observar un estado intermedio.
#Hacer Atómica Toda la Decisión Con un Script Lua
Redis ejecuta cada script Lua como una única operación ininterrumpible — ningún otro comando de ningún cliente (en ese servidor/shard) corre en el medio. Eso te da exactamente la atomicidad que necesita la secuencia check-then-act, sin un lock distribuido. Un limitador de log de ventana deslizante se ve así:
-- KEYS[1] = clave de rate limit, ej. "ratelimit:{user:42}"-- ARGV[1] = tamaño de la ventana en milisegundos-- ARGV[2] = máximo de requests permitidas en la ventana-- ARGV[3] = id único de esta request (miembro del sorted set)local key = KEYS[1]local window_ms = tonumber(ARGV[1])local limit = tonumber(ARGV[2])local member = ARGV[3]local time_parts = redis.call('TIME')local now_ms = (tonumber(time_parts[1]) * 1000) + math.floor(tonumber(time_parts[2]) / 1000)redis.call('ZREMRANGEBYSCORE', key, '-inf', now_ms - window_ms)local current = redis.call('ZCARD', key)if current < limit thenredis.call('ZADD', key, now_ms, member)redis.call('PEXPIRE', key, window_ms)return {1, limit - current - 1}elsereturn {0, 0}end
Notá que el script le pide la hora actual a Redis con TIME en lugar de confiar en el reloj de quien lo llama — cuatro instancias de la aplicación en cuatro hosts no coinciden en el milisegundo exacto, y dejar que cada una pase su propio Date.now() al script haría que el borde de la ventana fuera impreciso entre instancias. Dejar que el servidor sea la única fuente de verdad para el tiempo elimina esa fuente de desvío.
Del lado de la aplicación, solo hace falta un cliente que pueda registrar y llamar al script:
const Redis = require('ioredis');const redis = new Redis(process.env.REDIS_URL);const SCRIPT = require('fs').readFileSync('sliding_window.lua', 'utf8');redis.defineCommand('slidingWindowLimit', {numberOfKeys: 1,lua: SCRIPT,});async function checkRateLimit(clientId, limit, windowMs) {const key = 'ratelimit:{' + clientId + '}';const member = Date.now() + '-' + Math.random().toString(36).slice(2);const result = await redis.slidingWindowLimit(key, windowMs, limit, member);return { allowed: result[0] === 1, remaining: result[1] };}// Middleware de Expressapp.use(async (req, res, next) => {const { allowed, remaining } = await checkRateLimit(req.apiKey, 100, 60000);res.set('X-RateLimit-Remaining', String(remaining));if (!allowed) {return res.status(429).set('Retry-After', '1').json({ error: 'rate_limited' });}next();});
#Cómo Se Ve Esto Entre Instancias
Cada instancia habla con la misma clave de Redis, y cada verificación-e-incremento es una única ejecución atómica de script, así que no importa a qué instancia llegue una request dada — la decisión se toma contra una única pieza consistente de estado compartido.
#Detalles Operativos Que Salen en Entrevistas
- Ubicación de claves en cluster. Si Redis corre en modo cluster, un script Lua solo puede tocar claves que hasheen al mismo slot. Envolvé el id del cliente en un hash tag —
ratelimit:{user:42}— para que Redis enrute toda la clave a un solo slot sin importar qué más haya en el nombre. - Fail-open vs. fail-closed. Decidí de antemano qué pasa si Redis mismo queda inalcanzable. Fail-closed (rechazar todas las requests) protege tu backend pero convierte una caída de Redis en una caída total. Fail-open (permitir todas las requests) te mantiene disponible pero quita la protección justo cuando un pico podría estar causando el problema de Redis en primer lugar. La mayoría de los sistemas en producción usan fail-open específicamente para rate limiting, porque perder el rate limiting por unos segundos suele salir más barato que una caída total.
- Presupuesto de latencia. Un round trip a Redis agrega latencia real a cada request. Colocá el Redis de rate limiting en la misma región/AZ que tus instancias, y considerá una caché local de corta duración para "definitivamente todavía permitido" que evite el round trip en el caso común.
- Higiene de TTL. Siempre poné una expiración en la clave de rate limit (como hace el script con
PEXPIRE) para que las claves abandonadas de clientes que dejaron de mandar tráfico no se acumulen para siempre.
🧠 Check your understanding
0/3 · 0/3 answered1. ¿Por qué un rate limiter ingenuo en memoria aplica menos restricción de la debida una vez que corrés múltiples instancias detrás de un load balancer?
2. ¿Qué carrera específica sufre una implementación ingenua de GET-luego-INCR en Redis, aunque Redis en sí sea single-threaded?
3. ¿Por qué el script Lua obtiene la hora actual con el comando TIME de Redis en lugar de aceptar un timestamp de la aplicación que lo llama?