Hace dos semanas, nuestro sistema de notificaciones push sufrió una caída parcial crítica. Los dashboards de Datadog mostraban una latencia disparada a 3 segundos en el microservicio de autenticación, a pesar de que teóricamente teníamos un API Rate Limiting configurado a 100 peticiones por minuto (RPM) por usuario. Al analizar los logs de acceso, descubrimos el culpable: un script automatizado de un cliente estaba enviando 100 peticiones en el segundo 59 de un minuto y otras 100 en el segundo 01 del siguiente.
Para nuestro Rate Limiter "Fixed Window" (Ventana Fija) basado en contadores simples de Redis, esto era tráfico legítimo. Sin embargo, para nuestra base de datos, fueron 200 peticiones en 2 segundos, rompiendo efectivamente el contrato de servicio. Este escenario es clásico en la Seguridad Backend, pero a menudo se subestima hasta que ocurre un incidente en producción. En este artículo, detallaré cómo migramos de una implementación ingenua a un robusto Algoritmo Sliding Window (Ventana Deslizante) utilizando Redis Lua scripts para garantizar la atomicidad.
Análisis del Fallo: El Problema de la Frontera
Nuestro entorno corre sobre Kubernetes (EKS) con instancias de Node.js v18 conectadas a un clúster de AWS ElastiCache (Redis 7.0). El tráfico habitual oscila entre 15k y 20k RPM. La implementación original utilizaba el patrón estándar de INCR y EXPIRE.
Para un Control de tráfico preciso, necesitamos saber no solo "cuántas peticiones hubo en este minuto de reloj", sino "cuántas peticiones hubo en los últimos 60 segundos desde AHORA". Aquí es donde la mayoría de las librerías open source fallan o introducen latencia innecesaria al hacer múltiples round-trips a la base de datos.
Intento Fallido: Listas de Redis
Mi primer intento para solucionar esto fue usar Listas de Redis (`RPUSH` y `LLEN`). Insertaba un timestamp por cada petición y usaba un cron job o verificación perezosa para limpiar los antiguos. Fue un desastre. La operación `LRANGE` o mantener listas infinitas consumió la memoria RAM del clúster en cuestión de horas bajo carga pesada. Además, sin transacciones atómicas, las condiciones de carrera (race conditions) permitían que usuarios concurrentes superaran el límite antes de que la lista se actualizara.
La Solución: Redis Sorted Sets + Lua
La arquitectura definitiva utiliza Redis Lua para ejecutar la lógica en el servidor (evitando latencia de red) y Sorted Sets (ZSET) para mantener un registro temporal (Log) de las peticiones. En un ZSET, el "score" es el timestamp (en milisegundos) y el "valor" es un identificador único (o timestamp + microsegundos) para evitar colisiones.
Este enfoque implementa el Algoritmo Sliding Window real. A continuación, presento el script Lua optimizado que desplegamos en producción. Este script realiza cuatro operaciones críticas de forma atómica:
-- KEYS[1]: La clave del rate limit (ej. "ratelimit:user:123")
-- ARGV[1]: Límite de peticiones (ej. 100)
-- ARGV[2]: Ventana de tiempo en milisegundos (ej. 60000 para 1 min)
-- ARGV[3]: Timestamp actual (Unix time en milisegundos)
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current_time = tonumber(ARGV[3])
-- 1. Limpiar: Eliminar registros fuera de la ventana actual
-- Calculamos el tiempo de corte: todo lo anterior a (ahora - ventana) se borra
local clearBefore = current_time - window
redis.call('ZREMRANGEBYSCORE', key, 0, clearBefore)
-- 2. Contar: ¿Cuántos registros quedan en la ventana actual?
local requestCount = redis.call('ZCARD', key)
-- 3. Decidir: Si el conteo es menor al límite, permitimos la petición
if requestCount < limit then
-- Añadimos la petición actual.
-- Usamos current_time como score y valor (para unicidad simple añadir un UUID si es necesario)
redis.call('ZADD', key, current_time, current_time)
-- Establecemos un TTL al set completo para limpiar basura si el usuario deja de hacer peticiones
redis.call('EXPIRE', key, math.ceil(window / 1000) + 1)
return 1 -- Permitido
else
return 0 -- Bloqueado
end
Analicemos la magia de la línea redis.call('ZREMRANGEBYSCORE', key, 0, clearBefore). Esta es la clave del algoritmo. Antes de contar, eliminamos "físicamente" cualquier petición que haya ocurrido hace más de 60 segundos. Esto asegura que ZCARD siempre devuelva el número exacto de peticiones en la ventana deslizante actual, con precisión de milisegundos.
Análisis de Rendimiento y Memoria
Una preocupación válida al usar ZSETs es el consumo de memoria en comparación con un contador simple (Integer). Realizamos pruebas de carga simulando 50,000 usuarios activos simultáneos. Si bien el uso de memoria aumentó, la precisión ganada justificó la inversión. Además, el uso de TTL (Time To Live) en la clave asegura que los ZSETs de usuarios inactivos se purguen automáticamente.
| Métrica | Fixed Window (Contador) | Sliding Window (ZSET + Lua) |
|---|---|---|
| Precisión de Bloqueo | Baja (Permite ráfagas x2) | Alta (Exacta al milisegundo) |
| Atomicidad | Vulnerable a Race Cond. | Garantizada por Lua |
| Costo de Memoria (por usuario) | ~4 bytes | ~40 bytes por petición almacenada |
| Operaciones Redis (RTT) | 2 (GET + INCR) | 1 (EVALSHA) |
Como se observa, el costo de memoria es mayor porque almacenamos un registro por cada petición activa en la ventana. Sin embargo, al usar EVALSHA (cargar el script una vez y referenciarlo por su hash SHA1), reducimos el ancho de banda de red significativamente, ya que no enviamos el script completo en cada llamada, solo los parámetros.
Casos Borde y Advertencias
Aunque esta solución es superior para la Seguridad Backend, existen escenarios donde no es recomendable. Si su aplicación maneja millones de RPM y el límite por usuario es muy alto (ej. 10,000 peticiones por segundo), el tamaño del ZSET crecerá demasiado, afectando el rendimiento de Redis (O(log(N)) para inserciones). En esos casos extremos, algoritmos aproximados como Token Bucket o una implementación híbrida en memoria local + Redis son preferibles.
Además, asegúrese de manejar correctamente la sincronización de relojes (NTP) entre sus servidores de aplicación, ya que el script depende del `current_time` enviado desde el cliente (servidor Node/Go) a Redis. Si sus servidores tienen un desfase horario significativo, la ventana deslizante será inconsistente.
Conclusión
Implementar un API Rate Limiting efectivo va más allá de contar peticiones. Requiere entender la distribución del tráfico en el tiempo. Al movernos de un modelo de Ventana Fija a un Algoritmo Sliding Window con Redis Lua, no solo solucionamos un problema técnico de rendimiento, sino que fortalecimos la equidad de
Post a Comment