Llegamos a la lección que salda tres deudas a la vez. La primera la contrajimos en la 10-01: al pasar a siete trabajadores, la caché de divisas, el almacén de sesiones y el contador del limitador se fragmentaron en siete copias, y Marc se desloguea cada dos peticiones mientras un script malicioso disfruta de un cupo multiplicado por siete. La segunda viene del módulo 7: obtenerCatalogo() recalcula el catálogo entero en cada petición para devolver siempre lo mismo. La tercera la dejó la 10-02: el organizador del Auditorio Ribera sigue esperando cuatro segundos y medio a que se generen las 500 entradas del estreno del Festival de Jazz.

Las tres se resuelven con la misma pieza de infraestructura: Redis.

Contenido

  1. Qué es Redis y qué tipos de dato usaremos
  2. Levantarlo y conectarse con ioredis
  3. Caché del catálogo: el patrón cache-aside
  4. Claves bien diseñadas y el problema difícil: la invalidación
  5. Estampida de caché y sus remedios
  6. Qué no se debe cachear nunca, y medición
  7. Sesiones y límite de peticiones compartidos
  8. Colas de trabajo: el cambio de modelo y el 202
  9. BullMQ: productor, consumidor y opciones que importan
  10. Idempotencia, trabajos programados y observabilidad
  11. Persistencia: Redis puede perder datos

  1. Qué es Redis y qué tipos de dato usaremos

Redis es un almacén clave-valor en memoria. Tres características definen cómo se usa bien: vive en RAM (lecturas y escrituras en microsegundos, no en milisegundos); ejecuta los comandos en un solo hilo, igual que nuestro JavaScript, de modo que cada comando es atómico —no hay condiciones de carrera entre clientes— pero un comando lento bloquea a todos (por eso KEYS * está prohibido en producción); y tiene estructuras de datos, no solo cadenas, que es donde está su verdadera potencia.

Tipo Comandos clave Uso en Escena Viva
Cadena GET, SET, INCR Catálogo serializado en JSON; contadores del limitador
Hash HSET, HGETALL, HINCRBY Datos de una sesión de usuario; aforo por sesión de evento
Lista LPUSH, RPOP, BRPOP Base de las colas de trabajo (BullMQ la usa por dentro)
Conjunto SADD, SISMEMBER Identificadores de trabajos ya procesados (idempotencia)
Conjunto ordenado ZADD, ZRANGEBYSCORE Trabajos programados por marca de tiempo; ranking de más vendidos
TTL (transversal) EXPIRE, TTL, SET ... EX Caducidad automática de todo lo anterior

El TTL no es un tipo, pero es la característica que convierte a Redis en una caché: cualquier clave puede caducar sola, lo que nos ahorra escribir código de limpieza.

  1. Levantarlo y conectarse con ioredis

Para desarrollo, un contenedor basta:

# Redis 7 con persistencia AOF activada, en el puerto estandar.
docker run -d --name redis-escena-viva -p 6379:6379 -v redis-datos:/data \
  redis:7-alpine redis-server --appendonly yes
docker exec -it redis-escena-viva redis-cli ping   # debe responder PONG

En un docker-compose.yml sería un servicio más junto a PostgreSQL, con su image, su command, su puerto y su volumen. No profundizamos: los contenedores y la composición se cierran en el módulo 11. Instalamos el cliente (npm install ioredis) y conectamos, respetando la disciplina del proyecto: src/config/index.js es el único que lee process.env.

'use strict';

const Redis = require('ioredis');
const { configuracion } = require('../config/index.js');

let clienteRedis = null;

// Una unica conexion por proceso. ioredis multiplexa: no hace falta pool.
function obtenerClienteRedis() {
  if (clienteRedis) return clienteRedis;
  clienteRedis = new Redis(configuracion.redis.url, {
    retryStrategy: (intento) => Math.min(intento * 200, 2000), // espera creciente
    maxRetriesPerRequest: null,             // requisito de BullMQ
    keyPrefix: `${configuracion.entorno}:`, // aisla entornos
  });
  // Nunca dejamos que un fallo de Redis tumbe el proceso.
  clienteRedis.on('error', (error) => console.error('[redis]', error.message));
  return clienteRedis;
}

// cerrarClienteRedis() hace quit() y se engancha en el apagado ordenado.
module.exports = { obtenerClienteRedis, cerrarClienteRedis };

  1. Caché del catálogo: el patrón cache-aside

obtenerCatalogo() es el candidato perfecto: devuelve lo mismo a todo el mundo (no depende del usuario ni del rol), se lee muchísimo y se escribe poco, es caro de calcular (agrega los 3 eventos, sus 7 sesiones, el aforo disponible y los precios) y tolera un pequeño desfase, porque la venta real se valida contra la base de datos con SELECT ... FOR UPDATE (M7).

El patrón cache-aside tiene cuatro pasos: mirar; si falla, calcular; guardar con TTL; devolver.

'use strict';

const { obtenerClienteRedis } = require('../db/redis.js');

const TTL_CATALOGO_SEGUNDOS = 60;
const VERSION_ESQUEMA = 'v2'; // se sube si cambia la forma del objeto
// Espacio de nombres : entidad : version : parametros normalizados
const claveDeCatalogo = ({ categoria = 'todas', pagina = 1, orden = 'fecha' } = {}) =>
  `catalogo:eventos:${VERSION_ESQUEMA}:${categoria}:${orden}:p${pagina}`;

// Envuelve el repositorio del M7 sin que los controladores se enteren.
const crearCatalogoCacheado = ({ repositorioEventos, redis = obtenerClienteRedis() }) => ({
  async obtenerCatalogo(parametros) {
    const clave = claveDeCatalogo(parametros);
    let enCache = null;

    // 1. Mirar. Redis caido no puede tumbar el catalogo: degradamos.
    try { enCache = await redis.get(clave); } catch (e) { console.error('[cache]', e.message); }
    if (enCache) return { datos: JSON.parse(enCache), origen: 'cache' };

    // 2. Fallo: calcular con el repositorio de siempre.
    const datos = await repositorioEventos.obtenerCatalogo(parametros);

    // 3. Guardar con TTL ('EX' expresa la caducidad en segundos).
    const json = JSON.stringify(datos);
    try { await redis.set(clave, json, 'EX', TTL_CATALOGO_SEGUNDOS); }
    catch (e) { console.error('[cache]', e.message); }

    return { datos, origen: 'base-de-datos' }; // 4. devolver
  },
});

module.exports = { crearCatalogoCacheado, claveDeCatalogo };

La elegancia está en lo que no hemos tocado. Gracias al patrón repositorio del módulo 7 y a la inyección de dependencias del módulo 9, este objeto expone la misma interfaz que src/repositorios/eventos.js. En el ensamblado cambiamos qué se inyecta —configuracion.cache.activa ? crearCatalogoCacheado({ repositorioEventos }) : repositorioEventos— y ni los controladores, ni las rutas, ni las pruebas se enteran. Otra decisión importante: si Redis falla, la aplicación sigue funcionando, solo que más lenta. Una caché nunca debe ser un punto único de fallo para una lectura que se puede recalcular.

  1. Claves bien diseñadas y el problema difícil: la invalidación

Parte de la clave Ejemplo Por qué
Espacio de nombres catalogo: Permite borrados por patrón y compartir instancia
Entidad e identificador eventos:evt-003 Legible al depurar con redis-cli
Versión del esquema v2 Cambiar el formato invalida todo sin borrar nada
Parámetros normalizados :fecha:p1 Dos consultas distintas no comparten entrada

La versión merece énfasis. Si mañana añades el campo entradasDisponibles al objeto del catálogo y despliegas, los siete trabajadores nuevos leerán objetos viejos sin ese campo y fallarán. Subir VERSION_ESQUEMA a v3 hace que ninguna clave antigua se encuentre: despliegue seguro, sin FLUSHDB ni ventana de mantenimiento. Y ahora la parte difícil. Hay una broma clásica de Phil Karlton que dice que en informática solo hay dos problemas difíciles: la invalidación de cachés y poner nombres a las cosas. Describe algo real: guardar es trivial, saber cuándo lo guardado ha dejado de ser cierto no lo es.

Estrategia Cómo Ventajas Riesgos
TTL corto La clave caduca a los 60 s Simplísimo; se autorrepara Datos obsoletos hasta 60 s; recálculos innecesarios
Invalidación explícita Al vender, se borra la clave Datos frescos casi al instante Si olvidas un camino de escritura, sirves datos falsos para siempre

La combinación es la práctica sana: invalidación explícita como camino principal, TTL como red de seguridad.

'use strict';

// Se llama desde el mismo sitio que ya escucha 'venta-registrada' del
// GestorDeVentas (dominio, modulo 2).
const crearInvalidadorDeCatalogo = ({ redis }) => ({
  async invalidarPorVenta({ eventoId }) {
    let cursor = '0';
    const claves = [];
    do {
      // SCAN, nunca KEYS: KEYS bloquea el unico hilo de Redis.
      const [sig, lote] = await redis.scan(cursor, 'MATCH', 'catalogo:eventos:*', 'COUNT', 100);
      cursor = sig;
      claves.push(...lote);
    } while (cursor !== '0');
    if (claves.length > 0) await redis.del(...claves);
    await redis.del(`evento:${eventoId}:ficha:v2`); // invalidacion directa
  },
});

module.exports = { crearInvalidadorDeCatalogo };

Si el número de variantes crece, el SCAN deja de ser cómodo. La alternativa profesional es un conjunto de índice (al guardar una clave se añade su nombre a un SET; al invalidar se leen y se borran de golpe) o, más simple aún, un contador catalogo:generacion que forme parte de la clave: al incrementarlo, todas las anteriores quedan inalcanzables y caducan solas.

  1. Estampida de caché y sus remedios

Escenario del estreno: la clave del catálogo caduca a las 20:59:31. En ese milisegundo hay 1 000 peticiones en vuelo. Las 1 000 fallan en la caché, las 1 000 llaman a obtenerCatalogo(), las 1 000 golpean PostgreSQL con la consulta cara. Es la estampida de caché (thundering herd), y suele tumbar la base de datos justo en el peor momento. El primer remedio es el bloqueo con SET NX: solo un proceso recalcula, y los demás esperan un momento y releen.

'use strict';

async function obtenerConBloqueo({ redis, clave, ttlSegundos, calcular }) {
  const enCache = await redis.get(clave);
  if (enCache) return JSON.parse(enCache);

  // NX: solo se escribe si no existe. PX: caduca en 5 s, para que un
  // proceso que muera a mitad no deje el bloqueo puesto para siempre.
  if (await redis.set(`${clave}:bloqueo`, '1', 'PX', 5000, 'NX')) {
    try {
      const datos = await calcular();
      await redis.set(clave, JSON.stringify(datos), 'EX', ttlSegundos);
      return datos;
    } finally { await redis.del(`${clave}:bloqueo`); }
  }
  // No somos los elegidos: esperamos brevemente y releemos. Si aun no
  // esta, calculamos igualmente: mejor lento que un error.
  await new Promise((resolver) => setTimeout(resolver, 50));
  const reintento = await redis.get(clave);
  return reintento ? JSON.parse(reintento) : calcular();
}

Recálculo anticipado: se guarda junto al valor su instante de caducidad y, cuando falta poco, cada lectura tiene una probabilidad creciente de refrescarlo en segundo plano mientras sirve el actual, de modo que nadie espera nunca y la clave no llega a caducar de golpe. Es más elegante y algo más de código; para Escena Viva, el bloqueo SET NX es suficiente.

  1. Qué no se debe cachear nunca, y medición

Dato ¿Cachear? Motivo
Catálogo público de eventos Sí, TTL 60 s Igual para todos, caro, tolera desfase
Ficha de un evento Sí, TTL 300 s Cambia muy poco
Tasas de cambio de divisas Sí, TTL 3 600 s Ya se cacheaba en memoria (M8); ahora compartida
Pedidos de Lucia No Datos personales; podrían acabar en otro usuario
Respuestas que dependen del rol No (o con el rol en la clave) Un administrador ve campos que un asistente no
Aforo exacto al vender Nunca Vender contra un aforo obsoleto es sobreventa
Tokens y contraseñas No Ni en caché ni en registros (M8)

El caso del aforo merece detenerse. En el listado, mostrar "quedan 42 entradas" con 60 segundos de retraso es información orientativa aceptable. Pero la decisión de vender debe tomarse contra la base de datos, dentro de la transacción con SELECT ... FOR UPDATE de src/repositorios/compras-sql.js. Si alguien "optimizara" comprarEntradas leyendo el aforo de Redis, en el estreno venderíamos las mismas butacas a dos personas. La caché sirve para leer, no para decidir. Y un error de seguridad clásico: cachear una respuesta que incluye datos del usuario sin poner su identificador en la clave, con lo que Marc acaba viendo los pedidos de Lucia. Regla defensiva: si la respuesta depende de quién pregunta, o no se cachea, o el "quién" forma parte de la clave.

Misma prueba de carga de la lección 10-01, ahora con caché:

Métrica (GET /api/eventos, 50 conexiones, 7 trabajadores) Sin caché Con cache-aside
Peticiones por segundo 3 105 11 840
Latencia p50 / p99 15 ms / 54 ms 3 ms / 11 ms
Consultas a PostgreSQL en 10 s 31 050 7
Uso de CPU de PostgreSQL 78 % 4 %

La cifra que de verdad importa no son las peticiones por segundo: son las 7 consultas frente a 31 050. Hemos liberado la base de datos, el cuello de botella compartido que la lección 10-01 no podía tocar.

  1. Sesiones y límite de peticiones compartidos

Saldamos la deuda de la lección 10-01 con npm install connect-redis rate-limit-redis:

'use strict';

const session = require('express-session');
const { RedisStore } = require('connect-redis');
const rateLimit = require('express-rate-limit');
const { RedisStore: LimiteStore } = require('rate-limit-redis');
const { obtenerClienteRedis } = require('../db/redis.js');
const { configuracion } = require('../config/index.js');

// SESIONES. Antes: almacen en memoria (una copia por trabajador). Ahora:
// almacen compartido; los 7 trabajadores ven la misma sesion.
const crearSesion = () => session({
  store: new RedisStore({ client: obtenerClienteRedis(), prefix: 'sesion:', ttl: 60 * 60 * 8 }),
  name: 'escena_viva_sid',
  secret: configuracion.sesion.secreto,
  resave: false,
  saveUninitialized: false,
  cookie: { httpOnly: true, sameSite: 'lax', maxAge: 1000 * 60 * 60 * 8 },
});

// LIMITE. El almacen usa INCR + EXPIRE de forma atomica en Redis: el
// contador es unico para los 7 trabajadores y el limite vuelve a ser real.
const limiteCompra = rateLimit({
  windowMs: 60_000,
  limit: 20,
  standardHeaders: 'draft-7',
  store: new LimiteStore({ prefix: 'limite:compra:',
    sendCommand: (...args) => obtenerClienteRedis().call(...args) }),
  message: { error: { codigo: 'DEMASIADAS_PETICIONES', estado: 429, detalles: [],
    mensaje: 'Has superado el limite de compras por minuto.' } },
});

module.exports = { crearSesion, limiteCompra };

Comprobado con el ejercicio 2 de la lección 10-01: con limiteGeneral a 20 por minuto y 4 trabajadores, ahora la petición 21 recibe 429, venga del trabajador que venga. Lo mismo aplica a src/servicios/cambio-divisas.js: sustituir su Map en memoria por claves divisas:EUR:USD con TTL de una hora deja una sola llamada a la API externa por hora en toda la flota, en lugar de siete.

  1. Colas de trabajo: el cambio de modelo y el 202

La lección 10-02 sacó la generación del PDF del hilo principal, pero el organizador del Auditorio Ribera sigue esperando 4,4 segundos con la conexión HTTP abierta. Y si además hay que enviar por correo las 500 entradas, hablamos de minutos: se agotan los tiempos límite del proxy, el usuario recarga la página y duplica el trabajo, y si el proceso se reinicia a mitad, el trabajo se pierde sin rastro.

sequenceDiagram
  participant O as Organizador
  participant A as API
  participant R as Redis (cola)
  participant C as Consumidor
  O->>A: POST /api/pedidos/ped-77/entradas
  A->>R: encolar generar-entradas
  A-->>O: 202 Accepted + { trabajoId, estadoUrl }
  C->>R: tomar trabajo y procesar (QR + PDF + correo)
  O->>A: GET /api/trabajos/tr-9f3 (sondeo)
  A-->>O: 200 { estado: 'completado', descargaUrl }

La API pasa de "haz esto y espera" a "acepto tu encargo, aquí tienes el número de seguimiento". Eso es exactamente lo que significa el código 202 Accepted, y lo formalizaremos como decisión de diseño en la lección 10-05.

  1. BullMQ: productor, consumidor y opciones que importan

BullMQ (npm install bullmq) implementa colas fiables sobre Redis usando listas y conjuntos ordenados, con scripts Lua para garantizar atomicidad. El productor vive en la aplicación web:

'use strict';

const { Queue } = require('bullmq');
const { configuracion } = require('../config/index.js');

const colaEntradas = new Queue('generar-entradas', {
  connection: { url: configuracion.redis.url },
  defaultJobOptions: {
    attempts: 3,                                   // intentos maximos
    backoff: { type: 'exponential', delay: 2000 }, // 2 s, 4 s, 8 s
    removeOnComplete: { age: 3600, count: 1000 },  // limpieza automatica
    removeOnFail: { age: 24 * 3600 },              // los fallos duran un dia
  },
});

// El id del trabajo es el del pedido: encolar dos veces el mismo pedido
// NO crea dos trabajos. Idempotencia en el productor.
const encolarGeneracionDeEntradas = ({ pedidoId, sesionId, correo }) =>
  colaEntradas.add('generar-pdf',
    { pedidoId, sesionId, correo, solicitadoEn: new Date().toISOString() },
    { jobId: `entradas:${pedidoId}` });

module.exports = { colaEntradas, encolarGeneracionDeEntradas };

El consumidor vive en un proceso aparte (node src/procesos/consumidor-entradas.js), no en el servidor web: el trabajo pesado ya no comparte máquina de estados con las peticiones HTTP.

'use strict';

const { Worker } = require('bullmq');
const { configuracion } = require('../config/index.js');
const { PoolDeHilos } = require('../trabajadores/pool.js');
const { obtenerPedidoPorId } = require('../repositorios/pedidos.js');
const { enviarCorreoConEntradas } = require('../servicios/correo.js');
const { yaProcesado, marcarProcesado } = require('../servicios/idempotencia.js');

const pool = new PoolDeHilos({ tamano: 2 });

const consumidor = new Worker('generar-entradas', async (trabajo) => {
  const { pedidoId, correo } = trabajo.data;
  // El mismo trabajo puede ejecutarse dos veces (reintento tras un fallo
  // de red, o proceso muerto tras el trabajo pero antes de confirmarlo).
  if (await yaProcesado(`entradas:${pedidoId}`)) return { omitido: true };

  const pedido = await obtenerPedidoPorId(pedidoId);
  await trabajo.updateProgress(20);
  const { buffer, generadas } = await pool.ejecutar({
    pedidoId: pedido.id, eventoTitulo: pedido.eventoTitulo, entradas: pedido.entradas,
    salaNombre: pedido.salaNombre, fechaSesion: pedido.fechaSesion,
  });
  await trabajo.updateProgress(70);
  await enviarCorreoConEntradas({ destinatario: correo, adjunto: buffer });
  await marcarProcesado(`entradas:${pedidoId}`, 7 * 24 * 3600);
  return { generadas, enviadoA: correo };
}, {
  connection: { url: configuracion.redis.url },
  concurrency: 2,                          // trabajos simultaneos aqui
  limiter: { max: 30, duration: 60_000 },  // cuota del proveedor de correo
});

consumidor.on('failed', (t, error) => console.error(`[cola] ${t?.id}: ${error.message}`));

// Apagado ordenado, como en el M6: terminamos el trabajo en curso.
for (const senal of ['SIGTERM', 'SIGINT']) {
  process.on(senal, async () => { await consumidor.close(); await pool.cerrar(); });
}

module.exports = { consumidor };
Opción Qué hace Criterio
attempts Reintentos antes de darlo por fallido 3-5 con E/S externa; 1 si no es idempotente
backoff Espera entre reintentos exponential: no machacar un servicio ya caído
removeOnComplete Limpia trabajos terminados Sin ella, Redis crece indefinidamente
removeOnFail Retención de fallidos Consérvalos: son tu diagnóstico
concurrency Trabajos a la vez por consumidor Limitado por CPU y por el pool de hilos
limiter Techo de ritmo Cuotas de APIs externas (correo, pasarela de pago)
jobId Identidad del trabajo Deduplicación en el productor

Cuando un trabajo agota sus attempts pasa al conjunto failed (la "cola de fallidos"). No desaparece: queda con su carga, su error y su pila, y se puede reintentar con trabajo.retry() una vez arreglada la causa. Una alerta sobre el tamaño de failed es de las señales de producción más útiles que existen.

  1. Idempotencia, trabajos programados y observabilidad

Idempotencia del consumidor. Toda cola seria garantiza al menos una vez, no exactamente una vez. Si el trabajo envía correos, cobra o crea pedidos, la duplicación es un incidente real. La protección de src/servicios/idempotencia.js es una marca en Redis: marcarProcesado hace SET procesado:<clave> 1 EX <ttl> NX —atómico, y por tanto seguro con N consumidores— y yaProcesado comprueba con EXISTS antes de actuar.

Mejor todavía cuando se puede: hacer la operación naturalmente idempotente. Si el PDF se guarda como pedido-ped-77.pdf y el correo se registra con clave única por pedido, repetir el trabajo no hace daño aunque nadie compruebe nada.

Trabajos programados y repetibles. En el módulo 3 generábamos el informe nocturno de ocupación con un cron del sistema y un script suelto; con BullMQ pasa a ser parte de la aplicación:

const colaInformes = new Queue('informes', { connection: { url: configuracion.redis.url } });

// Repetible: cada dia a las 03:00, hora de Madrid.
const programarInformeNocturno = () => colaInformes.upsertJobScheduler(
  'informe-ocupacion-diario',
  { pattern: '0 3 * * *', tz: 'Europe/Madrid' },
  { name: 'ocupacion', data: { alcance: 'todas-las-salas' } }
);

// Retardado: recordatorio 24 h antes de la sesion, con la opcion 'delay'.
const programarRecordatorio = ({ pedidoId, fechaSesion }) =>
  colaInformes.add('recordatorio', { pedidoId },
    { delay: new Date(fechaSesion).getTime() - Date.now() - 24 * 3600 * 1000 });

La ventaja sobre el cron del sistema es doble: funciona con N trabajadores sin ejecutarse N veces (Redis coordina), y el trabajo hereda reintentos, historial y observabilidad. Precisamente sobre eso último, esto es lo mínimo que hay que vigilar en una cola:

Señal Cómo se obtiene Qué significa si se dispara
Trabajos en espera cola.getWaitingCount() Los consumidores no dan abasto
Antigüedad del más viejo cola.getJobs(['waited'], 0, 0) Retraso real percibido por el usuario
Trabajos fallidos cola.getFailedCount() Algo está roto de verdad
Trabajos activos cola.getActiveCount() Comparado con concurrency, indica saturación

Existe bull-board para verlo en una interfaz web; en Escena Viva basta con exponer los contadores en GET /api/salud/colas y dejar que la monitorización del módulo 11 los recoja.

  1. Persistencia: Redis puede perder datos

Redis vive en memoria y ofrece dos mecanismos de persistencia, combinables:

Mecanismo Cómo funciona Riesgo de pérdida
RDB (instantánea) Vuelca el conjunto a disco cada X cambios Todo desde la última instantánea (minutos)
AOF (registro de operaciones) Añade cada escritura a un fichero Hasta 1 segundo con everysec
Ambos RDB para restaurar rápido, AOF para durabilidad Recomendado en producción

Incluso con AOF puedes perder el último segundo. Por tanto: para la caché da igual (perderla solo significa recalcular); para las sesiones es molesto pero tolerable; para las colas importa, porque un trabajo perdido es un correo que nunca se envía. Por eso el pedido se escribe primero en PostgreSQL y el trabajo se encola después: si el trabajo se pierde, el pedido sigue ahí y un proceso de reconciliación puede reencolarlo. La regla que cierra la lección: la fuente de verdad sigue siendo la base de datos del módulo 7. Redis es una capa de velocidad y de coordinación, nunca el registro definitivo del negocio.

Errores Comunes y Consejos

  • Cachear sin TTL. Una clave sin caducidad es una fuga de memoria con datos obsoletos dentro.
  • Usar KEYS en producción, que bloquea el único hilo de Redis y congela toda la aplicación. SCAN, siempre.
  • Cachear respuestas que dependen del usuario sin el usuario en la clave. Es una fuga de datos personales.
  • Leer el aforo de la caché para decidir una venta. Sobreventa garantizada la noche del estreno.
  • Que un fallo de Redis tumbe la aplicación. Envuelve las lecturas en try/catch y degrada a la base de datos.
  • Poner el consumidor de la cola dentro del servidor web, o suponer que un trabajo se ejecuta una sola vez: se ejecuta al menos una vez, así que diseña para la repetición.
  • Consejo: prefija las claves con el entorno (produccion:, desarrollo:). Evita el clásico "he vaciado la caché de producción sin querer".
  • Consejo: en desarrollo, devuelve una cabecera X-Cache: HIT|MISS. Ver el ratio de aciertos en vivo enseña más que cualquier gráfica.

Ejercicios

Ejercicio 1: medir el ratio de aciertos

Instrumenta crearCatalogoCacheado para contar aciertos y fallos en Redis (INCR sobre metricas:cache:aciertos y :fallos) y exponerlos en GET /api/salud/cache. Lanza 2 000 peticiones con autocannon y calcula el ratio con TTL de 60 s y de 5 s. Interpreta la diferencia.

Ejercicio 2: cola con reintentos y cola de fallidos

Crea una cola enviar-correos cuyo procesador falle de forma determinista cuando el destinatario termine en @invalido.test. Configura 3 intentos con retroceso exponencial. Encola 5 trabajos, 2 de ellos inválidos, y comprueba el estado final de cada uno y el contenido del conjunto failed.

Ejercicio 3: idempotencia bajo duplicación

Encola dos veces el mismo trabajo de generación de entradas para ped-77, una vez con jobId fijo y otra sin él. Comprueba cuántas veces se ejecuta el procesador en cada caso. Después, simula un reintento forzando un fallo después de enviar el correo y verifica que la protección de idempotencia impide el segundo envío.

Soluciones

Ejercicio 1. Con TTL de 60 s y una prueba de 10 segundos, el ratio de aciertos ronda el 99,9 %: solo la primera petición de cada variante falla. Con TTL de 5 s hay un fallo cada 5 segundos por variante, así que el ratio baja a ~99,4 % y las consultas a PostgreSQL se multiplican por 12. El aprendizaje es que el TTL es un mando que regula el equilibrio entre frescura y carga, y que hay un punto de rendimientos decrecientes: pasar de 60 a 600 segundos apenas mejora el ratio, pero multiplica por diez la ventana de datos obsoletos. Con invalidación explícita bien puesta, se puede subir el TTL con tranquilidad.

Ejercicio 2. Los 3 trabajos válidos aparecen en completed al primer intento. Los 2 inválidos se reintentan a los 2 s, 4 s y 8 s, acumulan attemptsMade: 3 y acaban en failed, conservando failedReason y la pila; cola.getFailedCount() devuelve 2, y const [t] = await cola.getFailed(); await t.retry(); los devuelve a waiting. Detalle importante: con retroceso exponencial, 3 intentos consumen 14 segundos, así que con muchos trabajos fallando a la vez la cola se atasca. Por eso conviene combinar attempts moderados con una alerta sobre failed.

Ejercicio 3. Con jobId: 'entradas:ped-77', la segunda llamada a add no crea un trabajo nuevo: BullMQ devuelve el existente y el procesador se ejecuta una sola vez. Sin jobId se crean dos trabajos, el procesador se ejecuta dos veces y, sin la comprobación de idempotencia, Lucia recibiría dos correos con 500 entradas cada uno. En la segunda parte, al fallar después del envío, BullMQ reintenta y el procesador arranca de nuevo, pero yaProcesado('entradas:ped-77') devuelve true y sale con { omitido: true }. La lección clave: jobId protege contra duplicados en el productor, y la marca en Redis protege contra reejecuciones en el consumidor. Hacen falta las dos.

Conclusión

Redis ha resuelto tres problemas con una sola pieza. El catálogo se sirve desde caché con el patrón cache-aside, claves versionadas, invalidación al vender, TTL como red de seguridad y protección contra estampidas: de 3 105 a 11 840 peticiones por segundo, y de 31 050 consultas a PostgreSQL a 7. Las sesiones y los contadores del limitador han vuelto a ser correctos con siete trabajadores, saldando la deuda de la lección 10-01. Y el trabajo pesado ha salido del proceso web a una cola BullMQ con reintentos, retroceso exponencial, cola de fallidos, idempotencia en productor y consumidor, trabajos programados y observabilidad: el organizador del Auditorio Ribera recibe ahora un 202 Accepted inmediato y un identificador de seguimiento. También hemos marcado los límites: no se cachea lo personal, no se decide una venta contra la caché, y Redis puede perder datos, así que la fuente de verdad sigue siendo PostgreSQL.

Llegados aquí, Escena Viva usa todos los núcleos, no bloquea el bucle de eventos, cachea y encola. Pero todas las cifras que hemos ido dando —641, 3 105, 11 840 req/s; p99 de 204, 54 y 11 ms— han aparecido con un método que no hemos formalizado. En la lección siguiente, Optimización del Rendimiento, convertimos esa práctica en disciplina: qué se mide y por qué la media miente, cómo se diseña una prueba de carga honesta, cómo se lee un flamegraph, cómo se caza una fuga de memoria comparando instantáneas del montículo, y cuál es el orden correcto de las palancas antes de comprar una máquina más grande.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados