En el módulo 9 cerramos las pruebas con una frase incómoda: Escena Viva es correcta, pero es lenta y desaprovecha la máquina. Todas las pruebas pasan, la cobertura es razonable, y aun así el servidor que hemos alquilado tiene ocho núcleos y nosotros usamos uno. En el estreno del Festival de Jazz de Primavera (evt-003, sala Auditorio Ribera, dos sesiones), cuando se abre la venta y llegan cientos de personas a la vez, ese único hilo es el techo de todo el negocio.

Esta lección ataca ese desperdicio con la herramienta más directa que ofrece Node: el módulo cluster. Vamos a medir primero, entender el mecanismo después, implementarlo en el proyecto, y —muy importante— descubrir qué se rompe cuando una aplicación pensada para un proceso pasa a correr en ocho.

Contenido

  1. El problema, medido: un hilo, ocho núcleos
  2. Qué es cluster y cómo comparten puerto los trabajadores
  3. Cuántos trabajadores: os.availableParallelism()
  4. Implementación en Escena Viva: src/cluster.js
  5. Supervisión: reiniciar trabajadores sin caer en un bucle
  6. Comunicación entre procesos (IPC)
  7. Recarga sin cortes (zero-downtime reload)
  8. Lo que se rompe: el estado en memoria deja de ser compartido
  9. Qué NO arregla cluster
  10. child_process y fork, los primos del módulo
  11. Medición final y enlace con producción

  1. El problema, medido: un hilo, ocho núcleos

En el módulo 2 vimos la arquitectura: V8 ejecuta tu JavaScript en un solo hilo, libuv aporta un thread pool de 4 hilos para ciertas operaciones (fs, dns, crypto, zlib) y el resto de la E/S se delega al sistema operativo. La consecuencia práctica es aritmética simple:

Recurso Máquina de producción Lo que usa un proceso Node
Núcleos de CPU 8 1 para tu JavaScript
Aprovechamiento teórico 100 % ~12,5 %
Hilos de libuv — 4, solo para tareas concretas del núcleo

Antes de optimizar nada, medimos. Es la regla que desarrollaremos en la lección 10-04, pero la aplicamos ya:

npm install --save-dev autocannon      # herramienta de carga
NODE_ENV=production node src/servidor.js  # un solo proceso, como hasta ahora

# En otra terminal: 10 segundos, 50 conexiones simultaneas, sobre el catalogo
npx autocannon -c 50 -d 10 http://localhost:3000/api/eventos

Salida resumida en la máquina de referencia (8 núcleos):

Stat    2.5%   50%    97.5%  99%    Avg     Stdev
Latency 38ms   72ms   161ms  204ms  78.4ms  31.2ms

Req/Sec  610    640    672    675    641

641 peticiones por segundo y un percentil 99 de 204 ms. Mientras tanto, top muestra un solo núcleo al 100 % y siete a cero. Este número es nuestra línea base honesta: cualquier cosa que hagamos a partir de ahora se compara contra él.

  1. Qué es cluster y cómo comparten puerto los trabajadores

node:cluster permite lanzar varios procesos Node —trabajadores— coordinados por un proceso primario, todos capaces de atender el mismo puerto. Cada trabajador es un proceso completo: su propio V8, su propio montículo, su propio bucle de eventos.

La pregunta obvia es cómo pueden ocho procesos escuchar en el puerto 3000 sin que el sistema devuelva EADDRINUSE. La respuesta depende de la política:

  • Política por defecto en Linux y macOS (SCHED_RR, round-robin): solo el proceso primario abre el socket y acepta conexiones. Cuando llega una, elige un trabajador por turno rotatorio y le pasa el descriptor de fichero a través del canal IPC. El reparto es equilibrado por construcción.
  • Política del sistema operativo (SCHED_NONE, por defecto en Windows): el primario crea el socket de escucha y lo comparte con todos los trabajadores; es el núcleo del sistema quien decide qué proceso despierta con cada conexión. Es marginalmente más rápido, pero el reparto suele ser muy desigual: dos o tres trabajadores acaban absorbiendo casi todo el tráfico.

Se elige con cluster.schedulingPolicy antes de bifurcar, o con la variable NODE_CLUSTER_SCHED_POLICY (rr o none).

flowchart TD
  C[Clientes HTTP<br/>estreno Festival de Jazz] --> P[Proceso primario<br/>acepta y reparte SCHED_RR]
  P -->|IPC: descriptor de socket| W1[Trabajador 1<br/>arrancarServidor]
  P -->|IPC| W2[Trabajador 2<br/>arrancarServidor]
  P -->|IPC| W3[Trabajador 3<br/>arrancarServidor]
  P -->|IPC| W4[Trabajador N<br/>arrancarServidor]
  W1 --> BD[(PostgreSQL escena_viva)]
  W2 --> BD
  W3 --> BD
  W4 --> BD

Fíjate en un detalle del diagrama que será importante en el apartado 9: los trabajadores se multiplican, pero la base de datos sigue siendo una sola.

  1. Cuántos trabajadores: os.availableParallelism()

La respuesta ingenua es "tantos como núcleos". La API moderna para preguntarlo es os.availableParallelism() (Node 18.14+), que a diferencia de os.cpus().length respeta los límites del contenedor y la afinidad de CPU: si el orquestador te da 2 CPUs de una máquina de 64, devuelve 2, no 64.

'use strict';

const os = require('node:os');

// Numero de trabajadores razonable segun el paralelismo disponible.
// Se puede forzar por configuracion para pruebas y perfilado.
function calcularNumeroDeTrabajadores(solicitados) {
  const disponibles = os.availableParallelism();
  if (Number.isInteger(solicitados) && solicitados > 0) {
    return Math.min(solicitados, disponibles * 2);
  }
  // Margen de un nucleo para el primario y el sistema si hay holgura.
  return disponibles > 4 ? disponibles - 1 : disponibles;
}

module.exports = { calcularNumeroDeTrabajadores };

Por qué no siempre son todos los núcleos:

Situación Trabajadores recomendados Motivo
Servidor dedicado, carga de E/S N o N−1 El primario apenas consume; conviene dejar margen al sistema
Contenedor con límite de CPU (M11) Igual al límite, redondeando Más procesos solo añaden cambios de contexto
Memoria escasa Menos de N Cada trabajador tiene su montículo: 8 × ~80 MB no es gratis
La base de datos ya está saturada Menos de N Más procesos = más conexiones = más contención en el pool
Trabajo puramente de CPU N (o hilos, lección 10-02) Aquí el cuello es el cálculo, no la espera

  1. Implementación en Escena Viva: src/cluster.js

La clave del diseño es no tocar arrancarServidor(). En el módulo 6 dejamos src/app.js con la factoría crearAplicacion (que nunca llama a listen) y src/servidor.js con arrancarServidor, que crea el http.Server, conecta la base de datos y gestiona el apagado ordenado con SIGTERM/SIGINT y closeIdleConnections. Ese apagado ordenado, que en su momento parecía una elegancia, es justo lo que hace posible la recarga sin cortes del apartado 7.

'use strict';

const cluster = require('node:cluster');
const process = require('node:process');
const { configuracion } = require('./config/index.js');
const { calcularNumeroDeTrabajadores } = require('./utiles/paralelismo.js');
const { arrancarServidor } = require('./servidor.js');

// Politica de reparto explicita: round-robin en todas las plataformas
// donde este disponible, para que la carga no se concentre en dos procesos.
cluster.schedulingPolicy = cluster.SCHED_RR;

// Ventana y limite del cortacircuitos de reinicios.
const VENTANA_REINICIOS_MS = 60_000;
const MAXIMO_REINICIOS_POR_VENTANA = 10;

const reiniciosRecientes = [];

function registrarReinicio() {
  const ahora = Date.now();
  reiniciosRecientes.push(ahora);
  // Descartamos los reinicios fuera de la ventana deslizante.
  while (reiniciosRecientes.length > 0 && ahora - reiniciosRecientes[0] > VENTANA_REINICIOS_MS) {
    reiniciosRecientes.shift();
  }
  return reiniciosRecientes.length;
}

function bifurcarTrabajador() {
  const trabajador = cluster.fork();
  trabajador.on('message', (mensaje) => manejarMensajeDeTrabajador(trabajador, mensaje));
  return trabajador;
}

function manejarMensajeDeTrabajador(trabajador, mensaje) {
  if (mensaje && mensaje.tipo === 'metricas') {
    console.log(`[primario] ${trabajador.process.pid}: ${mensaje.peticiones} peticiones`);
  }
}

function arrancarPrimario() {
  const total = calcularNumeroDeTrabajadores(configuracion.trabajadores);
  console.log(`[primario] pid ${process.pid}, bifurcando ${total} trabajadores`);
  for (let i = 0; i < total; i += 1) bifurcarTrabajador();

  cluster.on('online', (t) => console.log(`[primario] ${t.process.pid} en linea`));
  cluster.on('listening', (t, dir) => console.log(`[primario] ${t.process.pid} escucha ${dir.port}`));
  cluster.on('disconnect', (t) => console.log(`[primario] ${t.process.pid} desconectado del IPC`));

  cluster.on('exit', (trabajador, codigo, senal) => {
    // Salida esperada (recarga o apagado ordenado): no reponemos.
    if (trabajador.exitedAfterDisconnect) return;

    const recientes = registrarReinicio();
    console.error(`[primario] ${trabajador.process.pid} murio (${codigo}/${senal}); ${recientes} en la ventana`);

    if (recientes > MAXIMO_REINICIOS_POR_VENTANA) {
      console.error('[primario] bucle de reinicios detectado; espero 30 s antes de reponer');
      setTimeout(bifurcarTrabajador, 30_000).unref();
      return;
    }
    bifurcarTrabajador();
  });

  // Apagado ordenado del conjunto: pedimos a cada trabajador que cierre.
  for (const senal of ['SIGTERM', 'SIGINT']) {
    process.on(senal, () => {
      for (const trabajador of Object.values(cluster.workers)) {
        trabajador.process.kill('SIGTERM');
      }
    });
  }
}

async function principal() {
  if (cluster.isPrimary) {
    arrancarPrimario();
    return;
  }
  // En el trabajador reutilizamos el arranque de siempre, sin modificarlo.
  await arrancarServidor();
}

principal().catch((error) => {
  console.error('Fallo al arrancar el cluster', error);
  process.exitCode = 1;
});

Puntos que merecen atención:

  • cluster.isPrimary decide el papel. El mismo fichero se ejecuta en los N+1 procesos; la bifurcación reejecuta el módulo de entrada desde cero.
  • exitedAfterDisconnect distingue una muerte inesperada (hay que reponer) de un cierre que hemos pedido nosotros (no hay que reponer). Sin esta comprobación, la recarga del apartado 7 entra en conflicto con la supervisión.
  • El cortacircuitos evita el patrón más doloroso: un fallo de configuración (por ejemplo, la base de datos caída) hace que cada trabajador muera al arrancar, el primario lo repone, muere otra vez... y la máquina se dedica a bifurcar procesos a 500 Hz. Con la ventana deslizante, tras 10 muertes en un minuto esperamos 30 segundos.
  • .unref() en el temporizador evita que ese setTimeout mantenga vivo al primario si todo lo demás ya ha terminado.

Añadimos el script "start:cluster": "node src/cluster.js" junto al "start" de siempre. Y trabajadores se lee, como todo lo demás, en src/config/index.js (el único punto que toca process.env, congelado y validado).

  1. Supervisión: reiniciar trabajadores sin caer en un bucle

El ciclo de vida de un trabajador emite eventos que conviene distinguir:

Evento Cuándo se emite Uso típico
fork El primario acaba de crear el proceso Contadores, tiempo de arranque
online El proceso hijo ya ejecuta JavaScript Detectar arranques que no llegan a escuchar
listening El trabajador ha llamado a listen Señal de "ya está listo": clave para la recarga
disconnect Se cerró el canal IPC Fase intermedia de un cierre ordenado
exit El proceso terminó Reponer si la salida no era esperada

La diferencia entre online y listening es más que un matiz: un trabajador puede estar "en línea" y morir después al no poder conectar con PostgreSQL. Solo listening garantiza que ese proceso puede atender tráfico.

  1. Comunicación entre procesos (IPC)

Cada trabajador tiene un canal con el primario. Desde el trabajador se usa process.send(mensaje); desde el primario, trabajador.send(mensaje). Los mensajes se serializan (por defecto en JSON; con serialization: 'advanced' se usa el algoritmo de clonado estructurado, que veremos en la 10-02).

En el trabajador, publicamos métricas cada 30 segundos:

'use strict';

const process = require('node:process');
const { monitorEventLoopDelay } = require('node:perf_hooks');

// Reutilizamos el histograma del modulo 2 como medida de salud del proceso.
const histograma = monitorEventLoopDelay({ resolution: 10 });
histograma.enable();

let peticionesAtendidas = 0;
const contarPeticion = () => { peticionesAtendidas += 1; };

function publicarMetricas() {
  if (typeof process.send !== 'function') return; // no estamos en un cluster
  process.send({
    tipo: 'metricas',
    peticiones: peticionesAtendidas,
    retrasoP99Ms: Number((histograma.percentile(99) / 1e6).toFixed(2)),
  });
  peticionesAtendidas = 0;
  histograma.reset();
}

setInterval(publicarMetricas, 30_000).unref();

module.exports = { contarPeticion, publicarMetricas };

Para qué sirve el IPC de verdad:

  • Agregar métricas: cada trabajador conoce solo su parte; el primario suma y expone el total.
  • Invalidar cachés: si el trabajador 3 vende entradas de ses-003-1, avisa al primario y este reenvía el aviso a todos, para que cada uno tire su copia del catálogo. Funciona, pero es un parche: la solución buena es que la caché no esté en memoria, y eso es la lección 10-03.
  • Coordinar la recarga: el primario ordena, el trabajador obedece.

Su coste: cada mensaje se serializa, viaja por un socket y se deserializa. Enviar un objeto pequeño cada 30 segundos es irrelevante; enviar el catálogo completo en cada venta es un error de diseño. El IPC es para señales de control, no para compartir datos.

  1. Recarga sin cortes (zero-downtime reload)

Desplegar una versión nueva matando los ocho trabajadores a la vez implica un hueco de varios segundos sin servicio. La alternativa es reiniciarlos de uno en uno, esperando a que el nuevo esté listening antes de tocar el siguiente.

'use strict';

const cluster = require('node:cluster');

// Sustituye un trabajador por otro nuevo, sin dejar de atender trafico.
function reemplazarTrabajador(antiguo, tiempoDeGraciaMs = 10_000) {
  return new Promise((resolver, rechazar) => {
    const nuevo = cluster.fork();

    nuevo.once('listening', () => {
      // El relevo ya acepta conexiones: ahora si podemos retirar al antiguo.
      antiguo.disconnect(); // deja de recibir conexiones nuevas

      // Si el apagado ordenado no termina a tiempo, forzamos.
      const temporizador = setTimeout(() => antiguo.process.kill('SIGKILL'), tiempoDeGraciaMs);
      antiguo.once('exit', () => { clearTimeout(temporizador); resolver(nuevo); });

      // El apagado ordenado que ya existe en servidor.js hace el resto:
      // cierra el servidor, espera a las peticiones en curso y llama a
      // closeIdleConnections para no quedarse atrapado en keep-alive.
      antiguo.process.kill('SIGTERM');
    });

    nuevo.once('exit', (codigo) => {
      if (codigo !== 0) rechazar(new Error(`El relevo murio con codigo ${codigo}`));
    });
  });
}

async function recargarTodos() {
  // Secuencial a proposito: en paralelo perderiamos capacidad de golpe.
  for (const trabajador of Object.values(cluster.workers)) {
    await reemplazarTrabajador(trabajador);
  }
  console.log('[primario] recarga completada');
}

// Convencion habitual: SIGHUP significa "recargate".
process.on('SIGHUP', () => {
  recargarTodos().catch((error) => console.error('Fallo en la recarga', error));
});

module.exports = { reemplazarTrabajador, recargarTodos };

Dos detalles críticos. Primero, disconnect() antes de SIGTERM: así el primario deja de enviarle conexiones nuevas mientras termina las que tiene. Segundo, closeIdleConnections (M6) es imprescindible: con keep-alive, un navegador puede mantener abierta una conexión ociosa durante minutos y el server.close() no retornaría nunca.

  1. Lo que se rompe: el estado en memoria deja de ser compartido

Este es el punto clave de la lección. Escena Viva funcionaba con un proceso, y ese proceso tenía memoria. Con ocho procesos hay ocho memorias, y todo lo que guardábamos "en una variable" se ha multiplicado por ocho sin avisar.

Componente Estado en memoria Qué pasa con 8 trabajadores
src/servicios/cambio-divisas.js Caché con caducidad de las tasas 8 cachés: 8 llamadas a la API externa donde había 1; hasta 8 tasas distintas a la vez
src/middleware/sesion.js (express-session) Almacén de sesiones en memoria Lucia inicia sesión en el trabajador 2; su siguiente petición cae en el 5 y está deslogueada
src/middleware/limites.js (express-rate-limit) Contadores por IP limiteCompra de 100 peticiones se convierte en 800 efectivas (100 por trabajador)
GestorDeVentas (EventEmitter del dominio) Oyentes en proceso Un evento aforo-bajo emitido en el trabajador 4 no lo oye nadie más
Contadores y métricas locales Variables del módulo Cada uno cuenta su trozo; el total requiere IPC

Traducido a incidentes reales del estreno del Festival de Jazz:

  • Sesiones intermitentes: Marc se autentica, navega, y de pronto la aplicación le pide iniciar sesión otra vez. No es un fallo esporádico: le pasa aproximadamente 7 de cada 8 peticiones. Con JWT de acceso (M8) el problema es menor, porque el token se verifica sin estado; pero el refresco opaco y el CSRF sí dependen de la sesión.
  • Límite de peticiones inútil: un script que compre entradas a toda velocidad recibe hasta 8 veces el cupo previsto. El limiteLogin deja de ser una defensa contra fuerza bruta.
  • Aforo con lecturas inconsistentes si alguien cachea en memoria: dos usuarios ven cifras distintas del mismo aforo, porque hablan con trabajadores distintos.

La regla general que hay que interiorizar: una aplicación que se va a ejecutar en varios procesos no puede guardar estado compartido en su propia memoria. El estado compartido va a un almacén externo. Ese almacén, en la lección 10-03, será Redis: connect-redis para las sesiones y rate-limit-redis para el limitador, y de paso la caché del catálogo.

Mientras tanto, hay un apaño legítimo si necesitas desplegar cluster ya: sticky sessions (afinidad de sesión), donde un balanceador manda siempre al mismo cliente al mismo proceso. Es frágil (un trabajador que muere se lleva las sesiones de sus clientes), impide repartir la carga bien y no arregla el límite de peticiones ni la caché. Sirve como puente, no como destino.

  1. Qué NO arregla cluster

Cluster multiplica procesos, y por tanto multiplica la capacidad de atender E/S concurrente. No hace magia:

  1. Una tarea que bloquea el bucle sigue bloqueando su trabajador. Generar el PDF con QR de una entrada consume CPU de forma síncrona. Con 8 trabajadores, una generación pesada bloquea a 1 de 8 en vez de a 1 de 1: has pasado del 100 % de indisponibilidad al 12,5 %, lo cual es una mejora estadística, no una solución. Si llegan 8 peticiones de PDF a la vez, vuelves al punto de partida. La solución real son los hilos de trabajo (10-02) y las colas (10-03).
  2. La base de datos sigue siendo compartida. Ocho trabajadores con un pool de 10 conexiones cada uno son 80 conexiones contra PostgreSQL; si el servidor admite 100, acabas de consumir el 80 % del presupuesto y cualquier proceso auxiliar se queda fuera. Al aumentar trabajadores hay que reducir el tamaño del pool de cada uno (lo veremos con detalle en 10-04).
  3. No arregla algoritmos malos. Una consulta N+1 sigue siendo N+1 en ocho procesos: ahora ocho veces en paralelo contra la misma base de datos.
  4. Multiplica la memoria. Ocho montículos de 100 MB son 800 MB. En una máquina justa, cluster provoca swapping y empeora la latencia.

  1. child_process y fork, los primos del módulo

cluster está construido sobre child_process.fork(), que lanza un proceso Node hijo con canal IPC incorporado. La diferencia es que cluster añade el reparto de sockets del servidor.

API Para qué Comparte puerto Canal IPC
cluster.fork() Escalar un servidor HTTP a N procesos Sí Sí
child_process.fork() Lanzar un proceso Node auxiliar (informe nocturno, migración) No Sí
child_process.spawn() Ejecutar un binario externo con streams (M3) No No
child_process.exec() Comando de shell corto capturando la salida No No

En Escena Viva usaremos spawn en el módulo 11 para tareas de despliegue, y fork puntualmente para procesos auxiliares. Advertencia de seguridad heredada del M8: nunca construyas la orden de exec concatenando datos del usuario; usa spawn con un array de argumentos.

  1. Medición final y enlace con producción

Repetimos exactamente la misma prueba, ahora con npm run start:cluster y 7 trabajadores:

npx autocannon -c 50 -d 10 http://localhost:3000/api/eventos
Configuración Req/s Latencia p50 Latencia p99 Núcleos usados
1 proceso 641 72 ms 204 ms 1
4 trabajadores 2 180 21 ms 68 ms 4
7 trabajadores 3 105 15 ms 54 ms 7

De 641 a 3 105 req/s: 4,8 veces, no 7. La mejora nunca es lineal, y las razones son concretas:

  • El primario consume CPU aceptando y repartiendo conexiones (política SCHED_RR).
  • La base de datos empieza a ser el cuello: más trabajadores no aceleran una consulta lenta.
  • Hay recursos compartidos: tarjeta de red, cachés de CPU, memoria.
  • La ley de Amdahl: la parte del trabajo que no se puede paralelizar (aquí, la base de datos) pone el techo.

Y la conclusión honesta: en producción casi nunca escribirás tu propio src/cluster.js. Lo hará PM2 en modo cluster o el orquestador de contenedores, materia del módulo 11. Entonces, ¿por qué lo hemos escrito? Porque PM2 hace exactamente esto y configurarlo bien exige entender qué está haciendo: cuántas instancias pedir, qué significa --wait-ready, por qué la recarga sin cortes necesita tu apagado ordenado, y sobre todo por qué tu aplicación debe estar preparada para no tener estado en memoria antes de escalarla. Esa preparación es tuya, no del supervisor.

Errores Comunes y Consejos

  • Bifurcar sin controlar los reinicios. Un error de arranque convierte al primario en una bomba de fork. Ventana deslizante y espera, siempre.
  • Olvidar exitedAfterDisconnect. Sin él, cada trabajador que retiras a propósito es repuesto de inmediato y la recarga nunca termina.
  • Poner lógica de negocio en el primario. El primario debe ser tonto: bifurcar, supervisar, repartir. Si hace trabajo, se convierte en el nuevo cuello de botella.
  • Enviar objetos grandes por IPC. La serialización se come lo que has ganado. Envía identificadores y señales, no cargas de datos.
  • Escalar a N trabajadores sin bajar el tamaño del pool de la base de datos. Es la forma más rápida de tumbar PostgreSQL con "una mejora de rendimiento".
  • Creer que cluster arregla el bloqueo de CPU. Reparte el daño; no lo elimina.
  • Consejo: pon un endpoint /salud que devuelva process.pid. Con curl repetido verás el reparto entre trabajadores y comprobarás de un vistazo que SCHED_RR funciona.
  • Consejo: en desarrollo, arranca en un solo proceso. Depurar (M9) con ocho procesos y puntos de interrupción es innecesariamente doloroso.

Ejercicios

Ejercicio 1: comprobar el reparto y el cortacircuitos

Añade a Escena Viva una ruta GET /api/salud que responda { pid, tiempoActivoSegundos, trabajador: true }. Arranca con 4 trabajadores y lanza 20 peticiones seguidas comprobando que los PID rotan. Después, haz que un trabajador salga con process.exit(1) a los 2 segundos de arrancar y verifica que el cortacircuitos entra en acción.

Ejercicio 2: demostrar la pérdida de estado

Con la aplicación en cluster (4 trabajadores) y limiteGeneral configurado a 20 peticiones por minuto, escribe un script que lance 60 peticiones a /api/eventos desde la misma IP. Cuenta cuántas reciben 429. Explica el resultado y calcula el límite efectivo.

Ejercicio 3: métricas agregadas por IPC

Implementa en el primario un agregador que sume las métricas de todos los trabajadores y las exponga por GET /api/metricas. Como el primario no atiende HTTP, resuelve el problema: ¿dónde vive ese endpoint y cómo obtiene los datos agregados?

Soluciones

Ejercicio 1. La ruta va en src/rutas/index.js y se resuelve con process.pid, process.uptime() y Boolean(process.env.NODE_UNIQUE_ID) (variable que cluster inyecta en cada trabajador). Con for i in $(seq 1 20); do curl -s localhost:3000/api/salud | jq -r .pid; done verás 4 PID alternándose de forma cíclica: es SCHED_RR funcionando. Para la segunda parte, un trabajador que muere repetidamente genera 10 reinicios en menos de un segundo; a partir del undécimo, el registro muestra "bucle de reinicios detectado" y el primario espera 30 s. Sin el cortacircuitos, el proceso consumiría un núcleo entero bifurcando.

Ejercicio 2. Con 4 trabajadores y SCHED_RR, las 60 peticiones se reparten en ~15 por trabajador. Como cada uno tiene su propio contador y el límite es 20, ninguna petición recibe 429: el límite efectivo es 4 × 20 = 80 por minuto. Para que fuera correcto, harían falta más de 80 peticiones. La conclusión práctica es que el limitador ha dejado de proteger nada útil, y la única solución sólida es un contador compartido: rate-limit-redis en la lección 10-03. El apaño de dividir el límite entre N (5 por trabajador) tampoco funciona bien, porque el reparto no es perfectamente uniforme y penalizaría a usuarios legítimos.

Ejercicio 3. El endpoint no puede vivir en el primario si el primario no escucha HTTP (y no debe: sería el cuello de botella). La solución limpia es al revés: el primario mantiene el agregado en memoria y lo empuja a los trabajadores cuando cambia; cada trabajador guarda la última copia recibida y la sirve desde su propia ruta /api/metricas.

// En el primario: agregar y difundir.
const totales = new Map(); // pid -> ultima metrica recibida

function manejarMensajeDeTrabajador(trabajador, mensaje) {
  if (!mensaje || mensaje.tipo !== 'metricas') return;
  totales.set(trabajador.process.pid, mensaje);
  const valores = [...totales.values()];
  const agregado = {
    tipo: 'agregado',
    peticiones: valores.reduce((suma, m) => suma + m.peticiones, 0),
    retrasoP99MaximoMs: Math.max(...valores.map((m) => m.retrasoP99Ms)),
    trabajadores: totales.size,
  };
  for (const otro of Object.values(cluster.workers)) otro.send(agregado);
}

Y en el trabajador, process.on('message', ...) guarda el último agregado en una variable que la ruta devuelve. Es una respuesta ligeramente desfasada (hasta 30 s), lo cual es perfectamente aceptable para métricas. La versión definitiva, sin IPC y sin desfase, es guardar los contadores en Redis: siguiente parada del módulo, aunque no la inmediata.

Conclusión

Escena Viva ya no desperdicia siete de cada ocho núcleos. Hemos medido honestamente la línea base (641 req/s), entendido que cluster reparte conexiones desde un primario con política SCHED_RR, dimensionado los trabajadores con os.availableParallelism(), implementado src/cluster.js reutilizando arrancarServidor() sin tocarlo, supervisado los trabajadores con protección contra el bucle de reinicios, comunicado procesos con IPC para señales de control, y logrado recargas sin cortes apoyándonos en el apagado ordenado del M6. El resultado: 3 105 req/s, 4,8 veces más, y un p99 que baja de 204 a 54 ms.

También hemos pagado un precio y lo hemos dejado por escrito: el estado en memoria —caché de divisas, sesiones y límite de peticiones— se ha fragmentado en ocho copias, con consecuencias reales para Lucia y Marc. Y hemos identificado la frontera de esta herramienta: cluster no desbloquea el bucle de eventos. Cuando un trabajador se pone a componer el PDF con QR de 500 entradas del Festival de Jazz, ese trabajador deja de atender a nadie.

Ese es exactamente el tema de la siguiente lección: Hilos de Trabajo (Worker Threads), donde sacaremos el trabajo de CPU del hilo principal, mediremos el retraso del bucle de eventos antes y después, y construiremos un pool de hilos propio, porque crear un hilo por petición es peor que no usar hilos.

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