Tot el que portes llegit al mòdul són regles, i en rendiment les regles s'equivoquen. De debò fa servir l'índex aquella consulta? De debò IN (SELECT ...) es converteix en un semi-join, com va prometre 07-05? De debò el problema són les estadístiques? Hi ha una sola manera honesta de respondre, i és l'eina que converteix aquest mòdul sencer en un mètode: EXPLAIN. Aquí aprendràs a llegir un pla d'execució node a node, a distingir el pla estimat del real, a reconèixer el senyal que delata unes estadístiques desfasades, i a interpretar els quinze nodes que apareixen al 95 % dels plans. I faràs la demostració central del mòdul: construiràs una taula de proves de dos milions de files —perquè amb els 20 productes de BotigaVerda no hi ha res a demostrar— i veuràs amb els teus ulls la diferència entre recórrer dos milions de files i saltar directament a deu.
Contingut
EXPLAINiEXPLAIN ANALYZE- Com es llegeix un pla
cost,rows,width,actual timeiloops- El senyal més útil: estimat enfront de real
- Els nodes que més veuràs
- Opcions útils d'
EXPLAIN - El banc de proves: dos milions de files
- La demostració: sense índex i amb índex
- I a la BotigaVerda real: per què s'ignora l'índex
- Verificant la promesa de 07-05
- Mesurar en altres motors i eines de visualització
- Manteniment:
VACUUM,ANALYZEi el bloat - Un mètode en sis passos
- Errors habituals i consells
- Exercicis
- Conclusió del mòdul
EXPLAIN i EXPLAIN ANALYZE
EXPLAIN i EXPLAIN ANALYZEEXPLAIN SELECT ...; -- el pla que el motor PENSA executar: només estimacions
EXPLAIN ANALYZE SELECT ...; -- EXECUTA la consulta: estimacions I mesures realsEl primer costa microsegons i et diu quin camí triaria; el segon costa el que costi la consulta i et diu què va passar de debò.
⚠️ L'avís imprescindible.
EXPLAIN ANALYZEexecuta la consulta de debò. Amb unSELECTés inofensiu; amb unUPDATE, unDELETEo unINSERT, modifica les dades. La manera segura d'analitzar-los és embolicar-los en una transacció que es desfà:
El pla es mostra, les files s'esborren… i el ROLLBACK ho desfà tot. BEGIN, COMMIT i ROLLBACK són el mòdul 9; aquí n'hi ha prou de fer servir aquest patró com una precaució obligatòria. És la mateixa xarxa de seguretat que ja vas veure a 05-04 abans d'un DELETE sense WHERE.
- Com es llegeix un pla
Un pla és un arbre de nodes: cadascun rep files dels seus fills, hi fa alguna cosa i les passa al seu pare. La sortida de text ho representa amb sagnat i fletxes ->, i es llegeix d'una manera concreta:
De dins cap a fora i de baix cap a dalt. El node més sagnat s'executa primer; el de la primera línia, l'últim, i és el que produeix el resultat final.
flowchart BT
A["<b>Seq Scan</b> on clients<br/><i>llegeix 15 files</i>"] --> C["<b>Hash Join</b><br/><i>aparella per client_id</i>"]
B["<b>Seq Scan</b> on comandes<br/><i>llegeix 20 files</i>"] --> H["<b>Hash</b><br/><i>taula hash de comandes</i>"]
H --> C
C --> S["<b>Sort</b><br/><i>ordena per data</i>"]
S --> L(["<b>Limit</b><br/><i>retorna 10: resultat</i>"])
En text, aquest mateix arbre s'imprimeix al revés: Limit a dalt de tot, els dos Seq Scan al fons. Les dues preguntes que respon la forma de l'arbre són sempre les mateixes: per on entra el motor a les dades? (els nodes de les fulles) i com les combina? (els nodes d'unió).
cost, rows, width, actual time i loops
cost, rows, width, actual time i loopsCada línia d'un pla porta els seus números. Amb EXPLAIN a seques només hi ha estimacions; amb ANALYZE s'hi afegeix la segona meitat entre parèntesis:
| Camp | Què significa |
|---|---|
cost=0.00..1.25 |
Cost d'arrencada .. cost total, en unitats arbitràries del planificador (1,0 = llegir una pàgina seqüencialment). El d'arrencada és el que costa abans d'emetre la primera fila |
rows=7 (estimat) |
Files que el planificador estima que sortiran d'aquest node |
width=45 |
Amplada mitjana estimada de cada fila, en bytes |
actual time=0.012..0.016 |
Mil·lisegons fins a la primera fila .. fins a l'última. Per execució |
rows=7 (real) |
Files que en van sortir de debò, de mitjana per execució |
loops=1 |
Quantes vegades es va executar aquest node |
El cost d'arrencada distingeix dues famílies de nodes: els que emeten files a mesura que les llegeixen (Seq Scan, Index Scan, Nested Loop) tenen arrencada ≈ 0; els que necessiten totes les files abans de retornar la primera (Sort, Hash, HashAggregate) el tenen alt. Això importa molt amb LIMIT.
El parany de
loops.actual timeirowssón mitjanes per execució, no totals. Un node ambactual time=0.05..0.08 rows=3 loops=20000no va trigar 0,08 ms: va trigar 0,08 × 20.000 = 1,6 segons, i va produir 60.000 files. Multiplica sempre perloopsabans de decidir on és el problema.
Al final del pla apareixen Planning Time (el que va trigar a decidir el pla) i Execution Time (el que va trigar a executar-lo). Si són comparables, tens una consulta trivial executada moltíssimes vegades: el cas de les sentències preparades i, sovint, de l'N+1 de 08-04.
- El senyal més útil: estimat enfront de real
Si només t'endús una cosa d'aquesta lliçó, que sigui aquesta: compara
rows=estimades ambrows=reals a cada node. Una divergència gran —d'un ordre de magnitud o més— és la causa arrel de gairebé tots els plans dolents.
-> Seq Scan on comandes (cost=0.00..41250.00 rows=42 width=25)
(actual time=0.03..1893.44 rows=284561 loops=1)El planificador esperava 42 files i en van sortir 284.561. Amb 42 files, encadenar un Nested Loop és la decisió perfecta; amb 284.561, és una catàstrofe. El pla no està mal triat: està ben triat per a una realitat que no existeix.
Les quatre causes, en ordre de freqüència: estadístiques desfasades després d'una càrrega o un creixement, que s'arreglen amb ANALYZE taula;; columnes correlacionades que el planificador suposa independents, amb CREATE STATISTICS; un predicat que no sap estimar (expressió complexa, funció pròpia), que cal reescriure o indexar com a expressió; i una mostra massa petita per a una distribució molt desigual, amb ALTER TABLE ... SET STATISTICS. Les quatre es desenvolupen a 08-04. Quan estimat i real s'assemblen, el pla acostuma a ser el millor disponible: si tot i així va lent, el problema és d'índexs o de volum, no del planificador.
- Els nodes que més veuràs
| Node | Què fa | Què sol significar veure'l |
|---|---|---|
Seq Scan |
Llegeix la taula sencera bloc a bloc | Normal en taules petites o filtres poc selectius; sospitós en taules grans amb filtre selectiu |
Index Scan |
Recorre l'índex i va a la taula per cada fila | El cas bo quan es retornen poques files |
Index Only Scan |
Ho resol tot dins de l'índex | El millor cas. Vigila el Heap Fetches: si és alt, falta VACUUM |
Bitmap Index Scan + Bitmap Heap Scan |
El primer construeix un mapa de bits dels blocs amb coincidències; el segon els llegeix en ordre físic, una vegada cadascun | Van sempre junts. El planificador els prefereix quan espera moltes files disperses: converteix accessos aleatoris en gairebé seqüencials |
Nested Loop |
Per cada fila de l'esquerra, busca a la dreta | Excel·lent si l'esquerra té poques files; catastròfic si en té milions. Mira'n els loops |
Hash Join |
Construeix una taula hash amb la taula petita i recorre la gran | El cavall de batalla dels JOIN grans per igualtat |
Merge Join |
Recorre les dues entrades ja ordenades en paral·lel | Apareix quan totes dues arriben ordenades (per índex o per Sort) |
Sort / Incremental Sort |
Ordenen files; el segon aprofita que ja vénen parcialment ordenades | Si apareix Sort Method: external merge Disk, se n'ha anat a disc: falta índex o work_mem. Veure Incremental Sort és bon senyal: l'índex cobreix part de l'ORDER BY |
HashAggregate / GroupAggregate |
Agrupen, el primer amb una taula hash i el segon sobre files ja ordenades | El GROUP BY habitual és el hash, i necessita memòria; el segon apareix amb molts grups o quan ja venien ordenades |
Limit |
Talla i atura l'execució | Ben col·locat, fa que la resta del pla s'aturi abans d'hora |
Materialize |
Desa en memòria un resultat per rellegir-lo | Típic dins d'un Nested Loop, per no recalcular la part dreta |
Gather / Parallel ... |
Reparteix la feina entre processos i en recull els resultats | Paral·lelisme. Workers Launched pot ser menor que Workers Planned |
Dues línies auxiliars que convé mirar sempre: Filter: amb el seu Rows Removed by Filter: (files llegides i llençades — si són milions, falta un índex o el filtre hauria d'anar abans) i Index Cond: (la part de la condició que sí que va resoldre l'índex; el que quedi a Filter és el que es va haver de comprovar fila a fila).
- Opcions útils d'
EXPLAIN
EXPLAINVan entre parèntesis i separades per comes: EXPLAIN (ANALYZE, BUFFERS, VERBOSE) SELECT ...;.
| Opció | Què hi afegeix | Quan fer-la servir |
|---|---|---|
ANALYZE |
Executa i mesura | Sempre que puguis |
BUFFERS |
Blocs llegits: shared hit (memòria cau), read (disc), dirtied, written |
Gairebé sempre: vegeu més avall |
VERBOSE |
Columnes de sortida, esquemes, noms complets | Consultes amb molts àlies |
SETTINGS |
Paràmetres de configuració canviats respecte del valor per omissió | Quan el mateix SQL dóna plans diferents en dos servidors |
TIMING OFF / WAL |
Mesura sense cronometrar cada node / registre de transaccions generat | Quan el cronometratge distorsiona / analitzant escriptures |
FORMAT JSON |
Sortida estructurada | Per a eines i visualitzadors |
Per què BUFFERS importa tant: el temps depèn de si la màquina està ocupada, de si la dada era a la memòria cau i de què més s'executa alhora. Els blocs llegits, no. Un pla que llegeix 16.250 blocs en llegirà 16.250 avui, demà i al portàtil del teu company. En una màquina compartida —un servidor d'integració, un contenidor, el núvol— els blocs són l'única mètrica reproduïble que tens.
- El banc de proves: dos milions de files
Aquí cal ser honest: amb BotigaVerda no es pot demostrar res d'això. Vint productes i quaranta-set línies caben en una pàgina de disc, i el planificador farà Seq Scan sempre i amb tota la raó. Ensenyar-te un pla inventat sobre aquestes taules seria mentir-te. Així que construirem una taula gran i reproduïble amb generate_series.
⚠️ BANC DE PROVES DEL MÒDUL 8. La taula
comandes_gransno forma part de l'esquema canònic de BotigaVerda (01-06). No té claus foranes, no està relacionada amb cap altra taula i cap mòdul posterior no la fa servir. Quan acabis la lliçó, esborra-la:DROP TABLE comandes_grans;.
-- Banc de proves del mòdul 8. NO forma part de BotigaVerda.
-- Ocupa uns 130 MB i triga entre 10 i 60 segons a generar-se.
DROP TABLE IF EXISTS comandes_grans;
CREATE TABLE comandes_grans (
id INTEGER PRIMARY KEY,
client_id INTEGER NOT NULL,
data_comanda DATE NOT NULL,
estat VARCHAR(20) NOT NULL,
import NUMERIC(10,2) NOT NULL
);
INSERT INTO comandes_grans (id, client_id, data_comanda, estat, import)
SELECT g,
(g % 200000) + 1, -- 200.000 clients, 10 comandes cadascun
DATE '2019-01-01' + (g / 782), -- 782 comandes al dia, en ordre cronològic
(ARRAY['pendent','pagat','enviat','lliurat','cancellat'])[(g % 5) + 1],
ROUND((random() * 395 + 5)::numeric, 2)
FROM generate_series(1, 2000000) AS g;
-- Imprescindible: sense estadístiques, el planificador va a cegues (08-04)
ANALYZE comandes_grans;SELECT pg_size_pretty(pg_relation_size('comandes_grans')) AS taula,
pg_relation_size('comandes_grans') / 8192 AS pagines,
COUNT(*) AS files
FROM comandes_grans;| taula | pagines | files |
|---|---|---|
| 127 MB | 16250 | 2000000 |
(Una execució d'exemple. La mida exacta depèn de la versió i de l'alineació dels tipus; l'ordre de magnitud, no.) Enfront de l'única pàgina de productes, aquí n'hi ha setze mil dues-centes cinquanta. Ara sí que hi ha alguna cosa a optimitzar.
- La demostració: sense índex i amb índex
Comencem sense índex: client_id no en té cap — només en té id, per la seva clau primària.
EXPLAIN (ANALYZE, BUFFERS) SELECT id, data_comanda, estat, import
FROM comandes_grans WHERE client_id = 12345;Gather (cost=1000.00..14750.30 rows=10 width=25) (actual time=0.412..142.118 rows=10 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=16250
-> Parallel Seq Scan on comandes_grans (cost=0.00..13749.30 rows=4 width=25)
(actual time=95.204..131.502 rows=3 loops=3)
Filter: (client_id = 12345)
Rows Removed by Filter: 666663
Planning Time: 0.096 ms
Execution Time: 142.180 ms(Una execució d'exemple: els mil·lisegons i els costos depenen de la teva màquina. El que cal llegir són les proporcions i els tipus de node.) Tres coses que aquest pla crida: Parallel Seq Scan, perquè no hi ha índex i cal recórrer la taula sencera —és tan gran que PostgreSQL reparteix la feina entre tres processos i els recull amb Gather—; Rows Removed by Filter: 666663 per treballador, és a dir dos milions de files llegides i llençades per quedar-se'n amb deu; i Buffers: shared hit=16250, les 16.250 pàgines de la taula, totes.
Ara l'índex, i la mateixa consulta:
CREATE INDEX idx_comandes_grans_client ON comandes_grans (client_id);
EXPLAIN (ANALYZE, BUFFERS) SELECT id, data_comanda, estat, import
FROM comandes_grans WHERE client_id = 12345;Index Scan using idx_comandes_grans_client on comandes_grans
(cost=0.43..39.05 rows=10 width=25) (actual time=0.038..0.061 rows=10 loops=1)
Index Cond: (client_id = 12345)
Buffers: shared hit=13
Planning Time: 0.121 ms
Execution Time: 0.086 msLa comparació, que és la raó de ser del mòdul sencer:
| Sense índex | Amb índex | Factor | |
|---|---|---|---|
| Node | Parallel Seq Scan + Gather (3 processos) |
Index Scan (1 procés) |
— |
| Cost estimat | ~14.750 | ~39 | ~380× |
| Blocs llegits | 16.250 | 13 | 1.250× |
| Files descartades | 2.000.000 | 0 | — |
| Temps d'execució | ~142 ms | ~0,09 ms | ~1.600× |
I fixa't en Index Cond enfront de Filter: al segon pla no hi ha Filter. L'índex no va filtrar després de llegir: es va posicionar directament a les deu files. Aquesta és la diferència entre buscar i descartar.
El tercer pla: Bitmap Heap Scan
Amb un filtre que retorna força files disperses, el planificador tria un camí intermedi:
EXPLAIN (ANALYZE, BUFFERS) SELECT id, import FROM comandes_grans WHERE client_id BETWEEN 1000 AND 1200;Bitmap Heap Scan on comandes_grans (cost=45.06..7527.19 rows=2010 width=12)
(actual time=1.204..18.442 rows=2010 loops=1)
Recheck Cond: ((client_id >= 1000) AND (client_id <= 1200))
Heap Blocks: exact=1988
Buffers: shared hit=1994
-> Bitmap Index Scan on idx_comandes_grans_client (cost=0.00..44.56 rows=2010 width=0)
(actual time=0.612..0.612 rows=2010 loops=1)
Index Cond: ((client_id >= 1000) AND (client_id <= 1200))Es llegeix de baix a dalt: el Bitmap Index Scan recorre l'índex i construeix un mapa de bits dels blocs que contenen coincidències; el Bitmap Heap Scan els llegeix en ordre físic, una sola vegada cadascun. Amb 2.010 files repartides per gairebé 2.000 blocs diferents, un Index Scan faria 2.010 salts aleatoris; el bitmap els converteix en un recorregut gairebé seqüencial de 1.988 blocs. És la resposta del planificador a "moltes files, però no tota la taula".
- I a la BotigaVerda real: per què s'ignora l'índex
Torna al món de les 20 files i crea l'índex més raonable del catàleg, sobre productes.preu:
CREATE INDEX idx_productes_preu ON productes (preu);
EXPLAIN ANALYZE SELECT p.id, p.nom, p.preu FROM productes AS p WHERE p.preu > 10;Seq Scan on productes p (cost=0.00..1.25 rows=7 width=45) (actual time=0.012..0.016 rows=7 loops=1) Filter: (preu > 10::numeric) Rows Removed by Filter: 13 Planning Time: 0.184 ms Execution Time: 0.031 ms
Seq Scan, amb l'índex acabat de crear i sense estrenar. I el planificador té tota la raó. Mira'n el cost: 1,25. Es descompon així: 1 pàgina × 1,0 (llegir l'únic bloc de la taula) + 20 files × 0,01 (processar-ne cadascuna) + 20 × 0,0025 (avaluar el filtre) = 1,25. No hi ha res més barat que això, perquè la taula sencera és un sol accés.
El podem obligar a fer servir l'índex per veure què hauria passat:
SET enable_seqscan = off; -- NOMÉS per a diagnòstic, mai en producció
EXPLAIN ANALYZE SELECT p.id, p.nom, p.preu FROM productes AS p WHERE p.preu > 10;
SET enable_seqscan = on;Index Scan using idx_productes_preu on productes p (cost=0.14..12.35 rows=7 width=45)
(actual time=0.031..0.041 rows=7 loops=1)
Index Cond: (preu > 10::numeric)
Planning Time: 0.211 ms
Execution Time: 0.062 msCost 12,35 enfront d'1,25: deu vegades més car. I el temps real, el doble. El motiu és exactament el de 08-01: fer servir l'índex obliga a llegir la pàgina de metadades, baixar per l'arbre, obtenir set ctid i tornar a llegir la mateixa pàgina de la taula que el Seq Scan hauria llegit d'una tirada. Tres o quatre accessos per fer la feina d'un. Tres conclusions, que tanquen la primera lliçó del mòdul: un índex sobre una taula que cap en una pàgina no compensa mai, i el planificador ho sap; enable_seqscan = off és una eina de diagnòstic, no una solució, i serveix per respondre "què hauria fet amb l'índex?" i res més; i els índexs que vas crear a 08-02 no acceleren res avui — són correctes, estan ben dissenyats i serien decisius amb volum real, però amb 20 files no hi ha cap problema a resoldre.
- Verificant la promesa de 07-05
07-05 va afirmar que PostgreSQL converteix IN (SELECT ...) en un semi-join, i que per això el seu rendiment és equivalent al d'un JOIN. Comprovem-ho:
EXPLAIN ANALYZE
SELECT c.id, c.nom FROM clients AS c WHERE c.id IN (SELECT client_id FROM comandes);Hash Semi Join (cost=1.45..2.71 rows=12 width=10) (actual time=0.048..0.062 rows=12 loops=1)
Hash Cond: (c.id = comandes.client_id)
-> Seq Scan on clients c (cost=0.00..1.15 rows=15 width=10) (actual time=0.008..0.010 rows=15 loops=1)
-> Hash (cost=1.20..1.20 rows=20 width=4) (actual time=0.021..0.021 rows=20 loops=1)
-> Seq Scan on comandes (cost=0.00..1.20 rows=20 width=4) (actual time=0.006..0.010 rows=20 loops=1)
Planning Time: 0.352 ms
Execution Time: 0.104 msHash Semi Join. Vas escriure una subconsulta i el motor va executar un join — un join especial que s'atura a la primera coincidència de cada fila esquerra, i per això retorna 12 clients i no 20 files. La promesa era certa, i ara no t'ho creus perquè ho vas llegir: ho has vist. Prova de canviar IN per NOT IN i veuràs que el Semi Join desapareix i apareix un filtre amb una subconsulta executada a part: la confirmació visual de per què 07-05 desaconsellava NOT IN.
- Mesurar en altres motors i eines de visualització
| Motor | Pla estimat | Pla real / E-S |
|---|---|---|
| PostgreSQL 16 | EXPLAIN |
EXPLAIN (ANALYZE, BUFFERS) |
| MySQL 8 | EXPLAIN, EXPLAIN FORMAT=JSON (amb costos) |
EXPLAIN ANALYZE (des de la 8.0.18) |
| SQLite | EXPLAIN QUERY PLAN (molt resumit) |
No hi ha equivalent; es mesura amb .timer on |
| SQL Server | Estimated Execution Plan (SET SHOWPLAN_XML ON) |
Actual Execution Plan + SET STATISTICS IO, TIME ON |
| Oracle | EXPLAIN PLAN FOR ... + DBMS_XPLAN.DISPLAY |
DBMS_XPLAN.DISPLAY_CURSOR(format => 'ALLSTATS LAST') |
Nota de dialecte: els conceptes viatgen (recorregut seqüencial, accés per índex, tipus de join, estimat enfront de real), però el vocabulari i les unitats no. El
costde PostgreSQL no és comparable amb el de MySQL ni amb el d'Oracle. I hi ha diferències de fons: SQL Server i MySQL/InnoDB fan servir índexs agrupats, on la taula és l'índex primari, així que el seu equivalent de l'Index Scanno necessita el segon accés del qual parlava 08-01. ElSET STATISTICS IO ONde SQL Server és el parent proper deBUFFERS, i per la mateixa raó: compta lectures lògiques, que són reproduïbles.
I quatre eines que fan llegible un pla de cent línies: explain.dalibo.com, on enganxes el pla (millor en FORMAT JSON) i el dibuixa com un arbre ressaltant el node més car i les estimacions errònies; pev2, el mateix visualitzador integrable a les teves pròpies eines; auto_explain, un mòdul del servidor que registra automàticament el pla de tota consulta que superi un llindar de temps —imprescindible per al que només falla en producció—; i pg_stat_statements, el rànquing de quines consultes analitzar (08-03).
- Manteniment:
VACUUM, ANALYZE i el bloat
VACUUM, ANALYZE i el bloatHi ha una part del rendiment que no depèn de les teves consultes. A PostgreSQL, un UPDATE no modifica la fila: escriu una versió nova i marca la vella com a morta. Un DELETE tampoc no esborra: marca. Les versions mortes s'acumulen i engreixen taules i índexs sense aportar res: és el bloat. Els seus efectes són mesurables: una taula amb un 60 % d'espai mort ocupa el triple de pàgines de les necessàries, així que cada Seq Scan en llegeix el triple, la memòria cau ret un terç i els Index Only Scan perden eficàcia (pugen els Heap Fetches).
| Ordre | Què fa | Bloqueig |
|---|---|---|
VACUUM taula; |
Marca l'espai mort com a reutilitzable | Cap que impedeixi treballar |
ANALYZE taula; |
Actualitza les estadístiques del planificador | Cap |
VACUUM FULL taula; |
Reescriu la taula i retorna l'espai al sistema | Bloqueig exclusiu: ningú no pot llegir ni escriure |
-- Diagnòstic ràpid de bloat i d'estadístiques velles
SELECT relname, n_live_tup, n_dead_tup, last_vacuum, last_autovacuum, last_analyze
FROM pg_stat_user_tables WHERE schemaname = 'public' ORDER BY n_dead_tup DESC;En condicions normals, autovacuum se n'encarrega sol. Es converteix en un problema quan una taula rep moltíssimes actualitzacions, o quan una transacció fa hores que és oberta i impedeix netejar files mortes que encara podria necessitar. I aquí arribem al límit d'aquest mòdul: el perquè de tot això —per què el motor desa diverses versions de cada fila i per què una transacció oberta bloqueja la neteja— és el model MVCC, i s'estudia al mòdul 9. De moment queda't amb l'operatiu: ANALYZE després de qualsevol càrrega massiva, i n_dead_tup disparat com a senyal d'alarma.
- Un mètode en sis passos
Això és el que de debò cal endur-se del mòdul. Davant d'una consulta lenta:
- Reprodueix i mesura. Aconsegueix la consulta exacta amb els seus paràmetres reals i executa-la amb
EXPLAIN (ANALYZE, BUFFERS). Sense mesura no hi ha diagnòstic. - Localitza el node culpable. Busca el node amb més
actual timemultiplicat perloops, i el que més blocs llegeix. Gairebé sempre és un de sol i és a baix de tot. - Compara estimat amb real. Si
rowsestimades i reals divergeixen molt, el problema són les estadístiques:ANALYZE, i torna al pas 1. No continuïs optimitzant sobre un pla basat en dades falses. - Mira què està llegint de més.
Seq Scansobre una taula gran amb unRows Removed by Filterenorme = falta un índex o la condició no és sargable.Sort ... Disk= falta un índex d'ordenació o memòria.Nested Loopamb milers deloops= mala estimació a la branca esquerra. - Aplica UNA sola intervenció i torna a mesurar. Reescriure (08-04) abans que indexar (08-02); indexar abans que canviar l'arquitectura. Un canvi cada vegada, o no sabràs quin va funcionar.
- Verifica que el pla ha canviat, no només que el temps ha baixat (pot haver baixat per la memòria cau). I setmanes després, comprova a
pg_stat_user_indexesque l'índex se segueix utilitzant.
Errors habituals i consells
- Llançar
EXPLAIN ANALYZEsobre unUPDATEo unDELETEsense transacció. L'executa de debò.BEGIN ... ROLLBACK, sempre. - Llegir el pla de dalt a baix com si fos codi. Es llegeix de dins cap a fora: el node més sagnat va primer. I no oblidis multiplicar per
loops: un node de 0,08 ms amb 20.000 voltes són 1,6 segons, i sol ser el culpable. - Comparar el
costde dos motors, o de dos servidors amb configuració diferent. Són unitats arbitràries i relatives. - Refiar-se només del temps en una màquina compartida. Fes servir
BUFFERS: els blocs llegits són reproduïbles. I no mesuris una sola vegada: la primera execució omple la memòria cau. - Deixar
enable_seqscan = offposat. És per diagnosticar, mai per a producció: obligues el planificador a triar malament la resta de les seves decisions. I no optimitzis senseANALYZEprevi: si les estimacions estan malament, tot el que hi facis a sobre està malament. - Consell: desa el pla d'abans. Un fitxer amb l'
EXPLAINprevi al canvi és l'única prova objectiva que has millorat alguna cosa. - Consell: analitza amb dades reals de producció. Un pla sobre 100 files de desenvolupament no diu absolutament res sobre 20 milions, i els problemes que només apareixen a les tres de la matinada no es reprodueixen a mà: per a això hi ha
auto_explain.
Exercicis
Exercici 1
Llegeix aquest pla, tret d'una BotigaVerda de mida real (2 milions de comandes, no el banc de proves d'aquesta lliçó):
Nested Loop (cost=0.42..248301.55 rows=38 width=48) (actual time=0.055..9412.331 rows=18422 loops=1)
Buffers: shared hit=1204885
-> Seq Scan on comandes co (cost=0.00..41250.00 rows=38 width=25)
(actual time=0.021..1842.117 rows=18422 loops=1)
Filter: (estat = 'pendent'::text)
Rows Removed by Filter: 1981578
-> Index Scan using clients_pkey on clients c (cost=0.42..5.44 rows=1 width=23)
(actual time=0.004..0.004 rows=1 loops=18422)
Index Cond: (id = co.client_id)
Planning Time: 0.412 ms
Execution Time: 9421.008 ms- En quin ordre s'executen els nodes?
- Quin és el problema principal i quin senyal el delata?
- Quant temps total consumeix realment l'
Index Scan? - Proposa dues intervencions, en l'ordre en què les provaries.
Exercici 2
Sobre comandes_grans ja creada i indexada per client_id, prediu abans d'executar quin node triarà el planificador en cada cas, i després comprova-ho amb EXPLAIN ANALYZE.
SELECT id FROM comandes_grans WHERE client_id = 500; -- a)
SELECT COUNT(*) FROM comandes_grans WHERE estat = 'lliurat'; -- b)
SELECT client_id FROM comandes_grans WHERE client_id BETWEEN 1 AND 100; -- c)
SELECT id FROM comandes_grans WHERE client_id + 0 = 500; -- d)Exercici 3
Escriu la seqüència completa d'ordres —incloses les precaucions— per analitzar el rendiment d'aquest DELETE sense modificar les dades, i digues què buscaries al pla.
Solucions
Solució 1
1. L'ordre. Primer el Seq Scan sobre comandes (el node més profund de la branca esquerra); per cada fila que produeix, el Nested Loop executa l'Index Scan sobre clients; el Nested Loop emet el resultat.
2. El problema és una estimació catastròficament errònia. El senyal és al Seq Scan: rows=38 estimades enfront de rows=18422 reals, gairebé 500 vegades més. Amb 38 files, un Nested Loop és l'elecció perfecta; amb 18.422 es converteix en 18.422 cerques indexades. I hi ha dos símptomes de suport: Rows Removed by Filter: 1981578 (es llegeixen dos milions de files per quedar-se amb el 0,9 %, així que falta un índex sobre estat) i Buffers: shared hit=1204885, més d'un milió de blocs llegits per retornar 18.422 files.
3. El temps de l'Index Scan. actual time és per execució: 0,004 ms × 18.422 loops ≈ 74 ms. És a dir, l'Index Scan no és el problema: dels 9.421 ms, 1.842 són el Seq Scan, 74 l'Index Scan, i la resta se la menja la maquinària de repetir el bucle 18.422 vegades. Oblidar-se de multiplicar per loops és l'error de lectura més comú.
4. Les intervencions, en ordre. Primer, ANALYZE comandes; i tornar a mirar el pla: és gratis, instantani i ataca la causa arrel — si l'estimació passa a ser correcta, el planificador canviarà sol el Nested Loop per un Hash Join i el temps caurà sense tocar res més. Després, si l'estimació ja era correcta, un índex per a les pendents, millor parcial: CREATE INDEX ... ON comandes (client_id) WHERE estat = 'pendent';, perquè són menys de l'1 % i així l'índex és minúscul (08-02).
Solució 2
| # | Node esperat | Per què |
|---|---|---|
a) client_id = 500 |
Index Scan |
10 files de 2.000.000: selectivitat del 0,0005 %. El cas ideal |
b) estat = 'lliurat' |
Parallel Seq Scan + Gather sota un Aggregate |
Retorna el 20 % de la taula; molt per sobre del llindar de selectivitat (08-03). Encara que hi hagués índex, no el faria servir |
c) client_id BETWEEN 1 AND 100 |
Bitmap Index Scan + Bitmap Heap Scan, o Index Only Scan |
Unes 1.000 files disperses: massa per a salts aleatoris un a un, poques per llegir la taula sencera. I com que només es demana client_id, que és a l'índex, és candidata a Index Only Scan |
d) client_id + 0 = 500 |
Parallel Seq Scan |
No és sargable (08-04): hi ha una operació sobre la columna filtrada, i l'índex queda descartat. Mateix resultat que a), unes 1.600 vegades més lent. És l'experiment més instructiu de l'exercici: mateix resultat, mateix índex disponible, i un + 0 que ho anul·la tot |
Solució 3
BEGIN;
EXPLAIN (ANALYZE, BUFFERS) DELETE FROM comandes_grans WHERE data_comanda < '2020-01-01';
ROLLBACK;La precaució clau és el BEGIN ... ROLLBACK: EXPLAIN ANALYZE executa el DELETE de debò, i sense la transacció perdries les files. Què buscar al pla:
- Com localitza les files. Sense índex sobre
data_comanda, unSeq Scanamb unRows Removed by Filterenorme. Amb índex, unBitmap Heap Scan— i com que les dades es van generar en ordre cronològic, les files a esborrar són físicament juntes, que és el millor escenari possible. - Quantes files esborra. El
rowsreal del node d'esborrat: uns 285.000 (365 dies × 782 al dia). I la comparació estimat/real, com sempre. - Una consideració que aquest mòdul no pot resoldre: un
DELETEde 285.000 files deixa 285.000 versions mortes queVACUUMhaurà de netejar, i manté bloquejos durant tota la transacció. Per això els esborrats massius es fan per lots, i per això el particionat (08-04) és tan atractiu per als històrics: esborrar un any sencer és desconnectar una partició, no executar unDELETE.
Conclusió del mòdul
Tanques el mòdul 8 amb el mètode, no només amb les regles:
EXPLAINestima;EXPLAIN ANALYZEexecuta i mesura — i per això unUPDATEo unDELETEcal embolicar-los enBEGIN ... ROLLBACK.- Un pla és un arbre que es llegeix de dins cap a fora.
costsón unitats relatives,rowsiwidthsón estimacions, iactual timeirowssón mitjanes per execució: cal multiplicar-les perloops. - El senyal més útil de tots és la divergència entre les files estimades i les reals. Quan és gran, el pla està ben triat per a una realitat que no existeix, i la causa acostuma a ser
ANALYZE. - Saps reconèixer els quinze nodes habituals, des del
Seq Scanfins alGather, i què significa que apareguinRows Removed by Filter,Heap FetchesoSort Method: external merge Disk. I saps queBUFFERSés millor mètrica que el temps en qualsevol màquina compartida: els blocs llegits són reproduïbles. - Ho has vist mesurat, no explicat: sobre dos milions de files, la mateixa consulta passa de 16.250 blocs i ~142 ms a 13 blocs i ~0,09 ms. Mentre que sobre els 20 productes de BotigaVerda l'índex costa deu vegades més que el
Seq Scan, i el planificador encerta ignorant-lo. I la promesa de 07-05 queda verificada amb el seu nom al pla:Hash Semi Join.
I amb això es tanca el mòdul 8. En cinc lliçons has passat de "no sé per què això va lent" a tenir un procediment: saps què és un B-tree i per què 15 milions de files caben en 3 nivells; saps que BotigaVerda tenia onze índexs que ningú no va crear i onze claus foranes sense cobrir, perquè PostgreSQL no les indexa; saps crear índexs compostos, parcials, d'expressió i cobridors, i triar entre B-tree, hash, GIN, GiST, BRIN i SP-GiST; saps —i això és el més rar de trobar— quan no indexar; saps reescriure una consulta per fer-la sargable, detectar un N+1 i actualitzar les estadístiques del planificador; i saps llegir un pla d'execució i decidir amb dades.
Però hi ha una suposició que hem mantingut durant vuitanta i escaig lliçons sense dir-la en veu alta: que som l'únic usuari de la base de dades. Tot el que has mesurat aquí suposa que ningú més no està llegint ni escrivint alhora, que cap fila no canvia mentre la consultes i que cap UPDATE no competeix amb el teu per la mateixa línia de comanda. Això és fals en qualsevol sistema real: a BotigaVerda, un client confirma una comanda mentre el magatzem actualitza l'estoc del mateix producte i l'analista llança l'informe mensual sobre aquestes mateixes taules. Al mòdul 9, Transaccions, veuràs què és una transacció i les quatre garanties ACID, com es controlen amb BEGIN, COMMIT, ROLLBACK i SAVEPOINT, què són els nivells d'aïllament i quines anomalies permet cadascun, i com funcionen els bloquejos i els interbloquejos — inclòs, per fi, el model MVCC que explica el bloat d'aquesta lliçó i per què CREATE INDEX CONCURRENTLY havia d'existir.
Curs de SQL
Mòdul 1: Introducció a SQL
- Què és SQL?
- Configurar el teu entorn SQL
- Sintaxi bàsica de SQL
- Entendre bases de dades i taules
- El model relacional: claus primàries i foranes
- La base de dades del curs: BotigaVerda
Mòdul 2: Consultes bàsiques de SQL
- Instrucció SELECT
- Àlies, expressions i columnes calculades
- Filtrar dades amb WHERE
- DISTINCT i eliminació de duplicats
- Ordenar dades amb ORDER BY
- Limitar resultats amb LIMIT
Mòdul 3: Treballar amb múltiples taules
- Operacions JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN i CROSS JOIN
- Unions de conjunts: UNION, INTERSECT i EXCEPT
Mòdul 4: Filtratge avançat de dades
- Utilitzar LIKE per a coincidència de patrons
- Operadors IN i BETWEEN
- Valors NULL i IS NULL
- Funcions d'agregació: COUNT, SUM, AVG, MIN i MAX
- Agregar dades amb GROUP BY
- Clàusula HAVING
Mòdul 5: Manipulació de dades
- Crear taules i restriccions amb CREATE TABLE
- Instrucció INSERT
- Instrucció UPDATE
- Instrucció DELETE
- Instrucció UPSERT (MERGE)
- Modificar l'esquema: ALTER TABLE i migracions segures
Mòdul 6: Funcions avançades de SQL
- Funcions de cadena
- Funcions numèriques
- Funcions de data i hora
- Conversió de tipus i tractament dels NULL: CAST i COALESCE
- Expressions condicionals
Mòdul 7: Subconsultes i consultes imbricades
- Introducció a les subconsultes
- Subconsultes correlacionades
- EXISTS i NOT EXISTS
- Utilitzar subconsultes en les clàusules SELECT, FROM i WHERE
- Subconsultes o JOIN: quin triar
Mòdul 8: Índexs i optimització del rendiment
- Entendre els índexs
- Creació i gestió d'índexs
- Tipus d'índex i quan no indexar
- Tècniques d'optimització de consultes
- Anàlisi del rendiment de les consultes
Mòdul 9: Transaccions i concurrència
- Introducció a les transaccions
- Propietats ACID
- Instruccions de control de transaccions
- Nivells d'aïllament i anomalies de concurrència
- Gestió de la concurrència: bloquejos i interbloquejos
Mòdul 10: Temes avançats
- Vistes
- Expressions de taula comunes (CTE)
- Funcions de finestra
- Procediments emmagatzemats
- Disparadors (triggers)
- JSON i dades semiestructurades
Mòdul 11: SQL a la pràctica
- Casos d'ús al món real
- Bones pràctiques
- Seguretat: injecció SQL, permisos i rols
- SQL per a l'anàlisi de dades
- SQL en el desenvolupament web
