Al mòdul 9 vam tancar les proves amb una frase incòmoda: Escena Viva és correcta, però és lenta i desaprofita la màquina. Totes les proves passen, la cobertura és raonable, i tot i així el servidor que hem llogat té vuit nuclis i nosaltres en fem servir un. A l'estrena del Festival de Jazz de Primavera (evt-003, sala Auditorio Ribera, dues sessions), quan s'obre la venda i arriben centenars de persones alhora, aquest únic fil és el sostre de tot el negoci.

Aquesta lliçó ataca aquest malbaratament amb l'eina més directa que ofereix Node: el mòdul cluster. Mesurarem primer, entendrem el mecanisme després, l'implementarem al projecte i —molt important— descobrirem què es trenca quan una aplicació pensada per a un procés passa a córrer en vuit.

Contingut

  1. El problema, mesurat: un fil, vuit nuclis
  2. Què és cluster i com comparteixen port els treballadors
  3. Quants treballadors: os.availableParallelism()
  4. Implementació a Escena Viva: src/cluster.js
  5. Supervisió: reiniciar treballadors sense caure en un bucle
  6. Comunicació entre processos (IPC)
  7. Recàrrega sense talls (zero-downtime reload)
  8. El que es trenca: l'estat en memòria deixa de ser compartit
  9. Què NO arregla cluster
  10. child_process i fork, els cosins del mòdul
  11. Mesura final i enllaç amb producció

  1. El problema, mesurat: un fil, vuit nuclis

Al mòdul 2 vam veure l'arquitectura: V8 executa el teu JavaScript en un sol fil, libuv aporta un conjunt de fils de 4 unitats per a certes operacions (fs, dns, crypto, zlib) i la resta de l'E/S es delega al sistema operatiu. La conseqüència pràctica és aritmètica simple:

Recurs Màquina de producció El que fa servir un procés Node
Nuclis de CPU 8 1 per al teu JavaScript
Aprofitament teòric 100 % ~12,5 %
Fils de libuv — 4, només per a tasques concretes del nucli

Abans d'optimitzar res, mesurem. És la regla que desenvoluparem a la lliçó 10-04, però ja l'apliquem:

npm install --save-dev autocannon      # eina de carrega
NODE_ENV=production node src/servidor.js  # un sol proces, com fins ara

# En una altra terminal: 10 segons, 50 connexions simultanies, sobre el cataleg
npx autocannon -c 50 -d 10 http://localhost:3000/api/esdeveniments

Sortida resumida a la màquina de referència (8 nuclis):

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 peticions per segon i un percentil 99 de 204 ms. Mentrestant, top mostra un sol nucli al 100 % i set a zero. Aquest número és la nostra línia base honesta: qualsevol cosa que fem a partir d'ara es compara amb ell.

  1. Què és cluster i com comparteixen port els treballadors

node:cluster permet llançar diversos processos Node —treballadors— coordinats per un procés primari, tots capaços d'atendre el mateix port. Cada treballador és un procés complet: el seu propi V8, el seu propi munt, el seu propi bucle d'esdeveniments.

La pregunta òbvia és com poden vuit processos escoltar al port 3000 sense que el sistema retorni EADDRINUSE. La resposta depèn de la política:

  • Política per defecte a Linux i macOS (SCHED_RR, round-robin): només el procés primari obre el socket i accepta connexions. Quan n'arriba una, tria un treballador per torn rotatori i li passa el descriptor de fitxer a través del canal IPC. El repartiment és equilibrat per construcció.
  • Política del sistema operatiu (SCHED_NONE, per defecte a Windows): el primari crea el socket d'escolta i el comparteix amb tots els treballadors; és el nucli del sistema qui decideix quin procés es desperta amb cada connexió. És marginalment més ràpid, però el repartiment sol ser molt desigual: dos o tres treballadors acaben absorbint gairebé tot el trànsit.

Es tria amb cluster.schedulingPolicy abans de bifurcar, o amb la variable NODE_CLUSTER_SCHED_POLICY (rr o none).

flowchart TD
  C[Clients HTTP<br/>estrena Festival de Jazz] --> P[Proces primari<br/>accepta i reparteix SCHED_RR]
  P -->|IPC: descriptor de socket| W1[Treballador 1<br/>arrencarServidor]
  P -->|IPC| W2[Treballador 2<br/>arrencarServidor]
  P -->|IPC| W3[Treballador 3<br/>arrencarServidor]
  P -->|IPC| W4[Treballador N<br/>arrencarServidor]
  W1 --> BD[(PostgreSQL escena_viva)]
  W2 --> BD
  W3 --> BD
  W4 --> BD

Fixa't en un detall del diagrama que serà important a l'apartat 9: els treballadors es multipliquen, però la base de dades continua sent una de sola.

  1. Quants treballadors: os.availableParallelism()

La resposta ingènua és "tants com nuclis". L'API moderna per preguntar-ho és os.availableParallelism() (Node 18.14+), que a diferència de os.cpus().length respecta els límits del contenidor i l'afinitat de CPU: si l'orquestrador et dona 2 CPU d'una màquina de 64, retorna 2, no 64.

'use strict';

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

// Nombre de treballadors raonable segons el parallelisme disponible.
// Es pot forcar per configuracio per a proves i perfilat.
function calcularNombreDeTreballadors(sollicitats) {
  const disponibles = os.availableParallelism();
  if (Number.isInteger(sollicitats) && sollicitats > 0) {
    return Math.min(sollicitats, disponibles * 2);
  }
  // Marge d'un nucli per al primari i el sistema si hi ha folganca.
  return disponibles > 4 ? disponibles - 1 : disponibles;
}

module.exports = { calcularNombreDeTreballadors };

Per què no sempre són tots els nuclis:

Situació Treballadors recomanats Motiu
Servidor dedicat, càrrega d'E/S N o N−1 El primari amb prou feines consumeix; convé deixar marge al sistema
Contenidor amb límit de CPU (M11) Igual al límit, arrodonint Més processos només afegeixen canvis de context
Memòria escassa Menys de N Cada treballador té el seu munt: 8 × ~80 MB no és gratis
La base de dades ja està saturada Menys de N Més processos = més connexions = més contenció al pool
Treball purament de CPU N (o fils, lliçó 10-02) Aquí el coll d'ampolla és el càlcul, no l'espera

  1. Implementació a Escena Viva: src/cluster.js

La clau del disseny és no tocar arrencarServidor(). Al mòdul 6 vam deixar src/app.js amb la factoria crearAplicacio (que mai no crida listen) i src/servidor.js amb arrencarServidor, que crea el http.Server, connecta la base de dades i gestiona l'aturada ordenada amb SIGTERM/SIGINT i closeIdleConnections. Aquella aturada ordenada, que en el seu moment semblava una elegància, és justament el que fa possible la recàrrega sense talls de l'apartat 7.

'use strict';

const cluster = require('node:cluster');
const process = require('node:process');
const { configuracio } = require('./config/index.js');
const { calcularNombreDeTreballadors } = require('./utils/parallelisme.js');
const { arrencarServidor } = require('./servidor.js');

// Politica de repartiment explicita: round-robin a totes les plataformes
// on estigui disponible, perque la carrega no es concentri en dos processos.
cluster.schedulingPolicy = cluster.SCHED_RR;

// Finestra i limit del tallacircuits de reinicis.
const FINESTRA_REINICIS_MS = 60_000;
const MAXIM_REINICIS_PER_FINESTRA = 10;

const reinicisRecents = [];

function registrarReinici() {
  const ara = Date.now();
  reinicisRecents.push(ara);
  // Descartem els reinicis fora de la finestra lliscant.
  while (reinicisRecents.length > 0 && ara - reinicisRecents[0] > FINESTRA_REINICIS_MS) {
    reinicisRecents.shift();
  }
  return reinicisRecents.length;
}

function bifurcarTreballador() {
  const treballador = cluster.fork();
  treballador.on('message', (missatge) => gestionarMissatgeDeTreballador(treballador, missatge));
  return treballador;
}

function gestionarMissatgeDeTreballador(treballador, missatge) {
  if (missatge && missatge.tipus === 'metriques') {
    console.log(`[primari] ${treballador.process.pid}: ${missatge.peticions} peticions`);
  }
}

function arrencarPrimari() {
  const total = calcularNombreDeTreballadors(configuracio.treballadors);
  console.log(`[primari] pid ${process.pid}, bifurcant ${total} treballadors`);
  for (let i = 0; i < total; i += 1) bifurcarTreballador();

  cluster.on('online', (t) => console.log(`[primari] ${t.process.pid} en linia`));
  cluster.on('listening', (t, dir) => console.log(`[primari] ${t.process.pid} escolta ${dir.port}`));
  cluster.on('disconnect', (t) => console.log(`[primari] ${t.process.pid} desconnectat de l'IPC`));

  cluster.on('exit', (treballador, codi, senyal) => {
    // Sortida esperada (recarrega o aturada ordenada): no reposem.
    if (treballador.exitedAfterDisconnect) return;

    const recents = registrarReinici();
    console.error(`[primari] ${treballador.process.pid} ha mort (${codi}/${senyal}); ${recents} a la finestra`);

    if (recents > MAXIM_REINICIS_PER_FINESTRA) {
      console.error('[primari] bucle de reinicis detectat; espero 30 s abans de reposar');
      setTimeout(bifurcarTreballador, 30_000).unref();
      return;
    }
    bifurcarTreballador();
  });

  // Aturada ordenada del conjunt: demanem a cada treballador que tanqui.
  for (const senyal of ['SIGTERM', 'SIGINT']) {
    process.on(senyal, () => {
      for (const treballador of Object.values(cluster.workers)) {
        treballador.process.kill('SIGTERM');
      }
    });
  }
}

async function principal() {
  if (cluster.isPrimary) {
    arrencarPrimari();
    return;
  }
  // Al treballador reutilitzem l'arrencada de sempre, sense modificar-la.
  await arrencarServidor();
}

principal().catch((error) => {
  console.error('Fallada en arrencar el cluster', error);
  process.exitCode = 1;
});

Punts que mereixen atenció:

  • cluster.isPrimary decideix el paper. El mateix fitxer s'executa als N+1 processos; la bifurcació reexecuta el mòdul d'entrada des de zero.
  • exitedAfterDisconnect distingeix una mort inesperada (cal reposar) d'un tancament que hem demanat nosaltres (no cal reposar). Sense aquesta comprovació, la recàrrega de l'apartat 7 entra en conflicte amb la supervisió.
  • El tallacircuits evita el patró més dolorós: una fallada de configuració (per exemple, la base de dades caiguda) fa que cada treballador mori en arrencar, el primari el reposa, torna a morir... i la màquina es dedica a bifurcar processos a 500 Hz. Amb la finestra lliscant, després de 10 morts en un minut esperem 30 segons.
  • .unref() al temporitzador evita que aquest setTimeout mantingui viu el primari si tota la resta ja ha acabat.

Afegim l'script "start:cluster": "node src/cluster.js" al costat del "start" de sempre. I treballadors es llegeix, com tota la resta, a src/config/index.js (l'únic punt que toca process.env, congelat i validat).

  1. Supervisió: reiniciar treballadors sense caure en un bucle

El cicle de vida d'un treballador emet esdeveniments que convé distingir:

Esdeveniment Quan s'emet Ús típic
fork El primari acaba de crear el procés Comptadors, temps d'arrencada
online El procés fill ja executa JavaScript Detectar arrencades que no arriben a escoltar
listening El treballador ha cridat listen Senyal de "ja està llest": clau per a la recàrrega
disconnect S'ha tancat el canal IPC Fase intermèdia d'un tancament ordenat
exit El procés ha acabat Reposar si la sortida no era esperada

La diferència entre online i listening és més que un matís: un treballador pot estar "en línia" i morir després en no poder connectar amb PostgreSQL. Només listening garanteix que aquell procés pot atendre trànsit.

  1. Comunicació entre processos (IPC)

Cada treballador té un canal amb el primari. Des del treballador s'usa process.send(missatge); des del primari, treballador.send(missatge). Els missatges es serialitzen (per defecte en JSON; amb serialization: 'advanced' es fa servir l'algorisme de clonatge estructurat, que veurem a la 10-02).

Al treballador, publiquem mètriques cada 30 segons:

'use strict';

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

// Reutilitzem l'histograma del modul 2 com a mesura de salut del proces.
const histograma = monitorEventLoopDelay({ resolution: 10 });
histograma.enable();

let peticionsAteses = 0;
const comptarPeticio = () => { peticionsAteses += 1; };

function publicarMetriques() {
  if (typeof process.send !== 'function') return; // no som dins d'un cluster
  process.send({
    tipus: 'metriques',
    peticions: peticionsAteses,
    retardP99Ms: Number((histograma.percentile(99) / 1e6).toFixed(2)),
  });
  peticionsAteses = 0;
  histograma.reset();
}

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

module.exports = { comptarPeticio, publicarMetriques };

Per a què serveix l'IPC de veritat:

  • Agregar mètriques: cada treballador coneix només la seva part; el primari suma i exposa el total.
  • Invalidar memòries cau: si el treballador 3 ven entrades de ses-003-1, avisa el primari i aquest reenvia l'avís a tothom, perquè cadascú llenci la seva còpia del catàleg. Funciona, però és un pedaç: la solució bona és que la memòria cau no estigui en memòria, i això és la lliçó 10-03.
  • Coordinar la recàrrega: el primari ordena, el treballador obeeix.

El seu cost: cada missatge es serialitza, viatja per un socket i es deserialitza. Enviar un objecte petit cada 30 segons és irrellevant; enviar el catàleg complet a cada venda és un error de disseny. L'IPC és per a senyals de control, no per compartir dades.

  1. Recàrrega sense talls (zero-downtime reload)

Desplegar una versió nova matant els vuit treballadors alhora implica un forat de diversos segons sense servei. L'alternativa és reiniciar-los d'un en un, esperant que el nou estigui listening abans de tocar el següent.

'use strict';

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

// Substitueix un treballador per un de nou, sense deixar d'atendre transit.
function reemplacarTreballador(antic, tempsDeGraciaMs = 10_000) {
  return new Promise((resoldre, rebutjar) => {
    const nou = cluster.fork();

    nou.once('listening', () => {
      // El relleu ja accepta connexions: ara si que podem retirar l'antic.
      antic.disconnect(); // deixa de rebre connexions noves

      // Si l'aturada ordenada no acaba a temps, forcem.
      const temporitzador = setTimeout(() => antic.process.kill('SIGKILL'), tempsDeGraciaMs);
      antic.once('exit', () => { clearTimeout(temporitzador); resoldre(nou); });

      // L'aturada ordenada que ja existeix a servidor.js fa la resta:
      // tanca el servidor, espera les peticions en curs i crida
      // closeIdleConnections per no quedar-se atrapat en keep-alive.
      antic.process.kill('SIGTERM');
    });

    nou.once('exit', (codi) => {
      if (codi !== 0) rebutjar(new Error(`El relleu ha mort amb codi ${codi}`));
    });
  });
}

async function recarregarTots() {
  // Sequencial a proposit: en parallel perdriem capacitat de cop.
  for (const treballador of Object.values(cluster.workers)) {
    await reemplacarTreballador(treballador);
  }
  console.log('[primari] recarrega completada');
}

// Convencio habitual: SIGHUP significa "recarrega't".
process.on('SIGHUP', () => {
  recarregarTots().catch((error) => console.error('Fallada en la recarrega', error));
});

module.exports = { reemplacarTreballador, recarregarTots };

Dos detalls crítics. Primer, disconnect() abans de SIGTERM: així el primari deixa d'enviar-li connexions noves mentre acaba les que té. Segon, closeIdleConnections (M6) és imprescindible: amb keep-alive, un navegador pot mantenir oberta una connexió ociosa durant minuts i el server.close() no retornaria mai.

  1. El que es trenca: l'estat en memòria deixa de ser compartit

Aquest és el punt clau de la lliçó. Escena Viva funcionava amb un procés, i aquell procés tenia memòria. Amb vuit processos hi ha vuit memòries, i tot el que guardàvem "en una variable" s'ha multiplicat per vuit sense avisar.

Component Estat en memòria Què passa amb 8 treballadors
src/serveis/canvi-divises.js Memòria cau amb caducitat de les taxes 8 memòries cau: 8 crides a l'API externa on n'hi havia 1; fins a 8 taxes diferents alhora
src/middleware/sessio.js (express-session) Magatzem de sessions en memòria Lucía inicia sessió al treballador 2; la seva petició següent cau al 5 i està desconnectada
src/middleware/limits.js (express-rate-limit) Comptadors per IP limitCompra de 100 peticions es converteix en 800 efectives (100 per treballador)
GestorDeVendes (EventEmitter del domini) Oients en procés Un esdeveniment aforament-baix emès al treballador 4 no l'escolta ningú més
Comptadors i mètriques locals Variables del mòdul Cadascun compta el seu tros; el total requereix IPC

Traduït a incidents reals de l'estrena del Festival de Jazz:

  • Sessions intermitents: Marc s'autentica, navega, i de sobte l'aplicació li demana iniciar sessió una altra vegada. No és una fallada esporàdica: li passa aproximadament 7 de cada 8 peticions. Amb JWT d'accés (M8) el problema és menor, perquè el token es verifica sense estat; però el refresc opac i el CSRF sí que depenen de la sessió.
  • Límit de peticions inútil: un script que compri entrades a tota velocitat rep fins a 8 vegades la quota prevista. El limitEntrada deixa de ser una defensa contra la força bruta.
  • Aforament amb lectures incoherents si algú fa memòria cau en memòria: dos usuaris veuen xifres diferents del mateix aforament, perquè parlen amb treballadors diferents.

La regla general que cal interioritzar: una aplicació que s'ha d'executar en diversos processos no pot guardar estat compartit a la seva pròpia memòria. L'estat compartit va a un magatzem extern. Aquest magatzem, a la lliçó 10-03, serà Redis: connect-redis per a les sessions i rate-limit-redis per al limitador, i de passada la memòria cau del catàleg.

Mentrestant, hi ha un apedaçament legítim si necessites desplegar cluster ja: sticky sessions (afinitat de sessió), on un balancejador envia sempre el mateix client al mateix procés. És fràgil (un treballador que mor s'emporta les sessions dels seus clients), impedeix repartir bé la càrrega i no arregla ni el límit de peticions ni la memòria cau. Serveix com a pont, no com a destinació.

  1. Què NO arregla cluster

Cluster multiplica processos, i per tant multiplica la capacitat d'atendre E/S concurrent. No fa màgia:

  1. Una tasca que bloqueja el bucle continua bloquejant el seu treballador. Generar el PDF amb QR d'una entrada consumeix CPU de manera síncrona. Amb 8 treballadors, una generació pesada en bloqueja 1 de 8 en comptes d'1 d'1: has passat del 100 % d'indisponibilitat al 12,5 %, cosa que és una millora estadística, no una solució. Si arriben 8 peticions de PDF alhora, tornes al punt de partida. La solució real són els fils de treball (10-02) i les cues (10-03).
  2. La base de dades continua sent compartida. Vuit treballadors amb un pool de 10 connexions cadascun són 80 connexions contra PostgreSQL; si el servidor n'admet 100, acabes de consumir el 80 % del pressupost i qualsevol procés auxiliar es queda fora. En augmentar els treballadors cal reduir la mida del pool de cadascun (ho veurem amb detall a la 10-04).
  3. No arregla els algorismes dolents. Una consulta N+1 continua sent N+1 en vuit processos: ara vuit vegades en paral·lel contra la mateixa base de dades.
  4. Multiplica la memòria. Vuit munts de 100 MB són 800 MB. En una màquina justa, cluster provoca intercanvi de memòria i empitjora la latència.

  1. child_process i fork, els cosins del mòdul

cluster està construït sobre child_process.fork(), que llança un procés Node fill amb canal IPC incorporat. La diferència és que cluster hi afegeix el repartiment de sockets del servidor.

API Per a què Comparteix port Canal IPC
cluster.fork() Escalar un servidor HTTP a N processos Sí Sí
child_process.fork() Llançar un procés Node auxiliar (informe nocturn, migració) No Sí
child_process.spawn() Executar un binari extern amb streams (M3) No No
child_process.exec() Ordre d'intèrpret curta capturant-ne la sortida No No

A Escena Viva farem servir spawn al mòdul 11 per a tasques de desplegament, i fork puntualment per a processos auxiliars. Advertiment de seguretat heretat del M8: no construeixis mai l'ordre d'exec concatenant dades de l'usuari; fes servir spawn amb un array d'arguments.

  1. Mesura final i enllaç amb producció

Repetim exactament la mateixa prova, ara amb npm run start:cluster i 7 treballadors:

npx autocannon -c 50 -d 10 http://localhost:3000/api/esdeveniments
Configuració Req/s Latència p50 Latència p99 Nuclis usats
1 procés 641 72 ms 204 ms 1
4 treballadors 2 180 21 ms 68 ms 4
7 treballadors 3 105 15 ms 54 ms 7

De 641 a 3 105 req/s: 4,8 vegades, no 7. La millora no és mai lineal, i les raons són concretes:

  • El primari consumeix CPU acceptant i repartint connexions (política SCHED_RR).
  • La base de dades comença a ser el coll d'ampolla: més treballadors no acceleren una consulta lenta.
  • Hi ha recursos compartits: targeta de xarxa, memòries cau de CPU, memòria.
  • La llei d'Amdahl: la part de la feina que no es pot paral·lelitzar (aquí, la base de dades) posa el sostre.

I la conclusió honesta: en producció gairebé mai no escriuràs el teu propi src/cluster.js. Ho farà PM2 en mode cluster o l'orquestrador de contenidors, matèria del mòdul 11. Aleshores, per què l'hem escrit? Perquè PM2 fa exactament això i configurar-lo bé exigeix entendre què està fent: quantes instàncies demanar, què significa --wait-ready, per què la recàrrega sense talls necessita la teva aturada ordenada, i sobretot per què la teva aplicació ha d'estar preparada per no tenir estat en memòria abans d'escalar-la. Aquesta preparació és teva, no del supervisor.

Errors Comuns i Consells

  • Bifurcar sense controlar els reinicis. Un error d'arrencada converteix el primari en una bomba de fork. Finestra lliscant i espera, sempre.
  • Oblidar exitedAfterDisconnect. Sense ell, cada treballador que retires expressament és reposat de seguida i la recàrrega no acaba mai.
  • Posar lògica de negoci al primari. El primari ha de ser tonto: bifurcar, supervisar, repartir. Si fa feina, es converteix en el nou coll d'ampolla.
  • Enviar objectes grans per IPC. La serialització es menja el que has guanyat. Envia identificadors i senyals, no càrregues de dades.
  • Escalar a N treballadors sense abaixar la mida del pool de la base de dades. És la manera més ràpida de tombar PostgreSQL amb "una millora de rendiment".
  • Creure que cluster arregla el blocatge de CPU. Reparteix el mal; no l'elimina.
  • Consell: posa un punt d'entrada /salut que retorni process.pid. Amb curl repetit veuràs el repartiment entre treballadors i comprovaràs d'un cop d'ull que SCHED_RR funciona.
  • Consell: en desenvolupament, arrenca en un sol procés. Depurar (M9) amb vuit processos i punts d'interrupció és innecessàriament dolorós.

Exercicis

Exercici 1: comprovar el repartiment i el tallacircuits

Afegeix a Escena Viva una ruta GET /api/salut que respongui { pid, tempsActiuSegons, treballador: true }. Arrenca amb 4 treballadors i llança 20 peticions seguides comprovant que els PID roten. Després, fes que un treballador surti amb process.exit(1) als 2 segons d'arrencar i verifica que el tallacircuits entra en acció.

Exercici 2: demostrar la pèrdua d'estat

Amb l'aplicació en cluster (4 treballadors) i limitGeneral configurat a 20 peticions per minut, escriu un script que llanci 60 peticions a /api/esdeveniments des de la mateixa IP. Compta quantes reben 429. Explica el resultat i calcula el límit efectiu.

Exercici 3: mètriques agregades per IPC

Implementa al primari un agregador que sumi les mètriques de tots els treballadors i les exposi per GET /api/metriques. Com que el primari no atén HTTP, resol el problema: on viu aquest punt d'entrada i com obté les dades agregades?

Solucions

Exercici 1. La ruta va a src/rutes/index.js i es resol amb process.pid, process.uptime() i Boolean(process.env.NODE_UNIQUE_ID) (variable que cluster injecta a cada treballador). Amb for i in $(seq 1 20); do curl -s localhost:3000/api/salut | jq -r .pid; done veuràs 4 PID alternant-se de manera cíclica: és SCHED_RR funcionant. Per a la segona part, un treballador que mor repetidament genera 10 reinicis en menys d'un segon; a partir de l'onzè, el registre mostra "bucle de reinicis detectat" i el primari espera 30 s. Sense el tallacircuits, el procés consumiria un nucli sencer bifurcant.

Exercici 2. Amb 4 treballadors i SCHED_RR, les 60 peticions es reparteixen en ~15 per treballador. Com que cadascun té el seu propi comptador i el límit és 20, cap petició no rep 429: el límit efectiu és 4 × 20 = 80 per minut. Perquè fos correcte, caldrien més de 80 peticions. La conclusió pràctica és que el limitador ha deixat de protegir res útil, i l'única solució sòlida és un comptador compartit: rate-limit-redis a la lliçó 10-03. L'apedaçament de dividir el límit entre N (5 per treballador) tampoc no funciona bé, perquè el repartiment no és perfectament uniforme i penalitzaria usuaris legítims.

Exercici 3. El punt d'entrada no pot viure al primari si el primari no escolta HTTP (i no ho ha de fer: seria el coll d'ampolla). La solució neta és a l'inrevés: el primari manté l'agregat en memòria i l'empeny cap als treballadors quan canvia; cada treballador guarda l'última còpia rebuda i la serveix des de la seva pròpia ruta /api/metriques.

// Al primari: agregar i difondre.
const totals = new Map(); // pid -> ultima metrica rebuda

function gestionarMissatgeDeTreballador(treballador, missatge) {
  if (!missatge || missatge.tipus !== 'metriques') return;
  totals.set(treballador.process.pid, missatge);
  const valors = [...totals.values()];
  const agregat = {
    tipus: 'agregat',
    peticions: valors.reduce((suma, m) => suma + m.peticions, 0),
    retardP99MaximMs: Math.max(...valors.map((m) => m.retardP99Ms)),
    treballadors: totals.size,
  };
  for (const altre of Object.values(cluster.workers)) altre.send(agregat);
}

I al treballador, process.on('message', ...) guarda l'últim agregat en una variable que la ruta retorna. És una resposta lleugerament desfasada (fins a 30 s), cosa perfectament acceptable per a mètriques. La versió definitiva, sense IPC i sense desfasament, és guardar els comptadors a Redis: parada següent del mòdul, encara que no la immediata.

Conclusió

Escena Viva ja no malbarata set de cada vuit nuclis. Hem mesurat honestament la línia base (641 req/s), entès que cluster reparteix connexions des d'un primari amb política SCHED_RR, dimensionat els treballadors amb os.availableParallelism(), implementat src/cluster.js reutilitzant arrencarServidor() sense tocar-lo, supervisat els treballadors amb protecció contra el bucle de reinicis, comunicat processos amb IPC per a senyals de control, i aconseguit recàrregues sense talls recolzant-nos en l'aturada ordenada del M6. El resultat: 3 105 req/s, 4,8 vegades més, i un p99 que baixa de 204 a 54 ms.

També hem pagat un preu i l'hem deixat per escrit: l'estat en memòria —memòria cau de divises, sessions i límit de peticions— s'ha fragmentat en vuit còpies, amb conseqüències reals per a Lucía i Marc. I hem identificat la frontera d'aquesta eina: cluster no desbloqueja el bucle d'esdeveniments. Quan un treballador es posa a compondre el PDF amb QR de 500 entrades del Festival de Jazz, aquell treballador deixa d'atendre ningú.

Aquest és exactament el tema de la lliçó següent: Fils de Treball (Worker Threads), on traurem la feina de CPU del fil principal, mesurarem el retard del bucle d'esdeveniments abans i després, i construirem un pool de fils propi, perquè crear un fil per petició és pitjor que no fer servir fils.

Curs de Node.js: De Principiant a Avançat

Mòdul 1: Introducció a Node.js

Mòdul 2: Conceptes Bàsics

Mòdul 3: Sistema de Fitxers i E/S

Mòdul 4: HTTP i Servidors Web

Mòdul 5: NPM i Gestió de Paquets

Mòdul 6: Framework Express.js

Mòdul 7: Bases de Dades i ORMs

Mòdul 8: Autenticació i Autorització

Mòdul 9: Proves i Depuració

Mòdul 10: Temes Avançats

Mòdul 11: Desplegament i DevOps

Mòdul 12: Projectes del Món Real

© Copyright 2026. Tots els drets reservats