A les tres lliçons anteriors hem donat molts números: 641 peticions per segon, després 3 105, després 11 840; percentils 99 de 204, 54 i 11 mil·lisegons; un retard del bucle d'esdeveniments que va passar de 4 176 ms a 11 ms. Aquests números no van sortir de la intuïció: van sortir de mesurar abans, canviar una cosa i mesurar després. Aquesta pràctica, que hem aplicat sense anomenar-la, és el contingut d'aquesta lliçó.

Aquí no hi trobaràs una llista de trucs, sinó un mètode, un catàleg d'instruments (proves de càrrega, perfils de CPU, gràfics de flama, instantànies del munt) i un catàleg de colls d'ampolla típics de Node amb el seu arranjament, tots ancorats al que ja saps del curs.

Contingut

  1. El mètode: mesurar, localitzar, arreglar, tornar a mesurar
  2. Què es mesura: latència, rendiment, errors, saturació
  3. Proves de càrrega amb autocannon
  4. Perfilat de CPU i gràfics de flama
  5. Perfilat de memòria i fuites
  6. El retard del bucle d'esdeveniments com a mètrica de salut
  7. Catàleg de colls d'ampolla típics a Node
  8. Optimitzacions de la capa HTTP
  9. La base de dades: el pool de connexions
  10. V8 sense caure en el microoptimitzat inútil
  11. L'ordre correcte de les palanques

  1. El mètode: mesurar, localitzar, arreglar, tornar a mesurar

El cicle té quatre passos i cap no és opcional: mesurar el sistema complet sota una càrrega realista i anotar la línia base; localitzar el coll d'ampolla amb un perfil, no amb una intuïció; arreglar només això, un canvi cada vegada; i tornar a mesurar amb la mateixa prova, comparant amb la línia base.

Un canvi per iteració és innegociable. Si toques tres coses i el sistema millora un 30 %, no saps quina de les tres va funcionar, ni si alguna va empitjorar les coses i les altres dues ho van compensar.

La citació de Donald Knuth es repeteix molt i gairebé sempre mutilada. La frase completa és: "hauríem d'oblidar-nos de les petites eficiències, diguem el 97 % del temps: l'optimització prematura és l'arrel de tots els mals. Tanmateix, no hauríem de deixar passar les nostres oportunitats en aquest 3 % crític." Els dos extrems són errors: reescriure un bucle per estalviar microsegons en codi que s'executa un cop al dia, i també ignorar que la teva consulta principal fa un escaneig seqüencial sobre una taula d'un milió de files. La diferència entre tots dos no és filosòfica: és que l'un l'has mesurat i l'altre no. Corol·lari pràctic: optimitzar sense mesurar és endevinar, i la intuïció del programador sobre on és el temps és notòriament dolenta. A Escena Viva qualsevol hauria apostat que el coll d'ampolla era generar el PDF; el perfil va demostrar que al catàleg el 62 % del temps se n'anava a serialitzar JSON i a una consulta sense índex.

  1. Què es mesura: latència, rendiment, errors, saturació

Quatre senyals, conegudes com les golden signals:

Senyal Què és Com es mesura a Escena Viva
Latència Temps de resposta Percentils per ruta, separant 2xx de 5xx
Rendiment (throughput) Peticions ateses per segon autocannon, i comptadors en producció
Taxa d'errors Proporció de 5xx i de temps exhaurits Registre estructurat (M6) per codi d'error
Saturació Com de ple està el recurs limitant CPU, memòria, connexions del pool, retard del bucle

Sobre la latència cal insistir en una cosa, i és que la mitjana menteix:

Estadístic Valor al catàleg Què significa
Mitjana / p50 78 ms / 72 ms Cap usuari concret no experimenta la mitjana
p95 161 ms 1 de cada 20 peticions va pitjor
p99 204 ms 1 de cada 100. Amb 12 peticions per pàgina, 1 de cada 8 visites veu això
p99.9 / màxim 890 ms La cua llarga: el que provoca les queixes

Si deu peticions triguen 10 ms i una triga 5 000 ms, la mitjana és 463 ms: un número que no descriu ni les ràpides ni la lenta. I l'aritmètica del p99 espanta quan es tradueix a experiència: una pàgina que fa 12 crides a l'API té una probabilitat d'1 − 0,99¹² ≈ 11 % de trobar-se almenys una petició del percentil 99. Optimitza la cua, no la mitjana. Aquests són els objectius de servei d'Escena Viva, acordats amb el negoci:

Ruta Objectiu p99 Disponibilitat Justificació
GET /api/esdeveniments < 150 ms 99,9 % És la primera pantalla; si va lenta, no es ven
GET /api/esdeveniments/:id/sessions < 200 ms 99,9 % Pas previ a la compra
POST /api/comandes < 800 ms 99,95 % Transaccional; es tolera més latència, no fallades
POST /api/autenticacio/entrada < 400 ms 99,9 % bcrypt amb cost 12 té un terra propi

Aquest últim cas demostra que no tot el que és lent és un problema: bcrypt amb cost 12 triga ~250 ms expressament, perquè aquella lentitud és la defensa contra la força bruta (M8), i optimitzar-la seria una regressió de seguretat.

  1. Proves de càrrega amb autocannon

Una prova de càrrega honesta compleix quatre condicions. Escalfament: els primers segons no compten, perquè V8 necessita executar el codi diverses vegades abans que el compilador optimitzador (TurboFan) faci la seva feina i les memòries cau estan fredes. Durada suficient: 30-60 segons com a mínim, ja que deu segons poden amagar una fuita o l'efecte d'una recol·lecció d'escombraries major. Concurrència realista: no 5 000 connexions si el teu pic real són 200. I escenari realista: no martellejar una sola ruta trivial, sinó reproduir el recorregut de l'usuari.

# Escalfament (10 s que es descarten) i mesura (60 s, 100 connexions).
npx autocannon -c 50 -d 10 -w 4 http://localhost:3000/api/esdeveniments > /dev/null
npx autocannon -c 100 -d 60 -w 4 --latency http://localhost:3000/api/esdeveniments

Un escenari de compra realista es defineix per programa:

'use strict';

const autocannon = require('autocannon');

// Escenari de l'estrena del Festival de Jazz: mirar el cataleg, obrir la
// fitxa de l'esdeveniment, consultar sessions i, en una fraccio, comprar.
const instancia = autocannon({
  url: 'http://localhost:3000',
  connections: 100,
  duration: 60,
  warmup: { connections: 10, duration: 10 },
  requests: [
    { method: 'GET', path: '/api/esdeveniments' },
    { method: 'GET', path: '/api/esdeveniments/evt-003' },
    { method: 'GET', path: '/api/esdeveniments/evt-003/sessions' },
    { method: 'POST', path: '/api/comandes',
      headers: { 'content-type': 'application/json', authorization: 'Bearer <token>' },
      body: JSON.stringify({ sessioId: 'ses-003-1', quantitat: 2 }) },
  ],
});

autocannon.track(instancia, { renderProgressBar: true });
instancia.on('done', (r) => console.log(`p99: ${r.latency.p99} ms, no-2xx: ${r.non2xx}`));

I així es llegeix la sortida:

Camp Què cal mirar
Latency p99 El número que governa els teus objectius de servei
Req/Sec Rendiment; compara'l sempre amb la línia base
non2xx Si no és 0, la prova no val: estàs mesurant la velocitat de fallar
errors / timeouts Connexions rebutjades o exhaurides: has superat la capacitat
Bytes/Sec Si t'acostes a l'amplada de banda, el coll d'ampolla és la xarxa, no el teu codi

k6 és l'alternativa quan necessites escenaris amb estat (entrada, galetes, sessions encadenades), llindars que facin fallar la prova a CI i càrrega distribuïda: autocannon és perfecte per al cicle ràpid de "mesura, canvia, mesura", i k6 per a la prova de regressió de rendiment en integració contínua (M11).

Advertiment important i no negociable. Una prova de càrrega és, tècnicament, un atac de denegació de servei. Llança-la només contra sistemes que siguin teus i en entorns de prova: mai contra producció sense coordinació prèvia (i allà, millor trànsit mirall o càrrega sintètica limitada), i mai contra una API de tercers, ni la de divises del M8, ni la passarel·la de pagament, ni un servei públic. És il·legal en moltes jurisdiccions i sempre és una falta de respecte professional.

  1. Perfilat de CPU i gràfics de flama

La prova de càrrega et diu que alguna cosa és lenta; el perfil et diu on.

Opció 1: el perfilador integrat de V8. node --prof src/servidor.js genera un isolate-*.log amb mostres del comptador de programa; es llança la càrrega, s'atura el servidor i node --prof-process isolate-*.log > perfil.txt el converteix en un informe llegible que comença amb el resum que més importa:

 [Summary]:  ticks  total  nonlib   name
              4821  61.9%   72.4%  JavaScript
              1901  24.4%          GC
               502   6.4%          Shared libraries
 [Bottom up (heavy) profile]:
  1204   15.5%  JSON.stringify
   890   11.4%    LazyCompile: *serialitzarCataleg src/repositoris/esdeveniments.js:88
   731    9.4%  LazyCompile: *calcularAforamentDisponible src/domini/sessio.js:41

Dues lectures immediates: JSON.stringify s'emporta el 15,5 % i la recol·lecció d'escombraries el 24,4 %, cosa que suggereix que estem creant massa objectes temporals. L'* davant del nom significa que V8 va optimitzar aquella funció; un ~ significaria que la va executar sense optimitzar, i això de vegades és la pista d'un problema de formes ocultes.

Opció 2: l'inspector de Chrome DevTools. Amb node --inspect src/servidor.js i chrome://inspect tens perfil de CPU amb gràfic de flama, instantànies del munt i comparació entre elles en una sola interfície. En un servidor remot es fa servir --inspect=0.0.0.0:9229 amb un túnel SSH, mai exposat a internet: l'inspector permet executar codi arbitrari al teu procés.

Opció 3: 0x i els gràfics de flama.

npx 0x --output-dir perfils -- node src/servidor.js
# Es llanca la carrega, s'atura amb Ctrl+C i s'obre l'HTML generat.

Com es llegeix un gràfic de flama, que és on gairebé tothom s'equivoca:

  • L'eix horitzontal NO és el temps, sinó la proporció de mostres; les funcions s'ordenen alfabèticament, no cronològicament.
  • L'amplada d'una caixa és el temps total en aquella funció, incloent-hi el que va cridar. És l'única cosa que importa buscar: caixes amples.
  • L'alçada és la profunditat de la pila. Una torre molt alta i estreta és només codi amb moltes capes: no és un problema.
  • Una caixa ampla amb un altiplà pla a sobre significa que el temps es consumeix en ella mateixa, no a les seves crides: allà tens la teva funció cara.
  • El color no significa res per defecte (llevat d'0x, que ressalta les funcions no optimitzades).

Al gràfic de flama d'Escena Viva abans de la memòria cau, un altiplà ample sobre obtenirCataleg → mapejarEsdeveniments → JSON.stringify ocupava gairebé dos terços de l'amplada; després de la lliçó 10-03 aquell altiplà desapareix i l'amplada l'ocupa el bucle ociós de libuv, que és exactament el que vols veure.

  1. Perfilat de memòria i fuites

Una fuita de memòria a Node no es manifesta com un error, sinó com una degradació: el procés creix, la recol·lecció d'escombraries treballa cada vegada més (i les seves pauses són temps robat al teu bucle d'esdeveniments), i finalment el procés mor amb JavaScript heap out of memory. Mètode de diagnòstic amb instantànies del munt: arrenca el procés i deixa'l estabilitzar-se; pren la instantània A (DevTools → Memory → Heap snapshot, o v8.writeHeapSnapshot()); aplica càrrega sostinguda durant diversos minuts; força una recol·lecció i pren la instantània B; i a DevTools tria la vista Comparison entre B i A.

La columna decisiva és Delta: quants objectes de cada tipus hi ha de més. Si Array o un constructor teu creix de manera monòtona instantània rere instantània, allà tens la fuita; després, Retainers et diu qui impedeix que el recol·lector se'ls emporti.

'use strict';

// v8.writeHeapSnapshot(ruta) pren la instantania sota demanda, tambe en
// produccio (amb compte: pausa el proces i genera centenars de MB).
// I aixo es la vigilancia barata i continua de l'us de memoria.
function registrarUsDeMemoria(registrador) {
  const us = process.memoryUsage();
  const mb = (bytes) => Math.round(bytes / 1048576);
  registrador.info({ rssMb: mb(us.rss), muntUsatMb: mb(us.heapUsed),
    muntTotalMb: mb(us.heapTotal), externaMb: mb(us.external) });
}

module.exports = { registrarUsDeMemoria };

Les dues fuites que veuràs a la vida real, i totes dues van aparèixer a Escena Viva. La primera, memòria cau sense límit: abans de la lliçó 10-03, la memòria cau de divises era un Map en memòria que amb parells fixos no creixia, però que si la clau hagués inclòs l'import (EUR:USD:4500) hauria crescut sense fi. Tota memòria cau en memòria necessita un sostre amb expulsió LRU (lru-cache) o anar-se'n a Redis, que caduca sola. La segona, oients no desregistrats: el nostre GestorDeVendes és un EventEmitter (M2), i si cada petició registra un oient que ningú no treu, l'emissor acumula referències als tancaments i amb ells a tot el que capturen.

'use strict';

// MALAMENT: l'oient sobreviu a la peticio i rete 'resposta' per
// sempre, i amb ella el socket, les capcaleres i el cos.
const controladorDolent = (gestor) => (peticio, resposta) => {
  gestor.on('venda-registrada', (venda) => resposta.write(JSON.stringify(venda)));
};

// BE: es registra, i es retira quan la peticio acaba. L'esdeveniment
// 'close' cobreix el final normal i la desconnexio del client.
const controladorBo = (gestor) => (peticio, resposta) => {
  const enVendre = (venda) => resposta.write(JSON.stringify(venda));
  gestor.on('venda-registrada', enVendre);
  resposta.on('close', () => gestor.off('venda-registrada', enVendre));
};

module.exports = { controladorBo };

L'avís MaxListenersExceededWarning: Possible EventEmitter memory leak detected és Node dient-te literalment que tens aquesta fuita. No el silenciïs mai pujant setMaxListeners: arregla el desregistre.

  1. El retard del bucle d'esdeveniments com a mètrica de salut

Si haguessis de triar una sola mètrica per saber si un procés Node està sa, seria aquesta: la CPU al 90 % pot ser normal, però un retard del bucle de 800 ms no ho és mai.

'use strict';

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

// resolution: cada quants ms es pren una mostra. 10 ms es un bon punt.
function crearVigilantDelBucle({ registrador, intervalMs = 30_000, llindarP99Ms = 100 }) {
  const histograma = monitorEventLoopDelay({ resolution: 10 });
  histograma.enable();
  const ms = (valor) => Number((valor / 1e6).toFixed(2));

  const temporitzador = setInterval(() => {
    const p99 = ms(histograma.percentile(99));
    registrador[p99 > llindarP99Ms ? 'warn' : 'info']({
      metrica: 'retard-bucle-esdeveniments', p99Ms: p99,
      p50Ms: ms(histograma.percentile(50)), maximMs: ms(histograma.max),
    });
    histograma.reset(); // finestra lliscant, no acumulativa
  }, intervalMs);

  temporitzador.unref();
  return { aturar: () => { clearInterval(temporitzador); histograma.disable(); } };
}

module.exports = { crearVigilantDelBucle };

Com interpretar els valors:

Retard p99 Diagnòstic
< 10 ms Sa
10-50 ms Càrrega alta però manejable
50-200 ms Hi ha feina síncrona: investiga amb un perfil de CPU
> 200 ms Alguna cosa bloqueja el bucle. És el símptoma de la lliçó 10-02
> 1 000 ms El procés està efectivament caigut encara que respongui al ping

És també el millor valor per a una comprovació de salut: un procés amb el bucle encallat respon 200 OK a un /salut trivial mentre deixa penjats tots els usuaris, i només un /salut que miri el retard del bucle detecta aquest estat.

  1. Catàleg de colls d'ampolla típics a Node

Coll d'ampolla Símptoma Arranjament Referència
E/S síncrona en calent (readFileSync, existsSync per petició) Retard del bucle alt, perfil amb fs a la pila Versió asíncrona, o llegir una vegada en arrencar M3
JSON gegant serialitzat a cada resposta JSON.stringify ample al gràfic de flama, GC alta Paginar, seleccionar camps, desar el JSON ja serialitzat 10-03, 10-05
Consulta N+1 Moltes consultes curtes idèntiques per petició include/JOIN, o DataLoader (10-06) M7
Manca d'índexs EXPLAIN ANALYZE mostra Seq Scan Crear l'índex i verificar que es fa servir M7
await seqüencial evitable Latència igual a la suma de les esperes Promise.all en operacions independents M2
Registre síncron El retard del bucle puja amb el volum de registre Registrador asíncron amb memòria intermèdia (pino), sense console.log en calent M6, M11
ReDoS (retrocés catastròfic) Una petició concreta congela el procés Regex sense ambigüitat imbricada, o validació per longitud/zod prèvia M8
Dades cacheables regenerades CPU i base de dades altes per retornar el mateix Cache-aside amb TTL 10-03
Feina de CPU al fil principal Retard del bucle en segons Fils o cua 10-02

Dos mereixen desenvolupament. El primer, await seqüencial on hi cabia Promise.all, és l'error de rendiment més comú i més barat d'arreglar:

'use strict';

// MALAMENT: tres 'await' en serie sobre els tres repositoris.
// Latencia = 40 + 35 + 30 = 105 ms.
//
// BE: les tres son independents. Latencia = max(40, 35, 30) = 40 ms.
async function carregarPantallaDeLEsdeveniment(esdevenimentId, repos) {
  const [esdeveniment, sessions, sala] = await Promise.all([
    repos.esdeveniments.obtenirEsdevenimentPerId(esdevenimentId),
    repos.sessions.llistarPerEsdeveniment(esdevenimentId),
    repos.sales.obtenirPerEsdeveniment(esdevenimentId),
  ]);
  return { esdeveniment, sessions, sala };
}

La condició és la independència: si la segona consulta necessita el resultat de la primera, l'await seqüencial és obligatori. I per a operacions on la fallada d'una no ha de tombar la resta, fes servir Promise.allSettled. El segon és el ReDoS: expressions regulars amb retrocés catastròfic, l'únic coll d'ampolla d'aquesta llista que a més és una vulnerabilitat de seguretat (OWASP, M8), perquè una sola petició de 40 caràcters pot congelar el procés durant minuts.

'use strict';

// PERILLOSA: (\s*\w+)+ te ambiguitat imbricada. Amb una entrada com
// 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!' el motor prova un nombre exponencial
// de combinacions abans de rendir-se, i el proces sencer es congela.
const REGEX_PERILLOSA = /^(\s*\w+)+$/;
// SEGURA: sense ambiguitat, un sol quantificador sobre classes disjuntes.
const REGEX_SEGURA = /^[\w\s]+$/;

// I a mes: limitar la longitud ABANS d'aplicar la regex.
const validarTitolDEsdeveniment = (titol) =>
  typeof titol === 'string' && titol.length <= 120 && REGEX_SEGURA.test(titol);

module.exports = { validarTitolDEsdeveniment };

La defensa en capes: validar longitud i forma amb zod (M6) abans de qualsevol regex, evitar quantificadors imbricats i desconfiar de les expressions copiades d'internet per validar correus o URL.

  1. Optimitzacions de la capa HTTP

Abans de tocar el teu codi, comprova que la capa de transport no regala feina:

Palanca Com Guany típic
Compressió compression (ja instal·lat), llindar d'1 KB 60-80 % menys bytes en JSON
ETag + 304 Ja ho vam fer a mà al M4; Express ho porta Resposta buida en lectures repetides
Cache-Control public, max-age=60 al catàleg Peticions que no arriben al teu servidor
keep-alive sortint Agent amb keepAlive: true a fetch/HTTP Estalvia la salutació TCP+TLS a cada crida
Paginació obligatòria Límit màxim al servidor Evita respostes de megabytes

La compressió no és gratis: consumeix CPU. Amb respostes petites (< 1 KB) costa més del que estalvia, i en un servidor amb la CPU saturada pot ser contraproduent; en producció el més habitual és delegar-la al proxy invers (M11). Sobre les connexions sortints, cada crida al servei de divises del M8 sense keep-alive paga salutació TCP i negociació TLS: uns 40-80 ms de pur protocol.

'use strict';

const { Agent, setGlobalDispatcher } = require('undici');

// Reutilitza connexions per a totes les crides sortints amb fetch.
setGlobalDispatcher(new Agent({
  connections: 32,        // connexions maximes per origen
  keepAliveTimeout: 30_000,
  keepAliveMaxTimeout: 120_000,
}));

  1. La base de dades: el pool de connexions

Obrir una connexió a PostgreSQL costa entre 5 i 50 ms (autenticació, TLS, procés al servidor). Per això Sequelize manté un pool: un conjunt de connexions obertes que es presten i es retornen.

'use strict';

const os = require('node:os');
const { Sequelize } = require('sequelize');
const { configuracio } = require('../config/index.js');

// Pressupost global de PostgreSQL: max_connections (100). Repartiment:
// treballadors de cluster x mida del pool, mes marge per a consumidors
// de cua, migracions i administracio.
const TREBALLADORS = Math.max(1, os.availableParallelism() - 1); // 7
const MAXIM_PER_PROCES = Math.max(2, Math.floor(80 / (TREBALLADORS + 2)));

const sequelize = new Sequelize(configuracio.baseDeDades.url, {
  logging: false,
  pool: {
    max: MAXIM_PER_PROCES, // 8 amb 7 treballadors
    min: 2,                // connexions calentes sempre llestes
    acquire: 10_000,       // espera maxima per una connexio lliure
    idle: 10_000,          // tanca les ocioses passat aquest temps
  },
});

module.exports = { sequelize };

Com es dimensiona, en ordre: esbrina max_connections del servidor (100 per defecte a PostgreSQL); reserva marge per a migracions, consumidors de cua i administració; divideix la resta entre el nombre de processos que connecten; i recorda que més gran no és millor, perquè un pool de 100 contra 4 nuclis de base de dades només afegeix contenció (hi ha una fórmula clàssica que suggereix nuclis × 2 + fusos de disc com a punt de partida).

Què passa quan el pool s'exhaureix: les peticions esperen fins a acquire i després fallen amb SequelizeConnectionAcquireTimeoutError, amb un símptoma característic i enganyós —latència altíssima sense CPU alta, perquè tothom està esperant—. Si ho veus, mira primer si hi ha consultes lentes retenint connexions o transaccions obertes que ningú no tanca.

  1. V8 sense caure en el microoptimitzat inútil

V8 optimitza el teu codi mitjançant formes ocultes (hidden classes): els objectes creats amb la mateixa estructura comparteixen una descripció interna i l'accés a les seves propietats es compila a un desplaçament fix, rapidíssim. Si canvies la forma, V8 en crea una altra i l'accés passa a ser polimòrfic o megamòrfic, molt més lent.

'use strict';

// MALAMENT: la forma de l'objecte canvia sobre la marxa (tres formes ocultes).
function crearEntradaDolenta(codi, preuCentims) {
  const entrada = {};
  entrada.codi = codi;
  entrada.preuCentims = preuCentims;
  if (preuCentims === 0) entrada.esInvitacio = true; // nomes de vegades
  return entrada;
}

// BE: una unica forma, sempre els mateixos camps i en el mateix ordre.
const crearEntrada = (codi, preuCentims) => ({
  codi, preuCentims, esInvitacio: preuCentims === 0,
});

// 'delete' degrada l'objecte a mode diccionari i anulla l'optimitzacio:
// en lloc de 'delete entrada.butaca', es reassigna.
const anullarButaca = (entrada) => ({ ...entrada, butaca: null });

module.exports = { crearEntrada, anullarButaca };

I sobre memòria: el munt antic té un límit (típicament ~2 GB en 64 bits, o menys en contenidors petits) que s'ajusta amb --max-old-space-size=2048. Dos matisos imprescindibles: pujar-lo no arregla una fuita, només endarrereix la mort i allarga les pauses de la recol·lecció d'escombraries; i en un contenidor cal posar-lo per sota del límit de memòria del contenidor, perquè si no el sistema mata el procés (OOM killer) abans que V8 se n'adoni (detall que reprendrem al M11).

L'advertiment general: aquestes microoptimitzacions importen en codi que s'executa milions de vegades. En un controlador HTTP que s'executa 3 000 vegades per segon i passa el 95 % del temps esperant la base de dades, reordenar propietats no canvia res. Aplica-les només si el perfil assenyala aquella funció.

  1. L'ordre correcte de les palanques

Quan alguna cosa va lenta hi ha un ordre per retorn de la inversió:

Ordre Palanca Guany típic Cost
1 Algorisme i estructura de dades 10× a 1000× Pensar; de vegades reescriure
2 Consulta i esquema (índex, N+1, SELECT de menys columnes) 10× a 100× Baix; migració
3 Memòria cau 5× a 50× Mitjà: invalidació (10-03)
4 Concurrència (cluster, fils, cues) 2× a 8× Mitjà-alt: estat compartit
5 Microoptimització de codi 1,05× a 1,5× Alt en llegibilitat
6 Maquinari Lineal, amb factura mensual Diners recurrents

Es recorre de dalt a baix. Comprar una màquina el doble de gran per tapar una consulta sense índex significa pagar el doble cada mes per un problema que s'arreglava amb CREATE INDEX. I així arribem al recorregut real d'Escena Viva en aquest mòdul: primer concurrència (10-01, 10-02), després memòria cau (10-03)... amb el matís honest que hauria estat més eficient començar per l'índex i la memòria cau. Ho vam fer en l'ordre del temari per raons didàctiques; al teu projecte, segueix la taula. Finalment, què es registra en producció per saber que tot continua bé:

Mètrica Llindar d'alerta Per què
p99 per ruta Per sobre de l'objectiu de servei 5 min Degradació real percebuda
Taxa de 5xx > 0,5 % Alguna cosa s'ha trencat
Retard p99 del bucle > 100 ms Feina síncrona colada
heapUsed Creixement monòton 30 min Fuita de memòria
Connexions del pool en ús > 80 % del màxim Saturació imminent
Ràtio d'encerts de memòria cau < 90 % Invalidació massa agressiva o TTL curt
Treballs en espera / fallits Creixement sostingut Consumidors insuficients o trencats

Recollir, emmagatzemar i alertar sobre aquestes mètriques és monitoratge en producció, matèria del mòdul 11; aquí hem après a produir-les i a interpretar-les.

Errors Comuns i Consells

  • Optimitzar sense perfil. És endevinar. A Escena Viva el sospitós era el PDF i el culpable era el JSON.
  • Fixar-se en la mitjana. Optimitza el p99; és el que la gent experimenta i recorda.
  • Mesurar en desenvolupament amb dades de joguina. Amb 3 esdeveniments tot és ràpid; amb 30 000 apareix la consulta sense índex.
  • Prova de càrrega amb non2xx diferent de zero, o canviar diverses coses alhora: en el primer cas mesures la velocitat de fallar, en el segon no sabràs què ha funcionat.
  • Perfilar en mode desenvolupament. Sense NODE_ENV=production, Express no desa les vistes a la memòria cau i hi ha comprovacions extra: el perfil menteix.
  • Pujar --max-old-space-size per tapar una fuita. Endarrereix la caiguda i empitjora les pauses de la GC.
  • Consell: guarda els resultats de la línia base al repositori; comparar contra el mes passat detecta regressions que cap prova unitària no veu.
  • Consell: afegeix una prova de càrrega amb llindar a CI (k6 amb thresholds) i el rendiment passa de ser una opinió a un requisit verificable.

Exercicis

Exercici 1: línia base i objectius

Defineix una prova de càrrega reproduïble per a GET /api/esdeveniments/evt-003/sessions amb 10 s d'escalfament i 60 s de mesura a 100 connexions. Registra mitjana, p50, p95, p99, req/s i non2xx. Compara-ho amb l'objectiu de servei de 200 ms i decideix si cal actuar.

Exercici 2: caçar el coll d'ampolla amb un gràfic de flama

Afegeix a Escena Viva un punt d'entrada GET /api/informes/ocupacio que calculi l'ocupació de les 7 sessions ordenant l'array d'entrades amb un algorisme quadràtic deliberat. Perfila amb 0x, localitza la caixa ampla, substitueix-la per Array.prototype.sort i torna a mesurar.

Exercici 3: diagnosticar una fuita

Introdueix una fuita registrant un oient de venda-registrada a cada petició sense retirar-lo. Llança 30 000 peticions registrant heapUsed cada 10 s. Pren dues instantànies del munt, compara-les i localitza el retenidor. Arregla-ho i verifica-ho.

Solucions

Exercici 1. L'escenari defineix warmup: { connections: 10, duration: 10 } i connections: 100, duration: 60. Resultat típic sense memòria cau: mitjana 96 ms, p50 84 ms, p95 210 ms, p99 340 ms, 1 030 req/s, non2xx: 0. El p99 de 340 ms incompleix l'objectiu de 200 ms, així que cal actuar. La lectura fina és més interessant que el veredicte: la mitjana (96 ms) sembla còmoda i la mediana també; només el p99 revela el problema. La causa habitual aquí és que les sessions es carreguen amb una consulta per sessió (N+1): l'arranjament és un include (M7) més memòria cau (10-03), i després d'aplicar-los el p99 baixa a ~35 ms.

Exercici 2. Al gràfic de flama apareix un altiplà ample i pla sobre ordenarPerOcupacio, ocupant ~70 % de l'amplada, amb molt poca profunditat a sobre: el senyal inequívoc que el temps es consumeix en aquella funció i no a les seves crides. Amb 7 sessions l'algorisme quadràtic és irrellevant; l'exercici revela el seu cost en fer-ho sobre les 1 811 entrades venudes (~3,3 milions de comparacions per petició). Substituir-ho per entrades.sort((a, b) => b.ocupacio - a.ocupacio) (O(n log n)) redueix el temps de la ruta de ~420 ms a ~6 ms. És la palanca número 1 de la taula —l'algorisme— i cap quantitat de cluster, fils o memòria cau no hauria donat un factor 70.

Exercici 3. heapUsed creix de manera monòtona: 42 MB, 78 MB, 121 MB, 168 MB... sense estabilitzar-se mai, ni tan sols després d'una recol·lecció forçada. Al voltant de la petició 10 000 apareix MaxListenersExceededWarning. A la comparació d'instantànies, el Delta mostra desenes de milers d'objectes Closure i ServerResponse de més; els Retainers apunten a GestorDeVendes._events['venda-registrada'], un array que creix sense fi. Cada tancament reté resposta, i amb ella el socket i les capçaleres: per això una fuita d'"un oient" costa kilobytes per petició. L'arranjament és resposta.on('close', () => gestor.off('venda-registrada', enVendre)), i després d'aplicar-lo heapUsed s'estabilitza en una serra entre 45 i 70 MB: pujada per assignació, baixada per recol·lecció. Aquesta forma de serra estable és exactament l'aspecte d'un procés sa.

Conclusió

Ja no optimitzem per intuïció. Tenim un mètode —mesurar, localitzar, arreglar una cosa, tornar a mesurar— i les quatre senyals que cal vigilar, sabent que la mitjana menteix i que el p99 és el que la gent experimenta. Sabem dissenyar una prova de càrrega honesta amb autocannon (escalfament, durada, concurrència i escenari realistes) i per què no es llança mai contra un sistema aliè. Sabem treure un perfil de CPU amb --prof, amb l'inspector o amb 0x, i llegir un gràfic de flama buscant caixes amples i no torres altes. Sabem caçar una fuita comparant instantànies del munt i mirant els retenidors. I tenim el retard del bucle d'esdeveniments com la mètrica de salut número u. A més, hem recorregut el catàleg de colls d'ampolla de Node —E/S síncrona, JSON gegant, N+1, manca d'índexs, await seqüencial, registre síncron, ReDoS, dades cacheables— amb el seu arranjament, hem afinat la capa HTTP i el pool de connexions, i hem posat les microoptimitzacions de V8 al seu lloc: al final de la llista, i només si el perfil les assenyala. Sobretot, tenim l'ordre de les palanques: algorisme, consulta, memòria cau, concurrència, maquinari.

Escena Viva ja és ràpida i està mesurada, així que el que li queda pendent no és rendiment sinó disseny. La seva API va créixer per acumulació: rutes amb verbs, codis d'estat fets servir a ull, sense paginació per cursor, sense versionat, sense política de deprecació, i amb un dubte que vam deixar obert al mòdul 4 i que no vam resoldre mai: què passa quan un POST /api/comandes s'envia dues vegades perquè l'usuari va perdre la cobertura a l'estrena. A la lliçó següent, Construcció d'APIs RESTful, passem de "funciona" a "està ben dissenyat".

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