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
- El mètode: mesurar, localitzar, arreglar, tornar a mesurar
- Què es mesura: latència, rendiment, errors, saturació
- Proves de càrrega amb
autocannon - Perfilat de CPU i gràfics de flama
- Perfilat de memòria i fuites
- El retard del bucle d'esdeveniments com a mètrica de salut
- Catàleg de colls d'ampolla típics a Node
- Optimitzacions de la capa HTTP
- La base de dades: el pool de connexions
- V8 sense caure en el microoptimitzat inútil
- L'ordre correcte de les palanques
- 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.
- 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.
- Proves de càrrega amb
autocannon
autocannonUna 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/esdevenimentsUn 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.
- 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:41Dues 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.
- 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.
- 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.
- 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.
- 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,
}));
- 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.
- 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ó.
- 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
non2xxdiferent 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-sizeper 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 (
k6ambthresholds) 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
- Què és Node.js?
- Instal·lació i Configuració de l'Entorn
- El Teu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Modern per a Node.js
- El Projecte del Curs: la Plataforma Escena Viva
Mòdul 2: Conceptes Bàsics
- Arquitectura de Node.js
- El Bucle d'Esdeveniments (Event Loop)
- Callbacks i Programació Asíncrona
- Promeses i async/await
- Esdeveniments i EventEmitter
- Mòduls CommonJS i require()
- Mòduls ES i Interoperabilitat
Mòdul 3: Sistema de Fitxers i E/S
- Lectura i Escriptura de Fitxers
- El Mòdul fs a Fons
- Rutes Multiplataforma amb el Mòdul path
- Treballant amb Streams
- Streams de Transformació i pipeline
- Buffers i Dades Binàries
Mòdul 4: HTTP i Servidors Web
- Creant un Servidor HTTP Simple
- Gestió de Sol·licituds i Respostes
- Enrutament Manual
- Servint Fitxers Estàtics
- Rebent Dades: Cossos de Petició i JSON
- Consumint APIs Externes des de Node.js
Mòdul 5: NPM i Gestió de Paquets
- Introducció a NPM i package.json
- Instal·lació i Ús de Paquets
- Versionat Semàntic i package-lock
- Scripts d'npm i Automatització del Projecte
- Creació i Publicació de Paquets
- Seguretat i Manteniment de Dependències
Mòdul 6: Framework Express.js
- Introducció a Express.js
- Configuració d'una Aplicació Express
- Enrutament a Express
- Middleware
- Middleware de Tercers Essencials
- Validació de Dades d'Entrada
- Gestió d'Errors
Mòdul 7: Bases de Dades i ORMs
- Introducció a les Bases de Dades
- Usant MongoDB amb Mongoose
- Operacions CRUD
- Relacions, Poblat i Consultes Avançades
- Usant Bases de Dades SQL amb Sequelize
- Migracions, Transaccions i Dades de Prova
Mòdul 8: Autenticació i Autorització
- Introducció a l'Autenticació
- Registre d'Usuaris i Hash de Contrasenyes
- Sessions i Galetes amb Passport.js
- Autenticació amb JWT
- Control d'Accés Basat en Rols
- Bones Pràctiques de Seguretat en APIs
Mòdul 9: Proves i Depuració
- Introducció a les Proves
- Proves Unitàries amb Mocha i Chai
- Dobles de Prova amb Sinon
- Proves d'Integració
- Cobertura i Automatització de les Proves
- Depuració d'Aplicacions Node.js
Mòdul 10: Temes Avançats
- El Mòdul Cluster
- Fils de Treball (Worker Threads)
- Memòria Cau i Cues de Treball amb Redis
- Optimització del Rendiment
- Construcció d'APIs RESTful
- GraphQL amb Node.js
Mòdul 11: Desplegament i DevOps
- Configuració i Variables d'Entorn
- Registre i Monitoratge en Producció
- Usant PM2 per a la Gestió de Processos
- Empaquetatge amb Docker
- Desplegant a Heroku i Altres PaaS
- Integració i Desplegament Continus
