Inicio / Artículos

El caché Stampede en Node.js: por qué un caché Redis puede sobrecargar su base de datos

Entienda por qué una configuración ingenua de Redis con caché separado sincroniza la carga en su base de datos cuando una clave activa vence, y cómo los bloqueos y las actualizaciones en segundo plano evitan esto.

Editor: REACTAPP.TOP7 min de lectura1648 palabras

Fuentes: “I Thought Redis Would Make My API Faster.” It Didn’t.

Agregar Redis delante de una consulta lenta es la solución típica para una base de datos bajo presión, y en un día tranquilo parece ser una victoria clara. Sin embargo, durante picos de tráfico, una configuración simple de caché puede empeorar las cosas drásticamente: en el momento en que una clave popular vence, todas las solicitudes concurrentes fallan al mismo tiempo y sobrecargan la base de datos. Este artículo explica ese fallo, conocido como estampida del caché, describe cómo se propaga a través de una API de Node.js y muestra dos patrones que lo previenen: un bloqueo de regeneración distribuido y la precomputación en segundo plano.

Una solución que funciona hasta los picos de tráfico

La configuración habitual es la de caché auxiliar. En cada solicitud, la API busca en Redis; si no lo encuentra, consulta la base de datos y escribe el resultado de vuelta en Redis para la próxima vez. Al implementarse en un día con poco tráfico, reduce inmediatamente la latencia.

Luego llega la hora pico de lunes por la mañana y la API deja de responder. El proceso de Node.js muestra un uso bajo del CPU, por lo que el bucle de eventos no es la causa sospechosa. Sin embargo, la base de datos principal está al 100% de uso del CPU.

Redis no es el cuello de botella

Raramente el problema radica en Redis en sí. Puede manejar cientos de miles de operaciones por segundo con una latencia de submilisegundos. Lo que causa problemas es la relación entre la aplicación y el caché, específicamente cómo se comporta la aplicación cuando faltan datos.

Un modelo mental común es una biblioteca silenciosa: se le pregunta al mostrador de recepción (Redis), y si el libro no está allí, un bibliotecario (la API) lo busca entre los estantes (PostgreSQL) y deja una copia en el mostrador. A gran escala, el escenario se asemeja más a una sala de operaciones bursátiles. Cuando miles de clientes solicitan el mismo artículo popular justo en el momento en que expira su entrada en la caché, todos encuentran el mostrador vacío al mismo tiempo y se dirigen hacia los estantes.

Anatomía de una estampida

Tomemos un endpoint como GET /products/trending. Construir su respuesta implica varias uniones, subconsultas y algo de agregación en Node.js, y tarda aproximadamente 1.2 segundos. El resultado se almacena en caché durante diez minutos.

En estado estable esto es muy eficiente: de diez mil solicitudes, solo una llega a la base de datos y las demás se sirven desde Redis en unos 5 ms.

Ahora supongamos que el TTL de diez minutos se agota a las 09:00:00. A las 09:00:01, llegan cincuenta solicitudes de forma simultánea. Todas fallan y cada una inicia la misma consulta de 1,2 segundos. De repente, la base de datos ejecuta cincuenta consultas intensivas en paralelo. El pool de conexiones se agota casi al instante, y todas las demás solicitudes a la API que necesitan acceder a la base de datos, incluso para datos no relacionados, quedan en cola detrás de esas cincuenta.

Vale la pena ser precisos aquí: esperar una conexión del pool no bloquea el bucle de eventos de Node.js, ya que esas esperas son asíncronas. Lo que ocurre en su lugar es que las solicitudes se acumulan como promesas pendientes y sockets abiertos, por lo que el proceso parece inactivo mientras los usuarios experimentan tiempos de espera excesivos.

Esto es la avalancha en el caché, también conocido como efecto dogpile. El caché no ha reducido la carga; más bien ha sincronizado un aumento repentino de carga en un único momento.

Por qué se produce una cascada en lugar de una recuperación

Una avalancha no es simplemente un período lento; se alimenta a sí misma.

  • Los tiempos de consulta explotan. Una consulta que tarda 1,2 segundos puede extenderse a 25 segundos cuando cincuenta copias compiten por la CPU, los bloqueos y las operaciones de entrada/salida.
  • Las instancias API mantienen miles de conexiones HTTP abiertas mientras esperan, consumiendo memoria y descriptores de archivo.
  • La CPU en el nivel API permanece baja, por lo que un escalador automático basado en la CPU podría nunca añadir capacidad, y agregar instancias API solo enviaría más consultas a la base de datos de todos modos.
  • Siguen llegando nuevas solicitudes que no se procesan, ya que las consultas lentas aún no han escrito un valor de vuelta. Con el tiempo, la base de datos falla por completo debido al agotamiento de memoria o a los tiempos de espera de conexión.

Prevenir avalanchas

Añadir un almacenamiento más rápido no puede solucionar lo que en realidad es un problema de coordinación. La solución consiste en cambiar quién tiene permiso para regenerar datos costosos y cuándo hacerlo.

Patrón 1: un bloqueo de regeneración distribuido

Cuando una API se ejecuta en varias instancias, la regeneración debe coordinarse entre todas ellas, lo que implica el uso de un bloqueo almacenado en Redis. La primera solicitud que no encuentra los datos en caché intenta obtener el bloqueo antes de consultar la base de datos. Cualquier otra solicitud que también no encuentre los datos mientras el bloqueo está en uso se mantiene alejada de la base de datos y realiza una de dos acciones:

  • Espere e intente nuevamente: espera brevemente, por ejemplo 50 ms, y vuelve a verificar en Redis hasta que aparezca el nuevo valor.
  • Proporcione datos obsoletos: devuelve el valor anterior, si el producto puede tolerar resultados ligeramente antiguos.

El ejemplo a continuación utiliza ioredis. Primero lee la caché. Si no está disponible, intenta realizar SET con NX y PX 5000, lo que crea el bloqueo solo si nadie más lo posee y hace que expire después de cinco segundos. El proceso ganador ejecuta la consulta, almacena el resultado durante 600 segundos y libera el bloqueo en un bloque finally; todos los demás realizan consultas periódicas hasta que el valor se actualice.

// Example: Using Redis for a locking/syncing pattern (Conceptual)
import { Redis } from 'ioredis';
const redis = new Redis();


async function getCachedTrendingProducts() {
  const cacheKey = 'products:trending';
  const lockKey = 'lock:products:trending';
  // Attempt 1: Get data
  let data = await redis.get(cacheKey);
  if (data) return JSON.parse(data);
  // Attempt 2: Cache miss. Try to acquire the regeneration lock.
  const acquiredLock = await redis.set(lockKey, 'locked', 'NX', 'PX', 5000); // 5s lock
  if (acquiredLock) {
    // We hold the lock. We are the ONLY worker hitting the DB.
    try {
      // 1.2s Database Operation
      const dbData = await fetchTrendingProductsFromDB();
      // Store in Redis (10 min TTL)
      await redis.set(cacheKey, JSON.stringify(dbData), 'EX', 600);
      return dbData;
    } finally {
      // Release lock
      await redis.del(lockKey);
    }
  } else {
    // We did NOT get the lock. Someone else is already regenerating.
    // Wait and Retry (e.g., exponential backoff or simple polling)
    return pollRedisUntilPopulated(cacheKey);
  }
}

El efecto es que, como máximo, una consulta costosa por clave llega a la base de datos a la vez, independientemente de cuántas solicitudes lleguen simultáneamente.

Existen algunas mejoras que vale la pena considerar en entornos de producción:

  • La expiración del bloqueo debe superar el tiempo máximo de ejecución de una consulta. Si la regeneración tarda más de cinco segundos, el bloqueo caduca y otro proceso inicia una consulta duplicada.
  • Eliminar el candado de forma incondicional puede suprimir un candado que otro proceso adquirió después de que el suyo expirara. Al almacenar un token único como valor del candado y eliminarlo solo si sigue coincidiendo, generalmente con un pequeño script en Lua, se evita esto.
  • pollRedisUntilPopulated necesita un tiempo de espera y una solución alternativa; de lo contrario, las solicitudes en espera pueden quedar atascadas si el titular del candado falla.
  • Dentro de un mismo proceso Node.js también se pueden combinar los fallos concurrentes compartiendo una promesa en ejecución por clave, lo que reduce la cantidad de intentos de bloqueo incluso antes de que intervenga Redis.
  • Patrón 2: precomputar en segundo plano

    El enfoque más sólido consiste en separar la entrega de datos de su generación. Los manejadores de solicitudes, que siguen un proceso síncrono, solo leen del caché y nunca lo reconstruyen. Si falta un valor, devuelven inmediatamente un estado vacío o de “carga”, lo cual es mucho mejor que mantener una conexión ocupada durante segundos.

    La regeneración se realiza mediante un proceso en segundo plano, como una tarea cron, un trabajador de BullMQ o un consumidor de captura de cambios de datos, que actualiza la clave antes de que expire. En el ejemplo a continuación, un trabajador programado cada nueve minutos reconstruye la lista de tendencias y la escribe de nuevo con el mismo tiempo de vida de diez minutos, de modo que la clave nunca queda inactiva en condiciones normales de operación:

    // Separate background worker file
    // For example, running every 9 minutes via Cron or BullMQ
    async function regenerateTrendingProductsCache() {
      const cacheKey = 'products:trending';
      try {
        // Proactive DB fetch (1.2s)
        const dbData = await fetchTrendingProductsFromDB();
        // Refresh the 10-minute cache
        await redis.set(cacheKey, JSON.stringify(dbData), 'EX', 600);
        console.log('Cache proactively refreshed.');
      } catch (err) {
        console.error('Proactive refresh failed:', err);
      }
    }
    

    Dado que las solicitudes entrantes siempre encuentran una clave ya cargada, no existe momento en el que puedan fallar colectivamente y recurrir a la base de datos. Dos aspectos garantizan esta seguridad: ejecutar la actualización mediante un único programador de tareas o detrás de un mecanismo de bloqueo para que no se actualicen múltiples instancias al mismo tiempo, y generar una alerta cuando la actualización falla, ya que el bloque catch por sí solo solo registra el error y la clave vencerá en un minuto. En cuanto a la programación de tareas, la guía sobre tareas en segundo plano fiables con BullMQ y Redis ofrece información más detallada.

    Elegir entre ellas

    El mecanismo de bloqueo mantiene el flujo habitual de uso del caché y es adecuado para datos solicitados de forma impredecible. La precomputación en segundo plano resulta útil para un pequeño conjunto de claves conocidas, costosas y leídas con frecuencia, como una lista de tendencias o un resumen del panel de control, ya que proporciona la latencia más predecible. Añadir un ligero desfase aleatorio a los TTL también ayuda cuando se escriben muchas claves al mismo tiempo y, de lo contrario, vencerían todas a la vez.

    Puntos clave

    • Redis es rápido; los problemas surgen de cómo reacciona la aplicación ante una falta en el caché, no del propio caché.
    • El enfoque ingenuo de uso del caché convierte una única expiración en un proceso sincronizado contra la dependencia más lenta, y el fallo puede ocultarse tras un bajo uso de CPU en la API.
    • Un bloqueo distribuido garantiza que solo un llamante genere una clave; es necesario gestionar con cuidado la expiración del bloqueo, su propiedad y los tiempos de espera.
  • La actualización en segundo plano elimina por completo la regeneración del camino de la solicitud y es la opción más eficaz para claves críticas y costosas.
  • Considere el caché como una decisión arquitectónica para proteger la base de datos bajo carga, y no como un medio rápido para mejorar el rendimiento.
  • Lecturas relacionadas