Aquesta és la lliçó que anunciem des del mòdul 5. Quan a 05-04 discutíem si desnormalitzar l'esquema de BiblioRed, la regla operativa era taxativa: els índexs són el primer que cal provar abans de desnormalitzar, i no es pot decidir una desnormalització sense haver llegit abans un pla d'execució. Aquí aprenem a fer les dues coses.

El problema concret ja té nom i té número. El llistat de préstecs vençuts per sucursal —el que el personal de taulell obre cada matí per trucar als socis que es retarden— triga catorze segons. Al portàtil de desenvolupament, amb 3.000 préstecs de prova, trigava 30 mil·lisegons. En producció, amb 2.841.077 files a prestecs, triga catorze segons i la persona que atén té un soci al davant mirant-la.

Catorze segons és un número interessant perquè és al pitjor lloc possible: és massa per treballar i massa poc perquè algú ho declari una avaria. Senzillament, "el sistema va lent". I d'aquella frase surten tots els redissenys innecessaris del món.

En aquesta lliçó veurem què és un índex i com aconsegueix convertir milions de comparacions en quatre lectures de disc; què costa un índex, perquè no són gratis; els tipus que existeixen i quan usar cadascun; què s'indexa i què no; com es llegeix un pla d'execució línia a línia; els antipatrons que anul·len un índex sense que ningú se n'adoni; i el cas complet d'aquells catorze segons, amb el seu diagnòstic, la seva solució i la seva mesura. Al final tindrem també l'ordre d'intervenció correcte davant d'una consulta lenta, que és el que tanca el cercle obert a 05-04.

Contingut

  1. Què és un índex: dues analogies que serveixen
  2. Com funciona un B-tree
  3. Els números: quantes lectures estalvia de debò
  4. El cost d'un índex: espai, escriptures i planificació
  5. CREATE INDEX: sintaxi, índexs únics i CONCURRENTLY
  6. Índexs compostos i la regla del prefix més a l'esquerra
  7. Índexs parcials
  8. Índexs d'expressió
  9. Panorama de tipus d'índex a PostgreSQL
  10. Què s'indexa i què no
  11. Llegir un pla d'execució: EXPLAIN i EXPLAIN ANALYZE
  12. Els nodes que apareixen una vegada i una altra
  13. Cost, estimacions i el senyal d'alarma
  14. Les estadístiques i ANALYZE
  15. Cas complet: els catorze segons del llistat de vençuts
  16. Antipatrons que anul·len un índex
  17. Com s'optimitza de debò: l'ordre d'intervenció
  18. Manteniment i índexs fora de PostgreSQL

  1. Què és un índex: dues analogies que serveixen

Definició. Un índex és una estructura de dades auxiliar, mantinguda automàticament pel gestor, que permet localitzar les files que compleixen una condició sense recórrer la taula sencera.

Dues analogies, i totes dues són d'una biblioteca, cosa que a BiblioRed ve molt a tomb.

La primera: l'índex d'un llibre. Per saber en quines pàgines es parla de "normalització" en un manual de 900 pàgines, no llegeixes les 900. Vas a l'índex analític del final, busques la paraula —que està ordenada alfabèticament, així que la trobes en segons— i llegeixes "normalització: 412, 418-431, 507". Tres dades que et porten directament al que busques.

Els tres elements d'aquella analogia són exactament els d'un índex de base de dades:

Al llibre A la base de dades
La paraula buscada La clau de l'índex (el valor de la columna indexada)
El número de pàgina El punter a la fila (a PostgreSQL, el ctid: pàgina i posició)
Que l'índex estigui ordenat L'estructura ordenada que permet la cerca ràpida
Que l'índex ocupi 30 pàgines més L'espai en disc que costa l'índex
Que calgui refer-lo si es reedita el llibre El cost de manteniment a cada escriptura

La segona: la mateixa biblioteca. Els 40.000 exemplars de BiblioRed estan col·locats a les prestatgeries en un ordre concret —per matèria i signatura—. Aquell ordre és un índex: permet trobar un llibre d'història sense recórrer les quatre sucursals. Però fixa't que només hi ha un ordre físic possible: els llibres no poden estar simultàniament ordenats per matèria, per autor i per any.

Per això les biblioteques tenen, a més, un catàleg: fitxes ordenades per autor, altres per títol, altres per matèria. Cada catàleg és un índex addicional que no canvia on és el llibre, només afegeix una manera més de trobar-lo. I cadascun s'ha d'actualitzar quan entra un exemplar nou.

Aquesta és exactament la situació d'una taula: les dades estan en un ordre (el que va imposar l'INSERT), i cada índex és un catàleg addicional que ofereix una altra manera d'arribar-hi, amb el seu cost d'espai i de manteniment.

  1. Com funciona un B-tree

El 95 % dels índexs que crearàs són B-tree (arbre B, en la seva variant B+). És el tipus per omissió a PostgreSQL i a pràcticament tots els gestors. Entendre com funciona explica gairebé tot el seu comportament.

Un B-tree és un arbre equilibrat en què:

  • Cada node conté claus ordenades i punters.
  • Els nodes interns només serveixen per dirigir la cerca: diuen "si busques alguna cosa menor que X, ves per aquí".
  • Els nodes fulla contenen les claus i els punters a les files reals.
  • Les fulles estan encadenades entre si, cosa que permet recorreguts per rang sense tornar a pujar.
  • L'arbre està equilibrat: totes les fulles són a la mateixa profunditat, així que tota cerca costa el mateix.

Un índex sobre prestecs(data_devolucio_prevista), dibuixat a escala reduïda:

graph TD
    R["<b>Arrel</b><br/>2023-04-01 | 2025-02-01"]
    I1["<b>Intern</b><br/>2021-06-01 | 2022-09-01"]
    I2["<b>Intern</b><br/>2023-11-01 | 2024-07-01"]
    I3["<b>Intern</b><br/>2025-09-01 | 2026-05-01"]

    H1["Fulla<br/>...2021-05-30 → ctid"]
    H2["Fulla<br/>2022-09-01...2023-03-31 → ctid"]
    H3["Fulla<br/>2023-11-01...2024-06-30 → ctid"]
    H4["Fulla<br/>2024-07-01...2025-01-31 → ctid"]
    H5["Fulla<br/>2025-09-01...2026-04-30 → ctid"]
    H6["Fulla<br/>2026-05-01... → ctid"]

    R --> I1
    R --> I2
    R --> I3
    I1 --> H1
    I1 --> H2
    I2 --> H3
    I2 --> H4
    I3 --> H5
    I3 --> H6
    H1 -.-> H2
    H2 -.-> H3
    H3 -.-> H4
    H4 -.-> H5
    H5 -.-> H6

Per buscar data_devolucio_prevista = '2024-03-15':

  1. A l'arrel: 2024-03-15 és entre 2023-04-01 i 2025-02-01 → baixa pel node intern del centre.
  2. A l'intern: és entre 2023-11-01 i 2024-07-01 → baixa per la fulla corresponent.
  3. A la fulla: busca la clau exacta i n'obté el ctid de la fila.
  4. Llegeix aquella pàgina de la taula.

Quatre accessos. Sobre 2,8 milions de files.

Per què és logarítmic

Cada node d'un B-tree ocupa una pàgina de disc —8 KB a PostgreSQL— i en aquella pàgina hi caben moltes claus. Amb una data (8 bytes) més un punter (6 bytes), en 8 KB hi caben de l'ordre de 500 entrades per node. Aquell número s'anomena factor de ramificació.

Nivell Nodes Files adreçables (factor 500)
0 (arrel) 1 500
1 500 250.000
2 250.000 125.000.000
3 125.000.000 62.500.000.000

Amb tres nivells s'adrecen 125 milions de files. Els 2,8 milions de prestecs caben còmodament en tres nivells, i a la pràctica els nivells superiors són permanentment a la memòria cau (el gestor de buffers de 01-04), així que la cerca real costa una o dues lectures de disc, no quatre.

La fórmula: el nombre de nivells creix amb el logaritme en base 500 del nombre de files. Multiplicar les dades per 500 afegeix un sol nivell. Aquesta és la raó per la qual un índex continua sent ràpid quan la taula creix: no escala amb el nombre de files, escala amb el seu logaritme.

  1. Els números: quantes lectures estalvia de debò

Posem xifres a prestecs, amb 2.841.077 files i uns 120 bytes per fila.

SELECT pg_size_pretty(pg_relation_size('prestecs')) AS taula,
       (pg_relation_size('prestecs') / 8192) AS pagines;
 taula  | pagines
--------+---------
 412 MB |   52736
Estratègia Pàgines llegides Temps aproximat
Recorregut seqüencial (Seq Scan) 52.736 Segons
Cerca per índex B-tree 3-4 Menys d'un mil·lisegon

La diferència no és d'un 20 %: és de quatre ordres de magnitud. Aquesta és la raó per la qual la primera reacció davant d'una consulta lenta ha de ser mirar si hi ha un índex, i no redissenyar l'esquema.

Però hi ha una lletra petita fonamental, i explica moltes decisions aparentment estranyes del planificador:

Un índex només compensa si la consulta torna una fracció petita de la taula. Si tornarà el 40 % de les files, és més ràpid recórrer la taula sencera seqüencialment que fer un milió de salts aleatoris.

La raó és física: llegir 52.736 pàgines seguides aprofita la lectura anticipada del sistema i del disc; llegir 400.000 pàgines saltejades no aprofita res. El punt d'equilibri a PostgreSQL sol estar entre el 5 % i el 10 % de la taula, i el decideix el planificador amb les estadístiques de l'apartat 14.

Per això veuràs plans amb Seq Scan sobre taules indexades i pensaràs que l'índex "no s'usa". Sovint és que el planificador ha calculat, amb raó, que no val la pena.

  1. El cost d'un índex: espai, escriptures i planificació

Aquí hi ha el motiu pel qual la resposta a "indexo aquesta columna?" no és sempre que sí.

Espai

SELECT indexrelname AS index,
       pg_size_pretty(pg_relation_size(indexrelid)) AS mida
FROM pg_stat_user_indexes
WHERE relname = 'prestecs'
ORDER BY pg_relation_size(indexrelid) DESC;
             index               |  mida
---------------------------------+--------
 prestecs_pkey                   | 61 MB
 idx_prestecs_soci               | 61 MB
 idx_prestecs_exemplar           | 61 MB
 idx_prestecs_data_prestec       | 61 MB

Quatre índexs sobre una taula de 412 MB sumen 244 MB: més de la meitat de la mida de les dades. En una base de dades amb molts índexs és habitual que els índexs ocupin més que les taules. Això no només costa disc: costa memòria cau, que és un recurs molt més escàs, i cada índex hi competeix amb les dades.

Escriptures

Aquest és el cost que de debò importa.

Operació Feina sense índexs Feina amb 4 índexs
INSERT Escriure 1 fila Escriure 1 fila + inserir en 4 arbres
UPDATE d'una columna no indexada Escriure la versió nova Igual, si cap a la mateixa pàgina (optimització HOT)
UPDATE d'una columna indexada Escriure la versió nova + actualitzar els índexs afectats
DELETE Marcar la fila + marcar entrades en 4 arbres

Una mesura típica sobre una càrrega de 100.000 préstecs:

-- Amb la taula sense índexs addicionals
INSERT INTO prestecs_carrega SELECT * FROM prestecs LIMIT 100000;
INSERT 0 100000
Time: 1842.331 ms
-- Amb quatre índexs creats
INSERT INTO prestecs_carrega SELECT * FROM prestecs LIMIT 100000;
INSERT 0 100000
Time: 6104.882 ms

Més del triple. I prestecs és una taula que s'escriu constantment durant l'horari de taulell.

D'aquí surt la pràctica estàndard de les càrregues massives: eliminar els índexs, carregar, recrear-los. Recrear un índex de zero sobre dades ja presents és molt més ràpid que mantenir-lo fila a fila.

Planificació

Cada índex addicional és una alternativa més que el planificador ha d'avaluar. Amb dos o tres índexs és imperceptible; amb quinze a la mateixa taula, el temps de planificació comença a notar-se en consultes que s'executen milers de vegades per minut.

La regla

Un índex no és gratis. Es crea per a una consulta concreta que s'executa amb prou freqüència per justificar el seu cost d'escriptura, i s'elimina quan aquella consulta deixa d'existir.

I per saber si sobra, PostgreSQL en porta el compte:

SELECT indexrelname AS index, idx_scan AS vegades_usat,
       pg_size_pretty(pg_relation_size(indexrelid)) AS mida
FROM pg_stat_user_indexes
WHERE relname = 'prestecs'
ORDER BY idx_scan;
             index               | vegades_usat |  mida
---------------------------------+--------------+--------
 idx_prestecs_data_prestec       |            0 | 61 MB
 idx_prestecs_exemplar           |       118402 | 61 MB
 idx_prestecs_soci               |      2044991 | 61 MB
 prestecs_pkey                   |      8811207 | 61 MB

Aquell idx_prestecs_data_prestec amb idx_scan = 0 és un índex que només costa: 61 MB de disc, un arbre per mantenir a cada INSERT, i zero benefici. Candidat clar a DROP INDEX, prèvia comprovació que les estadístiques cobreixen un període representatiu (no l'eliminis l'endemà de reiniciar el servidor, ni sense mirar si l'usa l'informe anual).

  1. CREATE INDEX: sintaxi, índexs únics i CONCURRENTLY

La forma bàsica:

CREATE INDEX idx_prestecs_soci ON prestecs (soci_id);
CREATE INDEX

Convenció de noms: idx_<taula>_<columnes>. No és obligatòria —PostgreSQL genera un nom si no l'hi dónes— però un índex sense nom recognoscible és un índex que ningú no gosarà esborrar d'aquí a tres anys.

Índexs únics

CREATE UNIQUE INDEX idx_socis_email ON socis (lower(email));

Aquí convé aclarir una relació que confon molta gent, i que enllaça amb les restriccions de 04-04:

Tota restricció UNIQUE i tota PRIMARY KEY s'implementen internament amb un índex únic. En declarar la restricció, l'índex es crea sol.

Restricció UNIQUE Índex únic
Com es declara ALTER TABLE ... ADD CONSTRAINT ... UNIQUE (col) CREATE UNIQUE INDEX ... ON t (col)
Crea un índex? Sí, automàticament És l'índex
La pot referenciar una clau forana? No
Admet expressions? No (lower(email))
Admet condició WHERE? No (índex parcial)
Visibilitat Apareix com a restricció de l'esquema Apareix com a índex

La recomanació: usa la restricció UNIQUE quan expressis una regla de negoci —queda documentada a l'esquema, i altres taules la poden referenciar— i l'índex únic només quan necessitis el que la restricció no pot donar: expressions o condicions parcials.

CONCURRENTLY

Crear un índex sobre una taula en producció té un problema seriós:

CREATE INDEX idx_prestecs_data ON prestecs (data_devolucio_prevista);

Aquesta instrucció pren un bloqueig SHARE sobre prestecs durant tota la seva execució. Amb 2,8 milions de files pot trigar un minut, i durant aquell minut ningú no pot escriure a la taula: els quatre taulells es queden parats. És el mecanisme de bloqueig de la lliçó 06-02 en acció.

L'alternativa:

CREATE INDEX CONCURRENTLY idx_prestecs_data ON prestecs (data_devolucio_prevista);
CREATE INDEX
Time: 94312.775 ms

Triga força més —fa dues passades sobre la taula— però no bloqueja les escriptures. Les seves condicions:

  • No es pot executar dins d'una transacció explícita (recorda les excepcions al DDL transaccional de 06-01).
  • Si falla a la meitat, deixa un índex invàlid que cal eliminar i refer. Detectar-los:
SELECT indexrelid::regclass AS index
FROM pg_index WHERE NOT indisvalid;
        index
-----------------------
 idx_prestecs_data

En producció, sempre CONCURRENTLY. En un entorn de desenvolupament o en una finestra de manteniment, la forma normal és més ràpida.

  1. Índexs compostos i la regla del prefix més a l'esquerra

Un índex pot abastar diverses columnes:

CREATE INDEX idx_prestecs_soci_data ON prestecs (soci_id, data_prestec);

I aquí apareix la regla que més confusió genera de tota la lliçó.

Regla del prefix més a l'esquerra. Un índex compost sobre (A, B, C) es pot usar per a consultes que filtrin per A, per A i B, o per A, B i C. No es pot usar eficaçment per a consultes que filtrin només per B, només per C, o per B i C.

Per què

Perquè l'índex està ordenat primer per A, i dins de cada valor d'A, per B. És exactament l'ordre d'una guia telefònica ordenada per cognom i després per nom:

  • Buscar "Alsina, Marta" és immediat.
  • Buscar tots els "Alsina" és immediat.
  • Buscar totes les "Marta" de la guia obliga a recórrer-la sencera, perquè les Martes estan disperses per totes les pàgines.

Demostració

-- Usa l'índex: filtra pel prefix (soci_id)
EXPLAIN (COSTS OFF)
SELECT * FROM prestecs WHERE soci_id = 14;
                        QUERY PLAN
-----------------------------------------------------------
 Index Scan using idx_prestecs_soci_data on prestecs
   Index Cond: (soci_id = 14)
-- Usa l'índex: prefix complet
EXPLAIN (COSTS OFF)
SELECT * FROM prestecs WHERE soci_id = 14 AND data_prestec = '2026-07-20';
                            QUERY PLAN
-------------------------------------------------------------------
 Index Scan using idx_prestecs_soci_data on prestecs
   Index Cond: ((soci_id = 14) AND (data_prestec = '2026-07-20'))
-- NO usa l'índex com s'espera: falta la primera columna
EXPLAIN (COSTS OFF)
SELECT * FROM prestecs WHERE data_prestec = '2026-07-20';
                    QUERY PLAN
---------------------------------------------------
 Seq Scan on prestecs
   Filter: (data_prestec = '2026-07-20')

Un matís honest: PostgreSQL pot usar un índex compost sense el prefix mitjançant un recorregut complet de l'índex (index scan sense condició d'inici), si l'índex és molt més petit que la taula. Però és un recurs desesperat i molt més lent que un índex adequat. La regla pràctica continua sent vàlida.

Com ordenar les columnes

Criteri Regla
Igualtat abans que rang Les columnes amb = van primer; la de <, > o BETWEEN, al final
Selectivitat A igualtat de l'anterior, primer la més selectiva (la que més files descarta)
Compartir prefix Si dues consultes comparteixen prefix, un sol índex serveix per a totes dues

Exemple aplicat. Aquestes dues consultes de BiblioRed:

SELECT * FROM prestecs WHERE soci_id = 14;
SELECT * FROM prestecs WHERE soci_id = 14 AND data_prestec >= '2026-01-01';

Un sol índex (soci_id, data_prestec) serveix per a totes dues: la igualtat primer, el rang després. Crear a més un índex sobre (soci_id) seria redundant i només costaria.

Índexs amb columnes incloses

PostgreSQL permet afegir columnes a l'índex que no participen en la cerca però sí en el resultat:

CREATE INDEX idx_prestecs_soci_inc
    ON prestecs (soci_id) INCLUDE (data_prestec, data_devolucio_prevista);

Serveix per aconseguir un Index Only Scan (apartat 12): si totes les columnes que la consulta necessita són a l'índex, no cal llegir la taula. És una optimització notable en consultes molt repetides.

  1. Índexs parcials

Índex parcial. Índex que només inclou les files que compleixen una condició WHERE. És més petit, més ràpid i més barat de mantenir.

És probablement la característica de PostgreSQL més infrautilitzada, i és exactament el que BiblioRed necessita.

Observa els números:

SELECT count(*) AS total,
       count(*) FILTER (WHERE data_devolucio IS NULL) AS oberts
FROM prestecs;
  total  |  oberts
---------+----------
 2841077 |    42017

De 2,8 milions de préstecs, només 42.017 estan oberts: l'1,5 %. I totes les consultes del taulell —préstecs vigents d'un soci, vençuts per sucursal, avisos de devolució— filtren per data_devolucio IS NULL. Els altres 2,8 milions de files són història que ningú no consulta en el dia a dia.

CREATE INDEX idx_prestecs_oberts
    ON prestecs (data_devolucio_prevista)
    WHERE data_devolucio IS NULL;

Comparació de mides:

SELECT indexrelname AS index, pg_size_pretty(pg_relation_size(indexrelid)) AS mida
FROM pg_stat_user_indexes WHERE relname = 'prestecs'
  AND indexrelname IN ('idx_prestecs_data','idx_prestecs_oberts');
         index           |  mida
-------------------------+--------
 idx_prestecs_data       | 61 MB
 idx_prestecs_oberts     | 992 kB

61 MB enfront de menys d'1 MB. Un índex 60 vegades menor que:

  • cap sencer a la memòria cau i es consulta pràcticament sense tocar el disc;
  • només s'actualitza quan es crea un préstec o es retorna, no a cada modificació de files històriques;
  • i respon a la consulta calenta igual de bé.

Condició perquè s'usi

PostgreSQL només usarà l'índex parcial si pot demostrar que la consulta implica la seva condició. La condició de l'índex ha d'aparèixer al WHERE de manera recognoscible:

-- SÍ que l'usa
SELECT * FROM prestecs
WHERE data_devolucio IS NULL AND data_devolucio_prevista < CURRENT_DATE;

-- NO l'usa: el planificador no pot saber que aquestes files són les mateixes
SELECT * FROM prestecs
WHERE data_devolucio_prevista < CURRENT_DATE;

Altres índexs parcials útils a BiblioRed

-- Només socis actius: el 91 % de les consultes del taulell
CREATE INDEX idx_socis_actius ON socis (cognoms, nom) WHERE actiu;

-- Només multes pendents de cobrament: unes 300 de 48.000
CREATE INDEX idx_multes_pendents ON multes (soci_id) WHERE estat = 'pendent';

-- Només esdeveniments publicats i futurs
CREATE INDEX idx_esdeveniments_publicats ON esdeveniments (inici) WHERE publicat AND estat = 'programat';

Regla mental: si una consulta freqüent porta sempre el mateix filtre sobre un estat, una bandera o un IS NULL, aquell filtre hauria de ser a la definició de l'índex, no només a la consulta.

  1. Índexs d'expressió

Un índex normal sobre titol no serveix per buscar LOWER(titol), perquè l'índex desa els títols tal com estan escrits i la consulta pregunta per una altra cosa. La solució és indexar l'expressió:

CREATE INDEX idx_materials_titol_lower ON materials (lower(titol));

I la consulta ha d'usar exactament la mateixa expressió:

EXPLAIN (COSTS OFF)
SELECT material_id, titol FROM materials WHERE lower(titol) = 'el mapa del temps';
                            QUERY PLAN
------------------------------------------------------------------
 Index Scan using idx_materials_titol_lower on materials
   Index Cond: (lower(titol) = 'el mapa del temps'::text)

Altres casos habituals:

-- Cerca de socis per any d'alta
CREATE INDEX idx_socis_any_alta ON socis (extract(year FROM data_alta));

-- Correu normalitzat i únic, insensible a majúscules
CREATE UNIQUE INDEX idx_socis_email_unic ON socis (lower(email));

-- Durada d'un esdeveniment, si es consulta sovint
CREATE INDEX idx_esdeveniments_duracio ON esdeveniments ((fi - inici));

Compte amb els parèntesis dobles de l'últim exemple: quan l'expressió no és una crida a funció, cal envoltar-la en parèntesis propis.

I un advertiment: la funció ha de ser immutable (IMMUTABLE), és a dir, tornar sempre el mateix per a la mateixa entrada. Per això no es pot indexar now() ni una funció que depengui de la configuració regional de la sessió:

CREATE INDEX idx_dolent ON prestecs ((data_prestec::text));
ERROR:  functions in index expression must be marked IMMUTABLE

A l'apartat 16 veurem la cara fosca d'això: una funció aplicada al WHERE sobre una columna indexada anul·la l'índex, i és un dels antipatrons més freqüents.

  1. Panorama de tipus d'índex a PostgreSQL

B-tree resol gairebé tot, però convé saber què hi ha disponible i per a què:

Tipus Operadors que accelera Casos d'ús típics A BiblioRed
B-tree (per omissió) =, <, <=, >, >=, BETWEEN, IN, LIKE 'text%', ORDER BY Gairebé tot Tots els que hem creat
Hash Només = Igualtat exacta sobre valors llargs Poques vegades; B-tree fa el mateix i més
GIN @>, ?, @@ jsonb, arrays, cerca de text complet Cercador del catàleg per paraules del títol i el resum
GiST &&, <@, <->, solapaments Rangs, geometries, veïns més propers L'EXCLUDE USING gist que evita solapaments d'esdeveniments en una sala (04-04)
SP-GiST Particions no equilibrades Dades jeràrquiques, adreces IP, text amb prefixos No aplica
BRIN Rangs sobre dades físicament ordenades Taules enormes amb correlació entre ordre físic i valor prestecs(data_prestec): les files s'insereixen en ordre cronològic

GIN per al cercador del catàleg

El catàleg web de BiblioRed permet buscar per paraules del títol i del resum. Amb LIKE '%paraula%' no hi ha índex que valgui (apartat 16). Amb cerca de text complet, sí:

ALTER TABLE materials
    ADD COLUMN cerca tsvector
    GENERATED ALWAYS AS (
        to_tsvector('catalan', coalesce(titol,'') || ' ' || coalesce(resum,''))
    ) STORED;

CREATE INDEX idx_materials_cerca ON materials USING gin (cerca);
SELECT material_id, titol
FROM materials
WHERE cerca @@ websearch_to_tsquery('catalan', 'mapa temps');
 material_id |        titol
-------------+-----------------------
         907 | El mapa del temps
        1482 | El temps dels mapes

La columna generada manté el vector actualitzat sense activadors —és la tècnica de 04-04— i l'índex GIN el fa consultable en mil·lisegons.

BRIN per a taules enormes ordenades per data

Un índex BRIN no desa una entrada per fila, sinó un resum per bloc de pàgines: el valor mínim i el màxim de cada rang. És diminut, i funciona bé quan l'ordre físic de les files es correspon amb el valor de la columna, que és just el que passa amb una taula d'historial on les files s'insereixen cronològicament.

CREATE INDEX idx_prestecs_data_brin ON prestecs USING brin (data_prestec);
SELECT indexrelname, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes WHERE indexrelname LIKE 'idx_prestecs_data%';
      indexrelname          | pg_size_pretty
----------------------------+----------------
 idx_prestecs_data          | 61 MB
 idx_prestecs_data_brin     | 48 kB

61 MB enfront de 48 kB. A canvi, és menys precís: descarta blocs sencers, no files, així que després cal filtrar. Per a l'informe anual de préstecs per trimestre és perfecte; per buscar un préstec concret, no.

Regla: si no saps quin triar, és B-tree. Els altres tipus responen a necessitats molt concretes i es reconeixen perquè B-tree no pot accelerar l'operador que necessites.

  1. Què s'indexa i què no

S'indexa

Cas Per què
Claus primàries Ja està fet automàticament
Claus foranes PostgreSQL NO les indexa sol. Vegeu a sota
Columnes usades a WHERE amb freqüència És el cas canònic
Columnes usades a JOIN Cada JOIN és una cerca repetida
Columnes usades a ORDER BY sobre moltes files Un índex evita l'ordenació
Columnes amb alta cardinalitat Molts valors diferents = alta selectivitat

El parany de les claus foranes

Això sorprèn gairebé tothom, i és una de les causes més freqüents de lentitud inexplicable:

PostgreSQL crea un índex automàticament per a la PRIMARY KEY i per a les restriccions UNIQUE, però NO per a les claus foranes. L'índex és al costat referenciat (la clau primària del pare), no al costat que referencia.

Conseqüències de no indexar prestecs.soci_id:

  1. Cada JOIN amb socis recorre prestecs sencera.
  2. Cada DELETE o UPDATE de la clau a socis recorre prestecs sencera per comprovar la integritat referencial de 02-06. Esborrar un soci en una taula de 2,8 milions de files pot trigar segons.

Trobar les claus foranes sense índex:

SELECT c.conrelid::regclass AS taula,
       a.attname AS columna
FROM pg_constraint c
JOIN unnest(c.conkey) AS k(attnum) ON true
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE c.contype = 'f'
  AND NOT EXISTS (
      SELECT 1 FROM pg_index i
      WHERE i.indrelid = c.conrelid
        AND i.indkey[0] = k.attnum
  );
      taula       |    columna
------------------+---------------
 inscripcions     | soci_id
 telefons_soci    | soci_id
 participacions   | ponent_id
 pagaments        | multa_id

Quatre claus foranes sense índex a BiblioRed. Les quatre són candidates immediates.

Regla pràctica: indexa totes les claus foranes, llevat que hagis comprovat que la taula filla és petita i no s'usa en JOIN.

No s'indexa

Cas Per què
Columnes de baixa cardinalitat Vegeu a sota
Taules molt petites (menys de ~1.000 files) El recorregut seqüencial cap a memòria i és més ràpid
Columnes que gairebé mai no es filtren Només costen
Taules amb moltíssima escriptura i poca lectura El cost de manteniment domina

Per què indexar actiu sol ser inútil

socis.actiu és un booleà. De 12.000 socis, 10.900 estan actius: el 91 %.

CREATE INDEX idx_socis_actiu ON socis (actiu);

EXPLAIN (ANALYZE, COSTS OFF)
SELECT * FROM socis WHERE actiu = true;
                          QUERY PLAN
-----------------------------------------------------------------
 Seq Scan on socis (actual time=0.011..2.884 rows=10900 loops=1)
   Filter: actiu
   Rows Removed by Filter: 1100
 Planning Time: 0.114 ms
 Execution Time: 3.402 ms

El planificador ignora l'índex, i fa bé: recuperar el 91 % de les files per índex significa saltar per gairebé tota la taula en ordre aleatori, cosa que és més lenta que llegir-la de cap a cap. L'índex existeix, ocupa espai, es manté a cada escriptura i no s'usa mai.

La mateixa columna amb la selectivitat invertida sí que serveix, i per això l'índex parcial és la resposta:

-- Els 1.100 socis inactius sí que són una fracció petita
CREATE INDEX idx_socis_inactius ON socis (cognoms) WHERE NOT actiu;

-- O, millor encara: indexar el que sí que es busca, restringit als actius
CREATE INDEX idx_socis_actius_nom ON socis (cognoms, nom) WHERE actiu;

Aquesta segona forma és la bona. La columna actiu no aporta selectivitat, però restringeix l'índex a les files interessants, i les columnes indexades són les que de debò es busquen.

  1. Llegir un pla d'execució: EXPLAIN i EXPLAIN ANALYZE

El pla d'execució és l'estratègia que l'optimitzador —aquella caixa del diagrama de 01-04— ha triat per respondre la consulta. Llegir-lo és l'habilitat central d'aquesta lliçó: sense ella, optimitzar és endevinar.

Instrucció Executa la consulta Dóna temps reals Risc
EXPLAIN consulta No No Cap
EXPLAIN ANALYZE consulta Executa també UPDATE/DELETE

Avís important. EXPLAIN ANALYZE sobre un DELETE esborra les files. Per analitzar una instrucció d'escriptura sense efectes, embolcalla-la en una transacció i desfés-la:

BEGIN;
EXPLAIN ANALYZE DELETE FROM prestecs WHERE prestec_id = 88301;
ROLLBACK;

La forma completa que convé usar sempre:

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT ...;
Opció Què afegeix
ANALYZE Executa i mostra temps i files reals
BUFFERS Pàgines llegides de memòria cau i de disc. Molt informatiu
VERBOSE Columnes de sortida de cada node
COSTS OFF Amaga els costos; útil per comparar plans sense soroll
SETTINGS Paràmetres no estàndard que afecten el pla

Com es llegeix l'arbre

Un pla és un arbre de nodes, i es llegeix de dins cap enfora i de baix a dalt:

  • Cada -> marca un nivell d'imbricació.
  • Els nodes més indentats s'executen primer.
  • Cada node consumeix les files que produeixen els seus fills i lliura files al seu pare.
  • La primera línia és l'últim pas: el que es torna al client.
 Sort                          ← 5è i últim: ordena el resultat
   ->  Hash Join               ← 4t: combina els dos costats
         ->  Seq Scan on a     ← 1r: recorre a
         ->  Hash              ← 3r: construeix la taula hash
               ->  Seq Scan b  ← 2n: recorre b

  1. Els nodes que apareixen una vegada i una altra

Nodes d'accés a dades

Node Què fa Quan és bon senyal Quan és dolent
Seq Scan Llegeix la taula sencera Taula petita, o se'n necessita gran part Taula gran + poques files tornades: falta un índex
Index Scan Recorre l'índex i va a la taula per cada coincidència Poques files Moltes files: millor Seq Scan
Index Only Scan Respon només amb l'índex, sense tocar la taula Sempre. És el millor que pot passar
Bitmap Heap Scan Recull els ctid de l'índex, els ordena i llegeix la taula en ordre físic Quantitat intermèdia de files
Bitmap Index Scan Fill de l'anterior: construeix el mapa de bits
Tid Scan Accés directe per ctid Rar

El Bitmap Heap Scan mereix explicació perquè desconcerta. Quan la consulta tornarà, diguem, 40.000 files de 2,8 milions, un Index Scan faria 40.000 salts aleatoris pel disc. El bitmap resol el problema en dues fases: primer recull de l'índex totes les adreces, després les ordena per posició física i llegeix la taula de principi a fi visitant només les pàgines necessàries. És el punt intermedi entre índex i recorregut seqüencial, i veure'l sol significar que el planificador està fent el que toca.

Nodes de combinació

Node Com funciona Bo quan
Nested Loop Per cada fila del costat extern, busca a l'intern El costat extern té poques files i l'intern té índex
Hash Join Construeix una taula hash amb el costat petit i recorre el gran Tots dos costats grans, sense índex útil, i el petit cap a memòria
Merge Join Recorre els dos costats ordenats alhora Tots dos ja vénen ordenats per la clau d'unió

El senyal d'alarma més comú: un Nested Loop el costat extern del qual torna moltes més files de les estimades. Si el planificador esperava 5 i n'hi ha 50.000, executarà 50.000 cerques en lloc de 5. És la causa número u de consultes que "de cop" passen de mil·lisegons a minuts.

Nodes de processament

Node Què fa A vigilar
Sort Ordena Sort Method: external merge Disk: ... significa que no cabia a memòria
Aggregate / HashAggregate / GroupAggregate Agrupa i calcula (el GROUP BY de 02-05) HashAggregate amb Disk indica falta de memòria
Limit Talla el resultat
Materialize Desa un resultat intermedi per reutilitzar-lo
Gather / Gather Merge Reuneix resultats de treballadors paral·lels Indica execució en paral·lel
Memoize Guarda a la memòria cau resultats d'un Nested Loop repetitiu Bon senyal

Quan vegis external merge Disk: 84320kB, l'ordenació se n'ha anat a disc. Sovint s'arregla pujant work_mem per a aquella consulta:

SET LOCAL work_mem = '64MB';

  1. Cost, estimacions i el senyal d'alarma

Cada node porta dos blocs de números:

Seq Scan on prestecs p  (cost=0.00..403820.46 rows=41960 width=20)
                        (actual time=0.048..13755.902 rows=42017 loops=1)
Element Significat
cost=0.00..403820.46 Cost estimat: primer número, cost de tornar la primera fila; segon, de tornar-les totes
rows=41960 Files que el planificador estima que tornarà el node
width=20 Bytes mitjans per fila
actual time=0.048..13755.902 Mil·lisegons reals fins a la primera fila i fins a l'última
rows=42017 Files reals tornades
loops=1 Quantes vegades s'ha executat aquest node

Tres advertiments imprescindibles sobre el cost:

  1. El cost no són mil·lisegons. És una unitat arbitrària en què 1,0 equival, per conveni, a llegir una pàgina seqüencialment. Serveix per comparar plans entre si, no per predir temps.
  2. Els costos són acumulatius: el d'un node inclou el dels seus fills. El cost total de la consulta és el de la primera línia.
  3. Amb loops > 1, els temps i les files són PER ITERACIÓ. Un node amb actual time=0.012..0.014 rows=1 loops=42017 no va trigar 0,014 ms: va trigar uns 590 ms en total. És l'error de lectura més freqüent i el que fa que la gent busqui el problema al lloc equivocat.

El senyal d'alarma

Compara sempre rows= estimat amb rows= real a cada node. Una diferència de més d'un ordre de magnitud és el diagnòstic més valuós que dóna un pla d'execució.

Quan el planificador estima 5 files i n'hi ha 50.000, totes les seves decisions posteriors estan mal fonamentades: va triar Nested Loop perquè creia que iteraria cinc vegades. I el pla no és dolent per l'algorisme, és dolent per la informació.

Diferència Interpretació Què fer
Estimat ≈ real El planificador està informat. Si va lent, és una altra cosa Buscar en un altre lloc
Estimat ≪ real Estadístiques desactualitzades, o correlació entre columnes ANALYZE, estadístiques esteses
Estimat ≫ real Igual, en sentit contrari El mateix
Diferència només en un node amb JOIN Correlació entre columnes que el planificador no coneix CREATE STATISTICS

  1. Les estadístiques i ANALYZE

El planificador no mira les dades: mira un resum estadístic de les dades. Si el resum està malament, el pla està malament.

SELECT attname AS columna, n_distinct, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 'prestecs' AND attname IN ('soci_id','data_devolucio');
     columna     | n_distinct |     most_common_vals      | most_common_freqs
-----------------+------------+---------------------------+--------------------
 soci_id         |      11842 | {14,15,16,882,1204}       | {0.0031,0.0028,...}
 data_devolucio  |         -0 |                           |

Què desa PostgreSQL de cada columna:

Dada Per a què serveix
n_distinct Quants valors diferents (negatiu = proporció sobre el total)
most_common_vals / most_common_freqs Els valors més freqüents i la seva proporció
Histograma Com es reparteix la resta de valors
null_frac Proporció de nuls
correlation Quant s'assembla l'ordre físic a l'ordre lògic. Determina si un BRIN serveix

Quan s'actualitzen

El procés autovacuum executa ANALYZE automàticament quan una taula acumula prou canvis (per omissió, un 10 % de les files). Cal forçar-lo a mà en tres situacions:

ANALYZE prestecs;
ANALYZE
  1. Després d'una càrrega massiva. Les estadístiques són d'abans de carregar.
  2. Després de crear un índex d'expressió. Necessita estadístiques pròpies de l'expressió.
  3. Abans de mesurar un pla, per no diagnosticar sobre informació obsoleta.

Si una columna té una distribució molt irregular, se li pot demanar més detall:

ALTER TABLE prestecs ALTER COLUMN soci_id SET STATISTICS 500;
ANALYZE prestecs;

El valor per omissió és 100 (100 valors freqüents i 100 cubetes d'histograma). Pujar-lo millora les estimacions i encareix una mica l'ANALYZE.

Estadístiques esteses: quan les columnes estan relacionades

El planificador suposa que les columnes són independents. Quan no ho són, s'equivoca per molt. Exemple real de BiblioRed: exemplars.sucursal_id i exemplars.estat no són independents —la sucursal Est és petita i presta poc, així que gairebé tots els seus exemplars estan disponibles—.

CREATE STATISTICS stat_exemplars_suc_estat (dependencies, ndistinct)
    ON sucursal_id, estat FROM exemplars;

ANALYZE exemplars;

És una eina poc coneguda i molt eficaç quan el símptoma és "l'estimat i el real se separen només quan filtro per dues columnes alhora".

  1. Cas complet: els catorze segons del llistat de vençuts

Anem al problema real, de principi a fi.

La consulta

SELECT s.nom          AS sucursal,
       so.cognoms, so.nom,
       m.titol,
       p.data_devolucio_prevista,
       CURRENT_DATE - p.data_devolucio_prevista AS dies_retard
FROM prestecs p
JOIN exemplars e   ON e.exemplar_id = p.exemplar_id
JOIN sucursals s   ON s.sucursal_id = e.sucursal_id
JOIN socis so      ON so.soci_id   = p.soci_id
JOIN materials m   ON m.material_id = e.material_id
WHERE p.data_devolucio IS NULL
  AND p.data_devolucio_prevista < CURRENT_DATE
  AND e.sucursal_id = 1
ORDER BY p.data_devolucio_prevista;

Pas 1: mesurar

EXPLAIN (ANALYZE, BUFFERS) SELECT ... ;
                                                    QUERY PLAN
-------------------------------------------------------------------------------------------------------------------
 Sort  (cost=418902.55..418913.03 rows=4192 width=98) (actual time=14023.881..14024.107 rows=312 loops=1)
   Sort Key: p.data_devolucio_prevista
   Sort Method: quicksort  Memory: 76kB
   ->  Hash Join  (cost=3894.12..418650.22 rows=4192 width=98) (actual time=142.905..14022.318 rows=312 loops=1)
         Hash Cond: (p.soci_id = so.soci_id)
         ->  Hash Join  (cost=2810.00..417555.44 rows=4192 width=64) (actual time=118.774..13998.002 rows=312 loops=1)
               Hash Cond: (e.material_id = m.material_id)
               ->  Hash Join  (cost=1204.00..415938.20 rows=4192 width=28) (actual time=32.118..13911.440 rows=312 loops=1)
                     Hash Cond: (p.exemplar_id = e.exemplar_id)
                     ->  Seq Scan on prestecs p  (cost=0.00..403820.46 rows=41960 width=20)
                                                 (actual time=0.048..13755.902 rows=42017 loops=1)
                           Filter: ((data_devolucio IS NULL) AND (data_devolucio_prevista < CURRENT_DATE))
                           Rows Removed by Filter: 2799060
                           Buffers: shared hit=1284 read=51452
                     ->  Hash  (cost=1079.00..1079.00 rows=9998 width=12) (actual time=31.702..31.703 rows=9998 loops=1)
                           Buckets: 16384  Batches: 1  Memory Usage: 558kB
                           ->  Seq Scan on exemplars e  (cost=0.00..1079.00 rows=9998 width=12)
                                                        (actual time=0.017..29.114 rows=9998 loops=1)
                                 Filter: (sucursal_id = 1)
                                 Rows Removed by Filter: 30002
 Planning Time: 1.204 ms
 Execution Time: 14025.663 ms

Pas 2: llegir-lo línia a línia

Execution Time: 14025.663 ms — El número que cal baixar. 14 segons.

Seq Scan on prestecs p ... (actual time=0.048..13755.902 rows=42017 loops=1) — Aquí hi ha el 98 % del temps. El node triga 13,7 dels 14 segons ell sol. Tota la resta és soroll.

Rows Removed by Filter: 2799060 — La línia més eloqüent del pla. PostgreSQL ha llegit 2.841.077 files i n'ha descartat 2.799.060. Ha fet el 98,5 % de la feina per no res.

Buffers: shared hit=1284 read=51452 — 51.452 pàgines llegides de disc (read) i només 1.284 trobades a la memòria cau (hit). Són 402 MB de disc per una consulta que torna 312 files.

rows=41960 estimat enfront de rows=42017 real — Aquí no hi ha problema d'estadístiques: l'estimació és excel·lent. El planificador sabia perfectament el que feia; va triar Seq Scan perquè no tenia cap alternativa. No hi ha cap índex que serveixi.

Seq Scan on exemplars e ... Rows Removed by Filter: 30002 — El segon problema, molt menor: recorre els 40.000 exemplars per quedar-se amb els 9.998 de la sucursal 1. Són 29 ms, no és el drama, però també hi falta un índex.

Sort Method: quicksort Memory: 76kB — L'ordenació és irrellevant: 312 files a memòria.

Hash Join — Correctes. Amb 42.017 files per un costat i 9.998 per l'altre, sense índexs, el hash és l'elecció adequada.

Pas 3: diagnòstic

Un diagnòstic s'escriu en una frase:

La consulta recorre els 2,8 milions de préstecs per quedar-se amb 42.017 oberts i vençuts —l'1,5 %—, perquè no existeix cap índex sobre data_devolucio ni sobre data_devolucio_prevista. Secundàriament, recorre els 40.000 exemplars per falta d'índex a sucursal_id.

Fixa't en el que no és el problema: no són els quatre JOIN, no és l'esquema normalitzat, no és l'ORDER BY, no és "la taula és molt gran". És un índex que falta.

Pas 4: els índexs

-- L'índex calent: parcial sobre els préstecs oberts
CREATE INDEX CONCURRENTLY idx_prestecs_oberts
    ON prestecs (data_devolucio_prevista)
    WHERE data_devolucio IS NULL;

-- Clau forana sense indexar, a més del filtre per sucursal
CREATE INDEX CONCURRENTLY idx_exemplars_sucursal
    ON exemplars (sucursal_id);

ANALYZE prestecs;
ANALYZE exemplars;
CREATE INDEX
CREATE INDEX
ANALYZE
ANALYZE

Per què parcial? Perquè les consultes del taulell sempre porten data_devolucio IS NULL, i així l'índex passa de 61 MB a menys d'1 MB, cap sencer a memòria i només es toca en prestar i en retornar, no a cada modificació de les files històriques.

Pas 5: mesurar de nou

                                                       QUERY PLAN
------------------------------------------------------------------------------------------------------------------------
 Sort  (cost=4218.66..4229.14 rows=4192 width=98) (actual time=38.902..38.941 rows=312 loops=1)
   Sort Key: p.data_devolucio_prevista
   Sort Method: quicksort  Memory: 76kB
   ->  Hash Join  (cost=1912.44..3966.33 rows=4192 width=98) (actual time=12.401..38.114 rows=312 loops=1)
         Hash Cond: (p.soci_id = so.soci_id)
         ->  Hash Join  (cost=828.32..2871.55 rows=4192 width=64) (actual time=7.882..33.220 rows=312 loops=1)
               Hash Cond: (e.material_id = m.material_id)
               ->  Nested Loop  (cost=0.71..2033.18 rows=4192 width=28) (actual time=0.094..27.556 rows=312 loops=1)
                     ->  Index Scan using idx_prestecs_oberts on prestecs p
                             (cost=0.29..912.44 rows=41960 width=20) (actual time=0.041..9.882 rows=42017 loops=1)
                           Index Cond: (data_devolucio_prevista < CURRENT_DATE)
                           Buffers: shared hit=118 read=6
                     ->  Index Scan using exemplars_pkey on exemplars e
                             (cost=0.42..0.42 rows=1 width=12) (actual time=0.000..0.000 rows=0 loops=42017)
                           Index Cond: (exemplar_id = p.exemplar_id)
                           Filter: (sucursal_id = 1)
                           Rows Removed by Filter: 1
 Planning Time: 1.882 ms
 Execution Time: 39.204 ms

Pas 6: comparar i comentar

Mètrica Abans Després Millora
Temps d'execució 14.025,663 ms 39,204 ms ×358
Pàgines llegides de disc 51.452 6 ×8.575
Files descartades per filtre 2.799.060 42.017 ×67
Node dominant Seq Scan 13,7 s Index Scan 9,9 ms

Catorze segons convertits en quaranta mil·lisegons. Sense tocar l'esquema. Sense desnormalitzar. Sense una sola taula nova. Dos CREATE INDEX.

Dues observacions sobre el pla nou:

  • El Hash Join s'ha convertit en Nested Loop. En abaratir-se l'accés a prestecs, el planificador ha canviat d'estratègia: ara recorre els 42.017 préstecs oberts i, per cadascun, busca el seu exemplar per clau primària. És un exemple perfecte que crear un índex no només accelera un node: canvia el pla sencer.
  • loops=42017 al node d'exemplars. Recorda l'apartat 13: aquell actual time=0.000..0.000 és per iteració. El node s'executa 42.017 vegades, i la seva contribució real són uns 17 ms dels 39. Està bé, però és el lloc on miraríem si calgués continuar baixant.

I si encara no n'hi hagués prou?

Suposem que el taulell necessités baixar de 40 ms —cosa dubtosa, però serveix per tancar el cercle de 05-04—. El pas següent no seria crear una taula de resum. Seria observar que la consulta filtra per sucursal i que la sucursal és a exemplars, no a prestecs, cosa que obliga a recórrer els 42.017 préstecs de les quatre sucursals per quedar-se amb els d'una.

La solució seria duplicar sucursal_id a prestecs —la tècnica 3 de 05-04, "duplicar un atribut per evitar un JOIN"— i indexar (sucursal_id, data_devolucio_prevista) WHERE data_devolucio IS NULL. Això portaria la consulta a l'ordre del mil·lisegon.

I fixa't en l'ordre en què hi hem arribat: mesurar, índex, mesurar altra vegada, i només aleshores plantejar tocar l'esquema, amb el número exacte del que es guanya. Aquest és exactament el procediment que 05-04 exigia i que l'apartat següent formalitza.

  1. Antipatrons que anul·len un índex

Existeix l'índex, és el correcte, i tot i així el pla mostra Seq Scan. Gairebé sempre és un d'aquests cinc.

  1. Funció sobre la columna al WHERE

-- MALAMENT: l'índex sobre data_prestec no serveix
SELECT * FROM prestecs WHERE extract(year FROM data_prestec) = 2026;
 Seq Scan on prestecs  (actual time=0.031..1204.882 rows=118402 loops=1)
   Filter: (EXTRACT(year FROM data_prestec) = '2026'::numeric)

L'índex desa dates; la consulta pregunta per anys. Són coses diferents.

-- BÉ: reescriure com a rang sobre la columna nua
SELECT * FROM prestecs
WHERE data_prestec >= DATE '2026-01-01'
  AND data_prestec <  DATE '2027-01-01';
 Index Scan using idx_prestecs_data_prestec on prestecs  (actual time=0.038..44.112 rows=118402 loops=1)
   Index Cond: ((data_prestec >= '2026-01-01') AND (data_prestec < '2027-01-01'))

L'alternativa, si la reescriptura no fos possible, és un índex d'expressió sobre extract(year FROM data_prestec). Però reescriure és gairebé sempre millor: el rang serveix a més per a qualsevol altre període.

Variant molt freqüent del mateix error:

-- MALAMENT
WHERE upper(cognoms) = 'ALSINA'
-- BÉ (amb índex d'expressió sobre lower(cognoms))
WHERE lower(cognoms) = 'alsina'

Nota el matís: aquí la funció no desapareix, però coincideix amb la de l'índex, i per això funciona.

  1. LIKE amb comodí al principi

-- MALAMENT: no hi ha B-tree que serveixi
SELECT * FROM materials WHERE titol LIKE '%mapa%';

Un B-tree ordena pel principi de la cadena. Buscar pel que hi ha al mig obliga a mirar-ho tot, igual que buscar en un diccionari totes les paraules que continguin "apa".

-- BÉ si busques per prefix: l'índex sí que serveix
SELECT * FROM materials WHERE titol LIKE 'El mapa%';

-- BÉ per a cerca per paraules: text complet amb GIN
SELECT * FROM materials WHERE cerca @@ websearch_to_tsquery('catalan', 'mapa');

-- BÉ per a cerca per subcadena arbitrària: índex de trigrames
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_materials_titol_trgm ON materials USING gin (titol gin_trgm_ops);
SELECT * FROM materials WHERE titol ILIKE '%mapa%';

Un detall important per al català: perquè LIKE 'El mapa%' usi l'índex quan la base de dades no està en la col·lació C, cal crear l'índex amb la classe d'operadors adequada:

CREATE INDEX idx_materials_titol_patro ON materials (titol text_pattern_ops);

  1. Comparar tipus diferents

-- MALAMENT: soci_id és INTEGER i es compara amb text
SELECT * FROM prestecs WHERE soci_id::text = '14';
 Seq Scan on prestecs
   Filter: ((soci_id)::text = '14'::text)

És el mateix parany de l'antipatró 1 disfressat: soci_id::text és una funció sobre la columna.

-- BÉ
SELECT * FROM prestecs WHERE soci_id = 14;

Passa molt amb controladors mal configurats que envien tots els paràmetres com a text, i amb columnes que desen números en VARCHAR —motiu addicional per triar bé els tipus, com deia 04-04—.

  1. OR mal plantejat

-- MALAMENT: un OR entre columnes diferents sol impedir l'ús d'índexs
SELECT * FROM socis WHERE email = '[email protected]' OR soci_id = 14;
 Seq Scan on socis
   Filter: ((email = '[email protected]'::text) OR (soci_id = 14))
-- BÉ: dues consultes indexades unides
SELECT * FROM socis WHERE email = '[email protected]'
UNION
SELECT * FROM socis WHERE soci_id = 14;
 HashAggregate  (actual time=0.084..0.086 rows=1 loops=1)
   ->  Append
         ->  Index Scan using idx_socis_email on socis
         ->  Index Scan using socis_pkey on socis socis_1

PostgreSQL de vegades resol l'OR amb un BitmapOr sobre dos índexs, i aleshores no cal reescriure. Quan no ho fa, el UNION és la sortida.

Cas relacionat, i molt habitual:

-- MALAMENT: el filtre opcional que desactiva l'índex
SELECT * FROM prestecs WHERE (:sucursal IS NULL OR sucursal_id = :sucursal);
-- BÉ: construir la consulta amb o sense la condició segons el paràmetre

  1. SELECT * innecessari

-- Amb SELECT *, cal anar a la taula per cada fila de l'índex
SELECT * FROM prestecs WHERE soci_id = 14;
 Index Scan using idx_prestecs_soci_data on prestecs  (actual time=0.028..0.312 rows=41 loops=1)
   Buffers: shared hit=44
-- Demanant només el que és a l'índex: Index Only Scan
SELECT soci_id, data_prestec FROM prestecs WHERE soci_id = 14;
 Index Only Scan using idx_prestecs_soci_data on prestecs  (actual time=0.019..0.041 rows=41 loops=1)
   Heap Fetches: 0
   Buffers: shared hit=4

44 pàgines enfront de 4, i Heap Fetches: 0 confirma que no s'ha tocat la taula. A més, SELECT * transfereix columnes que ningú no usa, trenca les aplicacions quan algú afegeix una columna, i fa il·legible què necessita realment cada consulta.

Resum d'antipatrons

Antipatró Reescriptura
WHERE f(col) = x WHERE col BETWEEN ... AND ..., o índex d'expressió
LIKE '%x%' Text complet amb GIN, o trigrames
WHERE col::text = '14' WHERE col = 14
WHERE a = 1 OR b = 2 UNION de dues consultes, o comprovar que hi ha BitmapOr
SELECT * Enumerar columnes; buscar l'Index Only Scan

  1. Com s'optimitza de debò: l'ordre d'intervenció

Aquí tanquem el cercle obert a 05-04.

Regla 1: mesurar abans de tocar

No s'optimitza el que no s'ha mesurat. Trobar la consulta culpable, no la sospitosa. L'eina és l'extensió pg_stat_statements:

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

SELECT round(total_exec_time::numeric, 0) AS ms_total,
       calls,
       round(mean_exec_time::numeric, 2)  AS ms_mitjana,
       left(query, 55) AS consulta
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5;
 ms_total  | calls  | ms_mitjana |                       consulta
-----------+--------+------------+--------------------------------------------------------
  8412088  |    602 |   13973.57 | SELECT s.nom AS sucursal, so.cognoms, so.nom, m.titol,
  1204882  | 884102 |       1.36 | SELECT * FROM socis WHERE soci_id = $1
   402118  |  12048 |      33.38 | SELECT count(*) FROM prestecs WHERE soci_id = $1 AND d
    88214  |     44 |    2005.00 | SELECT e.sucursal_id, count(*) FROM prestecs p JOIN ex
    12408  |   4012 |       3.09 | UPDATE exemplars SET estat = $1 WHERE exemplar_id = $1

Regla 2: atacar la consulta més costosa, no la més lletja

Mira la taula anterior amb atenció, perquè conté la lliçó sencera d'aquest apartat:

Consulta Mitjana Crides Temps total Optimitzar?
Llistat de vençuts 13.973 ms 602 8.412 s Sí: és el 82 % del temps
SELECT * FROM socis WHERE soci_id = $1 1,36 ms 884.102 1.204 s , encara que sembli ràpida
Informe trimestral 2.005 ms 44 88 s No: 44 execucions a l'any

La segona fila és la que sorprèn. Una consulta d'1,36 ms sembla perfecta, però s'executa 884.102 vegades i suma vint minuts de servidor. Baixar-la a 0,4 ms estalviaria més que optimitzar l'informe trimestral quaranta vegades.

I la tercera ensenya el contrari: un informe de dos segons sembla un escàndol, però s'executa 44 vegades l'any. Optimitzar-lo és temps perdut, per molt lleig que sigui el seu SQL.

La mètrica que decideix és total_exec_time, no mean_exec_time. Cost total = cost unitari × freqüència.

Regla 3: l'ordre d'intervenció

De menys invasiva a més. No passis al següent esglaó sense haver esgotat l'anterior i sense haver mesurat.

Ordre Intervenció Reversible Risc per a les dades Guany típic
1 Reescriure la consulta (treure SELECT *, eliminar antipatrons, evitar subconsultes correlacionades) Cap De ×1 a ×100
2 Actualitzar estadístiques (ANALYZE, estadístiques esteses) Cap Variable; de vegades enorme
3 Crear un índex (parcial, compost o d'expressió) Sí, DROP INDEX Cap De ×10 a ×1000
4 Ajustar la configuració (work_mem, shared_buffers, effective_cache_size) Cap De ×1 a ×5
5 Canviar l'esquema (tipus, columnes, particionat) Difícilment Baix Variable
6 Desnormalitzar (columna calculada, taula de resum, vista materialitzada) No a la pràctica Sí: inconsistència Alt
7 Memòria cau fora de la base de dades (Redis, com a 03-02) Sí: dades obsoletes Molt alt

Els quatre primers esglaons no toquen les dades, no poden introduir inconsistència i es desfan en un minut. Del cinquè endavant, cadascun afegeix una obligació permanent de manteniment.

I el cas d'aquesta lliçó ho confirma: catorze segons resolts a l'esglaó 3, amb dues instruccions reversibles. La temptació de saltar directament al 6 —"fem una taula de préstecs vençuts que s'actualitzi cada nit"— hauria costat un activador, un procés nocturn, un risc de descompensació, dades amb hores d'antiguitat i una discussió eterna sobre per què el llistat no inclou el préstec que acaba de vèncer. Tot això per ser més lent que un índex parcial de 992 kB.

Sobre particionat i replicació

Dues tècniques que de vegades es proposen com a solució a una consulta lenta i que gairebé mai no ho són:

  • El particionat divideix una taula gran en fragments per rang o per llista. Ajuda amb el manteniment (esborrar un any sencer és eliminar una partició) i amb consultes que filtren per la clau de partició. No substitueix un índex.
  • La replicació reparteix la càrrega de lectura entre diversos servidors, i és la tècnica que vam veure a 03-01. No fa més ràpida una consulta: permet executar més consultes alhora. Enviar l'informe mensual a una rèplica de només lectura és una idea excel·lent; no farà que trigui menys.

Cap de les dues no arregla una consulta lenta per falta d'índex. Només multipliquen el maquinari necessari per continuar fent-ho malament.

  1. Manteniment i índexs fora de PostgreSQL

Índexs inflats i REINDEX

Amb el MVCC de 06-02, els índexs també acumulen entrades mortes i s'inflen. Un índex inflat ocupa més del que hauria i la seva cerca toca més pàgines.

Estimar el problema:

SELECT indexrelname AS index,
       pg_size_pretty(pg_relation_size(indexrelid)) AS mida,
       idx_scan AS usos
FROM pg_stat_user_indexes
WHERE relname = 'prestecs';

Reconstruir:

REINDEX INDEX CONCURRENTLY idx_prestecs_oberts;
REINDEX

El CONCURRENTLY és essencial: sense ell, REINDEX bloqueja les escriptures de la taula, amb l'efecte de 06-02 sobre els quatre taulells.

Compte: reconstruir índexs no és una tasca rutinària. Es fa quan s'ha comprovat que hi ha inflament, no "per si de cas".

Índexs a MongoDB

El concepte és idèntic; canvia la sintaxi.

// Índex simple
db.prestecs.createIndex({ soci_id: 1 })

// Compost: la regla del prefix més a l'esquerra és LA MATEIXA
db.prestecs.createIndex({ soci_id: 1, data_prestec: -1 })

// Parcial: l'equivalent exacte del nostre índex calent
db.prestecs.createIndex(
  { data_devolucio_prevista: 1 },
  { partialFilterExpression: { data_devolucio: null } }
)

// Únic
db.socis.createIndex({ email: 1 }, { unique: true })

// Veure els índexs existents
db.prestecs.getIndexes()

I l'equivalent d'EXPLAIN ANALYZE:

db.prestecs.find({ soci_id: 14 }).explain("executionStats")
{
  queryPlanner: { winningPlan: { stage: "IXSCAN", indexName: "soci_id_1" } },
  executionStats: {
    nReturned: 41,
    totalKeysExamined: 41,
    totalDocsExamined: 41,
    executionTimeMillis: 0
  }
}

El que cal mirar és la mateixa idea que a PostgreSQL: COLLSCAN en lloc de IXSCAN és el Seq Scan de MongoDB, i totalDocsExamined molt més gran que nReturned és l'equivalent de Rows Removed by Filter.

La conclusió important: els índexs no són una peculiaritat relacional. Són la resposta universal al problema de trobar pocs elements entre molts, i tots els sistemes de dades, sense excepció, en tenen la seva versió.

Errors Habituals i Consells

Indexar-ho tot "per si de cas". Cada índex costa espai, escriptures, memòria cau i temps de planificació. Revisa idx_scan a pg_stat_user_indexes i elimina els que porten mesos sense usar-se.

No indexar les claus foranes. PostgreSQL no ho fa per tu. És la causa més freqüent de JOIN lents i d'esborrats que triguen segons. Executa la consulta de detecció de l'apartat 10 sobre la teva base de dades avui mateix.

Crear l'índex en producció sense CONCURRENTLY. Bloqueja les escriptures durant tota la creació. En una taula gran, és una aturada de servei autoinfligida.

Confondre cost amb mil·lisegons. El cost és una unitat interna per comparar plans. El temps és a actual time, i només amb ANALYZE.

Oblidar que amb loops > 1 els temps són per iteració. Multiplica sempre per loops abans de decidir que un node és innocent.

Optimitzar la consulta més lletja en lloc de la més costosa. Ordena per total_exec_time a pg_stat_statements i ataca el de dalt, encara que el seu SQL sigui impecable.

Envoltar la columna en una funció i esperar que l'índex funcioni. WHERE extract(year FROM data) = 2026 no usa l'índex sobre data. Reescriu-ho com a rang.

Usar EXPLAIN ANALYZE sobre un UPDATE o un DELETE en producció sense transacció. L'executa de debò. BEGIN; ... ROLLBACK;.

Mesurar amb dades de desenvolupament. Els 3.000 préstecs del teu portàtil caben a memòria i fan que tots els plans semblin bons. Els plans només són significatius amb volum i estadístiques realistes.

Crear un índex i no mesurar després. De vegades el planificador continua sense usar-lo —per selectivitat, per tipus o per estadístiques— i et quedes amb el cost sense el benefici. Torna a executar EXPLAIN ANALYZE sempre.

Consell final: escriu el número. "Abans 14.025 ms, després 39 ms, amb idx_prestecs_oberts." Aquella frase al registre de canvis val més que qualsevol explicació, permet comprovar d'aquí a un any si l'índex encara fa falta, i és l'única cosa que converteix una intuïció en una decisió d'enginyeria.

Exercicis

Exercici 1: Dissenyar els índexs de tres consultes

Aquestes tres consultes són les més executades del catàleg web i del taulell de BiblioRed. Per a cadascuna, indica quin índex crearies, per què en aquell ordre de columnes, i si seria parcial o no.

-- (a) Historial de préstecs d'un soci, del més recent al més antic
SELECT prestec_id, exemplar_id, data_prestec, data_devolucio
FROM prestecs
WHERE soci_id = 14
ORDER BY data_prestec DESC
LIMIT 20;

-- (b) Exemplars disponibles d'un material en una sucursal
SELECT exemplar_id, codi
FROM exemplars
WHERE material_id = 907 AND sucursal_id = 2 AND estat = 'disponible';

-- (c) Multes pendents d'un soci
SELECT multa_id, motiu, import, data_emissio
FROM multes
WHERE soci_id = 14 AND estat = 'pendent'
ORDER BY data_emissio;

Exercici 2: Diagnosticar un pla

Interpreta aquest pla d'execució. Indica: quin és el node problemàtic, quant temps real consumeix, quin és el diagnòstic i quina intervenció proposaries (amb l'esglaó de l'apartat 17 al qual correspon).

 Nested Loop  (cost=0.42..8902.18 rows=12 width=64) (actual time=0.088..9214.552 rows=41 loops=1)
   ->  Seq Scan on socis so  (cost=0.00..284.00 rows=12 width=28)
                             (actual time=0.021..4.118 rows=41 loops=1)
         Filter: (lower(cognoms) = 'alsina'::text)
         Rows Removed by Filter: 11959
   ->  Index Scan using idx_prestecs_soci on prestecs p
           (cost=0.42..718.02 rows=1 width=36) (actual time=224.402..224.622 rows=1 loops=41)
         Index Cond: (soci_id = so.soci_id)
         Filter: (data_devolucio IS NULL)
         Rows Removed by Filter: 218
 Planning Time: 0.412 ms
 Execution Time: 9215.104 ms

Exercici 3: Reescriure tres consultes que anul·len els seus índexs

Aquestes tres consultes de BiblioRed no usen cap índex tot i que existeixen els índexs adequats. Identifica l'antipatró de cadascuna i reescriu-la. Els índexs disponibles són: prestecs(data_prestec), socis(lower(email)), materials(titol text_pattern_ops) i materials USING gin (cerca).

-- (a)
SELECT count(*) FROM prestecs WHERE to_char(data_prestec, 'YYYY-MM') = '2026-07';

-- (b)
SELECT soci_id, nom FROM socis WHERE email = '[email protected]';

-- (c)
SELECT material_id, titol FROM materials WHERE titol LIKE '%temps%';

Solucions

Solució 1

(a) Historial de préstecs d'un soci

CREATE INDEX idx_prestecs_soci_data
    ON prestecs (soci_id, data_prestec DESC);
  • Ordre de columnes: soci_id primer perquè és la condició d'igualtat i és molt selectiva (11.842 valors diferents sobre 2,8 milions de files). data_prestec després perquè intervé a l'ORDER BY.
  • DESC a l'índex: permet que l'ORDER BY ... DESC es resolgui recorrent l'índex en el seu ordre natural, eliminant el node Sort. Amb LIMIT 20, el gestor llegeix 20 entrades i para.
  • No parcial: és un historial, i consulta explícitament préstecs ja retornats. Restringir-lo als oberts trencaria el cas d'ús.

Verificació esperada:

 Limit  (actual time=0.028..0.041 rows=20 loops=1)
   ->  Index Scan using idx_prestecs_soci_data on prestecs  (actual time=0.026..0.038 rows=20 loops=1)
         Index Cond: (soci_id = 14)

Sense node Sort: és el senyal que l'ordre de l'índex s'ha aprofitat.

(b) Exemplars disponibles d'un material en una sucursal

CREATE INDEX idx_exemplars_material_sucursal
    ON exemplars (material_id, sucursal_id)
    WHERE estat = 'disponible';
  • Ordre: material_id primer per ser molt més selectiu (milers de materials enfront de quatre sucursals). Amb només quatre sucursals, sucursal_id gairebé no descarta files i no ha d'anar davant.
  • Parcial sobre estat = 'disponible': és la condició fixa de la consulta calenta del catàleg web, i estat és de baixa cardinalitat, així que com a columna indexada seria inútil (apartat 10). Com a condició de l'índex és perfecta: el redueix als exemplars realment prestables i evita mantenir-lo quan canvien els estats dels altres.
  • No cal incloure estat entre les columnes: la condició de l'índex ja ho garanteix.

(c) Multes pendents d'un soci

CREATE INDEX idx_multes_soci_pendents
    ON multes (soci_id, data_emissio)
    WHERE estat = 'pendent';
  • Ordre: soci_id (igualtat, selectiu) i data_emissio (per a l'ORDER BY).
  • Parcial: de les 48.000 multes històriques només unes 300 estan pendents. L'índex passa de megabytes a kilobytes i només es toca en emetre o cobrar una multa.
  • S'hi podria afegir INCLUDE (motiu, import) per aconseguir un Index Only Scan, ja que la consulta només demana aquelles columnes. Amb 300 files el guany és marginal, però és el raonament correcte.

Solució 2

Node problemàtic: l'Index Scan using idx_prestecs_soci on prestecs p.

Temps real consumit: aquí hi ha el parany. El node mostra actual time=224.402..224.622 amb loops=41. Aquells 224 ms són per iteració, així que el total és 41 × 224,6 ≈ 9.209 ms, és a dir, pràcticament els 9.215 ms de tota la consulta. Qui llegeixi "224 ms" i ho doni per acceptable buscarà el problema on no és.

Diagnòstic: l'índex idx_prestecs_soci s'està usant per a la condició d'unió, però el filtre data_devolucio IS NULL s'aplica després, sobre les files ja recuperades de la taula. La línia Rows Removed by Filter: 218 ho diu: per cadascun dels 41 socis es llegeixen 219 préstecs de la taula per quedar-se amb 1. Són 8.979 accessos a pàgines, la majoria a disc.

Hi ha un segon problema, menor: el Seq Scan on socis amb lower(cognoms) recorre els 12.000 socis (4 ms). No és el drama, però delata que falta un índex d'expressió.

Intervenció proposada, esglaó 3 de l'apartat 17 (crear un índex):

-- Principal: índex parcial que incorpora el filtre al mateix índex
CREATE INDEX CONCURRENTLY idx_prestecs_soci_oberts
    ON prestecs (soci_id)
    WHERE data_devolucio IS NULL;

-- Secundari: índex d'expressió per a la cerca per cognoms
CREATE INDEX CONCURRENTLY idx_socis_cognoms_lower
    ON socis (lower(cognoms));

ANALYZE prestecs;
ANALYZE socis;

Amb l'índex parcial, cada iteració del bucle torna directament el préstec obert sense llegir els 218 retornats. El node passaria de 224 ms a microsegons, i la consulta sencera a l'ordre d'un mil·lisegon.

Observació addicional: també s'aprecia que l'estimació (rows=12) es queda curta enfront de la realitat (rows=41) al Seq Scan on socis. No és greu aquí, però si la diferència creixés, el Nested Loop deixaria de ser l'elecció adequada. Un ANALYZE després de crear l'índex d'expressió millorarà també aquella estimació.

Solució 3

(a) Funció sobre la columna (antipatró 1)

to_char(data_prestec, 'YYYY-MM') converteix la data en text, així que l'índex sobre data_prestec no pot intervenir.

SELECT count(*)
FROM prestecs
WHERE data_prestec >= DATE '2026-07-01'
  AND data_prestec <  DATE '2026-08-01';

Nota sobre el límit superior: s'usa < '2026-08-01' i no <= '2026-07-31'. Si la columna fos TIMESTAMP en lloc de DATE, <= '2026-07-31' deixaria fora tot l'últim dia a partir de les 00:00:01. El rang mig obert és correcte en tots dos casos, i és un costum que evita errors silenciosos.

(b) Índex d'expressió amb expressió diferent (variant de l'antipatró 1)

L'índex és sobre lower(email), però la consulta compara email tal qual, i a més amb el valor en majúscules. No coincideixen.

SELECT soci_id, nom
FROM socis
WHERE lower(email) = lower('[email protected]');

O, millor encara, normalitzant el valor a l'aplicació abans d'enviar-lo:

SELECT soci_id, nom FROM socis WHERE lower(email) = '[email protected]';

L'expressió del WHERE ha de ser idèntica a la de l'índex. És la regla que fa funcionar els índexs d'expressió, i la que els trenca quan s'oblida.

(c) LIKE amb comodí inicial (antipatró 2)

L'índex text_pattern_ops serveix per a prefixos (LIKE 'temps%'), no per a subcadenes.

-- Opció preferible: cerca de text complet amb l'índex GIN
SELECT material_id, titol
FROM materials
WHERE cerca @@ websearch_to_tsquery('catalan', 'temps');
 Bitmap Heap Scan on materials  (actual time=0.184..0.402 rows=38 loops=1)
   Recheck Cond: (cerca @@ websearch_to_tsquery('catalan', 'temps'))
   ->  Bitmap Index Scan on idx_materials_cerca  (actual time=0.121..0.121 rows=38 loops=1)

Avantatge afegit: la cerca de text complet aplica lematització en català, així que "mapa" troba també "mapes", i no troba falsos positius com "contratemps" que sí que retornaria LIKE '%temps%'.

Si de debò calgués la subcadena literal, l'alternativa és un índex de trigrames:

CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_materials_titol_trgm ON materials USING gin (titol gin_trgm_ops);
-- Ara sí:
SELECT material_id, titol FROM materials WHERE titol ILIKE '%temps%';

Conclusió

Els catorze segons ja no existeixen. I l'important no és que ara siguin quaranta mil·lisegons, sinó com hi hem arribat: mesurant, llegint un pla d'execució, trobant la línia que deia Rows Removed by Filter: 2799060, i creant dos índexs reversibles. Sense tocar l'esquema, sense desnormalitzar, sense una taula nova i sense una discussió d'arquitectura.

Hem entès el mecanisme des de dins. Un B-tree amb un factor de ramificació d'unes 500 entrades per node adreça 125 milions de files en tres nivells, i aquesta és la raó matemàtica que un índex continuï sent ràpid quan la taula creix: no escala amb el nombre de files, escala amb el seu logaritme. També hem vist l'altra cara, que s'oblida més: un índex costa espai, costa memòria cau, triplica el temps d'una càrrega massiva i, si ningú no l'usa, només costa. Per això idx_scan = 0 a pg_stat_user_indexes és una invitació a DROP INDEX.

Hem recorregut les eines: els índexs únics i la seva relació exacta amb les restriccions UNIQUE de 04-04; els compostos i la regla del prefix més a l'esquerra, que s'entén d'una vegada per sempre amb la guia ordenada per cognom i nom; els parcials, que a BiblioRed van convertir 61 MB en 992 kB perquè només l'1,5 % dels préstecs està obert; els d'expressió, amb la condició que el WHERE repeteixi l'expressió exacta; i el panorama de GIN per al cercador del catàleg, GiST per als solapaments d'esdeveniments que ja coneixíem de 04-04, i BRIN per a l'historial ordenat per data, amb els seus 48 kB enfront de 61 MB.

Hem après a llegir un pla de baix a dalt i de dins cap enfora; a distingir Seq Scan d'Index Scan, d'Index Only Scan i de Bitmap Heap Scan; a reconèixer quan un Nested Loop és una bona idea i quan és una catàstrofe; a no confondre el cost amb els mil·lisegons; a multiplicar per loops abans d'absoldre un node; i sobretot a mirar el senyal d'alarma més valuós de tots: la distància entre les files estimades i les reals, que gairebé sempre apunta a estadístiques velles o a columnes correlacionades.

I hem formalitzat el mètode. Mesurar amb pg_stat_statements i atacar la consulta amb més temps total, no la de pitjor aspecte —aquella consulta d'1,36 ms que s'executa 884.102 vegades costa més que l'informe trimestral de dos segons—. Després, l'ordre d'intervenció: reescriure → estadístiques → índex → configuració → esquema → desnormalitzar → memòria cau, sense saltar-se esglaons i mesurant entre l'un i l'altre. Els quatre primers no poden espatllar les dades; del cinquè endavant, cadascun afegeix una obligació permanent. Aquest és exactament el compromís que vam adquirir a 05-04, i ara ja tenim les eines per complir-lo.

Queda una última cosa, i és la que separa una base de dades d'un accident esperant a passar.

El sistema de BiblioRed és ara correcte —l'esquema està normalitzat i les transaccions garanteixen que les operacions passen senceres— i és ràpid. Però continua havent-hi dues preguntes de la regidoria de Vallmar sense respondre des del primer dia d'aquest mòdul: qui pot consultar els telèfons i els correus dels 12.000 socis, i què passaria exactament si el disc morís aquesta nit.

Cap de les dues no s'arregla amb un índex. La lliçó 06-04, Seguretat, Permisos i Còpies de Seguretat, tanca el mòdul i el bloc teòric del curs: l'autenticació i els rols de PostgreSQL, amb pg_hba.conf i per què el mètode trust no ha de sortir del teu portàtil; l'autorització amb GRANT i REVOKE, i el principi de mínim privilegi aplicat a tres rols concrets de BiblioRed —taulell, direcció i aplicació web—; les vistes i la seguretat a nivell de fila perquè cada sucursal vegi només el seu; la injecció SQL, com es produeix al cercador del catàleg i per què les consultes parametritzades són l'única defensa que funciona; el xifratge en trànsit i en repòs, i el tractament correcte de les contrasenyes; les dades personals dels socis, amb el que la tècnica pot aportar i el que correspon a un professional de compliance; i el bloc de còpies de seguretat, on el registre WAL que vam estudiar a 06-01 reapareixerà convertit en l'eina que permet recuperar la base de dades a l'instant anterior al desastre.

© Copyright 2026. Tots els drets reservats