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
- El problema, medido: un hilo, ocho núcleos
- Qué es
clustery cómo comparten puerto los trabajadores - Cuántos trabajadores:
os.availableParallelism() - Implementación en Escena Viva:
src/cluster.js - Supervisión: reiniciar trabajadores sin caer en un bucle
- Comunicación entre procesos (IPC)
- Recarga sin cortes (zero-downtime reload)
- Lo que se rompe: el estado en memoria deja de ser compartido
- Qué NO arregla cluster
child_processyfork, los primos del módulo- Medición final y enlace con producción
- 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/eventosSalida 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.
- Qué es
cluster y cómo comparten puerto los trabajadores
cluster y cómo comparten puerto los trabajadoresnode: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.
- Cuántos trabajadores:
os.availableParallelism()
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 |
- Implementación en Escena Viva:
src/cluster.js
src/cluster.jsLa 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.isPrimarydecide el papel. El mismo fichero se ejecuta en los N+1 procesos; la bifurcación reejecuta el módulo de entrada desde cero.exitedAfterDisconnectdistingue 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 esesetTimeoutmantenga 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).
- 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.
- 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.
- 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.
- 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
limiteLogindeja 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.
- Qué NO arregla cluster
Cluster multiplica procesos, y por tanto multiplica la capacidad de atender E/S concurrente. No hace magia:
- 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).
- 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).
- 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.
- Multiplica la memoria. Ocho montículos de 100 MB son 800 MB. En una máquina justa, cluster provoca swapping y empeora la latencia.
child_process y fork, los primos del módulo
child_process y fork, los primos del módulocluster 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.
- Medición final y enlace con producción
Repetimos exactamente la misma prueba, ahora con npm run start:cluster y 7 trabajadores:
| 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
/saludque devuelvaprocess.pid. Concurlrepetido verás el reparto entre trabajadores y comprobarás de un vistazo queSCHED_RRfunciona. - 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
- ¿Qué es Node.js?
- Instalación y Configuración del Entorno
- Tu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Moderno para Node.js
- El Proyecto del Curso: la Plataforma Escena Viva
Módulo 2: Conceptos Básicos
- Arquitectura de Node.js
- El Bucle de Eventos (Event Loop)
- Callbacks y Programación Asíncrona
- Promesas y async/await
- Eventos y EventEmitter
- Módulos CommonJS y require()
- Módulos ES e Interoperabilidad
Módulo 3: Sistema de Archivos y E/S
- Lectura y Escritura de Archivos
- El Módulo fs a Fondo
- Rutas Multiplataforma con el Módulo path
- Trabajando con Streams
- Streams de Transformación y pipeline
- Buffers y Datos Binarios
Módulo 4: HTTP y Servidores Web
- Creando un Servidor HTTP Simple
- Manejo de Solicitudes y Respuestas
- Enrutamiento Manual
- Sirviendo Archivos Estáticos
- Recibiendo Datos: Cuerpos de Petición y JSON
- Consumiendo APIs Externas desde Node.js
Módulo 5: NPM y Gestión de Paquetes
- Introducción a NPM y package.json
- Instalación y Uso de Paquetes
- Versionado Semántico y package-lock
- Scripts de npm y Automatización del Proyecto
- Creación y Publicación de Paquetes
- Seguridad y Mantenimiento de Dependencias
Módulo 6: Framework Express.js
- Introducción a Express.js
- Configuración de una Aplicación Express
- Enrutamiento en Express
- Middleware
- Middleware de Terceros Esenciales
- Validación de Datos de Entrada
- Manejo de Errores
Módulo 7: Bases de Datos y ORMs
- Introducción a las Bases de Datos
- Usando MongoDB con Mongoose
- Operaciones CRUD
- Relaciones, Poblado y Consultas Avanzadas
- Usando Bases de Datos SQL con Sequelize
- Migraciones, Transacciones y Datos de Prueba
Módulo 8: Autenticación y Autorización
- Introducción a la Autenticación
- Registro de Usuarios y Hash de Contraseñas
- Sesiones y Cookies con Passport.js
- Autenticación con JWT
- Control de Acceso Basado en Roles
- Buenas Prácticas de Seguridad en APIs
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con Mocha y Chai
- Dobles de Prueba con Sinon
- Pruebas de Integración
- Cobertura y Automatización de las Pruebas
- Depuración de Aplicaciones Node.js
Módulo 10: Temas Avanzados
- El Módulo Cluster
- Hilos de Trabajo (Worker Threads)
- Caché y Colas de Trabajo con Redis
- Optimización del Rendimiento
- Construcción de APIs RESTful
- GraphQL con Node.js
Módulo 11: Despliegue y DevOps
- Configuración y Variables de Entorno
- Registro y Monitorización en Producción
- Usando PM2 para la Gestión de Procesos
- Empaquetado con Docker
- Desplegando en Heroku y Otras PaaS
- Integración y Despliegue Continuos
