A 01-04, en enumerar les sis restriccions de REST, vam dir que cacheable era una d'elles i que hi tornaríem. Ha arribat el moment. L'API de la Botiga Aroma respon GET /v1/cafes en 40 mil·lisegons, cosa que està molt bé, tret d'un detall: la SPA demana aquell catàleg cada vegada que un usuari obre la pàgina d'inici, i el catàleg canvia un cop al dia. Milions de peticions idèntiques, amb la mateixa resposta, consumint base de dades, CPU i amplada de banda per no aportar absolutament res de nou.

La petició més ràpida és la que no es fa. La segona més ràpida és la que es respon amb un 304 Not Modified de 150 bytes. Aquesta lliçó converteix la restricció teòrica en capçaleres concretes i en codi dins del projecte: src/middleware/cache.js, Cache-Control calibrat recurs a recurs, ETag amb peticions condicionals, i el retrobament que portem anunciant des de 03-05, quan If-Match i el 412 tanquin el cercle de la concurrència optimista. Després anirem més enllà de la memòria cau —compressió, N+1, respostes parcials, treball asíncron— i acabarem amb el que hauria d'anar primer: mesurar abans d'optimitzar.

Contingut

  1. Els nivells de memòria cau
  2. Cache-Control a fons
  3. La política de memòria cau de la Botiga Aroma
  4. Validació condicional: ETag i Last-Modified
  5. El flux complet del 304
  6. If-Match i el 412: es tanca el cercle amb la concurrència optimista
  7. Implementació: src/middleware/cache.js
  8. Vary, negociació de contingut i CORS
  9. Invalidació: el problema difícil
  10. Memòria cau de servidor amb Redis i cache-aside
  11. Estampida i el bloqueig
  12. Compressió
  13. Connexions, HTTP/2 i latència de xarxa
  14. Base de dades: índexs, N+1 i consultes lentes
  15. Respostes parcials i paginació com a mesura de rendiment
  16. Treball asíncron amb 202
  17. Mesurar abans d'optimitzar

  1. Els nivells de memòria cau

Entre l'usuari i les dades hi ha diverses oportunitats de no fer feina. Cada nivell que encerta estalvia tot el que hi ha a la seva dreta.

graph LR
  U[Usuari] --> N[Memoria cau del navegador<br/>privada]
  N -->|fallada| C[CDN / proxy<br/>compartida]
  C -->|fallada| A[API Express]
  A --> R[Memoria cau d'aplicacio<br/>Redis]
  R -->|fallada| D[Base de dades]
  D --> P[Memoria cau de pagines<br/>del motor SQL]
Nivell Qui el controla Abast Estalvia
Navegador El Cache-Control que emetem Un usuari Tot: ni tan sols hi ha petició
CDN / proxy Cache-Control, s-maxage Tots els usuaris Xarxa i servidor
Aplicació (Redis) El nostre codi Totes les instàncies Base de dades
Base de dades El motor Disc

Dos conceptes que cal distingir bé perquè d'ells depèn una decisió de seguretat:

  • Memòria cau privada: la del navegador. Desa respostes d'un usuari.
  • Memòria cau compartida: CDN, proxy corporatiu. Desa respostes que serveix a molts usuaris.

D'aquí surt la regla més important de tota la lliçó: qualsevol resposta que depengui de qui pregunta ha de portar Cache-Control: private com a mínim. Si el GET /v1/comandes de la Marta acaba en una CDN sense aquella directiva, la propera persona que demani /v1/comandes podria rebre les comandes de la Marta. És una fuita de dades causada per una capçalera absent, i ha passat en producció a empreses grans més d'una vegada.

  1. Cache-Control a fons

És la capçalera que ho governa tot. Les seves directives, agrupades pel que fan:

Qui pot desar-ho

Directiva Efecte
public Qualsevol memòria cau, incloses les compartides
private Només la memòria cau privada del navegador
no-store Ningú no desa res, enlloc

Quant de temps

Directiva Efecte
max-age=N Vàlid N segons per a qualsevol memòria cau
s-maxage=N Igual, però només per a memòries cau compartides; té prioritat sobre max-age

Com es revalida

Directiva Efecte
no-cache Es pot desar, però cal revalidar abans de cada ús
must-revalidate Quan caduqui, prohibit servir-ho caducat
immutable No revalidis mai mentre sigui fresc
stale-while-revalidate=N Serveix allò caducat fins a N s mentre refresca per darrere
stale-if-error=N Si l'origen falla, serveix allò caducat fins a N s

no-cache no significa «no desar a la memòria cau». És l'error de lectura més repetit d'HTTP. no-cache significa «desa-ho, però pregunta'm abans de fer-ho servir». La que impedeix desar és no-store. La diferència importa molt: amb no-cache una resposta que no ha canviat es resol amb un 304 de 150 bytes; amb no-store es transfereix sencera cada vegada.

stale-while-revalidate és la directiva més infravalorada. Amb max-age=60, stale-while-revalidate=300, durant els primers 60 segons se serveix de la memòria cau sense més; entre el segon 60 i el 360, se serveix la còpia caducada immediatament i es refresca en segon pla. L'usuari no espera mai. Per a un catàleg que canvia un cop al dia, és exactament el comportament desitjable.

stale-if-error és resiliència de franc: si la teva API retorna 503, la CDN continua servint l'última còpia bona en comptes de propagar l'error. Combinada amb el que vam veure a 04-04, converteix una caiguda parcial en una degradació invisible.

Combinació Significat pràctic Exemple
public, max-age=300 Qualsevol ho desa 5 minuts Catàleg públic
public, max-age=60, s-maxage=600 Navegador 1 min, CDN 10 min Catàleg amb CDN
private, max-age=0, must-revalidate Només el navegador, i revalidant sempre Dades de l'usuari
no-store No es desa mai Tokens, pagaments
public, max-age=31536000, immutable Un any sense preguntar Imatge amb hash al nom
public, max-age=60, stale-while-revalidate=300 Sense esperes en refrescar Catàleg molt sol·licitat

  1. La política de memòria cau de la Botiga Aroma

Recurs Cache-Control Motiu
GET /v1/cafes public, max-age=60, s-maxage=300, stale-while-revalidate=600 Públic, canvia poc, molt demanat
GET /v1/cafes/{id} public, max-age=300, stale-while-revalidate=600 Ídem, encara més estable
GET /v1/cafes/{id}/imatge public, max-age=31536000, immutable El nom inclou un hash del contingut
GET /v1/cafes/{id}/ressenyes public, max-age=60 Públic, canvia amb cada ressenya
GET /v1/comandes private, no-cache Depèn de l'usuari; es revalida sempre
GET /v1/comandes/{id} private, no-cache Ídem
GET /v1/clients/{id} private, no-cache Dades personals
GET /v1/cistelles/{id} no-store Canvia constantment; sense valor a la memòria cau
POST /v1/sessions no-store Conté tokens
Qualsevol resposta d'error no-store Un 429 desat bloquejaria l'usuari de més
GET /salut no-store Ha de reflectir l'estat real ara

Quatre decisions que mereixen justificació:

no-cache a /v1/comandes, no no-store. Les comandes de la Marta canvien poc entre visites. Amb no-cache, el navegador desa la còpia i a la visita següent envia un If-None-Match; si res no ha canviat rep un 304 sense cos. S'estalvia tot el trànsit mantenint la garantia de frescor, que és el millor dels dos mons. I private impedeix que una CDN ho desi.

no-store a POST /v1/sessions. La resposta conté el token d'accés. Que quedi al disc del navegador o en un proxy és exactament el que no volem.

no-store als errors. Un 429 desat durant cinc minuts converteix un límit d'un minut en cinc. Un 503 desat sobreviu a la recuperació del servei.

Un any i immutable a les imatges. Només funciona perquè el nom del fitxer conté un hash del contingut (caf_001-a3f9.webp): si la imatge canvia, canvia la URL, així que la còpia antiga mai no és incorrecta. És el patró de fingerprinting, i és l'única manera segura de fer servir caducitats tan llargues.

Implementat com a middleware parametritzable:

// src/middleware/cache.js  (fitxer NOU — primera part)

/**
 * Fixa Cache-Control a la resposta. Es compon a la cadena de cada ruta,
 * perquè la política depèn del recurs.
 *
 * @param {object} opcions
 * @param {boolean} opcions.publica    Pot desar-la una memòria cau compartida?
 * @param {number}  opcions.maxEdat    Segons de frescor per al navegador.
 * @param {number}  [opcions.maxEdatCompartida]  Segons per a la CDN (s-maxage).
 * @param {number}  [opcions.revalidarEnSegonPla]  stale-while-revalidate.
 * @param {boolean} [opcions.senseEmmagatzemar]  no-store: ni es desa.
 */
export function cacheDe(opcions = {}) {
  const {
    publica = false,
    maxEdat = 0,
    maxEdatCompartida,
    revalidarEnSegonPla,
    senseEmmagatzemar = false,
  } = opcions;

  const directives = [];
  if (senseEmmagatzemar) {
    directives.push('no-store');
  } else {
    directives.push(publica ? 'public' : 'private');
    // max-age=0 s'expressa com a no-cache: "desa-ho, però revalida sempre".
    if (maxEdat === 0) directives.push('no-cache');
    else directives.push(`max-age=${maxEdat}`);
    if (maxEdatCompartida !== undefined) directives.push(`s-maxage=${maxEdatCompartida}`);
    if (revalidarEnSegonPla) {
      directives.push(`stale-while-revalidate=${revalidarEnSegonPla}`);
    }
  }

  const valor = directives.join(', ');
  return (req, res, next) => {
    res.set('Cache-Control', valor);
    next();
  };
}

/** Dreceres amb les polítiques ja decidides, per no repetir números per les rutes. */
export const cachePublicaCataleg = cacheDe({
  publica: true, maxEdat: 60, maxEdatCompartida: 300, revalidarEnSegonPla: 600,
});
export const cachePrivadaRevalidada = cacheDe({ publica: false, maxEdat: 0 });
export const senseCache = cacheDe({ senseEmmagatzemar: true });
// src/rutes/cafes.js  (MODIFICAT)
router.get('/', cachePublicaCataleg, validar(esquemaLlistarCafes, 'query'),
  asincron(controladors.cafes.llistar));

// src/rutes/comandes.js  (MODIFICAT)
router.get('/', autenticar, cachePrivadaRevalidada, validar(esquemaLlistarComandes, 'query'),
  asincron(controladors.comandes.llistar));

// src/rutes/sessions.js  (MODIFICAT)
router.post('/', limitLogin, senseCache, validar(esquemaLogin, 'body'),
  asincron(controladors.sessions.crear));

  1. Validació condicional: ETag i Last-Modified

max-age respon a «puc fer servir la meva còpia sense preguntar?». Quan caduca, la pregunta passa a ser «ha canviat?», i aquí entren els validadors.

ETag

Un identificador opac de la versió d'un recurs.

Tipus Sintaxi Significa
Fort ETag: "a3f9c2e1" Byte a byte idèntic
Feble ETag: W/"a3f9c2e1" Semànticament equivalent

Un ETag feble serveix per al 304 però no per a If-Match en escriptures ni per a peticions per rangs, precisament perquè no garanteix igualtat exacta. Com que la Botiga Aroma farà servir If-Match per a la concurrència optimista, necessitem ETags forts.

Dues maneres de generar-lo:

Mètode Com Avantatge Inconvenient
Hash del cos SHA-1/SHA-256 del JSON serialitzat Universal, no requereix model Cal generar la resposta sencera
Camp versio "v7" a partir de la columna que ja existeix Barat: se sap abans de serialitzar Només per a recursos amb versió

La Botiga Aroma fa servir totes dues: el camp versio de 03-05 per a recursos individuals que en tenen, i el hash per a col·leccions i per a tota la resta.

// src/middleware/cache.js  (segona part)
import crypto from 'node:crypto';

/** ETag fort a partir del cos serialitzat. */
export function etagDeCos(cos) {
  const text = typeof cos === 'string' ? cos : JSON.stringify(cos);
  const hash = crypto.createHash('sha256').update(text).digest('base64url').slice(0, 27);
  return `"${hash}"`;                        // cometes OBLIGATÒRIES a la sintaxi
}

/** ETag a partir del camp versio que ja porten cafès i comandes (03-05). */
export function etagDeVersio(recurs) {
  return `"v${recurs.versio}"`;
}

Les cometes no són decoratives: formen part de la sintaxi de la capçalera. Un ETag: a3f9 sense cometes és invàlid i molts intermediaris l'ignoren, amb la qual cosa la memòria cau deixa de funcionar sense donar cap error.

Last-Modified

Una data HTTP:

Last-Modified: Sun, 02 Aug 2026 09:14:22 GMT

És més feble que l'ETag per dues raons: té resolució d'un segon —dos canvis al mateix segon són indistingibles— i molts recursos no tenen una data de modificació fiable.

ETag Last-Modified
Precisió Total 1 segon
Capçalera de petició If-None-Match If-Modified-Since
Cost de càlcul Hash o versió Llegir una data
Serveix per a escriptures condicionals (If-Match) Poc fiable
Prioritat si hi són tots dos Guanya l'ETag

La Botiga Aroma emet totes dues quan disposa de dataActualitzacio, però l'ETag és el mecanisme principal.

  1. El flux complet del 304

Primera petició. El client no té res:

GET /v1/cafes/caf_001 HTTP/1.1
Host: api.botigaaroma.example
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=300, stale-while-revalidate=600
ETag: "v7"
Last-Modified: Sun, 02 Aug 2026 09:14:22 GMT
Vary: Accept, Accept-Language, Origin
Content-Length: 284

{"id":"caf_001","nom":"Etiòpia Yirgacheffe","origen":"Etiòpia","torrefaccio":"clar",
 "preuEuros":14.50,"estoc":120,"versio":7,"_links":{"self":{"href":"/v1/cafes/caf_001"}}}

Dins dels 300 segons: el navegador serveix la seva còpia sense preguntar. Zero peticions, zero latència.

Passats els 300 segons: la còpia està caducada, però el navegador té el validador i pregunta:

GET /v1/cafes/caf_001 HTTP/1.1
Host: api.botigaaroma.example
If-None-Match: "v7"
If-Modified-Since: Sun, 02 Aug 2026 09:14:22 GMT

Si res no ha canviat:

HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=300, stale-while-revalidate=600
ETag: "v7"
Vary: Accept, Accept-Language, Origin

Sense cos. 284 bytes es converteixen en uns 150 de capçaleres, i el navegador renova el frescor de la seva còpia uns altres 300 segons.

Si el cafè va canviar (versio va passar a 8):

HTTP/1.1 200 OK
ETag: "v8"
Content-Type: application/json

{"id":"caf_001", ..., "preuEuros":15.20, "versio":8, ...}
sequenceDiagram
  participant N as Navegador
  participant API as API

  N->>API: GET /v1/cafes/caf_001
  API->>N: 200 + ETag "v7" + max-age=300
  Note over N: Dins dels 300 s: serveix de memoria cau, sense peticio
  N->>API: GET amb If-None-Match "v7" (ja caducat)
  API->>API: Compara "v7" amb l'ETag actual
  API->>N: 304 Not Modified, sense cos
  Note over N: Frescor renovat uns altres 300 s
  N->>API: GET amb If-None-Match "v7" (despres d'un canvi)
  API->>N: 200 + ETag "v8" + cos nou

Tres detalls del 304 que s'equivoquen sovint:

  • No porta cos. Enviar-ne un és un error de protocol.
  • Sí que porta les capçaleres de memòria cau (Cache-Control, ETag, Vary): són les que renoven la validesa de la còpia desada.
  • If-None-Match admet diversos ETags separats per comes, i el comodí * que significa «si existeix qualsevol representació».

  1. If-Match i el 412: es tanca el cercle amb la concurrència optimista

Aquí hi ha el retrobament que vam anunciar a 03-05. If-None-Match serveix per llegir; If-Match serveix per escriure de manera segura.

El problema: l'actualització perduda

sequenceDiagram
  participant E1 as Empleat 1
  participant API as API
  participant E2 as Empleat 2

  E1->>API: GET /v1/cafes/caf_001 → preu 14,50, versio 7
  E2->>API: GET /v1/cafes/caf_001 → preu 14,50, versio 7
  E1->>API: PUT preu 15,20
  API->>E1: 200, versio 8
  E2->>API: PUT preu 13,90 (amb dades de la versio 7)
  API->>E2: 200, versio 9
  Note over API: El canvi de l'empleat 1 ha desaparegut sense avis

Ningú no ha vist cap error. El preu 15,20 no va existir mai.

La solució amb If-Match

PUT /v1/cafes/caf_001 HTTP/1.1
Host: api.botigaaroma.example
Authorization: Bearer <token d'empleat>
Content-Type: application/json
If-Match: "v7"

{"nom":"Etiòpia Yirgacheffe","origen":"Etiòpia","torrefaccio":"clar",
 "preuEuros":13.90,"estoc":120,"notesTast":"Gessamí, bergamota"}

Si la versió actual ja no és la 7:

HTTP/1.1 412 Precondition Failed
Content-Type: application/json
ETag: "v8"
Cache-Control: no-store

{
  "error": {
    "codi": "conflicte_versio",
    "missatge": "El recurs ha canviat des que el vas obtenir. Torna'l a llegir i reintenta-ho.",
    "detalls": []
  }
}

L'ETag: "v8" a la resposta d'error és un detall de disseny valuós: diu al client quina és la versió actual, així que pot rellegir, mostrar el conflicte a l'usuari i reintentar-ho sense una petició extra.

Per què la capçalera és millor que el camp versio del cos

A 03-05 vam implementar la concurrència optimista amb versio dins del JSON. Funciona, però If-Match és millor per cinc raons:

versio al cos If-Match a la capçalera
Estàndard Conveni propi HTTP (RFC 9110): ho entén qualsevol client
Separació Barreja metadada amb dades La metadada viatja com a metadada
Funciona amb DELETE No: DELETE no té cos
Els intermediaris ho entenen No Sí: proxys i gateways
Codi d'estat Un 409 ad hoc 412, que significa exactament això
Reutilitza l'ETag de lectura No Sí: el mateix valor que ja va rebre

L'última fila és la clau conceptual: el client ja va rebre l'ETag en llegir el recurs. No necessita entendre què és versio, ni extreure'l del cos: retorna l'etiqueta opaca que li van donar. Això és exactament el disseny d'HTTP.

I hi ha una decisió de contracte important: exigir If-Match o no.

Política Comportament sense If-Match Quan
Opcional L'escriptura procedeix (l'últim guanya) Recursos poc disputats
Obligatòria 428 Precondition Required Recursos crítics: preu, estoc

La Botiga Aroma l'exigeix a PUT /v1/cafes/{id} i a PATCH /v1/comandes/{id}, perquè un preu o un estoc mal trepitjats tenen conseqüències comercials reals. Això obliga a afegir un codi nou al catàleg: precondicio_requerida (428). L'anotem explícitament, com exigeix la regla del projecte —el catàleg només creix—, i cal documentar-lo a openapi.yaml.

Situació Codi Estat del catàleg
Falta If-Match on és obligatòria 428 precondicio_requerida — NOU
If-Match no coincideix 412 conflicte_versio — ja existia
If-None-Match coincideix en un GET 304 Sense cos, sense error
If-None-Match: * en un POST que crearia un duplicat 412 conflicte
// src/middleware/cache.js  (tercera part)
import { errors } from '../errors/error-api.js';

/**
 * Exigeix i comprova If-Match a les escriptures.
 * Es compon a la ruta ABANS del controlador; el servei rep la versió
 * esperada ja extreta i no ha de saber res d'HTTP.
 */
export function exigirIfMatch(req, res, next) {
  const ifMatch = req.get('If-Match');

  if (!ifMatch) {
    return next(
      errors.precondicioRequerida(
        'precondicio_requerida',
        'Aquesta operació exigeix la capçalera If-Match amb l\'ETag obtingut en llegir el recurs.'
      )
    );
  }

  if (ifMatch.trim() === '*') {
    req.versioEsperada = null;      // '*' = "existeix qualsevol versió": val qualsevol
    return next();
  }

  // S'admet una llista: If-Match: "v7", "v8"
  const versions = ifMatch
    .split(',')
    .map((e) => e.trim().replace(/^W\//, '').replace(/^"|"$/g, ''))
    .map((e) => (e.startsWith('v') ? Number(e.slice(1)) : Number.NaN))
    .filter((n) => Number.isInteger(n));

  if (versions.length === 0) {
    return next(errors.dadesInvalides('dades_invalides', 'La capçalera If-Match no és vàlida.'));
  }

  req.versionsAcceptades = versions;
  return next();
}
// src/errors/error-api.js  (MODIFICAT)
export const errors = {
  // ... la resta
  precondicioRequerida: (codi, missatge) => new ErrorApi(428, codi, missatge),
  precondicioFallida: (codi, missatge) => new ErrorApi(412, codi, missatge),
};
// src/serveis/cafes.js  (MODIFICAT — fragment)
export async function actualitzarCafe(id, dades, versionsAcceptades) {
  const actual = await repositoris.cafes.buscarPerId(id);
  if (!actual) throw errors.noTrobat('cafe_no_trobat', `No existeix el cafè ${id}.`);

  // null = If-Match: * → n'hi ha prou que existeixi.
  if (versionsAcceptades && !versionsAcceptades.includes(actual.versio)) {
    throw errors.precondicioFallida(
      'conflicte_versio',
      'El recurs ha canviat des que el vas obtenir. Torna\'l a llegir i reintenta-ho.'
    );
  }

  // L'escriptura continua sent condicional en SQL (03-05): entre la comprovació
  // anterior i aquest UPDATE s'hi pot colar una altra transacció. El WHERE versio = ?
  // és el que fa l'operació atòmica de debò.
  const files = repositoris.cafes.actualitzarSiVersio(id, dades, actual.versio);
  if (files === 0) {
    throw errors.precondicioFallida('conflicte_versio', 'Conflicte de versió en escriure.');
  }

  return repositoris.cafes.buscarPerId(id);
}

El comentari del mig és el punt fi: comprovar la versió al servei no n'hi ha prou; entre la lectura i l'escriptura hi ha una finestra. La garantia real la dona el WHERE versio = ? de l'UPDATE, que és atòmic. If-Match aporta la semàntica HTTP correcta i l'error primerenc; la correcció la continua donant la base de dades.

// src/rutes/cafes.js  (MODIFICAT)
router.put(
  '/:id',
  autenticar,
  exigirRol('empleat', 'administrador'),
  exigirIfMatch,                                   // ← 428 si falta
  validar(esquemaActualitzarCafe, 'body'),
  asincron(controladors.cafes.reemplacar)
);

I Accept-Patch, que ja emetíem des de 02-05, acompanya això: diu al client quin format de pedaç accepta el recurs, igual que l'ETag li diu quina versió té.

  1. Implementació: src/middleware/cache.js

Falta la peça que respon el 304 automàticament. L'estratègia: embolcallar res.json per calcular l'ETag just abans d'enviar i comparar amb If-None-Match.

// src/middleware/cache.js  (quarta part)

/**
 * Calcula l'ETag de la resposta i respon 304 si el client ja la té.
 *
 * Es registra globalment abans de les rutes: embolcalla res.json per
 * interceptar el cos just abans de serialitzar-lo.
 */
export function etagCondicional(req, res, next) {
  // Només té sentit en lectures.
  if (req.method !== 'GET' && req.method !== 'HEAD') return next();

  const jsonOriginal = res.json.bind(res);

  res.json = function (cos) {
    // 1. Mai no es posa ETag als errors: no representen el recurs.
    if (res.statusCode >= 400) return jsonOriginal(cos);

    // 2. Si el controlador ja va fixar un ETag (per exemple, el de versio), es respecta.
    const etag = res.get('ETag') ?? etagDeCos(cos);
    res.set('ETag', etag);

    // 3. Comparació amb el que el client diu que té.
    const ifNoneMatch = req.get('If-None-Match');
    if (ifNoneMatch) {
      const coincideix =
        ifNoneMatch.trim() === '*' ||
        ifNoneMatch
          .split(',')
          .map((e) => e.trim())
          .some((e) => e === etag || e === `W/${etag}`);   // W/ per a ETags febles

      if (coincideix) {
        // 304: sense cos. S'eliminen les capçaleres d'entitat que sobren.
        res.removeHeader('Content-Type');
        res.removeHeader('Content-Length');
        return res.status(304).end();
      }
    }

    return jsonOriginal(cos);
  };

  return next();
}
// src/app.js  (extracte després de 04-06)
app.disable('x-powered-by');                                 // 1
app.set('trust proxy', 1);
app.use(assignarTracaId);                                    // 2
app.use(capcaleresSeguretat);                                // 3  helmet
app.use(cors(opcionsCors));                                  // 4
// (5) registre estructurat → 04-07
app.use(limitGlobal);                                        // 6
app.use(compression(opcionsCompressio));                     // 7  ← NOU (apartat 12)
app.use(express.json({ limit: '100kb', /* ... */ }));        // 8
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
app.get('/salut', ...);                                      // 9
app.use(etagCondicional);                                    // 10 ← NOU
app.use('/v1', rutesV1);                                     // 11
app.use(gestorNoTrobat);                                     // 12
app.use(gestorErrors);                                       // 13

Per què etagCondicional a la posició 10, just abans de les rutes. Ha d'embolcallar res.json abans que cap controlador el cridi, i alhora estar després de tot allò que pugui respondre pel seu compte (rate limiting, analitzador), perquè a aquelles respostes no els volem posar ETag. I per què compression a la 7, abans: la compressió ha d'embolcallar l'escriptura de la resposta com més aviat millor, i a més no ha d'intentar comprimir un 304 que no té cos.

Una nota pràctica: Express porta el seu propi ETag automàtic (app.set('etag', ...)), però és feble per defecte, cosa que l'inutilitza per a If-Match. El nostre middleware el substitueix amb ETags forts i amb control explícit sobre quan s'emeten.

  1. Vary, negociació de contingut i CORS

Vary declara de quines capçaleres de la petició depèn la resposta. És el que impedeix que una memòria cau serveixi la resposta equivocada.

Vary: Accept, Accept-Language, Origin
Capçalera a Vary Per què Lliçó
Accept La representació pot diferir segons el tipus demanat 02-05
Accept-Language Les notes de tast estan traduïdes 02-05
Origin La resposta porta Access-Control-Allow-Origin reflectit 04-05
Authorization No s'hi posa: es fa servir private al seu lloc A sota

El cas d'Authorization mereix explicació perquè sembla la resposta òbvia i no ho és. Posar Vary: Authorization faria que la memòria cau desés una entrada per cada token diferent: com que els tokens canvien cada 15 minuts, la taxa d'encert seria pràcticament zero i el consum de memòria de la CDN, enorme. La manera correcta de protegir contingut personalitzat és Cache-Control: private, que impedeix directament que una memòria cau compartida el desi.

I l'advertiment de cost: cada capçalera a Vary multiplica les variants emmagatzemades. Vary: Accept, Accept-Language, Origin amb 2 tipus, 3 idiomes i 3 orígens són 18 còpies del mateix recurs. Declara només el que realment canvia la resposta.

  1. Invalidació: el problema difícil

«Només hi ha dues coses difícils en informàtica: la invalidació de memòria cau i posar noms a les coses.» — Phil Karlton

La dificultat és real: has distribuït còpies de les teves dades per navegadors i CDN de tot el món, i ara el preu de caf_001 ha canviat.

Estratègia Com Avantatge Inconvenient
TTL curt max-age=60 i esperar Trivial Fins a 60 s de dades obsoletes
Purga explícita Cridar l'API de la CDN en canviar Immediata Acoblament; la del navegador no es purga
Clau versionada La URL inclou un hash Perfecta, sense purga Només per a recursos amb URL controlable
Revalidació no-cache + ETag Sempre fresc Una petició per ús (encara que barata)
Per esdeveniment Un webhook dispara la purga Precisa i desacoblada Requereix infraestructura d'esdeveniments

La memòria cau del navegador no es pot purgar. Un cop enviat max-age=3600, aquell navegador servirà la còpia durant una hora i no hi ha res a fer. És la raó que els TTL per al navegador siguin curts (60 s) i els de la CDN llargs (300 s amb s-maxage): la CDN sí que es pot purgar.

// src/serveis/cache-invalidacio.js  (fitxer NOU)
import { redis } from '../config/redis.js';
import { entorn } from '../config/entorn.js';

/**
 * Invalida un recurs a tots els nivells que controlem.
 * La memòria cau del navegador NO es pot invalidar: per això el seu TTL és curt.
 */
export async function invalidarCafe(cafeId) {
  // 1. Memòria cau d'aplicació (Redis): esborrat directe.
  await redis.del(`aroma:cache:cafe:${cafeId}`);
  // 2. Les llistes depenen de l'element: s'invaliden per patró d'etiqueta.
  await redis.del('aroma:cache:cafes:llista');

  // 3. CDN: purga per etiqueta (surrogate key), no URL a URL.
  if (entorn.CDN_PURGA_URL) {
    await fetch(entorn.CDN_PURGA_URL, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${entorn.CDN_TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ tags: [`cafe-${cafeId}`, 'cataleg'] }),
      signal: AbortSignal.timeout(3000),
    }).catch((e) => {
      // Una purga fallida NO ha de trencar l'escriptura. Es registra i el TTL
      // acabarà resolent-ho: degradació acceptable.
      console.error('Fallada en purgar la CDN:', e.message);
    });
  }
}

Les etiquetes de purga (surrogate keys) són el mecanisme que fa això manejable. Cada resposta declara a quins grups pertany:

Surrogate-Key: cafe-caf_001 cataleg origen-etiopia

I en canviar caf_001 es purga per etiqueta, en comptes d'haver d'enumerar les dotzenes d'URL afectades (la fitxa, la llista, la llista filtrada per origen, l'ordenada per preu...). Sense etiquetes, la invalidació de col·leccions és pràcticament impossible de fer bé.

Per què la invalidació per esdeveniment encaixa amb els webhooks. La Botiga Aroma ja emet esdeveniments signats cap a RàpidEnviaments (comanda.pagada, comanda.enviada). El mateix mecanisme serveix per a la memòria cau: quan el servei de catàleg canvia un preu, publica cafe.actualitzat i qui hi estigui subscrit —la CDN, una altra instància, el tauler— purga el seu. L'avantatge és el desacoblament: el servei que escriu no necessita conèixer totes les memòries cau que existeixen, només anunciar que alguna cosa ha canviat.

  1. Memòria cau de servidor amb Redis i cache-aside

La memòria cau HTTP evita peticions. La memòria cau de servidor evita consultes a la base de dades per a les peticions que sí que arriben.

Què val la pena desar a la memòria cau a la Botiga Aroma:

Dada TTL Per què
Catàleg complet (primera pàgina) 60 s Petició més freqüent amb diferència
Fitxa de cafè per id 300 s Molt repetida, canvia poc
total d'una col·lecció 300 s El COUNT(*) és car i tolera estar una mica obsolet
Puntuació mitjana d'un cafè 600 s Agregació cara, precisió no crítica
Comandes d'un client No Personal, canvia, poc repetida
Estoc No Ha de ser exacte: desar-lo provoca sobrevenda

Les dues últimes files són tan importants com les primeres: saber què no s'ha de desar a la memòria cau evita incidents. Desar l'estoc 60 segons significa vendre cafè que no existeix.

El patró cache-aside (o lazy loading):

// src/serveis/cache.js  (fitxer NOU)
import { redis } from '../config/redis.js';

/**
 * Cache-aside: mira la memòria cau; si no hi és, calcula, desa i retorna.
 *
 * @param {string} clau     Clau completa, amb prefix d'espai de noms.
 * @param {number} ttl      Segons de vida.
 * @param {Function} calcular  Funció que obté la dada real.
 */
export async function ambCache(clau, ttl, calcular) {
  try {
    // 1. Hi és, a la memòria cau? (hit)
    const desat = await redis.get(clau);
    if (desat !== null) return JSON.parse(desat);
  } catch (e) {
    // 2. Si Redis falla, NO es trenca la petició: es continua contra la base de dades.
    //    La memòria cau és una optimització, mai una dependència dura.
    console.error('Memòria cau no disponible, es consulta l\'origen:', e.message);
  }

  // 3. Fallada de memòria cau (miss): es calcula.
  const valor = await calcular();

  // 4. Es desa sense esperar i sense trencar si falla.
  redis.set(clau, JSON.stringify(valor), 'EX', ttl).catch(() => {});

  return valor;
}
// src/serveis/cafes.js  (MODIFICAT — fragment)
export async function obtenirCafe(id) {
  return ambCache(`aroma:cache:cafe:${id}`, 300, async () => {
    const fila = await repositoris.cafes.buscarPerId(id);
    if (!fila) throw errors.noTrobat('cafe_no_trobat', `No existeix el cafè ${id}.`);
    return mapejadors.aCafePublic(fila);
  });
}

Quatre regles per no ficar-se en problemes:

  1. Desa el resultat ja mapejat, no la fila crua. Així l'objecte a Redis no conté columnes internes: si un dia algú el bolca per depurar, no filtra preu_centims ni actiu.
  2. La memòria cau mai no és una dependència dura. Si Redis cau, l'API funciona més lenta, no falla.
  3. Espai de noms a les claus (aroma:cache:), separat de l'aroma:rl: del rate limiting de 04-04.
  4. Invalida en escriure, a la mateixa operació que modifica la dada.

Un avís important: no desis objectes d'error ni excepcions a la memòria cau. A l'exemple, si el cafè no existeix es llança abans de desar res; si es desés el null, un cafè acabat de crear trigaria cinc minuts a aparèixer.

  1. Estampida i el bloqueig

Escenari: caf_001 és el cafè més demanat i la seva entrada de memòria cau caduca a les 12:00:00. En aquell instant hi ha 500 peticions en vol. Les 500 fallen la memòria cau, les 500 consulten la base de dades i les 500 escriuen el mateix valor.

Això és l'estampida de memòria cau (cache stampede o thundering herd), i és especialment cruel perquè passa justament als recursos més populars, és a dir, en el pitjor moment possible.

Tres defenses, de menys a més eficaç:

TTL amb jitter. En comptes de 300 segons exactes, un valor aleatori entre 270 i 330. Reparteix les caducitats i evita que milers de claus expirin alhora. És una línia de codi:

const ttlAmbJitter = (base) => Math.floor(base * (0.9 + Math.random() * 0.2));

Bloqueig (lock). Només un procés recalcula; la resta espera breument i reintenta la memòria cau:

// src/serveis/cache.js  (segona part)

/**
 * Cache-aside amb bloqueig: evita que N peticions simultànies recalculin el mateix.
 */
export async function ambCacheBloquejada(clau, ttl, calcular) {
  const desat = await redis.get(clau);
  if (desat !== null) return JSON.parse(desat);

  const clauBloqueig = `${clau}:lock`;
  // SET NX: només té èxit si la clau NO existia. És una operació atòmica,
  // així que exactament un procés obté el bloqueig.
  // EX 10: el bloqueig caduca sol, perquè un procés caigut no el retingui eternament.
  const obtingut = await redis.set(clauBloqueig, '1', 'NX', 'EX', 10);

  if (obtingut) {
    try {
      const valor = await calcular();
      await redis.set(clau, JSON.stringify(valor), 'EX', ttl);
      return valor;
    } finally {
      await redis.del(clauBloqueig);      // sempre s'allibera, hagi fallat o no
    }
  }

  // Un altre procés està recalculant: esperar una mica i tornar a mirar.
  await new Promise((r) => setTimeout(r, 50));
  const reintent = await redis.get(clau);
  if (reintent !== null) return JSON.parse(reintent);

  // Si continua sense ser-hi, es calcula igualment: millor duplicar feina que fallar.
  return calcular();
}

Refresc anticipat. Desar juntament amb el valor el seu instant de caducitat i refrescar-lo abans que expiri, de manera probabilística. No hi ha mai un moment en què la clau no existeixi. És el que fa stale-while-revalidate a la memòria cau HTTP, aplicat al servidor.

  1. Compressió

npm install compression
// src/app.js (fragment)
import compression from 'compression';

const opcionsCompressio = {
  // Per sota d'1 kB, comprimir costa més CPU del que estalvia en xarxa.
  threshold: 1024,

  filter(req, res) {
    // Permet desactivar-la per petició, útil per depurar.
    if (req.get('Aroma-Sense-Comprimir')) return false;
    // Les imatges ja estan comprimides: recomprimir és temps perdut.
    const tipus = res.get('Content-Type') ?? '';
    if (tipus.startsWith('image/') || tipus.startsWith('video/')) return false;
    return compression.filter(req, res);
  },

  level: 6,    // 1 = ràpid, 9 = màxim. 6 és l'equilibri habitual.
};

app.use(compression(opcionsCompressio));    // posició 7

L'estalvi en JSON és notable perquè és text amb molta repetició de claus:

Resposta Sense comprimir gzip Brotli
GET /v1/cafes (20 elements) 8,4 kB 1,9 kB 1,6 kB
GET /v1/cafes/caf_001 284 B (no es comprimeix: sota el llindar)
GET /v1/comandes (20 amb expandir) 42 kB 6,1 kB 5,2 kB

Quan no comprimir:

  • Respostes petites (per sota d'~1 kB): la sobrecàrrega supera l'estalvi.
  • Contingut ja comprimit: imatges, vídeo, PDF, ZIP.
  • Quan la CPU és el coll d'ampolla i la xarxa va sobrada.
  • Històricament, respostes amb secrets al costat de dades controlades per l'atacant, pels atacs BREACH/CRIME. Amb Authorization: Bearer en comptes de galetes el risc pràctic és molt menor, però convé conèixer-lo.

Brotli comprimeix millor que gzip i l'admeten tots els navegadors moderns; la majoria de les CDN l'apliquen elles soles. Si tens CDN, l'habitual és deixar que ella comprimeixi i estalviar aquella CPU al teu procés.

  1. Connexions, HTTP/2 i latència de xarxa

Recuperant 01-03 amb perspectiva de rendiment:

Mecanisme Què estalvia Guany típic
Keep-alive Handshake TCP + TLS per petició 100–300 ms per petició evitada
HTTP/2 multiplexat La cua d'espera del navegador (6 connexions) Molt amb moltes peticions paral·leles
HTTP/2 compressió de capçaleres Repetir Authorization a cada petició ~500 bytes per petició
HTTP/3 / QUIC Bloqueig per pèrdua de paquet Notable en xarxes mòbils
CDN a prop de l'usuari Distància física 50–200 ms

Recordatori de 04-04: el keepAliveTimeout de Node ha de ser més gran que el del balancejador, o apareixeran 502 esporàdics impossibles de reproduir.

I la perspectiva que ordena les prioritats: en una petició mòbil típica, la latència de xarxa domina sobre tota la resta. Si la teva API respon en 40 ms i l'usuari és a 150 ms de distància, optimitzar la consulta SQL per baixar a 30 ms millora el total un 5 %. Reduir de cinc peticions a una (04-01) o servir des d'una CDN millora molt més. L'optimització més rendible gairebé mai no és al servidor.

  1. Base de dades: índexs, N+1 i consultes lentes

Índexs. Cada filtre del contracte necessita el seu:

-- migracions/004-index-rendiment.sql
CREATE INDEX IF NOT EXISTS idx_cafes_origen        ON cafes(origen);
CREATE INDEX IF NOT EXISTS idx_cafes_torrefaccio   ON cafes(torrefaccio);
CREATE INDEX IF NOT EXISTS idx_cafes_preu          ON cafes(preu_centims);
-- Compost: cobreix el filtre per client I l'ordenació per data alhora.
CREATE INDEX IF NOT EXISTS idx_comandes_client_data ON comandes(client_id, data_creacio DESC);
CREATE INDEX IF NOT EXISTS idx_ressenyes_cafe      ON ressenyes(cafe_id);

L'ordre de les columnes en un índex compost importa: (client_id, data_creacio) serveix per filtrar per client i ordenar per data, però no per ordenar per data sense filtrar per client. I els índexs no són de franc: accel·leren les lectures i alenteixen les escriptures, perquè cal mantenir-los.

Com verificar que es fan servir:

EXPLAIN QUERY PLAN
SELECT * FROM comandes WHERE client_id = 'cli_842' ORDER BY data_creacio DESC LIMIT 20;
-- Bé: SEARCH comandes USING INDEX idx_comandes_client_data (client_id=?)
-- Malament: SCAN comandes    ← recorre la taula sencera

L'N+1, ara a la base de dades (a 04-01 el vam veure sobre HTTP):

// ❌ 1 consulta + N consultes: amb 20 comandes i 3 línies cadascuna, 61 consultes.
const comandes = repositoris.comandes.llistar(filtres);
for (const comanda of comandes) {
  comanda.linies = repositoris.linies.perComanda(comanda.id);
}

// ✅ 2 consultes, sempre, sigui quin sigui el nombre de comandes.
const comandes = repositoris.comandes.llistar(filtres);
const ids = comandes.map((c) => c.id);
const marcadors = ids.map(() => '?').join(',');       // ?,?,? segons el nombre real
const totesLesLinies = db
  .prepare(`SELECT * FROM linies_comanda WHERE comanda_id IN (${marcadors})`)
  .all(...ids);

// S'agrupen en memòria: O(n), molt més barat que N anades a la base de dades.
const perComanda = new Map();
for (const linia of totesLesLinies) {
  if (!perComanda.has(linia.comanda_id)) perComanda.set(linia.comanda_id, []);
  perComanda.get(linia.comanda_id).push(linia);
}
for (const comanda of comandes) comanda.linies = perComanda.get(comanda.id) ?? [];

Nota de seguretat sobre l'IN: els marcadors ? es generen a partir del nombre d'elements, i els valors van com a paràmetres. Mai no s'interpolen els ids al SQL (04-02). I cal acotar la mida de la llista, perquè els motors tenen un límit de paràmetres; amb limit màxim 100 estem molt per sota.

Consultes lentes. Un registre senzill detecta el que les proves no veuen mai:

// src/config/base-dades.js  (MODIFICAT)
const LLINDAR_MS = 50;

export function consultaInstrumentada(sql, parametres, executar) {
  const inici = performance.now();
  const resultat = executar();
  const duracio = performance.now() - inici;
  if (duracio > LLINDAR_MS) {
    // L'SQL és plantilla fixa, sense dades de l'usuari: segur de registrar.
    // Els PARÀMETRES no es registren: poden contenir dades personals (04-02).
    registrador.warn({ sql, duracioMs: Math.round(duracio) }, 'consulta lenta');
  }
  return resultat;
}

El comentari assenyala una decisió de RGPD: la plantilla SQL es registra, els valors no.

  1. Respostes parcials i paginació com a mesura de rendiment

Dos mecanismes del mòdul 2 que ara es llegeixen com a optimitzacions de primer ordre.

camps (02-05): si la llista d'Aroma Mòbil només mostra nom, preu i torrefacció, demanar l'objecte sencer malbarata amplada de banda i serialització.

GET /v1/cafes?camps=id,nom,preuEuros,torrefaccio&limit=20
Petició Mida Reducció
GET /v1/cafes?limit=20 8,4 kB
GET /v1/cafes?limit=20&camps=id,nom,preuEuros,torrefaccio 2,1 kB 75 %

Paginació (02-06): a més de ser obligatòria per disseny (04-01), és la defensa més efectiva contra la degradació amb el creixement. I el cursor de /comandes no és un caprici: amb desplacament=100000, el motor ha de localitzar i descartar 100.000 files abans de retornar-ne 20. Amb cursor, salta directament per índex.

Mètode Cost amb 1M de files, pàgina 5.000 Consistència amb escriptures
desplacament=100000 Lineal: descarta 100.000 files Pot duplicar o saltar files
cursor=eyJmZWNoYSI6... Constant: salta per índex Estable

  1. Treball asíncron amb 202

Algunes operacions no caben al cicle d'una petició. Generar la factura PDF d'una comanda amb vint línies, o l'exportació de dades de l'exercici de 04-02, triguen segons.

Mantenir la connexió oberta és mala idea: esgota els processos, xoca amb els timeouts del balancejador i fa que una fallada de xarxa obligui a repetir tota la feina. El patró correcte:

POST /v1/comandes/com_5001/factura HTTP/1.1
Authorization: Bearer <token>
Idempotency-Key: 8c2f...
HTTP/1.1 202 Accepted
Location: /v1/tasques/tas_9f3a
Cache-Control: no-store
Content-Type: application/json

{
  "id": "tas_9f3a",
  "estat": "en_curs",
  "recurs": "/v1/comandes/com_5001/factura",
  "_links": { "self": { "href": "/v1/tasques/tas_9f3a" } }
}

El client consulta el recurs de tasca:

GET /v1/tasques/tas_9f3a HTTP/1.1
HTTP/1.1 200 OK
Retry-After: 2
Content-Type: application/json

{ "id": "tas_9f3a", "estat": "en_curs", "progres": 0.4 }

I en acabar:

HTTP/1.1 303 See Other
Location: /v1/comandes/com_5001/factura

Quatre detalls de disseny:

  • 202 significa «acceptat, encara no fet», i és diferent de 201 («creat»). L'arbre de decisió de 02-04 ho contemplava.
  • La tasca és un recurs, amb la seva URI, la seva representació i els seus _links. No és un mecanisme a part.
  • Retry-After a la consulta evita que el client pregunti cada 50 ms. És el mateix mecanisme de 04-04, aplicat a alguna cosa que no és un error.
  • 303 See Other en acabar redirigeix al recurs final. I el codi operacio_en_curs del catàleg cobreix el cas de demanar la factura mentre s'està generant.

Per al tauler intern, en comptes de sondejar es fan servir SSE, que ja formen part de l'arquitectura de la Botiga Aroma: el servidor empeny el canvi d'estat quan passa.

  1. Mesurar abans d'optimitzar

Tot l'anterior és inútil —o contraproduent— si s'aplica a cegues. L'ordre correcte és sempre: mesurar, trobar el coll d'ampolla real, arreglar això, tornar a mesurar.

Per què la mitjana enganya

Deu peticions: nou de 20 ms i una de 2.000 ms.

Estadístic Valor Què diu
Mitjana 218 ms Un número que cap usuari no ha experimentat
p50 (mediana) 20 ms La meitat va així de ràpid
p95 2.000 ms 5 de cada 100 peticions són terribles
p99 2.000 ms Ídem

La mitjana és un promig de dues poblacions diferents i no en descriu cap. Els percentils són el que cal mirar, i el p99 és el més important, perquè una pàgina que fa 10 peticions té ~10 % de probabilitat que almenys una caigui al p99. El teu p99 és l'experiència habitual dels teus usuaris més actius.

Pressupost de latència

Es decideix abans d'optimitzar, i es converteix en el criteri per saber quan parar:

Endpoint p50 p95 p99
GET /v1/cafes 30 ms 80 ms 150 ms
GET /v1/cafes/{id} 15 ms 40 ms 80 ms
POST /v1/comandes 80 ms 200 ms 400 ms
POST /v1/sessions 150 ms 300 ms 500 ms

El login és deliberadament el més lent: bcrypt amb cost 12 triga ~100 ms a propòsit (03-06). Això no és un problema de rendiment que calgui arreglar, és una defensa funcionant. Sense el pressupost escrit, algú acabarà «optimitzant-lo» abaixant el cost de bcrypt.

Prova de càrrega amb autocannon

npm install --save-dev autocannon
# 50 connexions concurrents durant 30 segons.
npx autocannon -c 50 -d 30 http://localhost:3000/v1/cafes

# Amb autenticació i una ruta que no es desa a la memòria cau.
npx autocannon -c 20 -d 30 \
  -H "Authorization: Bearer $TOKEN" \
  http://localhost:3000/v1/comandes

# Comprovar l'efecte real de l'ETag: la segona tanda ha de ser molt més ràpida.
npx autocannon -c 50 -d 20 -H 'If-None-Match: "v7"' \
  http://localhost:3000/v1/cafes/caf_001

Sortida típica:

┌─────────┬──────┬──────┬───────┬──────┬─────────┬─────────┬────────┐
│ Stat    │ 2.5% │ 50%  │ 97.5% │ 99%  │ Avg     │ Stdev   │ Max    │
├─────────┼──────┼──────┼───────┼──────┼─────────┼─────────┼────────┤
│ Latency │ 8 ms │ 14 ms│ 46 ms │ 89 ms│ 17.2 ms │ 12.4 ms │ 210 ms │
└─────────┴──────┴──────┴───────┴──────┴─────────┴─────────┴────────┘

2894 requests/sec, 1.2 MB/sec read

Com llegir-ho sense enganyar-se:

  • Mira el p99 (columna 99 %), no la mitjana.
  • Compara sempre contra una línia base presa abans del canvi. Un número absolut tot sol no diu res.
  • Una sola màquina no és producció: no hi ha latència de xarxa real, ni CDN, ni altres instàncies, ni dades de debò.
  • Amb poques dades, tot va ràpid. Prova amb un volum realista: una taula de 100 files cap a la memòria i amaga la manca d'índexs.
  • Vigila la CPU i la memòria durant la prova. Si la CPU és al 100 %, el coll és teu; si és al 20 % i la latència puja, el coll és a la base de dades o en un tercer.

L'ordre de l'optimització

  1. Mesura i localitza l'endpoint lent real, amb dades de producció.
  2. Es pot evitar la petició? Memòria cau HTTP. És el guany més gran possible.
  3. Es pot reduir el nombre de peticions? expandir (04-01).
  4. Es pot evitar la consulta? Memòria cau de servidor.
  5. Es pot fer la consulta més barata? Índexs, N+1, camps.
  6. Es pot fer després? 202 i treball asíncron.
  7. Torna a mesurar i comprova contra el pressupost.

I un advertiment final: la memòria cau afegeix complexitat i una classe nova de bugs —dades obsoletes, invalidació incompleta, incoherències entre nivells—. Si un endpoint respon en 15 ms i es crida deu vegades al dia, desar-lo a la memòria cau no aporta res i sí que afegeix una manera més de fallar.

Errors Comuns i Consells

Creure que no-cache significa «no desar a la memòria cau». Significa «revalida sempre». La que impedeix desar és no-store.

Oblidar private en respostes personalitzades. Una CDN pot servir les comandes d'un usuari a un altre. És la fuita de dades més greu d'aquesta lliçó.

Desar respostes d'error a la memòria cau. Un 429 desat cinc minuts converteix un bloqueig d'un minut en cinc.

Fer servir ETags febles per a If-Match. No garanteixen igualtat exacta i l'estàndard no ho permet. L'ETag automàtic d'Express és feble.

Retornar cos en un 304. És un error de protocol; a més anul·la l'estalvi.

Oblidar Vary. Amb negociació de contingut o CORS, provoca respostes creuades intermitents i impossibles de reproduir.

Desar l'estoc a la memòria cau. Provoca sobrevenda. Hi ha dades que han de ser exactes sempre.

Posar TTL llargs al navegador. No es poden purgar. Curts al navegador, llargs a la CDN amb s-maxage.

Optimitzar sense mesurar. La major part del temps es perd on ningú no mira, i la feina es dedica on ja era ràpid.

Consell: comença pel catàleg. És el 80 % del trànsit i el més cacheable. Ben resolt, resol gairebé tot.

Consell: fes que la memòria cau sigui observable. Sense mètriques d'encerts i fallades no saps si funciona. És tema de 04-07.

Consell: prova el 304 explícitament. És fàcil que el middleware deixi de funcionar en un refactor i ningú no se n'assabenti: només es nota a la factura d'amplada de banda.

Exercicis

Exercici 1: decidir la política de memòria cau

Per a cada recurs, decideix el Cache-Control complet i si emetries ETag. Justifica cada elecció:

  1. GET /v1/cafes?origen=Etiòpia — catàleg filtrat, públic.
  2. GET /v1/clients/cli_842/preferencies — preferències de l'usuari autenticat.
  3. GET /v1/cafes/caf_001/imatge — imatge el nom de la qual inclou un hash.
  4. POST /v1/sessions — retorna tokens.
  5. GET /v1/comandes/com_5001 — comanda del mateix client, consultada sovint.
  6. GET /v1/cafes/caf_001/ressenyes?ordenar=-dataCreacio — ressenyes públiques.

Exercici 2: implementar If-Match a DELETE

Implementa l'esborrat condicional d'una ressenya: DELETE /v1/ressenyes/{id} ha d'exigir If-Match, retornar 204 si la versió coincideix, 412 conflicte_versio si no, 428 precondicio_requerida si falta la capçalera i 404 si no existeix. Escriu la ruta, el servei i una prova d'integració dels quatre casos.

Exercici 3: diagnosticar un problema de rendiment

GET /v1/comandes?limit=20&expandir=linies.cafe té un p50 de 45 ms i un p99 de 1.800 ms. La CPU del servidor és al 25 % durant la prova de càrrega. Enumera almenys quatre causes possibles, ordenades per probabilitat, i digues com verificaries i corregiries cadascuna.

Solucions

Solució 1

Núm. Cache-Control ETag Justificació
1 public, max-age=60, s-maxage=300, stale-while-revalidate=600 Sí (hash) Públic i estable. Necessita Vary: Accept-Language perquè les notes de tast es tradueixen. La CDN aguanta més perquè es pot purgar
2 private, no-cache Sí (hash o versio) Depèn de l'usuari: private obligatori. no-cache permet el 304, que és el millor per a dades personals estables
3 public, max-age=31536000, immutable No cal El hash al nom garanteix que la URL canvia si canvia el contingut. Amb immutable, el navegador ni revalida
4 no-store No Conté tokens. No ha de quedar en cap disc ni proxy
5 private, no-cache Sí (versio) Personal, es consulta sovint i canvia poc: el 304 estalvia molt. L'ETag de versió serveix a més per a If-Match
6 public, max-age=60 Sí (hash) Públic. TTL curt perquè una ressenya nova ha d'aparèixer aviat. Vary: Accept-Language

Solució 2

// src/rutes/ressenyes.js
router.delete(
  '/:id',
  autenticar,
  exigirRol('client', 'empleat', 'administrador'),
  exigirIfMatch,                                  // 428 si falta
  asincron(controladors.ressenyes.esborrar)
);
// src/controladors/ressenyes.js
export async function esborrar(req, res) {
  await serveis.ressenyes.esborrar(req.params.id, req.versionsAcceptades, req.usuari);
  res.status(204).end();                          // 204: sense cos
}
// src/serveis/ressenyes.js
export async function esborrar(id, versionsAcceptades, solicitant) {
  const ressenya = await repositoris.ressenyes.buscarPerId(id);

  // 404 també si és aliena: no confirmem existència (04-02).
  if (!ressenya) throw errors.noTrobat('ressenya_no_trobada', `No existeix la ressenya ${id}.`);
  const esPropietari = ressenya.clientId === solicitant.id;
  const esPersonal = ['empleat', 'administrador'].includes(solicitant.rol);
  if (!esPropietari && !esPersonal) {
    throw errors.noTrobat('ressenya_no_trobada', `No existeix la ressenya ${id}.`);
  }

  if (versionsAcceptades && !versionsAcceptades.includes(ressenya.versio)) {
    throw errors.precondicioFallida(
      'conflicte_versio',
      'La ressenya ha canviat des que la vas obtenir. Torna-la a llegir i reintenta-ho.'
    );
  }

  // Esborrat condicional atòmic: si una altra transacció s'hi ha colat, files serà 0.
  const files = repositoris.ressenyes.esborrarSiVersio(id, ressenya.versio);
  if (files === 0) {
    throw errors.precondicioFallida('conflicte_versio', 'Conflicte de versió en esborrar.');
  }
}
// proves/integracio/ressenyes-condicional.prova.js
describe('DELETE /v1/ressenyes/:id condicional', () => {
  it('204 quan la versió coincideix', async () => {
    const r = await request(app)
      .delete('/v1/ressenyes/res_101')
      .set('Authorization', `Bearer ${tokenEmpleat}`)
      .set('If-Match', '"v3"');
    assert.equal(r.status, 204);
    assert.equal(r.text, '');
  });

  it('412 conflicte_versio quan la versió no coincideix', async () => {
    const r = await request(app)
      .delete('/v1/ressenyes/res_102')
      .set('Authorization', `Bearer ${tokenEmpleat}`)
      .set('If-Match', '"v1"');                 // l'actual és v3
    assert.equal(r.status, 412);
    assert.equal(r.body.error.codi, 'conflicte_versio');
    assert.deepEqual(r.body.error.detalls, []);
  });

  it('428 precondicio_requerida quan falta If-Match', async () => {
    const r = await request(app)
      .delete('/v1/ressenyes/res_102')
      .set('Authorization', `Bearer ${tokenEmpleat}`);
    assert.equal(r.status, 428);
    assert.equal(r.body.error.codi, 'precondicio_requerida');
  });

  it('404 quan no existeix, encara que l\'If-Match sigui correcte', async () => {
    const r = await request(app)
      .delete('/v1/ressenyes/res_999')
      .set('Authorization', `Bearer ${tokenEmpleat}`)
      .set('If-Match', '"v1"');
    assert.equal(r.status, 404);
    assert.equal(r.body.error.codi, 'ressenya_no_trobada');
  });
});

Nota de disseny: el 404 es comprova abans que l'If-Match, perquè no té sentit parlar de la versió d'una cosa que no existeix, i perquè respondre 412 en un recurs inexistent filtraria informació sobre la seva existència.

Solució 3

Que la CPU sigui al 25 % descarta que el coll sigui el mateix procés Node. La distància entre p50 i p99 (40×) apunta a alguna cosa que passa només de vegades, no a un cost constant.

Núm. Causa probable Com verificar-la Correcció
1 N+1 en expandir linies.cafe: una consulta per línia Comptar les consultes d'una petició; EXPLAIN QUERY PLAN; el registre de consultes lentes Consulta agrupada amb IN (?,?,?) i agrupació en memòria (apartat 14)
2 Manca d'índex a comandes(client_id, data_creacio): amb poques comandes l'escaneig és ràpid i amb moltes no EXPLAIN QUERY PLAN mostra SCAN en comptes de SEARCH Crear l'índex compost
3 Contenció d'escriptura a SQLite: les lectures esperen que acabi un INSERT Correlacionar els pics de latència amb les escriptures concurrents Mode WAL, transaccions més curtes, o migrar a un motor client-servidor
4 Distribució de dades desigual: un client amb 500 comandes enfront de la majoria amb 3 Comparar la latència per clientId; mesurar amb dades realistes Paginació per cursor, límit d'expandir, memòria cau del cas pesat
5 Serialització de respostes grans: expandir multiplica la mida Comparar la mida de la resposta amb la latència camps per aprimar; compressió
6 Pauses del recol·lector d'escombraries per respostes grans en memòria Mètriques del procés; --trace-gc Reduir la mida de les respostes; fer streaming si són enormes

Procediment: primer el registre de consultes lentes de l'apartat 14, que en cinc minuts distingeix entre 1-2-3 (base de dades) i 5-6 (procés). Després mesurar contra el pressupost (p99 de 400 ms per a escriptures; per a aquesta lectura hauria d'estar a l'entorn de 150 ms) i parar quan es compleixi, ni abans ni després.

Conclusió

La restricció «cacheable» de 01-04 ja és implementació. Coneixes els quatre nivells de memòria cau i la regla que evita la fuita més greu —private en tot allò que depengui de qui pregunta—; domines Cache-Control directiva a directiva, inclòs aquell no-cache que no significa «no desar a la memòria cau» i aquell stale-while-revalidate que elimina l'espera del refresc; tens la política de la Botiga Aroma recurs a recurs, amb el catàleg desat a la memòria cau i /v1/comandes revalidat sempre. Has implementat la validació condicional amb ETag fort —del hash del cos o del camp versio que ja existia— i el flux complet del 304. I sobretot s'ha tancat el cercle que va quedar obert a 03-05: If-Match i el 412 resolen l'actualització perduda amb la semàntica estàndard d'HTTP, funcionen amb DELETE, els entenen els intermediaris i reutilitzen el mateix ETag que el client va rebre en llegir; a canvi, el catàleg d'errors creix amb precondicio_requerida (428), que cal documentar a openapi.yaml. Al projecte tens src/middleware/cache.js amb cacheDe, etagCondicional i exigirIfMatch, src/serveis/cache.js amb cache-aside i bloqueig, src/serveis/cache-invalidacio.js, i compression a la posició 7 amb etagCondicional a la 10 de src/app.js. Més enllà de la memòria cau: compressió, keep-alive, índexs, l'N+1 resolt amb una consulta agrupada, camps, paginació per cursor i el 202 amb recurs de tasca per a la factura. I, el primer encara que s'expliqui al final, el pressupost de latència, els percentils davant de la mitjana i autocannon per mesurar abans de tocar res.

Només queda una cosa abans de tancar el mòdul, i és la que fa possible tota la resta: saber què està passant. A 04-07, Observabilitat: logs, mètriques i traces, substituirem per fi el console.log de la posició 5 de src/app.js per logs estructurats amb pino, amb un logger fill per petició que arrossega el tracaId que emetem des de 03-02, els camps que cal registrar sempre i els que mai —contrasenyes, tokens, dades personals— amb la seva redacció automàtica. Instrumentarem la Botiga Aroma amb prom-client i els quatre senyals d'or, exposant /metriques protegit i fora de /v1, amb l'advertiment sobre la cardinalitat que rebenta els sistemes de mètriques. Veurem les traces distribuïdes amb traceparent i OpenTelemetry sobre un POST /v1/comandes complet —validació, SQL, gRPC d'inventari i webhook—, la diferència entre liveness i readiness a /salut, i com s'alerta sobre símptomes i SLO en comptes de sobre la CPU. I tancarem el mòdul amb el balanç de tot el que s'ha endurit.

Curs de REST API: Principis de Disseny i Desenvolupament d'APIs RESTful

Mòdul 1: Introducció a les APIs RESTful

Mòdul 2: Disseny d'APIs RESTful

Mòdul 3: Desenvolupament d'APIs RESTful

Mòdul 4: Bones Pràctiques i Seguretat

Mòdul 5: Eines i Frameworks

Mòdul 6: Casos d'Estudi i Projectes

© Copyright 2026. Tots els drets reservats