Aquesta és la lliçó més exigent del curs, i també la que més s'assembla al treball real. Aquí no hi ha SELECT d'escalfament: hi ha funcions de finestra, CTE recursives, jsonb, transaccions amb control d'errors, cinc exercicis de dues sessions psql en paral·lel i dos de lectura de plans d'execució. Tot sobre BiblioRed.
Com treballar aquesta lliçó. Igual que les anteriors —intentar-ho abans de mirar— amb una diferència important: necessites dos terminals oberts. El bloc de concurrència no es pot llegir, s'ha d'executar. Cada exercici d'aquell bloc indica exactament què teclejar a la Sessió A i a la Sessió B, i en quin ordre. La instrucció «no avancis la Sessió B fins que l'A hagi fet la seva part» no és un consell: si et saltes l'ordre, el fenomen que vols observar no passa i creuràs que l'exercici està malament.
Obre així els dos terminals, i posa'ls un indicador distingible per no confondre't:
# Terminal 1
psql -d biblioredx
\set PROMPT1 '[A] %/%R%# '
# Terminal 2
psql -d biblioredx
\set PROMPT1 '[B] %/%R%# 'Un avís sobre els plans d'execució: els números que veuràs a la teva màquina no coincidiran amb els de les solucions, perquè la teva taula prestecs té 20 files i els plans dels exercicis 12 i 13 corresponen a una BiblioRed en producció amb 900.000. El que cal aprendre a llegir no són els mil·lisegons: és la forma del pla i la relació entre les files estimades i les reals.
Aquesta lliçó no repeteix SQL bàsic (això és 07-01) ni normalització (això és 07-03).
Abans de Començar
Parteix del mateix joc de dades de la lliçó 07-01. Si no el tens carregat, torna-hi i executa l'script; la comprovació que està bé és la consulta de recomptes d'aquella lliçó (20 préstecs, 15 exemplars, 26 inscripcions).
Sobre aquell joc de dades cal afegir tres coses que aquesta lliçó necessita: la taula d'informes amb jsonb, un arbre de categories temàtiques i una cua d'avisos.
-- ============================================================
-- Complement del mòdul 7 per a la lliçó 07-04
-- ============================================================
CREATE TABLE informes_esdeveniment (
esdeveniment_id INTEGER PRIMARY KEY REFERENCES esdeveniments(esdeveniment_id) ON DELETE CASCADE,
assistents_reals SMALLINT NOT NULL CHECK (assistents_reals >= 0),
valoracio_mitjana NUMERIC(3,2),
observacions TEXT,
respostes_enquesta JSONB,
data_redaccio DATE NOT NULL
);
INSERT INTO informes_esdeveniment VALUES
(101, 7, 4.50, 'Bon ambient; la sala es va quedar justa.', '{
"canal": "web",
"temes": ["novella historica","club de lectura"],
"respostes": [
{"soci_id":14,"puntuacio":5,"recomanaria":true,"comentari":"Molt bon ritme"},
{"soci_id":11,"puntuacio":4,"recomanaria":true,"comentari":null},
{"soci_id":13,"puntuacio":5,"recomanaria":true,"comentari":"Repetire"},
{"soci_id":12,"puntuacio":4,"recomanaria":false,"comentari":"Sala petita"}
]}'::jsonb, '2026-03-14'),
(102, 9, 4.00, 'Public infantil molt participatiu.', '{
"canal": "paper",
"temes": ["infantil","contacontes"],
"respostes": [
{"soci_id":16,"puntuacio":5,"recomanaria":true,"comentari":"Encantats"},
{"soci_id":12,"puntuacio":3,"recomanaria":false,"comentari":"Massa soroll"},
{"soci_id":11,"puntuacio":4,"recomanaria":true,"comentari":null}
]}'::jsonb, '2026-04-20'),
(103, 5, 4.75, 'Grup reduit, molt bon nivell.', '{
"canal": "web",
"temes": ["escriptura","taller"],
"respostes": [
{"soci_id":14,"puntuacio":5,"recomanaria":true,"comentari":"Excellent"},
{"soci_id":13,"puntuacio":5,"recomanaria":true,"comentari":null},
{"soci_id":15,"puntuacio":4,"recomanaria":true,"comentari":"Curt"},
{"soci_id":11,"puntuacio":5,"recomanaria":true,"comentari":"Repetire"}
]}'::jsonb, '2026-05-12'),
(104, 8, 3.25, 'Aforament molt folgat; problemes de megafonia.', '{
"canal": "paper",
"temes": ["presentacio","novella"],
"respostes": [
{"soci_id":11,"puntuacio":3,"recomanaria":false,"comentari":"Poca gent"},
{"soci_id":12,"puntuacio":4,"recomanaria":true,"comentari":null},
{"soci_id":14,"puntuacio":4,"recomanaria":true,"comentari":null},
{"soci_id":16,"puntuacio":2,"recomanaria":false,"comentari":"No se sentia"}
]}'::jsonb, '2026-06-08'),
(105, 4, 4.67, 'Sessio tranquilla, bona conversa.', '{
"canal": "web",
"temes": ["novella contemporania","club de lectura"],
"respostes": [
{"soci_id":15,"puntuacio":5,"recomanaria":true,"comentari":"Molt bona"},
{"soci_id":16,"puntuacio":4,"recomanaria":true,"comentari":null},
{"soci_id":14,"puntuacio":5,"recomanaria":true,"comentari":null}
]}'::jsonb, '2026-07-18');
CREATE INDEX idx_informes_enquesta ON informes_esdeveniment USING gin (respostes_enquesta);
-- Arbre de matèries del catàleg
CREATE TABLE categories_tema (
categoria_id INTEGER PRIMARY KEY,
nom VARCHAR(60) NOT NULL,
pare_id INTEGER REFERENCES categories_tema(categoria_id)
);
INSERT INTO categories_tema VALUES
(1,'Ficcio',NULL), (2,'Narrativa',1), (3,'Novella historica',2),
(4,'Novella contemporania',2), (5,'No-ficcio',NULL), (6,'Historia',5),
(7,'Historia contemporania',6), (8,'Divulgacio cientifica',5);
-- Cua d'avisos a socis
CREATE TABLE avisos (
aviso_id SERIAL PRIMARY KEY,
soci_id INTEGER NOT NULL REFERENCES socis(soci_id),
tipus VARCHAR(20) NOT NULL,
missatge TEXT NOT NULL,
creat_en TIMESTAMPTZ NOT NULL DEFAULT now(),
estat VARCHAR(12) NOT NULL DEFAULT 'pendent',
enviat_en TIMESTAMPTZ
);
INSERT INTO avisos (soci_id, tipus, missatge) VALUES
(13,'venciment','EJ-3088 va vèncer el 26/04'),
(14,'venciment','EJ-3093 va vèncer el 31/05'),
(16,'venciment','EJ-3087 va vèncer el 01/07'),
(15,'reserva','La teva reserva d''El mapa del temps està disponible'),
(14,'multa','Tens 24,00 € pendents'),
(11,'reserva','La teva reserva caduca el 08/08');
-- Necessari per a l'exercici 5
CREATE UNIQUE INDEX uq_multa_prestec_motiu ON multes (prestec_id, motiu);Versió de PostgreSQL. Necessites la 12 o superior per a tot el d'aquesta lliçó; FOR UPDATE SKIP LOCKED existeix des de la 9.5 i FILTER des de la 9.4.
SQLite. Admet funcions de finestra i CTE recursives des de la 3.25, però no té jsonb (té funcions json_* diferents), ni FOR UPDATE, ni SKIP LOCKED, ni nivells d'aïllament configurables: bloqueja la base sencera en escriure. Els blocs (b), (c) i (d) d'aquesta lliçó són intrínsecament de PostgreSQL.
Contingut
- Bloc A — Consultes avançades: finestres, recursió i
jsonb(exercicis 1-3) - Bloc B — Transaccions i control d'errors (exercicis 4-6)
- Bloc C — Concurrència: cinc exercicis de dues sessions (exercicis 7-11)
- Bloc D — Índexs i plans d'execució (exercicis 12-13)
- Errors habituals i consells
- Exercicis de reforç
Bloc A — Consultes avançades
Exercici 1: Funcions de finestra
Dificultat: Intermedi
Enunciat. Quatre informes que la lliçó 07-01 no va poder resoldre perquè calien funcions de finestra:
- (a) El material més prestat de cada sucursal, amb el seu nombre de préstecs. Una fila per sucursal. La sucursal es determina per l'exemplar prestat, i els empats es resolen alfabèticament per títol.
- (b) El rànquing complet de materials de la sucursal Nord, mostrant en columnes separades
ROW_NUMBER,RANKiDENSE_RANK, per veure en què es diferencien davant d'un empat. - (c) Préstecs mensuals de la sucursal Centre el 2026, amb la diferència i la variació percentual respecte del mes anterior.
- (d) La recaptació acumulada de multes, pagament a pagament, ordenada per data.
Pista. Per a (a), numera dins de cada partició i queda't amb el número 1; el WHERE no pot filtrar funcions de finestra, cal embolcallar en una CTE.
Solució
-- (a) Top 1 per grup amb ROW_NUMBER
WITH recomptes AS (
SELECT e.sucursal_id, m.material_id, m.titol, count(*) AS n
FROM prestecs p
JOIN exemplars e ON e.exemplar_id = p.exemplar_id
JOIN materials m ON m.material_id = e.material_id
GROUP BY e.sucursal_id, m.material_id, m.titol
),
ranquing AS (
SELECT c.*,
ROW_NUMBER() OVER (PARTITION BY c.sucursal_id
ORDER BY c.n DESC, c.titol ASC) AS rn
FROM recomptes c
)
SELECT s.nom AS sucursal, r.titol, r.n AS prestecs
FROM ranquing r
JOIN sucursals s ON s.sucursal_id = r.sucursal_id
WHERE r.rn = 1
ORDER BY r.n DESC;
-- (b) Les tres funcions de rànquing sobre la mateixa partició
SELECT m.titol,
count(*) AS n,
ROW_NUMBER() OVER (ORDER BY count(*) DESC, m.titol) AS row_number,
RANK() OVER (ORDER BY count(*) DESC) AS rank,
DENSE_RANK() OVER (ORDER BY count(*) DESC) AS dense_rank
FROM prestecs p
JOIN exemplars e ON e.exemplar_id = p.exemplar_id
JOIN materials m ON m.material_id = e.material_id
WHERE e.sucursal_id = 2
GROUP BY m.material_id, m.titol;
-- (c) LAG per comparar amb el mes anterior
WITH mensual AS (
SELECT date_trunc('month', p.data_prestec)::date AS mes,
count(*) AS prestecs
FROM prestecs p
JOIN exemplars e ON e.exemplar_id = p.exemplar_id
WHERE e.sucursal_id = 1
AND p.data_prestec >= DATE '2026-01-01'
GROUP BY 1
)
SELECT to_char(mes, 'YYYY-MM') AS mes,
prestecs,
LAG(prestecs) OVER (ORDER BY mes) AS mes_anterior,
prestecs - LAG(prestecs) OVER (ORDER BY mes) AS diferencia,
round(100.0 * (prestecs - LAG(prestecs) OVER (ORDER BY mes))
/ NULLIF(LAG(prestecs) OVER (ORDER BY mes), 0), 1) AS variacio_pct
FROM mensual
ORDER BY mes;
-- (d) Acumulat amb SUM() OVER
SELECT pg.data_pagament,
pg.metode,
pg.import,
sum(pg.import) OVER (ORDER BY pg.data_pagament, pg.pagament_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS acumulat
FROM pagaments pg
ORDER BY pg.data_pagament, pg.pagament_id;Resultat esperat
(a)
| sucursal | titol | prestecs |
|---|---|---|
| Centre | El mapa del temps | 3 |
| Nord | El cor gelat | 2 |
| Sud | Kafka a la platja | 2 |
(b) Sucursal Nord:
| titol | n | row_number | rank | dense_rank |
|---|---|---|---|---|
| El cor gelat | 2 | 1 | 1 | 1 |
| El mapa del temps | 2 | 2 | 1 | 1 |
| Documental: la veu de Txernòbil | 1 | 3 | 3 | 2 |
(c)
| mes | prestecs | mes_anterior | diferencia | variacio_pct |
|---|---|---|---|---|
| 2026-01 | 1 | (NULL) | (NULL) | (NULL) |
| 2026-02 | 1 | 1 | 0 | 0.0 |
| 2026-03 | 1 | 1 | 0 | 0.0 |
| 2026-04 | 3 | 1 | 2 | 200.0 |
| 2026-05 | 2 | 3 | -1 | -33.3 |
| 2026-06 | 1 | 2 | -1 | -50.0 |
| 2026-07 | 3 | 1 | 2 | 200.0 |
(d)
| data_pagament | metode | import | acumulat |
|---|---|---|---|
| 2026-03-06 | efectiu | 2.20 | 2.20 |
| 2026-04-12 | targeta | 3.00 | 5.20 |
| 2026-05-10 | targeta | 4.00 | 9.20 |
| 2026-05-18 | passarella | 2.50 | 11.70 |
Explicació. Cinc punts, i tots són paranys reals:
La sucursal Est no apareix a (a), i és correcte: els seus dos exemplars (EJ-3085 reservat i EJ-3095 retirat) no han sortit mai, així que no hi ha cap fila de prestecs que l'esmenti. Si l'informe hagués de mostrar les quatre sucursals amb «(cap)» a Est, caldria partir de sucursals amb un LEFT JOIN contra la CTE. És la mateixa lliçó de l'antireunió de 07-01 aplicada a finestres.
No es pot filtrar per una funció de finestra al WHERE. WHERE ROW_NUMBER() OVER (...) = 1 dóna error de sintaxi, i no és un caprici: les funcions de finestra s'avaluen després del WHERE i del GROUP BY, gairebé al final de l'ordre lògic, al costat del SELECT. Per això cal embolcallar-les en una CTE o una taula derivada i filtrar al nivell de fora.
Les tres funcions de rànquing fan coses diferents davant d'un empat, i (b) ho ensenya amb dades: «El cor gelat» i «El mapa del temps» tenen 2 préstecs cadascun a Nord.
| Funció | Davant d'un empat | Números que surten |
|---|---|---|
ROW_NUMBER() |
Trenca l'empat arbitràriament | 1, 2, 3 |
RANK() |
Dóna el mateix número i salta | 1, 1, 3 |
DENSE_RANK() |
Dóna el mateix número i no salta | 1, 1, 2 |
Per a un «top 1 per grup» cal fer servir ROW_NUMBER: amb RANK sortirien dues files per a Nord, perquè les dues empatades tindrien rang 1. I si de debò vols les dues en cas d'empat, aleshores RANK és la correcta. És una decisió de negoci, no de sintaxi.
A (c), LAG es calcula sobre les files que retorna la consulta, no sobre el calendari. Si en algun mes no hi hagués hagut cap préstec, aquell mes no apareixeria i LAG compararia amb el mes anterior present, no amb l'immediatament anterior en el temps. Això dóna comparacions falses. La forma robusta és generar la sèrie de mesos i unir-la per l'esquerra —just el que fa l'exercici 2—. Aquí no cal perquè els set mesos tenen préstecs, però en producció és un error molt car.
A (d), el marc de la finestra importa. ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW acumula fila a fila. El marc per defecte quan hi ha ORDER BY és RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, que agrupa totes les files amb el mateix valor d'ordre. Si dos pagaments tinguessin la mateixa data, amb RANGE tots dos mostrarien el mateix acumulat (el de després de sumar els dos) i amb ROWS mostrarien l'esglaó intermedi. Per això l'ORDER BY de la finestra inclou pagament_id: fa únic l'ordre i elimina l'ambigüitat.
Exercici 2: CTE recursives
Dificultat: Avançat
Enunciat. Dos usos diferents de la recursió:
- (a) L'informe d'ocupació diària de juliol de 2026: una fila per cada dia del mes, amb el nombre de préstecs d'aquell dia. Els dies sense préstecs han d'aparèixer amb 0. Genera la sèrie de dates amb una CTE recursiva.
- (b) L'arbre de matèries de la taula
categories_tema: per a cada categoria descendent de «Ficcio» (inclosa ella), el seu nivell de profunditat i la seva ruta completa des de l'arrel.
Pista. Una CTE recursiva té sempre dues parts unides per UNION ALL: el cas base i el pas recursiu, que es refereix a la mateixa CTE.
Solució
-- (a) Sèrie de dates + LEFT JOIN
WITH RECURSIVE dies AS (
SELECT DATE '2026-07-01' AS dia -- cas base
UNION ALL
SELECT dia + 1 -- pas recursiu
FROM dies
WHERE dia < DATE '2026-07-31' -- condició de parada: obligatòria!
)
SELECT d.dia,
count(p.prestec_id) AS prestecs
FROM dies d
LEFT JOIN prestecs p ON p.data_prestec = d.dia
GROUP BY d.dia
ORDER BY d.dia;
-- (b) Recorregut d'un arbre
WITH RECURSIVE arbre AS (
SELECT categoria_id, nom, pare_id,
0 AS nivell,
nom::text AS ruta
FROM categories_tema
WHERE nom = 'Ficcio' -- cas base: l'arrel que ens interessa
UNION ALL
SELECT c.categoria_id, c.nom, c.pare_id,
a.nivell + 1,
a.ruta || ' > ' || c.nom
FROM categories_tema c
JOIN arbre a ON a.categoria_id = c.pare_id -- pas recursiu
)
SELECT nivell, categoria_id, nom, ruta
FROM arbre
ORDER BY ruta;Resultat esperat
(a) 31 files. Les primeres i les que tenen préstecs:
| dia | prestecs |
|---|---|
| 2026-07-01 | 0 |
| 2026-07-02 | 0 |
| 2026-07-03 | 0 |
| 2026-07-04 | 0 |
| 2026-07-05 | 1 |
| 2026-07-06 | 0 |
| ... | ... |
| 2026-07-10 | 1 |
| ... | ... |
| 2026-07-20 | 1 |
| 2026-07-22 | 1 |
| ... | ... |
| 2026-07-31 | 0 |
Total: 4 préstecs repartits en 4 dies, i 27 dies a zero.
(b)
| nivell | categoria_id | nom | ruta |
|---|---|---|---|
| 0 | 1 | Ficcio | Ficcio |
| 1 | 2 | Narrativa | Ficcio > Narrativa |
| 2 | 4 | Novella contemporania | Ficcio > Narrativa > Novella contemporania |
| 2 | 3 | Novella historica | Ficcio > Narrativa > Novella historica |
Explicació. L'estructura d'una CTE recursiva és sempre la mateixa i convé memoritzar-la:
WITH RECURSIVE nom AS (
<cas base> -- no es refereix a si mateixa
UNION ALL
<pas recursiu> -- SÍ que es refereix a "nom"
)El motor executa el cas base, fica el resultat en una taula de treball, executa el pas recursiu usant només les files noves de la iteració anterior, i repeteix fins que una iteració no produeix cap fila.
La condició de parada és responsabilitat teva. A (a), sense WHERE dia < DATE '2026-07-31' la consulta genera dates fins que peta. A (b), la parada és implícita: quan ja no hi ha categories el pare de les quals sigui al nivell actual, la iteració retorna zero files i acaba. Però si l'arbre tingués un cicle —una categoria que fos la seva pròpia antecessora— la consulta no acabaria mai. Contra això, PostgreSQL 14 i superiors tenen la clàusula CYCLE:
En versions anteriors, el patró manual és arrossegar un array amb el camí recorregut i afegir WHERE NOT c.categoria_id = ANY(a.cami) al pas recursiu. És exactament el que faltava a l'exercici 3 de la lliçó 07-02, on els requisits previs d'un curs podien formar un cicle A → B → C → A.
A PostgreSQL, (a) té una drecera molt millor:
SELECT g.dia::date, count(p.prestec_id)
FROM generate_series(DATE '2026-07-01', DATE '2026-07-31', INTERVAL '1 day') AS g(dia)
LEFT JOIN prestecs p ON p.data_prestec = g.dia::date
GROUP BY g.dia ORDER BY g.dia;generate_series és més curt, més ràpid i més llegible. La versió recursiva és a l'exercici perquè és portable (funciona a SQLite i a SQL Server) i perquè entendre el mecanisme és el que permet resoldre (b), on no hi ha drecera.
El LEFT JOIN és la raó de ser de l'exercici. Un informe d'ocupació diària construït amb GROUP BY data_prestec sobre la taula de préstecs retornaria 4 files, no 31. I un gràfic dibuixat amb aquelles 4 files presenta el mes com si hi hagués hagut activitat contínua. Els dies a zero són dades, no absència de dades, i l'única manera de tenir-los és generar l'eix temporal complet i unir per l'esquerra.
Exercici 3: jsonb i pivot manual
Dificultat: Avançat
Enunciat. Les enquestes de satisfacció dels esdeveniments es guarden a informes_esdeveniment.respostes_enquesta, de tipus jsonb. Es demana:
- (a) Llistar cada esdeveniment amb el canal de l'enquesta i el nombre de respostes rebudes.
- (b) Els informes les enquestes dels quals es van fer per web, usant l'operador de contenció
@>. - (c) Desplegar l'array de respostes amb
jsonb_array_elementsi calcular, per esdeveniment, la puntuació mitjana, el nombre de respostes que recomanarien i el percentatge. - (d) Un pivot manual: per esdeveniment, quantes respostes van donar cada puntuació (2, 3, 4 i 5), en columnes.
Pista. -> retorna jsonb; ->> retorna text. La diferència importa quan cal comparar o convertir.
Solució
-- (a) Navegació bàsica: -> i ->>
SELECT ie.esdeveniment_id,
ev.titol,
ie.respostes_enquesta ->> 'canal' AS canal,
jsonb_array_length(ie.respostes_enquesta -> 'respostes') AS n_respostes
FROM informes_esdeveniment ie
JOIN esdeveniments ev ON ev.esdeveniment_id = ie.esdeveniment_id
ORDER BY ie.esdeveniment_id;
-- (b) Contenció: @> pregunta "el jsonb de l'esquerra conté això?"
SELECT esdeveniment_id, respostes_enquesta ->> 'canal' AS canal
FROM informes_esdeveniment
WHERE respostes_enquesta @> '{"canal":"web"}'::jsonb
ORDER BY esdeveniment_id;
-- (c) Desplegar l'array a files i agregar
WITH respostes AS (
SELECT ie.esdeveniment_id,
(r ->> 'puntuacio')::int AS puntuacio,
(r ->> 'recomanaria')::boolean AS recomanaria
FROM informes_esdeveniment ie,
LATERAL jsonb_array_elements(ie.respostes_enquesta -> 'respostes') AS r
)
SELECT esdeveniment_id,
count(*) AS respostes,
round(avg(puntuacio), 2) AS mitjana,
count(*) FILTER (WHERE recomanaria) AS recomanen,
round(100.0 * count(*) FILTER (WHERE recomanaria) / count(*), 1) AS pct_recomana
FROM respostes
GROUP BY esdeveniment_id
ORDER BY mitjana DESC;
-- (d) Pivot manual amb FILTER
WITH respostes AS (
SELECT ie.esdeveniment_id, (r ->> 'puntuacio')::int AS puntuacio
FROM informes_esdeveniment ie,
LATERAL jsonb_array_elements(ie.respostes_enquesta -> 'respostes') AS r
)
SELECT esdeveniment_id,
count(*) FILTER (WHERE puntuacio = 2) AS "2",
count(*) FILTER (WHERE puntuacio = 3) AS "3",
count(*) FILTER (WHERE puntuacio = 4) AS "4",
count(*) FILTER (WHERE puntuacio = 5) AS "5",
count(*) AS total
FROM respostes
GROUP BY esdeveniment_id
ORDER BY esdeveniment_id;Resultat esperat
(a)
| esdeveniment_id | titol | canal | n_respostes |
|---|---|---|---|
| 101 | Club de lectura: Els pilars de la Terra | web | 4 |
| 102 | Contacontes de primavera | paper | 3 |
| 103 | Taller d'escriptura creativa | web | 4 |
| 104 | Presentació: La capsa dels desitjos | paper | 4 |
| 105 | Club de lectura: Tokio blues | web | 3 |
(b) Esdeveniments 101, 103 i 105.
(c)
| esdeveniment_id | respostes | mitjana | recomanen | pct_recomana |
|---|---|---|---|---|
| 103 | 4 | 4.75 | 4 | 100.0 |
| 105 | 3 | 4.67 | 3 | 100.0 |
| 101 | 4 | 4.50 | 3 | 75.0 |
| 102 | 3 | 4.00 | 2 | 66.7 |
| 104 | 4 | 3.25 | 2 | 50.0 |
(d)
| esdeveniment_id | 2 | 3 | 4 | 5 | total |
|---|---|---|---|---|---|
| 101 | 0 | 0 | 2 | 2 | 4 |
| 102 | 0 | 1 | 1 | 1 | 3 |
| 103 | 0 | 0 | 1 | 3 | 4 |
| 104 | 1 | 1 | 2 | 0 | 4 |
| 105 | 0 | 0 | 1 | 2 | 3 |
Explicació. Els quatre apartats cobreixen les quatre operacions que es fan servir el 95 % de les vegades amb jsonb:
-> enfront de ->>. respostes_enquesta -> 'canal' retorna "web" amb cometes, perquè és un valor jsonb de tipus cadena. respostes_enquesta ->> 'canal' retorna web, text pla. La conseqüència pràctica: WHERE respostes_enquesta -> 'canal' = 'web' falla o no troba res, perquè compara un jsonb amb un text. Cal fer servir ->> per comparar amb text, o -> 'canal' = '"web"'::jsonb. És l'error número u amb jsonb.
@> és l'operador que fa servir l'índex GIN. WHERE respostes_enquesta ->> 'canal' = 'web' dóna el mateix resultat que @> però no pot usar l'índex GIN que vam crear a Abans de Començar: un índex GIN per defecte indexa l'estructura del document i respon als operadors de contenció i existència (@>, ?, ?|, ?&), no a extraccions. Amb cinc files és igual; amb 200.000 informes, la diferència és de tres ordres de magnitud. Si necessites indexar una extracció concreta, el correcte és un índex d'expressió: CREATE INDEX ... ON informes_esdeveniment ((respostes_enquesta ->> 'canal')).
jsonb_array_elements és una funció que retorna files, no un valor. Per això apareix al FROM amb LATERAL, que li permet veure la columna ie.respostes_enquesta de la fila que s'està processant. La paraula LATERAL és opcional a PostgreSQL quan la funció va al FROM separada per coma, però escriure-la fa explícit el que està passant: per cada fila d'informes_esdeveniment, es generen tantes files com elements tingui el seu array.
El pivot manual amb FILTER. PostgreSQL no té la clàusula PIVOT d'altres motors; es fa amb un agregat condicional per columna. count(*) FILTER (WHERE puntuacio = 5) és SQL estàndard i equival a sum(CASE WHEN puntuacio = 5 THEN 1 ELSE 0 END), que és la versió portable. Compte amb la variant count(CASE WHEN puntuacio = 5 THEN 1 END): funciona perquè count ignora els nuls, però count(CASE WHEN ... THEN 1 ELSE 0 END) no funciona, perquè compta també els zeros i retorna sempre el total. És un error clàssic.
La limitació del pivot manual: les columnes cal escriure-les a mà. Si demà l'enquesta admet puntuacions de l'1 al 10, cal editar la consulta. Un pivot amb nombre de columnes variable no és expressable en SQL pur —el nombre de columnes del resultat s'ha de conèixer en analitzar la consulta— i es resol generant l'SQL des de l'aplicació o retornant el resultat en format llarg i pivotant a la capa de presentació.
Bloc B — Transaccions i control d'errors
Exercici 4: La transacció completa del préstec
Dificultat: Intermedi
Enunciat. L'Ivan Pereda (soci 15) es presenta al taulell de la sucursal Nord a recollir «El mapa del temps», que tenia reservat (reserva 502, estat activa). L'exemplar disponible és EJ-3082 (exemplar_id 3082).
Escriu la transacció completa que:
- Comprova que l'exemplar està realment disponible, bloquejant-lo.
- Insereix el préstec amb 21 dies de termini des del 2 d'agost de 2026.
- Marca l'exemplar com a
prestat. - Tanca la reserva com a
atesa.
Ha de ser atòmica: si qualsevol pas falla, no hi ha de quedar res. I ha de detectar el cas en què una altra persona s'hagi endut l'exemplar entre la consulta del catàleg i la pulsació del botó.
Solució
Versió interactiva a psql, per entendre el flux:
BEGIN;
-- 1) Bloquejar i comprovar. FOR UPDATE impedeix que una altra sessió el toqui
-- fins que aquesta transacció acabi.
SELECT exemplar_id, codi, estat
FROM exemplars
WHERE exemplar_id = 3082
FOR UPDATE;
-- Ha de retornar estat = 'disponible'. Si retorna una altra cosa: ROLLBACK.
-- 2) Registrar el préstec
INSERT INTO prestecs (prestec_id, soci_id, exemplar_id,
data_prestec, data_devolucio_prevista)
VALUES (21, 15, 3082, DATE '2026-08-02', DATE '2026-08-23');
-- 3) Canviar l'estat de l'exemplar, condicionat a l'estat esperat
UPDATE exemplars
SET estat = 'prestat'
WHERE exemplar_id = 3082 AND estat = 'disponible';
-- Ha de dir UPDATE 1. Si diu UPDATE 0: ROLLBACK.
-- 4) Tancar la reserva
UPDATE reserves
SET estat = 'atesa'
WHERE reserva_id = 502 AND estat = 'activa';
-- Ha de dir UPDATE 1.
COMMIT;Versió de producció, amb control d'errors real, com a funció:
CREATE OR REPLACE FUNCTION registrar_prestec(
p_soci_id INTEGER,
p_exemplar_id INTEGER,
p_dies INTEGER DEFAULT 21
) RETURNS INTEGER AS $$
DECLARE
v_estat VARCHAR(15);
v_material_id INTEGER;
v_prestec_id INTEGER;
v_files INTEGER;
BEGIN
-- 1) Bloquejar la fila de l'exemplar i llegir-ne l'estat
SELECT estat, material_id INTO v_estat, v_material_id
FROM exemplars
WHERE exemplar_id = p_exemplar_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'L''exemplar % no existeix', p_exemplar_id
USING ERRCODE = 'no_data_found';
END IF;
IF v_estat <> 'disponible' THEN
RAISE EXCEPTION 'L''exemplar % no està disponible (estat: %)',
p_exemplar_id, v_estat
USING ERRCODE = 'check_violation';
END IF;
-- 2) Registrar el préstec
INSERT INTO prestecs (soci_id, exemplar_id, data_prestec,
data_devolucio_prevista)
VALUES (p_soci_id, p_exemplar_id, CURRENT_DATE,
CURRENT_DATE + p_dies)
RETURNING prestec_id INTO v_prestec_id;
-- 3) Canviar l'estat
UPDATE exemplars SET estat = 'prestat'
WHERE exemplar_id = p_exemplar_id AND estat = 'disponible';
GET DIAGNOSTICS v_files = ROW_COUNT;
IF v_files <> 1 THEN
RAISE EXCEPTION 'Cursa detectada en marcar l''exemplar %', p_exemplar_id;
END IF;
-- 4) Tancar la reserva del soci sobre aquell material, si n'hi ha
UPDATE reserves SET estat = 'atesa'
WHERE soci_id = p_soci_id AND material_id = v_material_id
AND estat = 'activa';
RETURN v_prestec_id;
END;
$$ LANGUAGE plpgsql;Resultat esperat
BEGIN
exemplar_id | codi | estat
-------------+---------+------------
3082 | EJ-3082 | disponible
INSERT 0 1
UPDATE 1
UPDATE 1
COMMITI comprovant-ne l'efecte:
SELECT e.codi, e.estat, r.estat AS estat_reserva
FROM exemplars e
JOIN reserves r ON r.reserva_id = 502
WHERE e.exemplar_id = 3082;| codi | estat | estat_reserva |
|---|---|---|
| EJ-3082 | prestat | atesa |
Si una altra sessió s'hagués endut l'exemplar abans, la funció llançaria:
...i res de la transacció no quedaria: ni el préstec, ni el canvi d'estat, ni la reserva tancada.
Explicació. Quatre decisions que separen aquesta transacció d'una que funciona «gairebé sempre»:
FOR UPDATE al SELECT de comprovació. Sense ell, entre el SELECT que diu «disponible» i l'UPDATE que el marca com a prestat hi ha una finestra en què una altra sessió pot fer el mateix. FOR UPDATE bloqueja la fila fins al final de la transacció: la segona sessió es queda esperant al seu propi SELECT ... FOR UPDATE i, quan la primera confirma, llegeix l'estat ja actualitzat. És el patró de comprovar-i-actuar, i sense el bloqueig no és atòmic.
L'UPDATE porta AND estat = 'disponible' encara que ja ho hàgim comprovat. És un cinturó sobre els tirants: converteix l'UPDATE en una operació condicional el nombre de files afectades de la qual ens diu si la premissa continuava essent certa. GET DIAGNOSTICS ... ROW_COUNT és la manera de llegir aquell número des de plpgsql; a psql interactiu el veus a l'UPDATE 1 / UPDATE 0.
Un UPDATE 0 no és un error. És el parany més habitual: si la reserva ja estava tancada, el pas 4 retorna UPDATE 0 i la transacció confirma igualment. Aquí això és deliberat —pot no haver-hi reserva, i no passa res—, però al pas 3 no ho és, i per això allà sí que es comprova. Decidir explícitament, pas a pas, si un UPDATE 0 és acceptable o és una fallada, és la meitat de la feina d'escriure una transacció.
En plpgsql, tota la funció és una transacció implícita. No cal escriure BEGIN/COMMIT a dins: si la funció llança una excepció, tot el que hagi fet es desfà automàticament. El que sí que pot fer és capturar excepcions amb EXCEPTION WHEN ... THEN, i aquí convé saber que cada bloc EXCEPTION crea un punt de desament implícit, amb el seu cost. Un bucle d'un milió d'iteracions amb un bloc EXCEPTION a dins és notablement més lent que el mateix bucle sense ell.
Exercici 5: SAVEPOINT en un procés per lots
Dificultat: Intermedi
Enunciat. El procés nocturn del 2 d'agost de 2026 ha d'emetre una multa per retard a cada préstec vençut i sense retornar: els préstecs 9, 12 i 15. L'import és 0,20 €/dia amb topall de 15,00 €.
El problema: existeix l'índex uq_multa_prestec_motiu sobre (prestec_id, motiu), i alguns d'aquells préstecs ja tenen una multa de retard emesa. Si el procés s'executa com una transacció monolítica, el primer xoc avorta el lot sencer i no s'emet cap multa.
Escriu el lot de manera que la fallada d'un element no impedeixi processar els altres, fent servir SAVEPOINT. En acabar, informa de quantes se'n van emetre i quantes se'n van saltar.
Pista. ROLLBACK TO SAVEPOINT desfà fins al punt de desament i deixa la transacció utilitzable; sense ell, després d'un error la transacció queda avortada i tota instrucció posterior falla.
Solució
Versió interactiva, per veure el mecanisme:
BEGIN;
SAVEPOINT sp_p9;
INSERT INTO multes (multa_id, soci_id, prestec_id, motiu, import, data_emissio, estat)
VALUES (9, 13, 9, 'retard', LEAST(98 * 0.20, 15.00), DATE '2026-08-02', 'pendent');
-- ERROR: duplicate key value violates unique constraint "uq_multa_prestec_motiu"
ROLLBACK TO SAVEPOINT sp_p9; -- la transacció torna a ser utilitzable
SAVEPOINT sp_p12;
INSERT INTO multes (multa_id, soci_id, prestec_id, motiu, import, data_emissio, estat)
VALUES (9, 14, 12, 'retard', LEAST(63 * 0.20, 15.00), DATE '2026-08-02', 'pendent');
-- INSERT 0 1
RELEASE SAVEPOINT sp_p12;
SAVEPOINT sp_p15;
INSERT INTO multes (multa_id, soci_id, prestec_id, motiu, import, data_emissio, estat)
VALUES (10, 16, 15, 'retard', LEAST(32 * 0.20, 15.00), DATE '2026-08-02', 'pendent');
-- ERROR: duplicate key value violates unique constraint "uq_multa_prestec_motiu"
ROLLBACK TO SAVEPOINT sp_p15;
COMMIT;Versió de producció, amb el bucle i el recompte:
DO $$
DECLARE
r RECORD;
v_dies INTEGER;
v_emeses INTEGER := 0;
v_saltades INTEGER := 0;
BEGIN
FOR r IN
SELECT p.prestec_id, p.soci_id,
(DATE '2026-08-02' - p.data_devolucio_prevista) AS dies
FROM prestecs p
WHERE p.data_devolucio IS NULL
AND p.data_devolucio_prevista < DATE '2026-08-02'
ORDER BY p.prestec_id
LOOP
BEGIN -- bloc imbricat = SAVEPOINT implícit
INSERT INTO multes (soci_id, prestec_id, motiu, import,
data_emissio, estat)
VALUES (r.soci_id, r.prestec_id, 'retard',
LEAST(r.dies * 0.20, 15.00), DATE '2026-08-02', 'pendent');
v_emeses := v_emeses + 1;
EXCEPTION
WHEN unique_violation THEN
v_saltades := v_saltades + 1;
RAISE NOTICE 'Préstec %: ja tenia multa de retard, se salta',
r.prestec_id;
END;
END LOOP;
RAISE NOTICE 'Lot acabat: % emeses, % saltades', v_emeses, v_saltades;
END $$;Resultat esperat
NOTICE: Préstec 9: ja tenia multa de retard, se salta
NOTICE: Préstec 15: ja tenia multa de retard, se salta
NOTICE: Lot acabat: 1 emeses, 2 saltadesConcretament:
| prestec_id | dies de retard | Resultat | Motiu |
|---|---|---|---|
| 9 | 98 | Saltat | Ja existeix la multa 7, retard, del soci 13 |
| 12 | 63 | Emesa, 12,60 € | Només tenia la multa 4, de motiu perdua |
| 15 | 32 | Saltat | Ja existeix la multa 8, retard (anul·lada, però ocupa l'índex) |
Resultat final: 1 multa emesa, 2 saltades, i la transacció confirma.
Comprovació:
SELECT multa_id, soci_id, prestec_id, motiu, import, estat
FROM multes WHERE data_emissio = DATE '2026-08-02';| multa_id | soci_id | prestec_id | motiu | import | estat |
|---|---|---|---|---|---|
| 9 | 14 | 12 | retard | 12.60 | pendent |
Explicació. El que aquest exercici ensenya és una propietat de PostgreSQL que sorprèn qui ve d'altres motors:
A PostgreSQL, un error dins d'una transacció l'avorta sencera. A partir d'aquell moment, tota instrucció retorna
ERROR: current transaction is aborted, commands ignored until end of transaction block, i elCOMMITfinal es comporta com unROLLBACK.
És a dir: sense SAVEPOINT, un sol xoc de clau única a l'element número 3 d'un lot de 500 en tira els 500. I no falla sorollosament: el COMMIT respon ROLLBACK i cal estar mirant per adonar-se'n.
SAVEPOINT és l'antídot. Marca un punt al qual es pot tornar; ROLLBACK TO SAVEPOINT desfà només el fet després i retorna la transacció a estat utilitzable. RELEASE SAVEPOINT el descarta quan ja no cal (opcional, però convé en lots llargs: cada punt de desament viu consumeix recursos).
En plpgsql no s'escriu SAVEPOINT: es fa servir un bloc BEGIN ... EXCEPTION ... END. Aquell bloc crea i gestiona el punt de desament automàticament. És la forma idiomàtica i la que cal usar.
El cas del préstec 15 mereix un comentari de disseny. La seva multa prèvia (la 8) està en estat anullada, així que conceptualment «no compta» i potser hauria de poder emetre's una de nova. Però l'índex únic és sobre (prestec_id, motiu) sense més, i no distingeix estats. Si el negoci ho vol permetre, l'índex correcte seria parcial:
DROP INDEX uq_multa_prestec_motiu;
CREATE UNIQUE INDEX uq_multa_prestec_motiu
ON multes (prestec_id, motiu)
WHERE estat IN ('pendent','pagada');És el mateix patró de l'índex únic parcial que vam usar a 07-02 per a «un sol lloguer obert per còpia». Que un lot es salti un element per una restricció massa ampla és un símptoma de disseny, no només un problema de procés.
Exercici 6: Què queda després d'una seqüència de SAVEPOINT i ROLLBACK TO
Dificultat: Avançat
Enunciat. Un operador executa aquesta seqüència en una sola sessió. Indica, per a cada instrucció numerada, si el seu efecte sobreviu al COMMIT final o no, i descriu l'estat final de les taules afectades. Justifica cada resposta.
BEGIN;
INSERT INTO ponents (ponent_id, nom, cognoms, email, extern)
VALUES (4,'Lídia','Serna','[email protected]',TRUE); -- (1)
SAVEPOINT sp1;
UPDATE esdeveniments SET places_ofertes = 20 WHERE esdeveniment_id = 106; -- (2)
SAVEPOINT sp2;
INSERT INTO participacions VALUES (106,4,'tallerista',400.00); -- (3)
UPDATE esdeveniments SET estat = 'complet' WHERE esdeveniment_id = 106; -- (4)
ROLLBACK TO SAVEPOINT sp2;
INSERT INTO participacions VALUES (107,4,'narradora',150.00); -- (5)
SAVEPOINT sp3;
DELETE FROM ponents WHERE ponent_id = 2; -- (6)
ROLLBACK TO SAVEPOINT sp3;
UPDATE esdeveniments SET publicat = TRUE WHERE esdeveniment_id = 107; -- (7)
COMMIT;Segona part: què hauria passat si s'hagués omès el ROLLBACK TO SAVEPOINT sp3 després de la instrucció (6)?
Solució
| # | Instrucció | Sobreviu? | Per què |
|---|---|---|---|
| (1) | INSERT ponent 4 |
Sí | Passa abans de sp1; cap ROLLBACK TO no retrocedeix tan enrere |
| (2) | UPDATE places del 106 a 20 |
Sí | Passa entre sp1 i sp2. El ROLLBACK TO sp2 torna al punt sp2, que és posterior a aquesta instrucció |
| (3) | INSERT participació (106,4) |
No | Posterior a sp2, desfeta pel ROLLBACK TO sp2 |
| (4) | UPDATE estat del 106 a complet |
No | Posterior a sp2, desfeta pel ROLLBACK TO sp2 |
| (5) | INSERT participació (107,4) |
Sí | Posterior al ROLLBACK TO sp2 i anterior a sp3; res no la desfà |
| (6) | DELETE del ponent 2 |
No | Falla: viola la clau forana des de participacions (el ponent 2 participa en 102, 103 i 106). El ROLLBACK TO sp3 deixa la transacció utilitzable |
| (7) | UPDATE publicat del 107 |
Sí | Última instrucció, confirmada pel COMMIT |
Estat final de les taules:
| ponent_id | nom | cognoms |
|---|---|---|
| 1 | Rosa | Calduch |
| 2 | Aitor | Lemus |
| 3 | Delia | Marchetti |
| 4 | Lídia | Serna |
SELECT esdeveniment_id, places_ofertes, estat, publicat FROM esdeveniments WHERE esdeveniment_id IN (106,107);| esdeveniment_id | places_ofertes | estat | publicat |
|---|---|---|---|
| 106 | 20 | obert | t |
| 107 | 20 | programat | t |
participacions passa de 7 a 8 files: s'afegeix (107, 4, 'narradora', 150.00) i no s'afegeix (106, 4, 'tallerista', 400.00).
Segona part: sense el ROLLBACK TO SAVEPOINT sp3.
DELETE FROM ponents WHERE ponent_id = 2;
ERROR: update or delete on table "ponents" violates foreign key constraint
"participacions_ponent_id_fkey" on table "participacions"
UPDATE esdeveniments SET publicat = TRUE WHERE esdeveniment_id = 107;
ERROR: current transaction is aborted, commands ignored until end of transaction block
COMMIT;
ROLLBACKEs perd absolutament tot: la instrucció (7) ni s'executa, i el COMMIT respon literalment ROLLBACK. El ponent 4 no es crea, les places del 106 continuen en 15 i la participació (107,4) no existeix. Un sol error, sense punt de desament que el contingui, tira la feina sencera.
Explicació. El punt que cal fixar i que gairebé tothom confon la primera vegada:
ROLLBACK TO SAVEPOINT spdesfà el que ha passat després desp. El que ha passat abans desp—inclòs el que hi ha entresp1isp2— es manté.
La instrucció (2) és la que separa qui ho ha entès de qui no. Està entre dos punts de desament, i el ROLLBACK TO sp2 torna al segon, no al primer. Si l'operador l'hagués volgut desfer, hauria hagut d'escriure ROLLBACK TO sp1.
Segon punt: un ROLLBACK TO no destrueix el punt de desament. Després de ROLLBACK TO sp2, el punt sp2 continua existint i s'hi pot tornar més vegades. És RELEASE SAVEPOINT el que l'elimina. I ROLLBACK TO sp1 invalidaria automàticament sp2, perquè és posterior.
Tercer punt, el més important a la pràctica: la resposta ROLLBACK a un COMMIT és fàcil de no veure. En un psql interactiu salta a la vista; en un client d'aplicació que no comprova el valor retornat per commit(), la transacció es perd en silenci i l'aplicació creu que ha desat. Aquesta és la raó per la qual els ORM seriosos embolcallen cada operació en un punt de desament i per la qual l'ON_ERROR_STOP de psql (\set ON_ERROR_STOP on) hauria d'estar actiu en tot script de migració.
Bloc C — Concurrència: exercicis de dues sessions
Instruccions per a tot el bloc. Executa cada línia a la sessió indicada i en l'ordre indicat. Quan una sessió es quedi «penjada» sense retornar l'indicador, és perquè està esperant un bloqueig: és exactament el que volem observar. Abans de cada exercici, assegura't que cap de les dues sessions no té una transacció oberta (
ROLLBACK;per si de cas).
Exercici 7: Reproduir una actualització perduda i arreglar-la
Dificultat: Avançat
Enunciat. El reglament afegeix un recàrrec de 2,00 € a les multes pendents amb més de 30 dies. Dos administratius, en dues sucursals diferents, apliquen el recàrrec a la mateixa multa: la número 3 (Ivan Pereda, 1,60 €, pendent).
L'aplicació ho fa en dos passos: llegeix l'import, hi suma 2,00 en memòria i escriu el resultat.
Part 1. Reprodueix el fenomen. Quant hauria de valdre la multa al final i quant val?
Part 2. Arregla-ho de dues maneres diferents i explica quina prefereixes.
Solució — Part 1: l'actualització perduda
| Ordre | Sessió A | Sessió B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | SELECT import FROM multes WHERE multa_id = 3; → 1.60 |
|
| 3 | BEGIN; |
|
| 4 | SELECT import FROM multes WHERE multa_id = 3; → 1.60 |
|
| 5 | UPDATE multes SET import = 3.60 WHERE multa_id = 3; |
|
| 6 | COMMIT; |
|
| 7 | UPDATE multes SET import = 3.60 WHERE multa_id = 3; |
|
| 8 | COMMIT; |
|
| 9 | SELECT import FROM multes WHERE multa_id = 3; → 3.60 |
Hauria de valdre 5,60 € (1,60 + 2,00 + 2,00). Val 3,60 €. Un dels dos recàrrecs ha desaparegut sense deixar rastre: ni error, ni avís, ni entrada en cap registre. Els dos administratius van veure UPDATE 1 i creuen que la seva feina està feta.
Solució — Part 2, opció 1: SELECT ... FOR UPDATE
Restableix el valor (UPDATE multes SET import = 1.60 WHERE multa_id = 3;) i repeteix amb bloqueig:
| Ordre | Sessió A | Sessió B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | SELECT import FROM multes WHERE multa_id = 3 FOR UPDATE; → 1.60 |
|
| 3 | BEGIN; |
|
| 4 | SELECT import FROM multes WHERE multa_id = 3 FOR UPDATE; → es queda esperant |
|
| 5 | UPDATE multes SET import = 3.60 WHERE multa_id = 3; |
(continua esperant) |
| 6 | COMMIT; |
→ es desbloqueja i retorna 3.60 |
| 7 | UPDATE multes SET import = 5.60 WHERE multa_id = 3; |
|
| 8 | COMMIT; |
Resultat: 5,60 €. Correcte.
Solució — Part 2, opció 2: l'UPDATE atòmic
-- Sessió A -- Sessió B
UPDATE multes SET import = import + 2.00 UPDATE multes SET import = import + 2.00
WHERE multa_id = 3; WHERE multa_id = 3;Sense BEGIN explícit, cada UPDATE és la seva pròpia transacció. El segon espera que el primer confirmi i rellegeix la fila actualitzada abans d'aplicar el seu canvi, perquè en READ COMMITTED un UPDATE bloquejat reavalua la fila quan s'allibera.
Resultat: 5,60 €. També correcte, i sense bloqueig explícit.
Resultat esperat
| Enfocament | Resultat final | Viatges a la base de dades |
|---|---|---|
| Llegir, calcular, escriure (sense bloqueig) | 3,60 € — incorrecte | 2 |
SELECT ... FOR UPDATE + UPDATE |
5,60 € | 2 |
UPDATE ... SET import = import + 2.00 |
5,60 € | 1 |
Explicació. L'actualització perduda és el fenomen de concurrència més traïdor perquè el motor no el considera un error. Els dos UPDATE són legítims, tots dos afecten una fila, tots dos confirmen. La incoherència és al cap de l'aplicació, no a la base de dades.
Quan usar cada solució:
- L'
UPDATEatòmic és sempre preferible quan és possible. Un sol viatge, sense finestra de cursa, sense bloqueig a gestionar. La regla: si el valor nou es pot expressar en funció del valor vell dins del mateix SQL, fes-ho així. FOR UPDATEés necessari quan el càlcul no cap a l'UPDATE: quan cal consultar altres taules, aplicar lògica de negoci complexa o decidir si actualitzar. És el cas de la transacció del préstec de l'exercici 4.- El bloqueig optimista amb columna
versio(exercici 10) és la tercera via, i la que convé quan l'usuari té un formulari obert en pantalla: no es pot mantenir un bloqueig mentre algú pensa.
Nota important sobre READ COMMITTED, que és el nivell per defecte: al pas 7 de l'opció 2, l'UPDATE de la sessió B no treballa sobre la instantània que va veure en començar; quan el bloqueig s'allibera, PostgreSQL reavalua el WHERE sobre la versió més recent de la fila. Aquell comportament —que no és el que faria un aïllament estricte— és el que salva l'opció 2, i no funciona en REPEATABLE READ: allà el segon UPDATE avortaria amb ERROR: could not serialize access due to concurrent update.
Exercici 8: READ COMMITTED enfront de REPEATABLE READ
Dificultat: Avançat
Enunciat. Una consulta del catàleg web llegeix dues vegades, dins de la mateixa transacció, quants exemplars disponibles hi ha del material 902 («El mapa del temps»). Entre les dues lectures, un company retorna un exemplar.
Executa l'escenari dues vegades: primer amb READ COMMITTED i després amb REPEATABLE READ. Anota què veu la Sessió A a cada lectura i explica la diferència.
Estat de partida: del material 902 hi ha dos exemplars, EJ-3081 (prestat) i EJ-3082 (disponible). Disponibles: 1.
Solució — Escenari 1: READ COMMITTED (el nivell per defecte)
| Ordre | Sessió A | Sessió B |
|---|---|---|
| 1 | BEGIN; (READ COMMITTED per defecte) |
|
| 2 | SELECT count(*) FROM exemplars WHERE material_id=902 AND estat='disponible'; → 1 |
|
| 3 | UPDATE exemplars SET estat='disponible' WHERE exemplar_id=3081; |
|
| 4 | COMMIT; |
|
| 5 | SELECT count(*) FROM exemplars WHERE material_id=902 AND estat='disponible'; → 2 |
|
| 6 | COMMIT; |
La Sessió A ha vist 1 i després 2 dins de la mateixa transacció: una lectura no repetible.
Solució — Escenari 2: REPEATABLE READ
Restableix l'estat (UPDATE exemplars SET estat='prestat' WHERE exemplar_id=3081;) i repeteix:
| Ordre | Sessió A | Sessió B |
|---|---|---|
| 1 | BEGIN ISOLATION LEVEL REPEATABLE READ; |
|
| 2 | SELECT count(*) ... ; → 1 |
|
| 3 | UPDATE exemplars SET estat='disponible' WHERE exemplar_id=3081; |
|
| 4 | COMMIT; |
|
| 5 | SELECT count(*) ... ; → 1 |
|
| 6 | COMMIT; |
|
| 7 | SELECT count(*) ... ; (ja fora de la transacció) → 2 |
La Sessió A veu 1 les dues vegades. Només després del COMMIT, en una transacció nova, veu el 2.
Resultat esperat
| Nivell | 1a lectura | 2a lectura | Fenomen |
|---|---|---|---|
READ COMMITTED |
1 | 2 | Lectura no repetible |
REPEATABLE READ |
1 | 1 | Cap: instantània estable |
Explicació. La diferència és en quan es pren la instantània del que cada transacció llegeix:
- En
READ COMMITTED, cada instrucció pren la seva pròpia instantània, en començar. Per això la segona consulta veu els canvis que es van confirmar entre l'una i l'altra. És el nivell per defecte de PostgreSQL, i és el correcte per al 95 % de les aplicacions: maximitza la concurrència i mai no llegeix dades sense confirmar. - En
REPEATABLE READ, la instantània es pren una sola vegada, a la primera instrucció de la transacció, i es manté fins al final. Tot el que la transacció llegeixi serà coherent entre si, com si el món s'hagués congelat.
Quan importa de debò. Si l'informe mensual de direcció fa vuit consultes —préstecs, socis, multes, pagaments, esdeveniments...— i s'executa en READ COMMITTED mentre el sistema està en ús, les vuit consultes poden veure estats diferents de la base de dades. El total de multes pot no quadrar amb el desglossament per motiu. En REPEATABLE READ això és impossible: les vuit veuen exactament el mateix instant.
El preu. En REPEATABLE READ, si la teva transacció intenta modificar una fila que una altra transacció va modificar i confirmar després de la teva instantània, PostgreSQL avorta la teva amb:
No és una fallada: és el contracte. L'aplicació ha d'estar preparada per reintentar la transacció sencera. Si no ho està, pujar el nivell d'aïllament canvia un problema de dades incoherents per un problema d'errors en producció.
Nota terminològica. L'estàndard SQL diu que REPEATABLE READ permet lectures fantasma (files noves que apareixen en una consulta de rang). La implementació de PostgreSQL, basada en MVCC amb instantànies, no les permet: el seu REPEATABLE READ és més fort que el mínim exigit per l'estàndard. Per al biaix d'escriptura —el fenomen que sí que se li escapa— existeix SERIALIZABLE, que és el quart nivell i l'únic que garanteix equivalència amb una execució en sèrie.
Exercici 9: Provocar i resoldre un interbloqueig
Dificultat: Avançat
Enunciat. Dos processos actualitzen les dades de dos socis, però en ordre diferent: el procés A comença per la Marta Alsina (14) i continua amb l'Ivan Pereda (15); el procés B comença per l'Ivan i continua amb la Marta.
Part 1. Provoca l'interbloqueig i observa què fa PostgreSQL. Part 2. Arregla-ho sense canviar el que fa cada procés.
Solució — Part 1: l'interbloqueig
| Ordre | Sessió A | Sessió B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | UPDATE socis SET actiu=TRUE WHERE soci_id=14; → UPDATE 1 |
|
| 3 | BEGIN; |
|
| 4 | UPDATE socis SET actiu=TRUE WHERE soci_id=15; → UPDATE 1 |
|
| 5 | UPDATE socis SET actiu=TRUE WHERE soci_id=15; → espera (B té la fila 15) |
|
| 6 | UPDATE socis SET actiu=TRUE WHERE soci_id=14; → espera (A té la fila 14) |
|
| 7 | (al cap d'~1 segon, una de les dues rep l'error) |
Sortida a la sessió víctima (la que PostgreSQL decideixi avortar):
ERROR: deadlock detected
DETAIL: Process 18422 waits for ShareLock on transaction 9931; blocked by process 18455.
Process 18455 waits for ShareLock on transaction 9930; blocked by process 18422.
HINT: See server log for query details.
CONTEXT: while updating tuple (0,3) in relation "socis"L'altra sessió es desbloqueja immediatament i pot continuar fins al seu COMMIT. La víctima queda amb la transacció avortada i ha de fer ROLLBACK i reintentar.
Solució — Part 2: ordenar els bloqueigs
La causa és l'ordre invers d'adquisició. La solució és que tots els processos bloquegin les files en el mateix ordre, per exemple per clau primària ascendent:
| Ordre | Sessió A | Sessió B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | UPDATE socis SET actiu=TRUE WHERE soci_id=14; |
|
| 3 | BEGIN; |
|
| 4 | UPDATE socis SET actiu=TRUE WHERE soci_id=14; → espera |
|
| 5 | UPDATE socis SET actiu=TRUE WHERE soci_id=15; → UPDATE 1 |
(espera) |
| 6 | COMMIT; |
→ es desbloqueja, UPDATE 1 |
| 7 | UPDATE socis SET actiu=TRUE WHERE soci_id=15; → UPDATE 1 |
|
| 8 | COMMIT; |
Hi ha espera, però no interbloqueig: B es queda darrere d'A i totes dues acaben. I en una sola instrucció, encara millor:
PostgreSQL bloqueja les files en l'ordre en què les troba, que és determinista per a la mateixa consulta i el mateix pla.
Resultat esperat
| Escenari | Resultat |
|---|---|
| Ordre invers | ERROR: deadlock detected en una de les dues sessions, al cap d'~1 s |
| Ordre consistent | Totes dues acaben; B espera l'A |
| Una sola instrucció | Totes dues acaben; sense finestra d'interbloqueig |
Explicació. Un interbloqueig és un cicle d'esperes: A espera un recurs que té B, i B n'espera un que té A. Cap no pot avançar i cap no deixarà anar el que té.
PostgreSQL el detecta i el resol sol. Cada cert temps —deadlock_timeout, un segon per defecte— comprova si hi ha un cicle al graf d'esperes, i si n'hi ha, avorta una de les transaccions per trencar-lo. Tria la víctima segons criteris interns; no pots predir quina serà. Per això el diagnòstic és sempre el mateix:
ERROR: deadlock detectedno és una fallada de la base de dades: és una fallada de l'aplicació, que ha demanat bloqueigs en ordre incoherent. El motor només ho ha detectat a temps.
Les tres regles per no tenir-ne:
- Ordre consistent. Fixa un criteri d'ordenació —clau primària ascendent és el més simple— i aplica'l a tot el codi que bloquegi diverses files. Si el teu procés llegeix una llista d'identificadors per actualitzar-los, ordena-la abans.
- Transaccions curtes. Com menys temps se sosté un bloqueig, menor és la finestra. No facis mai una crida de xarxa, ni esperis un usuari, amb una transacció oberta.
- Reintent automàtic. Fins i tot amb les dues regles anteriors, un interbloqueig pot passar. Tota operació transaccional important s'hauria d'embolcallar en un bucle de reintent que capturi el codi SQLSTATE
40P01(deadlock_detected) i ho torni a intentar, típicament 3 vegades amb espera creixent.
Nota: els interbloqueigs també apareixen sense que el programador toqui dues taules. Dos INSERT en taules relacionades per clau forana adquireixen bloqueigs sobre la fila pare, i dos processos que insereixen fills de pares diferents en ordre creuat es poden bloquejar mútuament. Per això el criteri d'ordre s'ha d'aplicar a les claus, no a les taules.
Exercici 10: Bloqueig optimista amb la columna versio
Dificultat: Avançat
Enunciat. L'esdeveniment 106 («Taller d'iniciació a la genealogia», 15 places) té 14 places cobertes: en queda una. Dues persones obren el formulari d'inscripció del portal alhora; les dues veuen «1 plaça disponible» i les dues premen «Inscriure-m'hi» amb uns segons de diferència.
El portal no pot mantenir un bloqueig mentre el formulari és a la pantalla: l'usuari pot trigar minuts o anar-se'n a dinar. Implementa bloqueig optimista fent servir la columna esdeveniments.versio.
Prepara l'escenari:
UPDATE esdeveniments SET places_ofertes = 5, versio = 1 WHERE esdeveniment_id = 106;
-- 106 té 4 places ocupades (inscripcions dels socis 14, 15 i 16)
-- → amb 5 ofertes, en queda exactament 1 de lliureSolució
| Ordre | Sessió A | Sessió B |
|---|---|---|
| 1 | (l'usuari obre el formulari) SELECT places_ofertes, versio FROM esdeveniments WHERE esdeveniment_id=106; → 5, versió 1 |
|
| 2 | (un altre usuari obre el formulari) mateix SELECT → 5, versió 1 |
|
| 3 | (passen 4 minuts) | (passen 5 minuts) |
| 4 | BEGIN; |
|
| 5 | UPDATE esdeveniments SET estat='complet', versio = versio + 1 WHERE esdeveniment_id=106 AND versio = 1; → UPDATE 1 |
|
| 6 | INSERT INTO inscripcions VALUES (106,11,DATE '2026-08-02','confirmada',0,1); |
|
| 7 | COMMIT; |
|
| 8 | BEGIN; |
|
| 9 | UPDATE esdeveniments SET estat='complet', versio = versio + 1 WHERE esdeveniment_id=106 AND versio = 1; → UPDATE 0 |
|
| 10 | (l'aplicació detecta el 0 i avorta) ROLLBACK; |
|
| 11 | Rellegeix: SELECT versio, estat FROM esdeveniments WHERE esdeveniment_id=106; → versió 2, complet |
La versió d'aplicació, en plpgsql:
CREATE OR REPLACE FUNCTION inscriure_optimista(
p_esdeveniment_id INTEGER, p_soci_id INTEGER, p_versio_vista INTEGER
) RETURNS TEXT AS $$
DECLARE
v_files INTEGER;
v_lliures INTEGER;
BEGIN
SELECT ev.places_ofertes - COALESCE(sum(i.places_ocupades), 0)
INTO v_lliures
FROM esdeveniments ev
LEFT JOIN inscripcions i
ON i.esdeveniment_id = ev.esdeveniment_id
AND i.estat IN ('confirmada','assistida')
WHERE ev.esdeveniment_id = p_esdeveniment_id
GROUP BY ev.places_ofertes;
IF v_lliures < 1 THEN
RETURN 'SENSE_PLACES';
END IF;
UPDATE esdeveniments
SET versio = versio + 1,
estat = CASE WHEN v_lliures = 1 THEN 'complet' ELSE estat END
WHERE esdeveniment_id = p_esdeveniment_id
AND versio = p_versio_vista; -- <-- el cor del mètode
GET DIAGNOSTICS v_files = ROW_COUNT;
IF v_files = 0 THEN
RETURN 'CONFLICTE_VERSIO'; -- un altre s'ha avançat: reintentar
END IF;
INSERT INTO inscripcions (esdeveniment_id, soci_id, data_inscripcio,
estat, acompanyants, places_ocupades)
VALUES (p_esdeveniment_id, p_soci_id, CURRENT_DATE, 'confirmada', 0, 1);
RETURN 'OK';
END;
$$ LANGUAGE plpgsql;Resultat esperat
-- Sessió A
SELECT inscriure_optimista(106, 11, 1); --> OK
-- Sessió B, amb la versió que va llegir (1)
SELECT inscriure_optimista(106, 13, 1); --> CONFLICTE_VERSIOI l'estat final:
| esdeveniment_id | places_ofertes | versio | estat | inscripcions confirmades |
|---|---|---|---|---|
| 106 | 5 | 2 | complet | 4 (socis 14, 15, 16 i 11) |
El soci 13 no queda inscrit i l'aplicació li pot mostrar un missatge honest: «l'última plaça s'acaba d'ocupar».
Explicació. El bloqueig optimista resol un problema que el bloqueig pessimista no pot: l'interval entre llegir i escriure pot durar minuts, i mantenir un bloqueig durant aquell temps és inacceptable —bloquejaria tots els altres i, si l'usuari tanca el navegador, el bloqueig es queda penjat fins que expiri la connexió.
El mecanisme té tres peces i totes tres són necessàries:
- Una columna
versio(un enter, o untimestamp, o qualsevol valor que canviï amb cada modificació). - L'aplicació llegeix i recorda la versió que ha vist.
- L'
UPDATEinclouAND versio = <la que vaig veure>i incrementa la versió. Si un altre s'ha avançat, la condició no es compleix i l'UPDATEafecta 0 files.
El que fa que funcioni és que UPDATE 0 no és un error: és informació. Cal comprovar-ho explícitament, i aquest és el punt que més s'oblida. Un UPDATE que retorna 0 files i no es comprova converteix el bloqueig optimista en un adorn decoratiu.
Optimista enfront de pessimista:
Pessimista (FOR UPDATE) |
Optimista (versio) |
|
|---|---|---|
| Quan | Lectura i escriptura seguides, a la mateixa transacció | Hi ha una pausa llarga entre totes dues (formulari, cua, API) |
| Cost sense conflicte | Un bloqueig sostingut | Cap |
| Cost amb conflicte | Espera | Es perd la feina i cal reintentar |
| Quan NO usar-lo | Transaccions llargues o interactives | Conflictes molt freqüents: es reintenta sense parar |
I el detall final: a la Sessió B, la comprovació de places lliures (v_lliures) ja hauria retornat SENSE_PLACES en aquest cas concret, perquè A va confirmar abans. La comprovació de versió és la que cobreix el cas pitjor: que les dues sessions arribin a l'UPDATE alhora, quan cap de les dues no ha vist el canvi de l'altra. Les dues comprovacions no són redundants: la primera dóna un missatge millor, la segona és la que garanteix la correcció.
Exercici 11: Consumir una cua amb SKIP LOCKED
Dificultat: Avançat
Enunciat. La taula avisos té 6 avisos pendents. Dos processos d'enviament s'executen en paral·lel i cadascun ha de prendre 2 avisos per processar-los. Cap avís no es pot processar dues vegades i cap procés no s'ha de quedar esperant l'altre.
Part 1. Comprova què passa amb FOR UPDATE a seques.
Part 2. Resol-ho amb FOR UPDATE SKIP LOCKED.
Solució — Part 1: FOR UPDATE bloqueja
| Ordre | Sessió A | Sessió B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | SELECT aviso_id, missatge FROM avisos WHERE estat='pendent' ORDER BY aviso_id LIMIT 2 FOR UPDATE; → avisos 1 i 2 |
|
| 3 | BEGIN; |
|
| 4 | La mateixa consulta → es queda esperant |
La Sessió B es penja: vol les mateixes dues files —són les dues primeres per aviso_id— i estan bloquejades. Amb dos processos, la cua és seqüencial; amb deu, nou estan aturats. FOR UPDATE a seques converteix una cua paral·lela en una cua d'un.
Solució — Part 2: SKIP LOCKED
Tanca les dues transaccions (ROLLBACK a totes dues) i repeteix:
| Ordre | Sessió A | Sessió B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | SELECT aviso_id, tipus, missatge FROM avisos WHERE estat='pendent' ORDER BY aviso_id LIMIT 2 FOR UPDATE SKIP LOCKED; → avisos 1 i 2 |
|
| 3 | BEGIN; |
|
| 4 | La mateixa consulta → avisos 3 i 4, sense esperar | |
| 5 | UPDATE avisos SET estat='enviat', enviat_en=now() WHERE aviso_id IN (1,2); |
|
| 6 | COMMIT; |
|
| 7 | UPDATE avisos SET estat='enviat', enviat_en=now() WHERE aviso_id IN (3,4); |
|
| 8 | COMMIT; |
Resultat esperat
Pas 2, Sessió A:
| aviso_id | tipus | missatge |
|---|---|---|
| 1 | venciment | EJ-3088 va vèncer el 26/04 |
| 2 | venciment | EJ-3093 va vèncer el 31/05 |
Pas 4, Sessió B:
| aviso_id | tipus | missatge |
|---|---|---|
| 3 | venciment | EJ-3087 va vèncer el 01/07 |
| 4 | reserva | La teva reserva d'El mapa del temps està disponible |
Estat final de la cua:
| estat | count |
|---|---|
| enviat | 4 |
| pendent | 2 |
Els avisos 5 i 6 queden per a la ronda següent. Zero solapament, zero espera.
Explicació. SKIP LOCKED modifica el comportament del bloqueig d'una manera molt concreta:
En lloc d'esperar que una fila bloquejada s'alliberi, la salta i busca la següent que compleixi el
WHERE.
Això és exactament el que una cua de treball necessita, i és la raó per la qual PostgreSQL es pot fer servir com a sistema de cues sense afegir-hi cap component a part.
Quatre detalls imprescindibles del patró:
LIMITacota el lot. Sense ell, la primera sessió bloquejaria tota la cua pendent.ORDER BYdóna un ordre de procés. Aquí és per identificador (FIFO); podria ser per prioritat, per antiguitat o pel que el negoci exigeixi.- La marca de processat va a la mateixa transacció. Si l'
UPDATE ... SET estat='enviat'es fes en una transacció diferent, entre elSELECTi ell hi hauria una finestra en què el bloqueig ja no existeix i un altre procés podria agafar el mateix avís. - El que passa si el procés mor entre el pas 4 i el 7 és el millor del patró: la transacció no confirmada es desfà, els bloqueigs s'amollen i els avisos tornen a estar
pendent. La cua s'autorepara. Si en canvi haguessis marcat els avisos com a «en procés» en una transacció a part, una caiguda els deixaria encallats en aquell estat per sempre i caldria un procés de rescat.
Quan NO usar SKIP LOCKED. Mai en una consulta de negoci normal. Una consulta de saldo amb SKIP LOCKED retornaria un resultat incomplet —li faltarien les files que algú estigui modificant— sense cap avís. SKIP LOCKED només té sentit quan «qualsevol subconjunt disponible» és una resposta vàlida, i això passa a les cues i pràcticament enlloc més.
Bloc D — Índexs i plans d'execució
Exercici 12: Del Seq Scan a l'Index Scan
Dificultat: Avançat
Enunciat. A la BiblioRed de producció, prestecs té 912.000 files i d'elles unes 3.100 estan obertes. La pantalla «els meus préstecs en curs» executa aquesta consulta i triga gairebé un segon:
EXPLAIN (ANALYZE, BUFFERS)
SELECT prestec_id, exemplar_id, data_prestec, data_devolucio_prevista
FROM prestecs
WHERE soci_id = 14 AND data_devolucio IS NULL; Seq Scan on prestecs (cost=0.00..21455.00 rows=4 width=20)
(actual time=112.338..873.912 rows=2 loops=1)
Filter: ((data_devolucio IS NULL) AND (soci_id = 14))
Rows Removed by Filter: 911998
Buffers: shared hit=1024 read=8431
Planning Time: 0.184 ms
Execution Time: 873.984 msEs demana: (a) diagnosticar el pla; (b) proposar l'índex, justificant-ne la forma; (c) predir el pla resultant i estimar la millora.
Solució
(a) Diagnòstic.
| Senyal del pla | Què significa |
|---|---|
Seq Scan on prestecs |
Es llegeixen les 912.000 files, una per una |
Rows Removed by Filter: 911998 |
De tot el que s'ha llegit, es descarta el 99,9998 % |
rows=4 ... rows=2 |
L'estimació (4) és raonable; el problema no és una estadística dolenta |
Buffers: read=8431 |
S'han anat a disc 8.431 blocs: uns 66 MB de lectura física |
Execution Time: 873 ms |
Gairebé un segon per una consulta que retorna 2 files |
El diagnòstic és inequívoc: falta un índex. El motor sap que només hi ha 4 files candidates (estima bé), però no té cap manera de trobar-les sense mirar-les totes. La relació entre files llegides i files retornades —456.000 a 1— és la definició d'índex absent.
(b) L'índex proposat.
Tres decisions, i cadascuna té la seva raó:
- Índex parcial. La condició
data_devolucio IS NULLes compleix en 3.100 files de 912.000: un 0,34 %. L'índex parcial indexa només aquelles i ocupa uns 80 KB enfront dels ~20 MB d'un índex complet sobresoci_id. A més, les files que es tanquen surten de l'índex automàticament en retornar-se el préstec, així que es manté petit per sempre. soci_idcom a única columna. Dins de l'índex parcial, l'única discriminació que queda per fer és el soci. Afegirdata_devolucioa les columnes seria redundant: a l'índex parcial valNULLa totes les files.- No és
UNIQUE. Un soci pot tenir diversos préstecs oberts.
(c) El pla resultant.
Index Scan using idx_prestecs_oberts on prestecs
(cost=0.28..12.42 rows=4 width=20) (actual time=0.021..0.028 rows=2 loops=1)
Index Cond: (soci_id = 14)
Buffers: shared hit=4
Planning Time: 0.211 ms
Execution Time: 0.049 msResultat esperat
| Mètrica | Abans | Després | Factor |
|---|---|---|---|
| Node arrel | Seq Scan |
Index Scan |
— |
| Files llegides | 912.000 | 2 | 456.000× |
Blocs (Buffers) |
9.455 | 4 | 2.360× |
| Temps d'execució | 873,98 ms | 0,05 ms | ~17.500× |
| Mida de l'índex | — | ~80 KB | — |
Explicació. L'important d'aquest exercici no és que un índex acceleri una consulta —això ja ho sabies des de 06-03—, sinó com es llegeix el pla per arribar a aquella conclusió amb seguretat.
Rows Removed by Filter és la mètrica reina. És el nombre de files que el motor va llegir i va llençar. Si és enorme comparat amb les que retorna, hi ha un índex esperant a ser creat. Si és petit, el Seq Scan pot ser l'elecció correcta: llegir seqüencialment 500 files és més ràpid que saltar per un índex, i el planificador ho sap.
Buffers distingeix el problema d'E/S del problema de CPU. shared hit són blocs que ja eren a memòria; read, els que es van haver de portar de disc. Aquí 8.431 lectures físiques expliquen la major part dels 873 ms. Un pla amb molts hit i pocs read que continua essent lent té un problema diferent (massa comparacions, una funció cara, un JOIN mal triat).
La comparació entre rows= estimades i rows= reals és l'altre diagnòstic clau. Aquí són 4 i 2: el planificador encerta, així que el problema és d'accés, no d'estadístiques. Si hagués estimat 4 i n'hagués trobat 300.000, el diagnòstic seria el contrari: estadístiques desactualitzades, i la solució ANALYZE prestecs; abans que cap índex.
Un avís: després de crear l'índex, executa ANALYZE prestecs; i torna a mesurar. I no creïs índexs «per si de cas»: cada índex alenteix les escriptures i ocupa espai. Un índex es justifica amb un pla abans i un pla després, com en aquest exercici.
Exercici 13: Índexs que existeixen i no es fan servir, i elecció entre dos compostos
Dificultat: Avançat
Enunciat. Part 1. Aquestes tres consultes de la BiblioRed de producció fan Seq Scan tot i que existeixen els índexs adequats. Identifica el motiu de cadascuna i reescriu la consulta perquè l'índex es faci servir. Els índexs existents són:
CREATE INDEX idx_prestecs_data ON prestecs (data_prestec);
CREATE INDEX idx_exemplars_codi ON exemplars (codi);
CREATE INDEX idx_materials_titol ON materials (titol);-- (a)
SELECT count(*) FROM prestecs WHERE EXTRACT(YEAR FROM data_prestec) = 2026;
-- (b)
SELECT * FROM exemplars WHERE codi::text = 'EJ-3081' || '';
-- (c)
SELECT material_id, titol FROM materials WHERE titol LIKE '%Txernòbil%';Part 2. El taulell executa constantment aquestes dues consultes:
-- Q1: exemplars disponibles d'un material en una sucursal (unes 900 vegades/hora)
SELECT exemplar_id, codi FROM exemplars
WHERE material_id = 902 AND sucursal_id = 1 AND estat = 'disponible';
-- Q2: inventari complet d'una sucursal per estat (unes 20 vegades/hora)
SELECT estat, count(*) FROM exemplars
WHERE sucursal_id = 1 GROUP BY estat;Només pots crear un índex compost. Tria entre (material_id, sucursal_id, estat) i (sucursal_id, estat, material_id) i raona amb la regla del prefix més a l'esquerra.
Solució — Part 1
(a) Funció sobre la columna indexada.
EXTRACT(YEAR FROM data_prestec) no és data_prestec. L'índex B-tree guarda dates ordenades; no sap res de l'any extret. Reescriptura per rang:
SELECT count(*) FROM prestecs
WHERE data_prestec >= DATE '2026-01-01'
AND data_prestec < DATE '2027-01-01';Ara la condició és directament sobre la columna i l'índex serveix. L'alternativa —si aquesta consulta fos molt freqüent— és un índex d'expressió:
...però la reescriptura per rang és preferible: serveix per a qualsevol interval, no només per a anys sencers.
(b) Conversió de tipus i expressió al costat de la columna.
codi::text força una conversió i 'EJ-3081' || '' obliga a avaluar una concatenació. El primer és el greu: convertir la columna la treu de l'índex. Reescriptura:
Regla general: les transformacions van al costat del literal, mai al costat de la columna. Si el tipus del paràmetre no coincideix, converteix-lo tu abans de passar-lo, o declara el paràmetre amb el tipus correcte. Aquest és l'error que més vegades apareix quan un ORM envia un varchar a una columna integer o a l'inrevés.
(c) Comodí a l'esquerra.
LIKE '%Txernòbil%' no té prefix fix. Un B-tree ordena pel principi de la cadena: sense un principi conegut, no hi ha rang a recórrer. LIKE 'Txernòbil%' sí que el faria servir, però canvia el significat de la consulta.
La solució correcta és un índex d'un altre tipus, GIN amb trigrames:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_materials_titol_trgm
ON materials USING gin (titol gin_trgm_ops);
-- La consulta no canvia i ara sí que fa servir l'índex:
SELECT material_id, titol FROM materials WHERE titol ILIKE '%txernòbil%';L'índex de trigrames descompon cada títol en seqüències de tres caràcters i pot respondre a cerques de subcadena en qualsevol posició. Per a cerca de text per paraules completes, l'alternativa és tsvector + GIN, que a més gestiona lematització i paraules buides.
Solució — Part 2
L'índex a crear és (material_id, sucursal_id, estat). Raonament:
| Consulta | Amb (material_id, sucursal_id, estat) |
Amb (sucursal_id, estat, material_id) |
|---|---|---|
Q1 (material_id, sucursal_id, estat) |
Òptim: les tres columnes es fan servir com a prefix complet. Índex recorregut fins al final | També serveix: les tres condicions són d'igualtat, així que l'ordre no n'impedeix l'ús |
Q2 (sucursal_id + GROUP BY estat) |
No serveix: sucursal_id és la segona columna, i sense condició sobre material_id no hi ha prefix. Seq Scan |
Òptim: sucursal_id és prefix, i estat ve darrere, així que les files ja surten agrupades |
Aquí les dues opcions són bones per a Q1 i només la segona és bona per a Q2. Aleshores, per què triar la primera?
Perquè les freqüències són 900 enfront de 20 per hora, i perquè Q1 és la consulta interactiva. Q1 l'executa una persona esperant davant del taulell; Q2 és un informe que pot trigar 200 ms sense que ningú es queixi. Amb (material_id, sucursal_id, estat), Q1 accedeix directament al petit conjunt d'exemplars d'aquell material i l'índex és molt selectiu des de la primera columna: hi ha 40.000 materials, així que material_id = 902 redueix a un grapat de files. Amb (sucursal_id, estat, material_id), Q1 comença per sucursal_id, que només té 4 valors diferents: el primer nivell de l'índex tot just discrimina, i cal recórrer una part molt més gran.
La regla del prefix més a l'esquerra, enunciada amb precisió:
Un índex compost
(A, B, C)es pot fer servir per a condicions sobreA, sobreA, Bi sobreA, B, C. No es pot fer servir per a condicions que només esmentinB,CoB, C.
I la regla de disseny que se'n deriva: la columna més selectiva i sempre present va primer. «Sempre present» pesa més que «més selectiva»: un índex la primera columna del qual falta a la consulta no es fa servir en absolut.
Resultat esperat
| Consulta | Problema | Solució |
|---|---|---|
(a) EXTRACT(YEAR ...) |
Funció sobre la columna | Reescriure com a rang de dates |
(b) codi::text = ... |
Conversió de tipus sobre la columna | Comparar directament, sense conversió |
(c) LIKE '%...%' |
Comodí inicial, sense prefix | Índex GIN amb pg_trgm |
| Part 2 | Un sol índex per a dues consultes | (material_id, sucursal_id, estat), prioritzant Q1 |
Explicació. Els tres casos de la part 1 comparteixen una única causa: l'índex indexa la columna, no una expressió sobre la columna. Així que la condició transforma el valor de la columna —amb una funció, amb una conversió, amb una concatenació—, el motor deixa de poder buscar a l'índex, perquè no sap quina relació hi ha entre l'ordre dels valors originals i el dels transformats.
La manera de detectar-ho en un pla és directa: si al node apareix Filter: amb una expressió al voltant del nom de la columna, l'índex no s'està fent servir per a això. Si apareix Index Cond: amb la columna nua, sí.
Sobre la part 2, tres matisos que convé tenir presents:
- La resposta canvia si canvien les dades. Si BiblioRed tingués 400 sucursals en lloc de 4,
sucursal_idseria molt més selectiva i l'elecció s'acostaria. Les decisions d'indexació depenen de la cardinalitat real, i aquella es mesura (SELECT count(DISTINCT ...)), no se suposa. - PostgreSQL sí que pot usar un índex sense el seu prefix, però malament. Amb
enable_seqscan = offveuries unIndex Scanque recorre l'índex sencer comparant cada entrada. És pitjor que elSeq Scani per això el planificador no el tria. «Pot usar-lo» no significa «li serveix». - La regla no s'aplica igual als índexs GIN i BRIN, que no són arbres ordenats. La regla del prefix més a l'esquerra és una propietat dels B-tree.
Errors Habituals i Consells
1. Filtrar per una funció de finestra al WHERE. No es pot: s'avaluen després. Embolcalla en CTE o taula derivada.
2. Confondre ROW_NUMBER, RANK i DENSE_RANK. Per a un «top 1 per grup» només serveix ROW_NUMBER; RANK retorna tots els empatats.
3. LAG sobre una sèrie amb forats. Compara amb la fila anterior present, no amb el període anterior. Genera l'eix temporal complet i uneix per l'esquerra.
4. Oblidar la condició de parada d'una CTE recursiva. Bucle infinit. I si el graf pot tenir cicles, fes servir CYCLE o arrossega el camí recorregut.
5. Comparar -> amb text. -> 'canal' = 'web' no troba res. Fes servir ->> per a text i @> quan vulguis aprofitar l'índex GIN.
6. count(CASE WHEN ... THEN 1 ELSE 0 END) en un pivot. Compta també els zeros. Fes servir FILTER o treu l'ELSE 0.
7. Comprovar i actuar sense FOR UPDATE. Entre el SELECT que confirma i l'UPDATE que actua hi ha una finestra. Si el càlcul cap al mateix UPDATE, fes-lo atòmic i no necessitaràs bloqueig.
8. Ignorar un UPDATE 0. En bloqueig optimista és el senyal de conflicte; en una transacció de negoci sol ser una fallada silenciosa. Comprova-ho sempre i decideix explícitament si és acceptable.
9. Creure que un error dins d'una transacció es pot ignorar. A PostgreSQL avorta la transacció sencera i el COMMIT respon ROLLBACK. Fes servir SAVEPOINT (o blocs EXCEPTION en plpgsql) en tot procés per lots.
10. Bloquejar files en ordres diferents. És la causa del 90 % dels interbloqueigs. Fixa un ordre —clau primària ascendent— i respecta'l a tot el codi.
11. Pujar el nivell d'aïllament sense implementar reintents. REPEATABLE READ i SERIALIZABLE avorten transaccions legítimes per conflicte de serialització. Sense bucle de reintent, canvies dades incoherents per errors en producció.
12. Usar SKIP LOCKED fora d'una cua. Retorna resultats incomplets sense avisar.
13. Transformar la columna al WHERE. EXTRACT, lower(), ::text, ||: qualsevol d'ells anul·la l'índex. Les transformacions van al literal.
14. Crear índexs sense mesurar. Cada índex alenteix les escriptures. Un índex es justifica amb un EXPLAIN ANALYZE abans i un altre després.
Consell de mètode per al bloc de concurrència. Quan una sessió es quedi penjada i no sàpigues per què, obre'n una tercera i consulta qui bloqueja qui:
SELECT pid, state, wait_event_type, wait_event,
left(query, 60) AS consulta,
pg_blocking_pids(pid) AS bloquejat_per
FROM pg_stat_activity
WHERE datname = current_database() AND state <> 'idle';pg_blocking_pids retorna la llista de processos que estan bloquejant cadascun. És l'eina que més temps estalvia en un incident real de producció.
Exercicis
Sense pistes i més exigents.
Exercici A: Informe de rendiment d'esdeveniments per sucursal
En una sola consulta, retorna per sucursal: nombre d'esdeveniments celebrats, places ofertes, places ocupades, percentatge d'ocupació, valoració mitjana ponderada pel nombre de respostes de l'enquesta, i la posició de la sucursal al rànquing de valoració. Fes servir jsonb per a les respostes i una funció de finestra per al rànquing.
Exercici B: Transacció de devolució amb recàrrec, resistent a la concurrència
Escriu la funció registrar_devolucio(p_exemplar_id) que, en una única transacció: localitza el préstec obert d'aquell exemplar bloquejant-lo, anota la data de devolució d'avui, retorna l'exemplar a l'estat disponible, i —si hi ha retard— emet una multa de 0,20 €/dia amb topall de 15,00 €, sense duplicar-la si el procés s'executa dues vegades. Ha de fallar netament si l'exemplar no té préstec obert. Explica què passa si dues sessions la invoquen alhora sobre el mateix exemplar.
Exercici C: Diagnòstic d'un pla amb Nested Loop
Diagnostica aquest pla de la BiblioRed de producció, digues quin és el node problemàtic, quina és la causa arrel i quines dues intervencions proposaries, en ordre de prioritat.
HashAggregate (cost=48211.02..48214.02 rows=300 width=40)
(actual time=6841.220..6841.402 rows=4 loops=1)
Group Key: su.nom
-> Nested Loop (cost=0.29..48196.02 rows=3000 width=32)
(actual time=0.412..6802.118 rows=418 loops=1)
-> Seq Scan on materials m (cost=0.00..1204.00 rows=30 width=8)
(actual time=0.098..38.442 rows=127 loops=1)
Filter: (lower(titol) ~~ '%txernòbil%'::text)
Rows Removed by Filter: 39873
-> Index Scan using idx_exemplars_material on exemplars e
(cost=0.29..1599.50 rows=100 width=32)
(actual time=1.204..53.210 rows=3 loops=127)
Index Cond: (material_id = m.material_id)
Planning Time: 1.882 ms
Execution Time: 6841.688 msSolucions
Solució A
WITH ocupacio AS (
SELECT ev.esdeveniment_id, ev.sala_id, ev.places_ofertes,
COALESCE(sum(i.places_ocupades) FILTER (
WHERE i.estat IN ('confirmada','assistida')), 0) AS ocupades
FROM esdeveniments ev
LEFT JOIN inscripcions i ON i.esdeveniment_id = ev.esdeveniment_id
WHERE ev.estat = 'celebrat'
GROUP BY ev.esdeveniment_id, ev.sala_id, ev.places_ofertes
),
enquestes AS (
SELECT ie.esdeveniment_id,
count(*) AS n_respostes,
avg((r ->> 'puntuacio')::int) AS mitjana
FROM informes_esdeveniment ie,
LATERAL jsonb_array_elements(ie.respostes_enquesta -> 'respostes') AS r
GROUP BY ie.esdeveniment_id
),
per_sucursal AS (
SELECT su.sucursal_id, su.nom AS sucursal,
count(*) AS esdeveniments,
sum(o.places_ofertes) AS ofertes,
sum(o.ocupades) AS ocupades,
round(100.0 * sum(o.ocupades) / sum(o.places_ofertes), 1) AS pct_ocupacio,
round(sum(e.mitjana * e.n_respostes) / sum(e.n_respostes), 2) AS valoracio
FROM ocupacio o
JOIN sales sa ON sa.sala_id = o.sala_id
JOIN sucursals su ON su.sucursal_id = sa.sucursal_id
LEFT JOIN enquestes e ON e.esdeveniment_id = o.esdeveniment_id
GROUP BY su.sucursal_id, su.nom
)
SELECT sucursal, esdeveniments, ofertes, ocupades, pct_ocupacio, valoracio,
RANK() OVER (ORDER BY valoracio DESC) AS posicio
FROM per_sucursal
ORDER BY posicio;| sucursal | esdeveniments | ofertes | ocupades | pct_ocupacio | valoracio | posicio |
|---|---|---|---|---|---|---|
| Sud | 1 | 8 | 5 | 62.5 | 4.75 | 1 |
| Est | 1 | 10 | 4 | 40.0 | 4.67 | 2 |
| Nord | 1 | 20 | 9 | 45.0 | 4.00 | 3 |
| Centre | 2 | 52 | 15 | 28.8 | 3.88 | 4 |
Centre surt 3,88 perquè la seva valoració és la mitjana ponderada dels seus dos esdeveniments: el 101 (4,50 amb 4 respostes) i el 104 (3,25 amb 4 respostes). (4.50×4 + 3.25×4) / 8 = 3.875. Si s'hagués fet la mitjana de les mitjanes sortiria el mateix per casualitat —tots dos tenen 4 respostes—, però així que les mides difereixin, la mitjana de mitjanes és incorrecta. Aquest és el punt de l'exercici: ponderar pel nombre de respostes, no fer la mitjana de mitjanes.
Nota sobre les tres CTE: cadascuna agrega al seu propi nivell per evitar el problema de multiplicació de files de l'exercici 15 de 07-01. Unir inscripcions i informes_esdeveniment al mateix JOIN faria que cada resposta d'enquesta es repetís per cada inscripció.
Solució B
CREATE OR REPLACE FUNCTION registrar_devolucio(p_exemplar_id INTEGER)
RETURNS TABLE (prestec INTEGER, dies_retard INTEGER, multa NUMERIC) AS $$
DECLARE
v_prestec RECORD;
v_dies INTEGER;
v_import NUMERIC(8,2) := 0;
v_multa_id INTEGER;
BEGIN
-- 1) Localitzar i BLOQUEJAR el préstec obert d'aquell exemplar
SELECT p.prestec_id, p.soci_id, p.data_devolucio_prevista
INTO v_prestec
FROM prestecs p
WHERE p.exemplar_id = p_exemplar_id AND p.data_devolucio IS NULL
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'L''exemplar % no té cap préstec obert', p_exemplar_id
USING ERRCODE = 'no_data_found';
END IF;
-- 2) Tancar el préstec
UPDATE prestecs SET data_devolucio = CURRENT_DATE
WHERE prestec_id = v_prestec.prestec_id;
-- 3) Retornar l'exemplar al circuit
UPDATE exemplars SET estat = 'disponible'
WHERE exemplar_id = p_exemplar_id;
-- 4) Multa si escau, sense duplicar
v_dies := CURRENT_DATE - v_prestec.data_devolucio_prevista;
IF v_dies > 0 THEN
v_import := LEAST(v_dies * 0.20, 15.00);
INSERT INTO multes (soci_id, prestec_id, motiu, import,
data_emissio, estat)
VALUES (v_prestec.soci_id, v_prestec.prestec_id, 'retard',
v_import, CURRENT_DATE, 'pendent')
ON CONFLICT (prestec_id, motiu) DO NOTHING
RETURNING multa_id INTO v_multa_id;
IF v_multa_id IS NULL THEN
v_import := 0; -- ja existia: no es duplica
END IF;
END IF;
RETURN QUERY SELECT v_prestec.prestec_id, GREATEST(v_dies, 0), v_import;
END;
$$ LANGUAGE plpgsql;Què passa amb dues sessions simultànies sobre el mateix exemplar. La primera executa el SELECT ... FOR UPDATE i bloqueja la fila del préstec. La segona es queda esperant en aquell mateix SELECT. Quan la primera confirma, la segona es desbloqueja i reavalua el WHERE sobre la versió actualitzada de la fila: com que data_devolucio ja no és NULL, la fila deixa de complir la condició, FOUND és fals i la funció llança «no té cap préstec obert». És exactament el comportament desitjat: la segona devolució del mateix exemplar és un error, no una duplicació silenciosa.
L'ON CONFLICT ... DO NOTHING és la segona xarxa de seguretat, per si el procés es reintenta després d'una fallada de xarxa que va deixar la transacció confirmada però sense resposta.
Solució C
Node problemàtic: l'Index Scan intern del Nested Loop. Fixa't en loops=1 enfront de loops=127: aquell node s'executa 127 vegades, una per cada fila del costat extern, i cada execució triga uns 53 ms → 127 × 53 ≈ 6.700 ms, que és pràcticament tot el temps de la consulta.
Causa arrel: una estimació molt equivocada al Seq Scan extern. El planificador va estimar rows=30 per al filtre lower(titol) ~~ '%txernòbil%' i en va trobar 127. Amb 30 iteracions previstes, el Nested Loop semblava barat; amb 127 de reals, surt quatre vegades més car del que s'havia calculat. La mala estimació és inevitable: PostgreSQL no té estadístiques útils per a un LIKE amb comodí inicial sobre una funció, i aplica una selectivitat per defecte.
Dues intervencions, en ordre de prioritat:
- Índex GIN amb trigrames sobre
titol(pg_trgm). Ataca la causa arrel: converteix elSeq Scande 40.000 files en un accés indexat, elimina les 39.873 files descartades i, de passada, millora radicalment l'estimació, perquè l'índex permet al planificador acotar millor el nombre de files. Amb això, elNested Looppassa a iterar sobre un conjunt petit i correctament estimat. - Pujar l'objectiu d'estadístiques de
materials.titol(ALTER TABLE materials ALTER COLUMN titol SET STATISTICS 500; ANALYZE materials;). És un pedaç complementari: no arregla elSeq Scan, però millora l'estimació i pot portar el planificador a triar unHash Joinen lloc delNested Loop, que per a 127 × 3 files seria més estable.
El que no cal fer és tocar l'Index Scan intern: idx_exemplars_material funciona correctament —3 files per iteració, exactament el que ha de ser—, i el seu únic problema és que el criden 127 vegades. En un Nested Loop lent, el culpable gairebé mai no és el node intern: és el nombre d'iteracions que li imposa l'extern.
Conclusió
Has tancat el mòdul amb els tretze exercicis més exigents del curs, i amb ells has practicat el repertori complet d'un professional de bases de dades en producció: funcions de finestra per al top N per grup, els rànquings amb empats i les comparacions període contra període; CTE recursives per generar eixos temporals sense forats i recórrer jerarquies; jsonb amb els seus operadors de navegació, contenció i desplegament, i el pivot manual amb FILTER. Després, les transaccions de debò: la del préstec amb el seu FOR UPDATE, la seva comprovació de files afectades i el seu control d'errors; SAVEPOINT perquè un lot no mori per un element; i el raonament exacte sobre què sobreviu a una seqüència de punts de desament. Els cinc exercicis de dues sessions t'han fet veure amb els teus propis ulls una actualització perduda, la diferència entre READ COMMITTED i REPEATABLE READ, un interbloqueig detectat pel motor, el bloqueig optimista salvant l'última plaça i una cua consumida en paral·lel amb SKIP LOCKED. I els dos últims t'han ensenyat a llegir un pla: Rows Removed by Filter, Buffers, loops, la distància entre files estimades i reals, i per què un índex que existeix pot no servir de res.
Si hi ha una idea que resumeix el mòdul sencer, és aquesta: en producció, les fallades que importen no donen error. Un COUNT(*) que compta un on hauria de comptar zero, un informe que multiplica les places ofertes pel nombre d'inscrits, un recàrrec que desapareix perquè dos administratius el van aplicar alhora, un lot que confirma després d'haver avortat, un índex que existeix i que la consulta no fa servir. Cap d'ells no apareix en un registre d'errors. Tots es detecten de la mateixa manera: comprovant el nombre de files, llegint el pla, executant l'escenari en dues sessions i desconfiant dels resultats que surten a la primera.
Amb aquesta lliçó s'acaba el mòdul 7 i s'acaba la part del curs en què tu escrivies consultes soltes. El que ve és el sistema sencer. El mòdul 8, Casos d'Estudi, recorre tres projectes complets de principi a fi: a 08-01 un sistema relacional amb tot el cicle —requisits, model, esquema, consultes, índexs i explotació—; a 08-02 un cas no relacional on el mateix problema es modela en documents i es comprova què s'hi guanya i què s'hi perd; i a 08-03 la persistència poliglota, on una sola aplicació combina un motor relacional per a les transaccions, un magatzem documental per al catàleg, un de clau-valor per a la sessió i un de cerca per al text lliure, i cal decidir quina dada viu a cada lloc i com es mantenen coherents entre si. Després, el mòdul 9 reuneix els llibres, cursos i eines amb què continuar pel teu compte. Ja pots tancar el segon terminal: al mòdul 8 tornem a mirar el plànol complet.
Fonaments de Bases de Dades
Mòdul 1: Introducció a les Bases de Dades
- Conceptes Bàsics de Bases de Dades
- Tipus de Bases de Dades
- Història i Evolució de les Bases de Dades
- Sistemes Gestors de Bases de Dades i Arquitectura
Mòdul 2: Bases de Dades Relacionals
- Model Relacional
- Llenguatge SQL
- Operacions Bàsiques en SQL
- Consultes Multitaula: JOIN i Subconsultes
- Agregació i Agrupació de Dades
- Integritat Referencial
Mòdul 3: Bases de Dades No Relacionals
- Introducció a NoSQL
- Tipus de Bases de Dades NoSQL
- Modelatge de Dades en NoSQL
- Comparació entre Bases de Dades Relacionals i No Relacionals
Mòdul 4: Disseny d'Esquemes
- Principis de Disseny d'Esquemes
- Diagrames Entitat-Relació (ER)
- Transformació de Diagrames ER a Esquemes Relacionals
- Tipus de Dades i Restriccions
Mòdul 5: Normalització
Mòdul 6: Transaccions, Rendiment i Seguretat
- Transaccions i Propietats ACID
- Concurrència i Nivells d'Aïllament
- Índexs i Optimització de Consultes
- Seguretat, Permisos i Còpies de Seguretat
Mòdul 7: Exercicis Pràctics
- Exercicis de SQL
- Exercicis de Disseny d'Esquemes
- Exercicis de Normalització
- Exercicis de Consultes Avançades i Transaccions
Mòdul 8: Casos d'Estudi
- Cas d'Estudi: Base de Dades Relacional
- Cas d'Estudi: Base de Dades No Relacional
- Cas d'Estudi: Persistència Poliglota
