La lliçó anterior va tancar amb una promesa: CicloUrbana és en producció i hi arriba sola, i a partir d'ara la veurem funcionar i farem que vagi ràpid. Aquest és el primer pas, i convé començar desfent un malentès: ajustar el rendiment no consisteix a aplicar trucs. No és afegir una memòria cau, pujar el pool de connexions a 100 ni canviar el recol·lector d'escombraries perquè en un article deien que era més ràpid. Tot això són palanques, i sense un mètode al darrere són tres maneres diferents de moure un número a l'atzar.

El mètode és curt i no s'estalvia cap pas: mesurar, trobar el coll d'ampolla, corregir una sola cosa, tornar a mesurar. Aquesta lliçó el recorre sencer sobre la xarxa de Ribalta. Veurem per què la mitjana de latència menteix i què cal mirar en el seu lloc, com establir una línia base amb una prova de càrrega que reprodueixi l'hora punta, dues lleis que diuen on val la pena esforçar-se, i després el recorregut prioritzat pels llocs on de debò se'n va el temps: la base de dades primer —l'N+1 i els índexs—, després el pool de connexions, la JVM, els fils i la xarxa. Acabarem amb un cas complet: un endpoint que triga 2,4 segons en p95 i acaba trigant 90 mil·lisegons.

Contingut

  1. El mètode: no optimitzar mai sense mesurar
  2. Objectius: SLI, SLO i per què la mitjana menteix
  3. La línia base: la prova de càrrega
  4. Un script de k6 per a l'hora punta de Ribalta
  5. Llegir els resultats de la prova
  6. Dues lleis pràctiques: Little i Amdahl
  7. On se'n va el temps en una aplicació Spring Boot
  8. La base de dades (I): detectar l'N+1
  9. La base de dades (II): índexs i EXPLAIN ANALYZE
  10. La base de dades (III): paginació, projeccions i lots
  11. HikariCP: dimensionar el pool amb criteri
  12. La JVM: memòria, recol·lector i pauses
  13. Fils: Tomcat, el pool i els fils virtuals
  14. La xarxa: compressió i mida de la resposta
  15. Perfilatge: async-profiler, JFR i bolcats
  16. Cas pràctic: l'endpoint d'estacions
  17. Errors Comuns i Consells
  18. Exercicis

  1. El mètode: no optimitzar mai sense mesurar

Hi ha una frase de Donald Knuth que tothom cita a mitges. La completa diu: «els programadors gasten una quantitat enorme de temps preocupant-se per la velocitat de parts no crítiques, i aquests intents tenen un impacte molt negatiu quan es consideren la depuració i el manteniment. 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. Tot i així, no hauríem de deixar passar les nostres oportunitats en aquest 3 % crític.»

El que importa és el final: existeix un 3 % crític i cal trobar-lo. L'error no és optimitzar, és optimitzar a cegues. I la intuïció sobre on és aquest 3 % és notòriament dolenta: gairebé tots els desenvolupadors aposten pel seu propi codi Java, quan en una aplicació com CicloUrbana el temps es passa gairebé sempre esperant la base de dades o un servei remot.

El cicle de treball és aquest:

flowchart LR
    A[Definir l'objectiu<br/>SLO mesurable] --> B[Mesurar la linia base<br/>prova de carrega]
    B --> C[Localitzar el coll d'ampolla<br/>perfilatge i traces]
    C --> D[Corregir UNA cosa]
    D --> E[Tornar a mesurar]
    E --> F{Es compleix<br/>l'objectiu?}
    F -->|No| C
    F -->|Si| G[Parar i documentar]

Tres regles governen aquest cicle. Un canvi cada vegada: si toques el pool, la memòria cau i l'índex alhora i millora, no saps quin ha servit ni quin està empitjorant una altra cosa —i a CicloUrbana aquesta disciplina és barata, perquè cada canvi és un desplegament automàtic (08-05)—. Parar quan es compleix l'objectiu: sense criteri d'aturada l'ajust no s'acaba mai i acaba afegint complexitat que ningú no necessita; «ràpid» no és un objectiu, «p95 per sota de 300 ms amb 200 usuaris concurrents» sí que ho és. I mesurar on importa: un System.currentTimeMillis() al voltant d'un mètode al teu portàtil, amb la base de dades buida i sense concurrència, no diu res sobre Ribalta a les vuit del matí.

  1. Objectius: SLI, SLO i per què la mitjana menteix

Abans de mesurar cal decidir què es mesura i quin valor és acceptable:

Terme Què és Exemple a CicloUrbana
SLI (indicator) La mètrica concreta que s'observa Latència de POST /api/v1/lloguers
SLO (objective) L'objectiu intern sobre aquest SLI p95 < 300 ms i p99 < 800 ms durant el 99 % del mes
SLA (agreement) El compromís contractual, amb penalització L'ajuntament exigeix un 99,5 % de disponibilitat mensual

L'elecció de l'estadístic no és un detall: és la decisió més important de tot el capítol. Suposem 1.000 peticions a l'endpoint d'inici de lloguer en una hora punta de Ribalta:

Grup Peticions Latència
Estació a la memòria cau, tot bé 900 40 ms
Estació amb moltes bicicletes 80 300 ms
Coincideix amb el bloqueig d'un lot nocturn 20 3.000 ms

La mitjana és (900×40 + 80×300 + 20×3000) / 1000 = 120 ms. Un número excel·lent que es pot portar a una reunió sense envermellir. Ara els percentils: ordenades les 1.000 latències, la posició 950 (p95) cau al grup de 300 ms, i la posició 990 (p99) cau al grup de 3.000 ms.

p50 = 40 ms · mitjana = 120 ms · p95 = 300 ms · p99 = 3.000 ms

Les mateixes dades expliquen dues històries oposades. I la que importa és la segona: 20 de cada 1.000 ciutadans esperen tres segons mirant la pantalla del mòbil davant d'una bicicleta. En una hora punta amb 12.000 lloguers, són 240 persones cada matí. La mitjana els ha amagat perquè fa la mitjana sobre una distribució amb cua llarga, que és exactament la forma que tenen totes les distribucions de latència reals.

Quatre regles pràctiques. La mitjana no és mai el SLO: serveix, com a molt, per detectar una regressió grollera. p95 descriu l'experiència típica dolenta i p99 o p99,9 la pitjor, i com més crític és el servei més a la dreta cal mirar. Els percentils no es promitgen: la mitjana dels p95 de tres instàncies no és el p95 del conjunt, una cosa amb conseqüències tècniques molt concretes que veurem a 09-03 amb els histogrames. I latència i disponibilitat es miren juntes, perquè un endpoint que retorna 500 en 5 ms té un p99 magnífic.

  1. La línia base: la prova de càrrega

Sense línia base no hi ha millora, només sensació de millora. Una prova de càrrega sotmet l'aplicació a un trànsit controlat i reproduïble, per poder comparar l'abans i el després amb el mateix patró.

k6 JMeter
Escenaris JavaScript, al repositori XML, normalment amb interfície gràfica
Corba d'aprenentatge Baixa per a qui programa Mitjana; molts conceptes propis
Recursos del generador Molt baixos (Go, corrutines) Alts (un fil per usuari virtual)
Control de versions Natural: és codi Difícil: el .jmx no es revisa bé
Integració a CI Excel·lent, imatge oficial Possible, més pesada
Protocols HTTP, gRPC, WebSocket Moltíssims (JDBC, JMS, LDAP...)
Informes Consola, JSON, sortida a Prometheus Interfície gràfica i informes HTML
Quan triar-lo Per defecte a CicloUrbana Protocols exòtics o equips ja formats

Triem k6: l'escenari viu al costat del codi, s'executa a la canalització de 08-05 i no necessita una màquina potent per generar 500 usuaris virtuals. Tres advertiments abans de l'script: no provis contra producció sense acordar-ho —es prova contra pre (07-02) i amb dades de volum realista, perquè una base amb 12 estacions i 30 lloguers no revela res—; el generador no pot ser el coll d'ampolla, així que si k6 corre al mateix portàtil que l'aplicació estàs mesurant el portàtil; i escalfa la JVM, perquè els primers segons són codi interpretat i pool fred, i es descarten.

  1. Un script de k6 per a l'hora punta de Ribalta

L'escenari reprodueix el que passa entre les 7:45 i les 9:15 a Ribalta: molta gent consulta estacions properes, una part inicia un lloguer i, uns minuts després, el finalitza.

import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Trend, Rate } from 'k6/metrics';

const latenciaInici = new Trend('lloguer_inici_ms');
const lloguersFallits = new Rate('lloguers_fallits');

const BASE = __ENV.BASE_URL || 'https://pre.ciclourbana.ribalta.example';
const ESTACIONS = [1, 2, 3, 4];

export const options = {
  stages: [
    { duration: '1m',  target: 50  },   // rampa: escalfar la JVM i el pool
    { duration: '3m',  target: 200 },   // pujada fins a l'hora punta
    { duration: '5m',  target: 200 },   // altipla: aqui es mesura de debo
    { duration: '1m',  target: 0   },   // baixada ordenada
  ],
  thresholds: {
    'http_req_failed':   ['rate<0.01'],                 // menys de l'1 % d'errors
    'http_req_duration': ['p(95)<300', 'p(99)<800'],    // el SLO de l'apartat 2
    'lloguer_inici_ms': ['p(95)<400'],
  },
};

export function setup() {                    // una sola vegada, token compartit
  const res = http.post(`${BASE}/api/v1/auth/login`,
      JSON.stringify({ correu: '[email protected]', clau: __ENV.CLAU }),
      { headers: { 'Content-Type': 'application/json' } });
  return { token: res.json('token') };
}

export default function (dades) {
  const capcaleres = { headers: { 'Authorization': `Bearer ${dades.token}`,
                                  'Content-Type': 'application/json' } };
  const estacio = ESTACIONS[Math.floor(Math.random() * ESTACIONS.length)];

  group('consultar estacions', () => {
    const res = http.get(`${BASE}/api/v1/estacions?page=0&size=20`, capcaleres);
    check(res, { 'estacions 200': (r) => r.status === 200 });
    sleep(Math.random() * 2 + 1);          // el ciutada mira el mapa
  });

  group('iniciar i finalitzar lloguer', () => {
    const inici = http.post(`${BASE}/api/v1/lloguers`,
        JSON.stringify({ estacioOrigenId: estacio, tipusTarifa: 'ESTANDARD' }), capcaleres);
    latenciaInici.add(inici.timings.duration);
    const creat = check(inici, { 'lloguer 201': (r) => r.status === 201 });
    lloguersFallits.add(!creat);
    if (!creat) return;

    sleep(3);                               // trajecte simulat
    const fi = http.patch(`${BASE}/api/v1/lloguers/${inici.json('id')}/finalitzar`,
        JSON.stringify({ estacioDestiId: estacio }), capcaleres);
    check(fi, { 'finalitzar 200': (r) => r.status === 200 });
  });
}

Què fa cada peça i per què. stages defineix la forma de la càrrega, i la rampa inicial no és cortesia: sense ella es mesura la JVM interpretant bytecode i HikariCP obrint connexions, no l'estat estacionari —l'altiplà és l'única part els números de la qual valen—. thresholds converteix el SLO en una condició de fallada: si no es compleix, k6 surt amb un codi diferent de zero i la canalització de 08-05 es posa en vermell, de manera que el rendiment deixa de ser una opinió i passa a ser una prova. setup() s'executa una vegada i comparteix el token amb tots els usuaris virtuals, per no mesurar el cost del login a cada iteració. sleep() és imprescindible: sense pauses no se simulen 200 ciutadans, se simula un atac; el temps de reflexió és el que fa que la concurrència de l'escenari s'assembli a la real. I les mètriques pròpies (Trend, Rate) separen el SLO de negoci —iniciar un lloguer— de l'agregat de totes les peticions.

S'executa així:

k6 run --env BASE_URL=https://pre.ciclourbana.ribalta.example \
       --env CLAU="$CLAU_CARREGA" \
       --out json=resultats-base.json carrega-hora-punta.js

  1. Llegir els resultats de la prova

     http_req_duration..............: avg=214ms min=11ms med=98ms max=4.8s p(90)=402ms p(95)=1.19s
       { expected_response:true }...: avg=201ms min=11ms med=95ms max=4.8s p(90)=380ms p(95)=1.11s
     http_req_failed................: 0.71%  432 out of 60731
     http_reqs......................: 60731  112.4/s
     iterations.....................: 15182  28.1/s
     vus............................: 200    min=1 max=200
     lloguer_inici_ms...............: avg=486ms med=210ms p(95)=2.41s
     ✓ estacions 200
     ✗ lloguer 201  99.29% ✓ 15074 ✗ 108

     ✗ http_req_duration: p(95)<300  ->  p(95)=1.19s

Com s'interpreta, en ordre. Ha fallat cap llindar? Sí: el p95 global és 1,19 s davant dels 300 ms del SLO, així que la prova ha fet la seva feina. Hi ha errors? Un 0,71 %, per sota del llindar de l'1 %, però no és soroll i cal mirar de quin tipus són: un 500 és una fallada, mentre que un 409 pot ser l'índex únic parcial de 04-08 rebutjant un segon lloguer del mateix usuari, cosa que és correcta. Mitjana contra percentils? avg=214ms davant de p(95)=1.19s i max=4.8s: la cua llarga de l'apartat 2, exactament. Quina operació concreta? lloguer_inici_ms té un p95 de 2,41 s amb una mediana de 210 ms, per tant el problema no està repartit: és en un punt i només sota càrrega —una operació amb mediana bona i p95 deu vegades pitjor gairebé sempre espera per un recurs compartit: una connexió del pool, un bloqueig de fila o una pausa de GC—. Escala? 112 peticions per segon amb 200 usuaris virtuals; si en doblar els usuaris el rendiment no puja i la latència sí, s'ha assolit la saturació i hi ha un recurs limitat al darrere.

Aquesta és la línia base. Es desa al repositori amb la data i la versió desplegada, perquè l'única manera honesta d'afirmar «això va més ràpid» és comparar-hi.

  1. Dues lleis pràctiques: Little i Amdahl

La llei de Little relaciona tres magnituds en qualsevol sistema estable:

L = λ × W
concurrencia = rendiment × latencia

Amb els números de la prova: λ = 112 pet/s, W = 0,214 s de mitjana, per tant L = 24 peticions dins del sistema alhora. Aquest número és or:

  • Si hi ha 24 peticions concurrents i el pool d'HikariCP té 10 connexions, hi ha peticions esperant connexió. Aquí tens el candidat.
  • Si Tomcat té 200 fils i només se'n fan servir 24, pujar els fils no arregla res. És l'error més freqüent de l'ajust per intuïció.
  • Al revés: si l'objectiu és 300 peticions per segon amb 200 ms de latència, caldrà suportar L = 60 peticions simultànies. La llei et diu el requisit abans de provar.

La llei d'Amdahl diu quant pot millorar el conjunt en accelerar-ne una part:

acceleracio_maxima = 1 / ((1 - P) + P / S)

on P és la fracció del temps que ocupa la part optimitzada i S quant s'accelera. Dos casos de CicloUrbana:

Què s'optimitza P S Acceleració total
Les consultes SQL, que són el 70 % del temps 0,70 10× 2,7×
La serialització JSON, que és el 5 % 0,05 10× 1,05×
Les consultes, eliminant-les del tot amb memòria cau 0,70 ∞ 3,3× (el sostre)

La lectura és demolidora: fer deu vegades més ràpida la serialització millora el conjunt un 5 %. Mitja setmana de feina per a un guany que no es veu. Per això el primer pas sempre és esbrinar el repartiment del temps, i per això la taula de l'apartat següent està ordenada.

  1. On se'n va el temps en una aplicació Spring Boot

Repartiment típic en una aplicació de gestió com CicloUrbana, amb l'ordre en què convé buscar:

Ordre Font Pes habitual Símptoma característic Eina
1 Base de dades: N+1, manca d'índex, consulta pesada 40-80 % La latència creix amb el volum de dades Log SQL, comptador de consultes, EXPLAIN ANALYZE
2 Espera de recursos: pool de connexions, bloqueigs 0-40 % sota càrrega Mediana bona, p99 dolentíssim hikaricp.connections.* (09-03), pg_locks
3 Xarxa externa: passarel·la de pagaments (07-06) 0-30 % Latència constant i insensible al volum Traces (09-06), mètriques del client
4 Serialització i mida de resposta 5-15 % CPU alta, respostes de megabytes Perfilatge de CPU
5 GC i memòria 1-10 % Pics periòdics, pauses simultànies a tot -Xlog:gc, jvm.gc.pause
6 El teu propi codi Java 1-10 % Un mètode concret crema al gràfic de flama async-profiler, JFR

La conclusió pràctica: comença sempre per la base de dades i baixa per la llista. I no passis al nivell següent fins que no hagis esgotat l'anterior, perquè l'ordre reflecteix la llei d'Amdahl aplicada al cas mitjà.

  1. La base de dades (I): detectar l'N+1

Recuperem el problema de 04-04 amb la formulació operativa: el nombre de consultes d'un endpoint ha de ser constant, no proporcional al nombre de resultats. Primer, veure-ho. A dev n'hi ha prou d'obrir el SQL:

spring:
  jpa.properties.hibernate:
    format_sql: true
    generate_statistics: true          # resum per sessio en acabar
logging.level:
  org.hibernate.SQL: DEBUG
  org.hibernate.orm.jdbc.bind: TRACE   # els parametres, a Hibernate 6

El resum de generate_statistics és la dada clau:

Session Metrics { 201 JDBC statements executed;
                  1876453200 nanoseconds spent executing 201 JDBC statements; }

201 sentències per llistar 200 estacions. No cal més diagnòstic.

Veure el SQL a mà no escala: tan bon punt l'endpoint és complex, ningú no compta línies. La solució és un comptador de consultes per petició, amb datasource-proxy:

<dependency>
    <groupId>net.ttddyy</groupId>
    <artifactId>datasource-proxy</artifactId>
    <version>1.10</version>
</dependency>

S'embolcalla el DataSource en un BeanPostProcessor anotat amb @Profile({"dev","test"}) —mai en producció, perquè intercepta cada consulta— que retorna ProxyDataSourceBuilder.create(ds).name("ciclourbana").countQuery().logSlowQueryBySlf4j(200, MILLISECONDS).build(): countQuery() acumula el recompte per fil i logSlowQueryBySlf4j avisa de les consultes que passen de 200 ms. A sobre, un filtre escriu al final de cada petició quantes consultes s'han executat —i com que el FiltreRastreig de 03-06 ja ha posat el rastreId al MDC, la línia és directament correlacionable amb la resta del log:

@Component
@Profile({"dev", "test"})
public class FiltreComptadorConsultes extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain cadena) throws ServletException, IOException {
        QueryCountHolder.clear();
        long t0 = System.nanoTime();
        try {
            cadena.doFilter(req, res);
        } finally {
            QueryCount comptador = QueryCountHolder.get("ciclourbana");
            long ms = (System.nanoTime() - t0) / 1_000_000;
            if (comptador != null && comptador.getTotal() > 10) {
                log.warn("{} {} -> {} consultes en {} ms",
                        req.getMethod(), req.getRequestURI(), comptador.getTotal(), ms);
            }
            QueryCountHolder.clear();     // igual que MDC.remove(): el fil es reutilitza
        }
    }
}

Amb això, un endpoint que es degrada avisa sol tan bon punt algú introdueix un getBicicletes() dins d'un bucle. I la tècnica arriba fins a les proves: en un @DataJpaTest (06-04) es pot afirmar assertThat(comptador.getTotal()).isEqualTo(1), i així «no hi ha N+1» es converteix en una prova de regressió de debò.

La correcció ja la coneixem de 04-04 i 04-06: @EntityGraph(attributePaths = "bicicletes") o JOIN FETCH quan calen les entitats, @BatchSize quan la col·lecció es pagina, i —sovint la millor— una projecció que faci comptar la base de dades i no porti entitats en absolut.

  1. La base de dades (II): índexs i EXPLAIN ANALYZE

Un índex és una estructura auxiliar que evita llegir la taula sencera. La pregunta no és «hi poso un índex?» sinó «quines columnes filtren i ordenen les meves consultes reals?». I la resposta la dóna PostgreSQL, no la intuïció.

La consulta de l'històric del ciutadà:

EXPLAIN (ANALYZE, BUFFERS)
SELECT l.id, l.inici, l.fi, l.import_total
FROM lloguers l
WHERE l.usuari_id = 4711 AND l.estat = 'FINALITZAT'
ORDER BY l.inici DESC
LIMIT 20;

Abans de tocar res, amb 3,2 milions de files:

Limit  (cost=98234.11..98234.16 rows=20) (actual time=812.443..812.451 rows=20 loops=1)
  ->  Sort  (cost=98234.11..98301.44 rows=26932) (actual time=812.441..812.445 rows=20)
        Sort Key: inici DESC
        Sort Method: top-N heapsort  Memory: 27kB
        ->  Seq Scan on lloguers l  (cost=0.00..97517.00) (actual time=0.031..798.220)
              Filter: ((usuari_id = 4711) AND ((estat)::text = 'FINALITZAT'::text))
              Rows Removed by Filter: 3172896
              Buffers: shared hit=1204 read=61313
Execution Time: 812.478 ms

Com es llegeix, de dins cap enfora:

Línia Què significa
Seq Scan Llegeix la taula completa. El delicte principal.
Rows Removed by Filter: 3172896 Ha llegit 3,2 milions de files per descartar-les. El senyal més clar d'índex absent.
cost= vs actual time= Estimació del planificador vs realitat. Si divergeixen molt, falten estadístiques: ANALYZE lloguers.
Buffers: read=61313 Blocs llegits de disc (8 KB cadascun): ~480 MB moguts per retornar 20 files.
Sort Method: top-N heapsort Ordena en memòria; si digués external merge Disk, estaria ordenant a disc.
Execution Time El número que importa.

Ja existia idx_lloguers_usuari de 04-08, però el planificador el descarta: amb 27.000 lloguers d'aquest usuari, saltar de l'índex a la taula 27.000 vegades li surt més car que escombrar-la. La solució és un índex compost que cobreixi el filtre i l'ordre:

CREATE INDEX CONCURRENTLY idx_lloguers_historial
    ON lloguers (usuari_id, estat, inici DESC);
Limit  (cost=0.56..12.83 rows=20) (actual time=0.041..0.061 rows=20 loops=1)
  ->  Index Scan using idx_lloguers_historial on lloguers l
        Index Cond: ((usuari_id = 4711) AND ((estat)::text = 'FINALITZAT'::text))
        Buffers: shared hit=24
Execution Time: 0.084 ms

De 812 ms a 0,08 ms: gairebé deu mil vegades. No hi ha cap optimització de codi Java capaç d'acostar-s'hi, i aquest és l'argument de tota la lliçó.

L'ordre de les columnes de l'índex compost no és arbitrari. La regla, en aquest ordre: primer les columnes d'igualtat (usuari_id, estat), després les de rang o ordre (inici DESC). Un índex (usuari_id, estat, inici) serveix per consultar per usuari_id, per usuari_id + estat i pels tres; no serveix per consultar només per estat. És el principi del prefix esquerre, i explica per què un índex ben pensat sol fer-ne innecessaris dos o tres.

Índexs parcials. Quan només interessa un subconjunt:

-- Nomes els lloguers oberts: unes desenes davant de milions
CREATE INDEX idx_lloguers_en_curs ON lloguers (estacio_origen_id)
    WHERE fi IS NULL;

Ocupa kilobytes en lloc de centenars de megabytes i és el mateix mecanisme de l'uk_lloguers_usuari_en_curs de 04-08.

El cost d'indexar de més. Un índex no és gratis: ocupa espai, cada INSERT, UPDATE i DELETE l'ha d'actualitzar, i empitjora la feina del planificador. Una taula amb dotze índexs pot trigar el triple a escriure. Per saber quins sobren:

SELECT relname, indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes WHERE idx_scan = 0 ORDER BY pg_relation_size(indexrelid) DESC;

idx_scan = 0 després de setmanes en producció significa que aquest índex només costa. I per trobar les consultes cares, l'extensió pg_stat_statements ordenada per total_exec_time respon a la pregunta «quina consulta consumeix el 40 % del temps de base de dades».

  1. La base de dades (III): paginació, projeccions i lots

L'OFFSET gran. Pageable genera LIMIT 20 OFFSET 200000, i PostgreSQL ha de produir i descartar 200.000 files abans de retornar-ne 20. La pàgina 1 vola; la pàgina 10.000 triga segons. Per a llistats profunds —l'històric complet de lloguers, una exportació— s'utilitza paginació per keyset:

// En lloc de: findAll(PageRequest.of(pagina, 20))
@Query("""
       select l from Lloguer l
       where l.usuari.id = :usuariId
         and (l.inici < :ultimInici or (l.inici = :ultimInici and l.id < :ultimId))
       order by l.inici desc, l.id desc
       """)
List<Lloguer> seguentPagina(Long usuariId, Instant ultimInici, Long ultimId,
                            Limit limit);

El client envia l'última fila vista en lloc d'un número de pàgina. La consulta fa servir l'índex per posicionar-se en lloc de comptar, i el cost és constant a qualsevol profunditat. Els seus límits: no permet saltar a la pàgina 500 ni mostrar el total —i aquest count(*) de cada Page és, moltes vegades, més car que la consulta de dades: per això existeix Slice, que l'evita.

Projeccions davant d'entitats (04-06). Carregar l'entitat completa porta totes les columnes, crea objectes gestionats al context de persistència i obliga Hibernate a fer dirty checking sobre cadascun en confirmar:

Entitat Projecció
Columnes llegides Totes Només les declarades
Objectes gestionats Sí, amb cost de memòria i de comprovació No
Risc de LazyInitializationException Sí No
Quan fer-la servir Quan vas a modificar Quan només vas a mostrar

La regla de CicloUrbana: els endpoints de només lectura retornen projeccions. És simultàniament més ràpid i més segur.

readOnly i lots. @Transactional(readOnly = true) (04-07) fa que Hibernate faci servir FlushMode.MANUAL i no desi l'estat original per comparar: menys memòria i menys CPU a cada lectura. I per a les escriptures massives de l'importador nocturn:

spring:
  jpa:
    properties:
      hibernate:
        jdbc.batch_size: 50          # agrupa 50 sentencies per viatge
        order_inserts: true          # les agrupa per taula per poder fer-ne lots
        order_updates: true
        batch_versioned_data: true   # necessari amb @Version (04-03)

Inserir 10.000 files passa de 10.000 viatges de xarxa a 200. Requisit: el generador d'identificadors no pot ser IDENTITY, perquè obliga a executar cada INSERT per separat per conèixer la clau; per això CicloUrbana fa servir seqüències amb allocationSize = 50 des de 04-03.

  1. HikariCP: dimensionar el pool amb criteri

La intuïció diu «més connexions, més rendiment». És falsa, i el motiu és que una connexió ocupada és un nucli de CPU i un disc de la base de dades treballant. Quan hi ha més connexions actives que capacitat real, el servidor de base de dades dedica temps a canviar de context i a competir per bloqueigs: el rendiment baixa i la latència puja.

La fórmula que documenta el mateix HikariCP:

connexions = (nuclis_del_servidor_de_BD × 2) + discos_efectius

La instància de PostgreSQL de CicloUrbana té 4 nuclis i emmagatzematge SSD en xarxa (1 «disc» efectiu): 4 × 2 + 1 = 9, arrodonit a 10. Sí, deu connexions serveixen 112 peticions per segon: per la llei de Little, si una consulta dura 5 ms, cada connexió pot atendre 200 consultes per segon.

spring:
  datasource:
    hikari:
      maximum-pool-size: 10
      minimum-idle: 10             # fix: evita la latencia d'obrir sota carrega
      connection-timeout: 3000     # fallar rapid, no encuar indefinidament
      max-lifetime: 1800000        # per sota del timeout del costat servidor
      leak-detection-threshold: 20000
      pool-name: ciclourbana-pool

Quatre detalls amb conseqüències. minimum-idle igual que maximum-pool-size: un pool elàstic estalvia uns recursos irrellevants i afegeix la latència d'una handshake TCP més autenticació justament quan arriba el pic. connection-timeout: 3000: si no hi ha connexió en 3 segons es llança una excepció, mentre que un valor de 30 s converteix una degradació en una cua que acaba consumint els 200 fils de Tomcat —així és com un problema de base de dades enderroca l'aplicació sencera—. leak-detection-threshold avisa d'una connexió retinguda més de 20 s, símptoma clàssic d'una crida HTTP dins d'una transacció. I max-lifetime ha de ser menor que el temps de tancament del costat servidor, o el pool lliurarà connexions mortes.

Detectar l'espera és el que decideix si el pool està mal dimensionat. La mètrica hikaricp.connections.pending (09-03) mesura quants fils esperen i hikaricp.connections.acquire quant triguen. Si pending és persistentment més gran que zero hi ha dues causes possibles i només una s'arregla pujant el pool: si les consultes són lentes o les transaccions llarguíssimes, arregla això i no el pool; si la base de dades va sobrada i la concurrència és real, puja el pool amb moderació i torna a mesurar.

  1. La JVM: memòria, recol·lector i pauses

Memòria en contenidor (07-04). La JVM moderna detecta els límits del cgroup, però per defecte reserva només el 25 % de la memòria disponible per al heap. En un contenidor d'1 GB això són 256 MB, i la resta es malbarata:

ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:InitialRAMPercentage=50 \
    -XX:+ExitOnOutOfMemoryError -Xlog:gc*:file=/tmp/gc.log:time,uptime:filecount=5,filesize=10M"

El 75 % —no el 100 %— deixa lloc per al que no és heap: metaespai, piles de fils, memòries intermèdies directes i la mateixa JVM. Si el heap ocupa tot el límit del contenidor, el procés mor amb OOMKilled (codi 137) sense deixar ni una excepció, i el restartPolicy de Kubernetes el reinicia en bucle. +ExitOnOutOfMemoryError és preferible a arrossegar-se: una instància amb la memòria esgotada respon malament i continua rebent trànsit; morta, la sonda de 07-01 la treu del balancejador.

Triar recol·lector:

G1 (per defecte) ZGC (-XX:+UseZGC) Parallel
Objectiu Equilibri Latència mínima Rendiment màxim
Pauses típiques 10-200 ms < 1 ms, amb heaps enormes Centenars de ms
Heap recomanat 2-32 GB 8 GB - diversos TB < 4 GB
Cost en CPU i memòria Moderat ~10-15 % més d'ambdós Baix
Quan fer-lo servir CicloUrbana avui Si el p99 el marquen les pauses Lots nocturns, no APIs

El criteri real: no canviïs de recol·lector fins que no hagis comprovat, als logs de GC, que les pauses expliquen el teu p99. Amb 2 GB de heap i G1, les pauses de CicloUrbana són de 15-40 ms; el p95 d'1,19 s no ve d'aquí.

Llegir els logs de GC:

[12.481s][info][gc] GC(38) Pause Young (Normal) (G1 Evacuation Pause) 1204M->318M(2048M) 22.418ms
[41.902s][info][gc] GC(72) Pause Full (System.gc()) 1902M->1841M(2048M) 1842.771ms

Què cal mirar. La notació 1204M->318M(2048M) és abans → després (total): ha recuperat 886 MB, per tant és sa. En canvi 1902M->1841M ha recuperat 61 MB d'1,9 GB, és a dir, la memòria ja no baixa —fuita o heap insuficient—, que és exactament el símptoma que anticipava el tancament de 08-05. La Pause Full d'1,8 s atura tots els fils i una de sola n'hi ha prou per explicar un p99 desastrós; si es repeteixen, s'acosta un OutOfMemoryError. I la freqüència importa tant com la durada: pauses joves curtes cada pocs segons són el normal, però cada 200 ms significa que es genera brossa a mans plenes, gairebé sempre per carregar entitats que no calen —l'apartat 10 un altre cop—.

  1. Fils: Tomcat, el pool i els fils virtuals

server:
  tomcat:
    threads:
      max: 200            # fils que atenen peticions
      min-spare: 20
    accept-count: 100     # cua del sistema operatiu quan els 200 estan ocupats
    max-connections: 8192 # connexions acceptades simultaniament
    connection-timeout: 20s

La relació entre tots dos pools és la font d'un malentès car. Amb 200 fils de Tomcat i 10 connexions d'HikariCP, fins a 190 fils poden estar bloquejats esperant una connexió. Això no és una fallada de disseny: és un embut deliberat que protegeix la base de dades. El que sí que és una fallada és no acotar l'espera —d'aquí el connection-timeout: 3000 de l'apartat 11—.

I pujar threads.max a 800 gairebé mai no ajuda: si el coll és a les 10 connexions, els 600 fils nous només afegeixen 600 piles d'1 MB i més canvis de context. La llei de Little diu quants fils calen de debò: fils ≈ λ × W; amb 112 pet/s i 214 ms, uns 24.

Fils virtuals (07-03), a Java 21 i Spring Boot 3.2+:

spring.threads.virtual.enabled: true

Cada petició s'atén en un fil virtual, que en bloquejar-se en E/S allibera el fil de plataforma en lloc d'ocupar-lo. Per a una aplicació com CicloUrbana —bloquejant i amb molta espera de base de dades— és la manera més barata de sostenir alta concurrència sense pila reactiva. Amb dos advertiments que ja hem vist: synchronized al voltant d'una operació bloquejant fixa el fil virtual i anul·la l'avantatge, i els ThreadLocal —el MDC del FiltreRastreig, el SecurityContext— continuen funcionant, però ja no hi ha un pool acotat que limiti la concurrència: el límit passa a ser el pool de connexions, així que el seu connection-timeout es torna encara més important.

  1. La xarxa: compressió i mida de la resposta

El temps que el ciutadà percep inclou la transferència. Un llistat de 200 estacions en JSON són uns 180 KB; sobre una xarxa mòbil de Ribalta, mig segon per ell sol.

server.compression:
  enabled: true
  mime-types: application/json,application/problem+json,text/html,text/plain
  min-response-size: 1KB          # comprimir 200 bytes costa mes del que estalvia

Amb JSON, gzip redueix entre un 70 % i un 90 % perquè el format repeteix les claus a cada element: 180 KB passen a uns 20 KB. El cost és CPU del servidor; si hi ha un proxy o un ALB al davant (08-03), sovint és millor que comprimeixi ell.

Però la millor compressió és no enviar la dada. Aquí és on els DTOs de 03-05 deixen de ser una qüestió de disseny i es converteixen en rendiment: EstacioResponse publica sis camps en lloc d'abocar l'entitat amb les seves auditories, la seva versio i la seva col·lecció de bicicletes. Menys bytes serialitzats, menys CPU, menys xarxa i menys memòria. Tres regles: pagina sempre els llistats (mai un findAll() sense Pageable), no imbriquis col·leccions completes a la resposta —enllaça a un subrecurs—, i fes servir ETag (03-03) perquè el client que ja té la dada rebi un 304 Not Modified de dos-cents bytes.

  1. Perfilatge: async-profiler, JFR i bolcats

Quan la base de dades ja està neta i el temps continua anant-se'n a algun lloc, toca perfilar.

Eina Què dóna Cost Quan
async-profiler CPU, assignacions, bloqueigs, amb gràfic de flama ~1 % Investigació puntual, fins i tot en prod
JFR Gravació contínua d'esdeveniments de la JVM ~1 % Deixar-lo sempre actiu en prod
/actuator/threaddump Foto de tots els fils i el seu estat Nul Aplicació penjada o lenta ara
/actuator/heapdump Bolcat complet del heap Alt: atura la JVM Sospita de fuita; amb compte
# async-profiler: 30 segons de CPU en un grafic de flama
./profiler.sh -d 30 -e cpu -f /tmp/perfil.html $(pgrep -f ciclourbana.jar)

# JFR permanent, amb bolcat sota demanda
java -XX:StartFlightRecording=disk=true,maxsize=200M,settings=profile -jar app.jar
jcmd $(pgrep -f ciclourbana.jar) JFR.dump name=1 filename=/tmp/ciclourbana.jfr

Com es llegeix un gràfic de flama. L'eix horitzontal no és temps: és la proporció de mostres. Cada barra és un mètode i a sobre hi ha els mètodes als quals crida. Es llegeix de baix a dalt buscant altiplans amples: una barra ampla significa que aquest mètode era a la pila en moltes mostres. Si l'altiplà és al capdamunt, aquest mètode consumeix CPU per ell mateix; si és ample però amb torres a sobre, el cost és en el que crida. A CicloUrbana, un perfilatge típic mostra un altiplà ample sota PgConnection.execSQLQuery —esperant la base de dades, que és l'esperat— i, si alguna cosa va malament al codi, una torre inesperada sota ObjectMapper.writeValue o sota un equals d'entitats.

I l'ús més rendible de /actuator/threaddump: amb l'aplicació lenta, es descarrega i es compten els fils per estat. Cinquanta fils http-nio-8080-exec-* en WAITING sobre HikariPool.getConnection és un diagnòstic tancat, sense necessitat de perfilar res.

  1. Cas pràctic: l'endpoint d'estacions

Símptoma. GET /api/v1/estacions?page=0&size=20 té un p95 de 2,4 s a la prova de càrrega. La mediana és 210 ms.

Pas 1 — mesurar. El FiltreComptadorConsultes de l'apartat 8 escriu al log de pre:

WARN [7f3a91c2] GET /api/v1/estacions -> 43 consultes en 2380 ms

43 consultes per a 20 estacions: 1 + 20 + 20 + 2. El patró és inequívoc.

Pas 2 — localitzar. El codi:

@Transactional(readOnly = true)
public PaginaResponse<EstacioResponse> llistar(Pageable pageable) {
    return PaginaResponse.de(estacioRepositori.findAll(pageable)
            .map(e -> new EstacioResponse(
                    e.getId(), e.getNom(), e.getAdreca(),
                    e.getBicicletes().size(),                       // consulta mandrosa (20)
                    comptarDisponibles(e.getId()))));               // una altra consulta (20)
}

Dos N+1 encadenats. I comptarDisponibles executa select count(*) from bicicletes where estacio_id = ? and estat = 'DISPONIBLE', l'EXPLAIN ANALYZE de la qual revela un Seq Scan: idx_bicicletes_estat existeix, però per ell sol no filtra bé.

Pas 3 — corregir. Dos canvis, un per causa. Una projecció que fa comptar la base de dades en una sola consulta:

public interface EstacioRepositori extends JpaRepository<Estacio, Long> {

    @Query("""
           select e.id as id, e.nom as nom, e.adreca as adreca,
                  size(e.bicicletes) as total,
                  (select count(b) from Bicicleta b
                    where b.estacio = e and b.estat = 'DISPONIBLE') as disponibles
           from Estacio e where e.activa = true
           """)
    Page<EstacioResum> resumPaginat(Pageable pageable);
}

I l'índex compost que sosté aquesta subconsulta:

-- V13__index_bicicletes_estacio_estat.sql
CREATE INDEX CONCURRENTLY idx_bicicletes_estacio_estat
    ON bicicletes (estacio_id, estat);

Igualtat primer, i amb les dues columnes del filtre l'índex cobreix la consulta: PostgreSQL compta sense tocar la taula.

Pas 4 — tornar a mesurar. Mateixa prova de k6, mateixa màquina, mateixes dades:

Mètrica Abans Després Factor
Consultes per petició 43 1 43×
p50 210 ms 28 ms 7,5×
p95 2.410 ms 91 ms 26×
p99 3.980 ms 148 ms 27×
Rendiment (pet/s) 112 604 5,4×
Connexions en espera (pic) 37 0 —
Bytes per resposta 184 KB 21 KB 8,7× (projecció + gzip)

Quatre observacions valen més que els números. No s'hi ha afegit ni una memòria cau ni una màquina: s'ha tret feina innecessària. pending va baixar a zero sol, cosa que demostra que el pool no estava mal dimensionat sinó retingut per consultes de més —pujar-lo a 50 hauria amagat el problema i castigat la base de dades—. El p95 va millorar més que la mediana (26× davant de 7,5×), que és el típic quan la causa era espera per un recurs compartit. I el SLO de l'apartat 2 —p95 < 300 ms— es compleix, així que es para.

Aquest era el cas fàcil: hi havia feina malbaratada. Quan ja no n'hi ha i la consulta és mínima i està indexada, però es repeteix mil vegades per minut amb el mateix resultat, la palanca següent és diferent: no executar-la. Això és la memòria cau, i és la lliçó següent.

Errors Comuns i Consells

Optimitzar sense línia base i fer servir la mitjana com a objectiu. Sense un número anterior, «va més ràpid» és una impressió: desa el resultat de k6 amb la data i la versió al repositori. I la mitjana amaga exactament els usuaris pitjor tractats, així que el SLO s'escriu en p95 i p99.

Canviar diverses coses alhora. Si millores l'índex, puges el pool i actives la compressió al mateix desplegament, no sabràs quin ha valgut ni podràs revertir el que sobra.

Pujar el pool de connexions com a primera mesura. Gairebé sempre empitjora: trasllada la congestió de la teva aplicació a la base de dades, on és més difícil de veure i afecta tothom.

Mesurar al portàtil amb dades de joguina. Un Seq Scan sobre 40 files és instantani. Els problemes de rendiment són problemes de volum i de concurrència, i es reprodueixen a pre amb dades realistes.

Deixar org.hibernate.SQL: DEBUG o datasource-proxy en producció. Tots dos afegeixen cost per consulta i omplen el disc: van sota @Profile({"dev","test"}), i si cal veure SQL en producció es puja el nivell en calent amb /actuator/loggers (07-01) i es baixa després.

Confondre un Full GC amb «cal pujar la memòria». De vegades sí; moltes vegades és que es carreguen entitats que no calen. Mira primer l'apartat 10.

Consell: posa un límit de consultes per petició a les proves, amb un assertThat(comptador.getTotal()).isLessThanOrEqualTo(3) en un @DataJpaTest: és l'única manera que un N+1 no torni d'aquí a tres mesos. Executa a més la prova de càrrega a la canalització nocturna, perquè amb thresholds una regressió es detecta el dia que s'introdueix i no el dia que truca l'ajuntament. I EXPLAIN ANALYZE abans de crear un índex, amb una revisió de pg_stat_user_indexes cada trimestre per esborrar els que ningú no fa servir.

Exercicis

Exercici 1: diagnosticar a partir dels senyals

Després d'un desplegament, l'operació de CicloUrbana observa: p50 de POST /api/v1/lloguers = 45 ms (sense canvis), p99 = 4,2 s (abans 300 ms), CPU de l'aplicació al 20 %, CPU de la base de dades al 25 %, hikaricp.connections.pending amb pics de 60, cap Full GC, i el log del comptador de consultes diu «3 consultes en 4.100 ms». Formula la hipòtesi més probable, digues què comprovaries per confirmar-la i què no faries.

Exercici 2: índex i EXPLAIN

El quadre de comandament de l'ajuntament executa aquesta consulta cada minut i triga 1,8 s:

SELECT estacio_origen_id, count(*) FROM lloguers
WHERE inici >= now() - interval '1 hour' AND estat = 'EN_CURS'
GROUP BY estacio_origen_id;

EXPLAIN ANALYZE mostra Seq Scan on lloguers amb Rows Removed by Filter: 3198412. Proposa l'índex, justifica l'ordre de les columnes, decideix si ha de ser parcial i estima l'efecte.

Exercici 3: dimensionar amb la llei de Little

CicloUrbana ha de suportar 400 peticions per segon amb p95 < 250 ms. La latència mitjana mesurada és 180 ms, dels quals 120 ms són base de dades (dues consultes de 60 ms). El servidor de PostgreSQL té 8 nuclis i SSD. Calcula la concurrència necessària, dimensiona el pool d'HikariCP i els fils de Tomcat, i digues si el sistema pot complir l'objectiu o on es trencarà primer.

Solucions

Solució 1.

Hipòtesi: espera per connexió del pool, no lentitud de l'aplicació. Els senyals encaixen un a un: la mediana intacta indica que el camí ràpid no ha canviat; el p99 disparat amb CPU baixa a banda i banda descarta saturació de càlcul; pending amb pics de 60 diu literalment que 60 fils esperen connexió; «3 consultes en 4.100 ms» és la signatura exacta —poques consultes, molt de temps—, perquè el temps no se'n va executant SQL sinó esperant per poder-lo executar; i l'absència de Full GC descarta pauses de memòria.

Causa més probable: alguna cosa reté connexions més temps del degut. Els dos sospitosos habituals: una crida HTTP dins d'una transacció —molt probable aquí, perquè el desplegament va tocar el cobrament i la passarel·la de 07-06 pot trigar segons— o una transacció allargada per feina que no necessita la base de dades.

Com confirmar-ho: amb /actuator/threaddump durant un pic, buscant fils http-nio-* en WAITING sobre HikariPool.getConnection i —sobretot— fils que ja tenen connexió i estan bloquejats en un sòcol de la passarel·la; activant leak-detection-threshold: 20000, la traça de pila del qual assenyala el mètode culpable; mirant les mètriques de Resilience4j (07-06), perquè si la latència de la passarel·la va pujar alhora el diagnòstic està tancat; i observant hikaricp.connections.usage, que mesura quant es reté cada connexió.

Què NO faria: pujar maximum-pool-size. Amb la CPU de la base de dades al 25 % el problema no és manca de capacitat, i més connexions retingudes per crides remotes només allarguen l'agonia. Tampoc pujar els fils de Tomcat: crearia més fils per esperar el mateix. La correcció és treure la crida a la passarel·la fora de la transacció —confirmar primer i cobrar després, a l'AFTER_COMMIT de 07-03— i verificar que el connection-timeout és de segons, no de trenta.

Solució 2.

CREATE INDEX CONCURRENTLY idx_lloguers_inici_estat
    ON lloguers (estat, inici, estacio_origen_id);

Ordre de les columnes: estat primer perquè és el predicat d'igualtat; inici després perquè és de rang —una columna de rang a l'índex «consumeix» la resta per a futures igualtats, així que va darrere de totes les igualtats—; i estacio_origen_id al final perquè l'índex cobreixi la consulta: PostgreSQL pot agrupar llegint només l'índex (Index Only Scan) sense visitar la taula.

Parcial? Sí, i és la millor decisió de l'exercici:

CREATE INDEX CONCURRENTLY idx_lloguers_en_curs_inici
    ON lloguers (inici, estacio_origen_id) WHERE estat = 'EN_CURS';

Els lloguers EN_CURS són uns centenars entre 3,2 milions. L'índex parcial ocupa kilobytes en lloc de ~150 MB, es manté gairebé de franc a cada escriptura i, com que totes les files indexades ja compleixen el filtre d'estat, el planificador només ha de recórrer el rang de l'última hora.

Efecte estimat: de Seq Scan sobre 3,2 milions de files (~1,8 s) a recórrer uns centenars d'entrades contigües de l'índex: per sota de 5 ms, dos o tres ordres de magnitud. Verificació obligatòria: repetir EXPLAIN (ANALYZE, BUFFERS) i comprovar que apareix Index Scan/Index Only Scan, que Rows Removed by Filter desapareix i que Buffers: read cau a unes desenes. I CONCURRENTLY perquè la taula és en producció: sense això, el CREATE INDEX bloqueja les escriptures durant tota la construcció (04-08).

Solució 3.

Concurrència necessària (Little): L = λ × W = 400 × 0,180 = 72 peticions simultànies dins del sistema.

Pool de connexions: la fórmula dóna 8 × 2 + 1 = 17, arrodonim a 20. Aguanta? Cada petició ocupa una connexió 120 ms; una connexió serveix 1 / 0,120 = 8,3 peticions per segon; 20 connexions donen 20 × 8,3 = 166 pet/s. No arriba a 400: aquí es trenca primer.

Què fer, en ordre. Primer, reduir el temps de base de dades per petició, que és la palanca correcta: dues consultes de 60 ms per iniciar un lloguer és moltíssim i amb els índexs adequats baixen a 5 ms, de manera que amb W_bd = 10 ms una connexió serveix 100 pet/s i 20 connexions donen 2.000 pet/s, cinc vegades l'objectiu. Només si després d'optimitzar continua faltant capacitat, escalar horitzontalment (08-04): tres instàncies amb 20 connexions cadascuna són 60 connexions contra la base de dades, a prop del límit raonable per a 8 nuclis, i el pas següent serien rèpliques de lectura. El que seria un error és pujar el pool a 80: 80 connexions actives sobre 8 nuclis multipliquen els canvis de context i la competència per bloqueigs, el rendiment agregat baixa i el p95 puja.

Fils de Tomcat: amb L = 72, els 200 per defecte sobren de llarg; threads.max: 200 i accept-count: 100 són correctes. Amb fils virtuals activats, el límit efectiu passa a ser el pool de connexions, cosa que reforça la conclusió: l'objectiu es compleix optimitzant les consultes, no ampliant pools.

Latència resultant: amb les consultes a 10 ms, W ≈ 70 ms i L = 400 × 0,07 = 28. Molt per sota del SLO de 250 ms en p95, amb marge per a la variabilitat.

Conclusió

CicloUrbana ja no s'ajusta per intuïció. Tens un mètode que no s'estalvia passos —definir l'objectiu, mesurar la línia base, localitzar el coll, corregir una cosa, tornar a mesurar— i saps per què la mitjana menteix i per què el SLO s'escriu en p95 i p99, amb els 20 ciutadans de cada 1.000 que la mitjana amagava. Saps muntar una prova de càrrega amb k6 que reprodueix l'hora punta de Ribalta, amb rampa, altiplà i thresholds que posen en vermell la canalització de 08-05, i llegir-ne la sortida en l'ordre correcte: llindars, errors, percentils davant de mitjana, operació concreta i saturació. La llei de Little et diu quanta concurrència hi ha realment dins del sistema i, amb ella, si un pool està ben dimensionat; la d'Amdahl et diu que optimitzar la serialització un 900 % millora el total un 5 %, i per això el recorregut està ordenat.

Aquest recorregut l'has fet sencer. La base de dades primer: detectar l'N+1 de 04-04 amb generate_statistics i amb un comptador de consultes per petició que avisa sol, i corregir-lo amb @EntityGraph, JOIN FETCH o —millor— una projecció; llegir un EXPLAIN ANALYZE distingint Seq Scan d'Index Scan i sabent què signifiquen Rows Removed by Filter i Buffers; dissenyar índexs compostos amb les igualtats al davant i l'ordre al darrere, índexs parcials que ocupen kilobytes, i esborrar els que ningú no fa servir. Després la paginació per keyset davant de l'OFFSET gran, les projeccions davant de les entitats, readOnly i els lots d'Hibernate. Després HikariCP, amb la fórmula i l'explicació de per què un pool gran empitjora les coses, i pending com el senyal que distingeix «falta pool» de «sobren consultes». I finalment la JVM —MaxRAMPercentage, G1 davant de ZGC, llegir -Xlog:gc i reconèixer la memòria que puja i no baixa—, els fils de Tomcat i la seva relació amb el pool, els fils virtuals de 07-03 com a alternativa, la compressió i el perfilatge amb async-profiler, JFR i els bolcats de 07-01.

El cas pràctic resumeix la lliçó sencera: un endpoint amb un p95 de 2,4 s, 43 consultes per petició i un Seq Scan, que després de treure feina —una projecció i un índex— respon en 91 ms i multiplica per cinc el rendiment, sense una màquina més i sense memòria cau. Aquest ordre importa: primer s'elimina la feina innecessària. Però quan la feina ja és mínima i tot i així es repeteix mil vegades per minut per retornar sempre el mateix —el catàleg d'estacions de Ribalta, que canvia una vegada al mes i es consulta cent vegades per segon—, queda una palanca diferent: no executar-la. La lliçó següent, Memòria cau amb Spring Cache, la desenvolupa sencera: quines dades mereixen memòria cau i quines no, @Cacheable i els seus paranys, Caffeine i Redis, i la part de debò difícil, que és invalidar.

Curs de Spring Boot

Mòdul 1: Introducció a Spring Boot

Mòdul 2: Conceptes bàsics de Spring Boot

Mòdul 3: Construint serveis web RESTful

Mòdul 4: Accés a dades amb Spring Boot

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

Mòdul 7: Funcions avançades de Spring Boot

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats