Tres lliçons senceres defensant que cada fet ha d'estar emmagatzemat exactament una vegada, i ara una lliçó explicant quan convé trencar aquesta regla. Pot semblar una contradicció, i no ho és: és la diferència entre saber una regla i saber-la fer servir.
Al final de la lliçó anterior ens vam trobar quatre vegades amb la mateixa situació. duracio_min a esdeveniments, calculada a partir d'inici i fi. places_ocupades a inscripcions, calculada a partir d'acompanyants. L'import d'una multa, que no es pot recalcular si l'ordenança canvia. La tarifa_hora d'una cessió de sala, que ha de quedar congelada amb el valor del dia de la factura. Totes quatre són redundàncies. Totes quatre violen la lletra de la tercera forma normal. I totes quatre són correctes.
Desnormalitzar és introduir redundància en un esquema expressament, per aconseguir alguna cosa concreta que l'esquema normalitzat no donava. Les tres paraules que importen són "expressament", "concreta" i "no donava". Sense les tres, no és desnormalització: és no haver fet la feina.
Aquesta lliçó tanca el mòdul amb la decisió inversa, presa amb el mateix rigor amb què hem pres les anteriors. Què es guanya i què es paga exactament. Quan està justificat i quan no. Les tècniques una a una, amb SQL sobre BiblioRed. Com es manté la coherència del que s'ha duplicat expressament. I una guia de decisió amb les preguntes que cal respondre abans de tocar res —i els senyals que indiquen que cal fer marxa enrere.
Contingut
- Què és desnormalitzar i la regla d'or
- El primer que cal provar: els índexs, no la desnormalització
- La balança: què es guanya i què es paga
- Quan està justificat: els tres casos
- Tècnica 1: la columna redundant o calculada
- Tècnica 2: columnes generades enfront de columnes mantingudes a mà
- Tècnica 3: duplicar un atribut per evitar un JOIN
- Tècnica 4: taules de resum preagregades
- Tècnica 5: vistes materialitzades
- Tècnica 6: taules d'historial planes
- Tècnica 7: l'esquema en estrella dels magatzems de dades
- Com es manté la coherència d'allò desnormalitzat
- El pont amb NoSQL: el modelatge documental és desnormalització elevada a mètode
- Guia de decisió: les preguntes abans i els senyals per desfer-ho
- Què és desnormalitzar i la regla d'or
Definició. Desnormalitzar és modificar deliberadament un esquema normalitzat per introduir-hi redundància —dades duplicades o derivades— amb l'objectiu de millorar el rendiment de lectura o de preservar un valor històric, acceptant a canvi el cost de mantenir aquesta redundància coherent.
Fixa't en tres coses d'aquesta definició.
Es parteix d'un esquema normalitzat. No es pot desnormalitzar el que mai no va estar normalitzat. Un esquema que va néixer amb redundància perquè ningú no va analitzar les dependències no està desnormalitzat: està mal dissenyat. La diferència no és semàntica: en un esquema desnormalitzat saps exactament quina dada està duplicada, per què, i qui és responsable de mantenir-la; en un esquema mal dissenyat, no.
L'objectiu és concret. "Per si de cas" i "perquè vagi més ràpid" no són objectius. "L'informe mensual de direcció triga quaranta segons i ha de trigar menys de dos" sí que ho és. "L'import de la multa no ha de canviar si l'ordenança puja" també.
S'accepta un cost, i cal anomenar-lo. Tota desnormalització té una factura, i la factura la paga algú: qui escriu, qui manté l'activador, o l'usuari que un dia veu dues xifres diferents per al mateix.
La regla d'or
Primer normalitza. Després desnormalitza expressament, mesurant. Mai a l'inrevés.
És la regla que ordena tota la lliçó i val la pena desglossar-la:
"Primer normalitza" significa que el punt de partida és sempre l'esquema en 3FN o FNBC. És el que expressa correctament el domini, el que no admet contradiccions, i el que qualsevol professional entendrà. És també, i això s'oblida, l'única referència contra la qual es pot mesurar si la desnormalització ha servit d'alguna cosa.
"Expressament" significa documentat. Cada columna redundant ha de tenir escrit al costat: què duplica, per què, qui la manté i què passa si es desincronitza. Sense això, d'aquí a dos anys algú la veurà, pensarà que és un error, i l'"arreglarà" o —pitjor— la deixarà podrir.
"Mesurant" significa amb números abans i després. Si no pots dir "aquesta consulta trigava 4,2 segons i ara triga 80 mil·lisegons", no saps si la desnormalització ha servit. I molt sovint no serveix: el problema era en una altra banda.
"Mai a l'inrevés" és l'important. Començar amb un esquema redundant "perquè així serà més ràpid" i normalitzar-lo quan doni problemes és l'ordre equivocat, perquè la desnormalització prematura pren decisions sobre un patró d'ús que encara no coneixes, i desfer-la després és infinitament més car que fer-la ara: hi ha dades, hi ha codi i hi ha informes que en depenen.
- El primer que cal provar: els índexs, no la desnormalització
Abans de continuar, un advertiment que evita la major part de les desnormalitzacions innecessàries que es veuen en producció.
Quan una consulta va lenta, la desnormalització no és el primer que cal provar. És l'últim.
L'ordre correcte d'intervencions, de menys invasiva a més, és aquest:
| Ordre | Intervenció | Reversible | Risc per a les dades |
|---|---|---|---|
| 1 | Crear un índex adequat | Sí, DROP INDEX |
Cap |
| 2 | Reescriure la consulta (evitar subconsultes correlacionades, SELECT *, funcions sobre columnes indexades) |
Sí | Cap |
| 3 | Actualitzar les estadístiques del planificador (ANALYZE) |
Sí | Cap |
| 4 | Ajustar la configuració del servidor (memòria de treball, cau) | Sí | Cap |
| 5 | Desnormalitzar | Difícilment | Sí: inconsistència |
Els quatre primers no toquen les dades, no introdueixen la possibilitat que la base de dades es contradigui, i es desfan en un minut. El cinquè és permanent a la pràctica.
I l'experiència és contundent: la immensa majoria de les consultes lentes en un esquema normalitzat s'arreglen amb un índex. Un JOIN de cinc taules amb les claus foranes indexades sobre uns centenars de milers de files és una operació de mil·lisegons a PostgreSQL. Si triga segons, gairebé sempre falta un índex, la consulta demana columnes que no necessita, o el planificador treballa amb estadístiques velles.
Els índexs, el pla d'execució, EXPLAIN ANALYZE i com es llegeix, i l'optimització de consultes en general són el contingut de la lliçó 06-03. És literalment la lliçó següent a aquest mòdul, i l'ordre no és casual: primer s'aprèn a fer que l'esquema normalitzat vagi ràpid, i només aleshores es planteja canviar-lo.
Regla operativa: no desnormalitzis cap consulta sense haver executat abans el seu
EXPLAIN ANALYZEi haver comprovat que no hi ha cap índex que l'arregli. Si encara no saps llegir un pla d'execució, no estàs en condicions de decidir una desnormalització.
- La balança: què es guanya i què es paga
Tota desnormalització és un intercanvi. Aquests són els dos plats de la balança, i convé tenir-los escrits per poder comparar en cada cas concret.
El que es guanya
| Benefici | En què consisteix | Quant pot valer |
|---|---|---|
| Menys JOIN | Les dades que es llegeixen juntes són a la mateixa taula | Notable amb molts JOIN o taules grans; irrellevant amb dues taules petites ben indexades |
| Lectures més ràpides | Menys pàgines de disc per llegir, menys feina del planificador | D'un 10 % a diversos ordres de magnitud, segons el cas |
| Agregats precalculats | Un informe que sumava deu milions de files llegeix una taula de mil | Aquí és on la desnormalització guanya de debò: de minuts a mil·lisegons |
| Consultes més simples | Menys codi SQL per escriure i mantenir a l'aplicació | Real, encara que gairebé mai no és motiu suficient per si sol |
| Estabilitat històrica | Un valor queda congelat i no canvia encara que canviï el seu origen | No és rendiment: és correcció. És el cas més fort de tots |
El que es paga
| Cost | En què consisteix | Gravetat |
|---|---|---|
| Redundància | El mateix fet en dos llocs | És la porta d'entrada a tota la resta |
| Risc d'inconsistència | Els dos llocs poden discrepar, i amb el temps discrepen | Alta. És exactament el que la normalització existia per impedir |
| Escriptures més cares | Cada INSERT/UPDATE/DELETE toca més files i més taules |
Proporcional al desequilibri lectura/escriptura |
| Escriptures més complexes | La lògica de manteniment cal escriure-la, provar-la i mantenir-la | Mitjana-alta. És codi nou que pot fallar |
| Més espai | Les dades duplicades ocupen més | Baixa. Gairebé mai no decideix res avui |
| Risc de contenció | Un comptador en una fila única es converteix en un coll d'ampolla de concurrència | Alta i poc anticipada. Vegeu la nota de sota |
| Esquema més difícil d'entendre | La següent persona no sabrà si aquella columna és font o còpia | Mitjana. Es mitiga documentant |
La nota sobre la contenció mereix aturar-s'hi, perquè és el cost que menys s'anticipa. Si afegeixes total_prestecs a la taula socis i l'actualitzes a cada préstec, cada operació de taulell ha de bloquejar la fila del soci. Amb un soci prestant d'un en un no passa res. Però si demà afegeixes total_prestecs a sucursals, totes les operacions de la sucursal Nord competeixen per la mateixa fila, i en hora punta el taulell es serialitza. Els mecanismes de bloqueig i els nivells d'aïllament són la lliçó 06-02; de moment queda't amb això: un comptador global és una desnormalització amb un cost de concurrència que pot ser molt pitjor que el JOIN que evitava.
- Quan està justificat: els tres casos
De tots els motius que s'al·leguen per desnormalitzar, només tres resisteixen l'examen.
Cas 1: relació lectura/escriptura molt desequilibrada
La desnormalització canvia cost d'escriptura per velocitat de lectura. Només compensa si es llegeix moltíssim més del que s'escriu.
A BiblioRed, la fitxa pública d'un material —títol, autor, disponibilitat per sucursal— es consulta des del catàleg web unes 40.000 vegades al dia. Les dades que mostra canvien, com a molt, quan entra un exemplar nou: dues o tres vegades per setmana. La relació és de desenes de milers de lectures per escriptura, i aquí una còpia ben mantinguda es paga sola.
A l'altre costat, la taula prestecs s'escriu constantment durant l'horari del taulell i es llegeix sobretot per soci. Desnormalitzar-la per accelerar un informe que s'executa una vegada al mes és un mal negoci.
El criteri numèric: si la relació lectures/escriptures no arriba a 10:1, gairebé mai no compensa. Per damunt de 100:1, comença a ser interessant. I aquesta proporció cal mesurar-la, no estimar-la.
Cas 2: agregats costosos sobre moltes files
Aquest és el cas on la desnormalització guanya de llarg i cap altra tècnica no se li acosta.
Direcció vol un quadre de comandament amb els préstecs per sucursal i mes dels últims vuit anys. Sobre l'esquema normalitzat, això és un GROUP BY sobre 84.000 files de prestecs amb tres JOIN. Avui triga uns segons. Quan BiblioRed porti vint anys i tingui milions de préstecs, trigarà minuts, i el quadre de comandament s'obrirà una vegada cada matí... per a cadascun dels quinze responsables.
La clau és que les dades de mesos tancats no canvien mai. Recalcular a cada consulta el total de març del 2019 és llençar feina. Precalcular-lo una vegada i desar-lo és la decisió evident. És la tècnica 4 de la secció 8.
Cas 3: dades històriques que han de quedar congelades
I aquest és el cas més important dels tres, perquè no és una qüestió de rendiment sinó de correcció. Aquí la desnormalització no és un compromís: és l'única resposta correcta.
Considera la multa 900 de BiblioRed: emesa el 12 de març del 2026 al soci 14 per un retard de 35 dies, import 3,50 €, tarifa vigent 0,10 €/dia. A l'abril, l'ajuntament apuja la tarifa a 0,15 €/dia.
Quant deu la Marta Alsina per aquella multa? 3,50 €. Se li va comunicar per escrit, consta al rebut, i no pot canviar. Si multes no desés l'import i el calculés amb un JOIN a la taula de tarifes, a l'abril aquella multa passaria a valer 5,25 € retroactivament. Això no és un problema de disseny: és un error de facturació.
El mateix raonament val per a:
- El nom del soci en el moment del pagament. Si un rebut diu "Rebut de Marta Alsina" i ella es canvia el cognom, el rebut emès no canvia. El nom del rebut és una dada històrica, no una referència viva a
socis. - El preu d'adquisició d'un exemplar. Es va pagar el que es va pagar.
- L'adreça d'enviament d'una comanda, en un comerç electrònic. La comanda es va enviar allà, encara que el client s'hagi mudat.
Aquest és exactament el patró que a la lliçó 03-03 vam anomenar duplicació històrica congelada en parlar del modelatge documental, i vam classificar com a "categoria B": un camp duplicat que és correcte per semàntica, no una còpia que calgui propagar. La disciplina que exigeix és diferent de la d'una còpia viva: no s'actualitza mai, i precisament per això no té risc d'inconsistència.
La prova per distingir-ho: pregunta't "si el valor original canvia demà, aquest ha de canviar també?". Si la resposta és no, no és una desnormalització de rendiment: és una dada diferent que casualment va coincidir amb l'original en el moment de crear-la, i desar-la és el correcte.
- Tècnica 1: la columna redundant o calculada
La més comuna i la més fàcil de fer malament. Consisteix a desar en una taula un valor que es pot obtenir comptant o sumant files d'una altra.
El cas: la fitxa del soci a l'aplicació de taulell mostra quants préstecs té en total i quants estan oberts. Normalitzat:
SELECT s.soci_id, s.nom, s.cognoms,
COUNT(*) AS total_prestecs,
COUNT(*) FILTER (WHERE p.data_devolucio IS NULL) AS prestecs_oberts
FROM socis s
LEFT JOIN prestecs p ON p.soci_id = s.soci_id
WHERE s.soci_id = 14
GROUP BY s.soci_id, s.nom, s.cognoms;Amb un índex sobre prestecs(soci_id) això és instantani per a un soci. Aquí no hi ha res per desnormalitzar, i és important dir-ho: la temptació d'afegir el comptador apareix abans d'haver comprovat si cal.
On sí que apareix el problema és al llistat dels 12.000 socis de la sucursal Nord amb el seu nombre de préstecs, que l'aplicació pagina de 50 en 50. Allà l'agregat es calcula sobre tota la taula a cada pàgina.
La desnormalització:
ALTER TABLE socis
ADD COLUMN total_prestecs INTEGER NOT NULL DEFAULT 0,
ADD COLUMN prestecs_oberts INTEGER NOT NULL DEFAULT 0;
-- Càrrega inicial des de la font de veritat
UPDATE socis s
SET total_prestecs = c.total,
prestecs_oberts = c.oberts
FROM (
SELECT soci_id,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE data_devolucio IS NULL) AS oberts
FROM prestecs GROUP BY soci_id
) c
WHERE c.soci_id = s.soci_id;La consulta passa a ser un SELECT directe sobre socis, sense JOIN ni agregació.
El que cal documentar i no oblidar:
| Pregunta | Resposta per a aquest cas |
|---|---|
| Què duplica? | Un agregat de prestecs |
| Quina és la font de veritat? | prestecs, sempre. Si discrepen, prestecs té raó |
| Qui ho manté? | Vegeu la secció 12: aplicació, activador o lot |
| Què passa si es desincronitza? | La fitxa mostra un número equivocat. Impacte baix, però visible |
| Com es detecta? | Consulta d'auditoria periòdica |
| Com es recalcula? | L'UPDATE ... FROM de dalt |
Aquesta última fila és la més important i la que més s'oblida: tota columna desnormalitzada necessita un procediment documentat per recalcular-la des de zero. És la xarxa de seguretat, i algun dia es farà servir.
La consulta d'auditoria:
-- Detectar socis el comptador dels quals no quadra amb la realitat
SELECT s.soci_id, s.total_prestecs AS desat, COUNT(p.prestec_id) AS real_
FROM socis s
LEFT JOIN prestecs p ON p.soci_id = s.soci_id
GROUP BY s.soci_id, s.total_prestecs
HAVING s.total_prestecs <> COUNT(p.prestec_id);Si aquesta consulta retorna files, el mecanisme de manteniment té un forat. Programar-la com a comprovació nocturna costa cinc minuts i estalvia mesos de desconcert.
- Tècnica 2: columnes generades enfront de columnes mantingudes a mà
No tota columna derivada té el mateix risc. Hi ha una diferència enorme entre les que manté el SGBD i les que manté el teu codi, i triar bé és la decisió més rendible d'aquesta lliçó.
Columnes generades: el SGBD garanteix la coherència
Ja les coneixem del mòdul 4 i les vam examinar a 05-03:
-- A esdeveniments
duracio_min INTEGER GENERATED ALWAYS AS
(EXTRACT(EPOCH FROM (fi - inici)) / 60) STORED
-- A inscripcions
places_ocupades SMALLINT GENERATED ALWAYS AS (1 + acompanyants) STOREDLa paraula ALWAYS és la garantia: PostgreSQL recalcula el valor a cada INSERT i cada UPDATE, i rebutja qualsevol intent d'escriure'l a mà:
ERROR: cannot insert a non-DEFAULT value into column "places_ocupades" DETAIL: Column "places_ocupades" is a generated column.
És una desnormalització sense risc d'inconsistència. Viola la 3FN en la lletra, i no en l'esperit, perquè el perill que la 3FN prevé està eliminat per un altre mecanisme.
La seva limitació és important: una columna generada només pot dependre de columnes de la seva pròpia fila i fer servir funcions deterministes. No pot comptar files d'una altra taula, ni consultar sucursals, ni fer servir now(). Per això total_prestecs a socis no pot ser una columna generada: depèn d'una altra taula.
Columnes mantingudes a mà: el risc és teu
Quan la columna generada no hi arriba, el manteniment passa a ser responsabilitat d'algú, i allà comencen els problemes de la secció 12.
La taula de decisió
| Situació | Solució | Risc |
|---|---|---|
| Deriva de columnes de la mateixa fila, funció determinista | Columna generada ALWAYS ... STORED |
Cap |
| Deriva de columnes de la mateixa fila, però s'ha de congelar en el temps | Columna normal + valor calculat en inserir | Baix: no es toca mai més |
| Deriva d'una altra taula, tolera segons de retard | Columna normal + activador | Mitjà |
| Deriva d'una altra taula, tolera hores de retard | Columna normal + procés per lots | Mitjà, però controlat |
| Agregat sobre milions de files | Taula de resum o vista materialitzada | Vegeu les seccions 8 i 9 |
I el detall que ja vam assenyalar a 05-03 i que convé fixar: un import històric no pot ser una columna generada, encara que ho sembli. import = tarifa × dies és una fórmula, sí, però si tarifa canvia, una columna generada recalcularia l'import de les multes antigues. Ha de ser una columna normal, calculada una vegada en emetre la multa i mai més. La diferència entre duracio_min —derivada d'inici i fi, que són de la pròpia fila i no canvien— i un import derivat d'una dada externa mutable és exactament aquesta.
- Tècnica 3: duplicar un atribut per evitar un JOIN
La tècnica més simple: copiar una columna de la taula A a la taula B per no haver-les d'unir.
El cas: la llista de préstecs actius al taulell mostra el títol del material. Normalitzat calen tres JOIN, perquè llibres és una vista sobre materials + materials_llibre des de la jerarquia de 04-03:
SELECT p.prestec_id, p.data_prestec, m.titol
FROM prestecs p
JOIN exemplars e ON e.exemplar_id = p.exemplar_id
JOIN materials m ON m.material_id = e.material_id
WHERE p.data_devolucio IS NULL AND e.sucursal_id = 2;La desnormalització:
ALTER TABLE prestecs ADD COLUMN titol_material VARCHAR(200);
UPDATE prestecs p
SET titol_material = m.titol
FROM exemplars e
JOIN materials m ON m.material_id = e.material_id
WHERE e.exemplar_id = p.exemplar_id;La consulta es queda en un SELECT sobre prestecs amb un sol JOIN a exemplars per al filtre de sucursal.
I ara la part honesta: en aquest cas concret, gairebé segur que no val la pena. Amb índexs a les claus foranes, tres JOIN sobre unes desenes de milers de files són mil·lisegons. S'ha introduït una còpia viva —si algú corregeix un títol mal catalogat, cal propagar-lo a tots els préstecs— a canvi d'un benefici que probablement no es nota. És l'exemple perfecte de desnormalització que sembla raonable i no ho és.
Quan sí que valdria la pena? Quan l'atribut duplicat compleix almenys una d'aquestes dues condicions:
- És immutable. L'ISBN d'una edició no canvia mai. Copiar-lo és gratis: no hi ha res per propagar. És la "categoria A" de 03-03.
- S'ha de congelar. El títol del material en el moment del préstec, per a un rebut o un històric. És la categoria B: es copia una vegada i no es toca mai més.
Si l'atribut és viu —pot canviar i la còpia l'ha de seguir— la duplicació exigeix propagació, i aleshores cal preguntar-se si el JOIN que s'evita compensa el mecanisme que s'afegeix. La major part de les vegades, no.
- Tècnica 4: taules de resum preagregades
Aquí és on la desnormalització deixa de ser un compromís discutible i passa a ser la solució òbvia.
El cas: el quadre de comandament de direcció amb els préstecs per sucursal i mes des del 2018.
CREATE TABLE resum_prestecs_mes (
any_ SMALLINT NOT NULL,
mes SMALLINT NOT NULL,
sucursal_id INTEGER NOT NULL,
total_prestecs INTEGER NOT NULL,
socis_diferents INTEGER NOT NULL,
dies_prestec_mitjans NUMERIC(5,2),
prestecs_amb_retard INTEGER NOT NULL,
tancat BOOLEAN NOT NULL DEFAULT FALSE,
actualitzat_el TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT pk_resum_prestecs_mes PRIMARY KEY (any_, mes, sucursal_id),
CONSTRAINT chk_resum_mes CHECK (mes BETWEEN 1 AND 12),
CONSTRAINT fk_resum_sucursal FOREIGN KEY (sucursal_id)
REFERENCES sucursals (sucursal_id) ON UPDATE CASCADE
);La càrrega, que és una única consulta agregada:
INSERT INTO resum_prestecs_mes
(any_, mes, sucursal_id, total_prestecs, socis_diferents,
dies_prestec_mitjans, prestecs_amb_retard, tancat)
SELECT EXTRACT(YEAR FROM p.data_prestec)::SMALLINT,
EXTRACT(MONTH FROM p.data_prestec)::SMALLINT,
e.sucursal_id,
COUNT(*),
COUNT(DISTINCT p.soci_id),
AVG(p.data_devolucio - p.data_prestec),
COUNT(*) FILTER (WHERE p.data_devolucio > p.data_devolucio_prevista),
TRUE
FROM prestecs p
JOIN exemplars e ON e.exemplar_id = p.exemplar_id
WHERE p.data_prestec < date_trunc('month', CURRENT_DATE) -- només mesos tancats
GROUP BY 1, 2, 3
ON CONFLICT (any_, mes, sucursal_id) DO UPDATE
SET total_prestecs = EXCLUDED.total_prestecs,
socis_diferents = EXCLUDED.socis_diferents,
dies_prestec_mitjans = EXCLUDED.dies_prestec_mitjans,
prestecs_amb_retard = EXCLUDED.prestecs_amb_retard,
actualitzat_el = now();El quadre de comandament passa d'agregar 84.000 files (i creixent) a llegir una taula d'uns 400 registres —8 anys × 12 mesos × 4 sucursals—. La diferència de rendiment no és d'un 20 %: és de tres ordres de magnitud, i no es pot aconseguir de cap altra manera.
Tres detalls de disseny que fan que aquesta tècnica funcioni bé:
La columna tancat. Distingeix els mesos acabats —que no canviaran mai— del mes en curs, que sí. Els tancats es calculen una vegada i s'obliden; només el mes actual necessita refresc. És el que fa que el cost de manteniment sigui gairebé zero.
La columna actualitzat_el. Qualsevol que miri la taula sap de quan són les dades. Sense ella, ningú no pot jutjar si una xifra és fiable.
L'ON CONFLICT ... DO UPDATE. Permet executar la càrrega les vegades que calgui sense duplicar res. Un procés que es pot repetir sense efectes secundaris és infinitament més fàcil d'operar que un que s'ha d'executar exactament una vegada.
I la regla que no es negocia: la taula de resum és derivada, mai font de veritat. Si resum_prestecs_mes i prestecs discrepen, prestecs té raó i el resum es regenera. El dia que algú comenci a corregir xifres directament al resum, la desnormalització s'haurà convertit en un segon sistema de dades incoherent amb el primer.
- Tècnica 5: vistes materialitzades
Una vista materialitzada és una taula de resum que el SGBD gestiona per tu: es defineix amb una consulta, PostgreSQL desa el resultat en disc, i es refresca quan l'hi demanes.
CREATE MATERIALIZED VIEW mv_disponibilitat_material AS
SELECT m.material_id,
m.titol,
e.sucursal_id,
su.nom AS sucursal_nom,
COUNT(*) AS exemplars_totals,
COUNT(*) FILTER (WHERE e.estat = 'disponible') AS disponibles,
COUNT(*) FILTER (WHERE e.estat = 'prestat') AS prestats
FROM materials m
JOIN exemplars e ON e.material_id = m.material_id
JOIN sucursals su ON su.sucursal_id = e.sucursal_id
GROUP BY m.material_id, m.titol, e.sucursal_id, su.nom;
-- Un índex únic és obligatori per poder refrescar sense bloquejar (vegeu a sota)
CREATE UNIQUE INDEX uq_mv_disponibilitat
ON mv_disponibilitat_material (material_id, sucursal_id);Es consulta com qualsevol taula:
SELECT sucursal_nom, disponibles
FROM mv_disponibilitat_material
WHERE material_id = 4021 AND disponibles > 0;El refresc
És el punt crític, i la diferència entre les dues formes és gran:
-- Bloqueja la vista: ningú no la pot llegir mentre dura
REFRESH MATERIALIZED VIEW mv_disponibilitat_material;
-- No bloqueja: els lectors continuen veient la versió anterior fins que acaba.
-- Requereix l'índex UNIQUE de dalt. És més lent, però és el que es fa servir en producció.
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_disponibilitat_material;Quan es refresca
| Estratègia | Com | Quan fer-la servir |
|---|---|---|
| Programat | Tasca de sistema cada N minuts o hores | L'habitual. Dades que toleren retard: catàleg, informes |
| Després d'un lot | Al final del procés nocturn d'importació | Quan la font canvia en moments coneguts |
| Sota demanda | L'usuari prem "actualitzar informe" | Informes pesants que es demanen poques vegades |
| Per activador | Un TRIGGER sobre la taula base llança el refresc |
Gairebé mai. Refrescar la vista sencera a cada escriptura anul·la el benefici |
Per al catàleg web de BiblioRed, cada 10 minuts és més que suficient: que el web digui "2 disponibles" quan fa tres minuts que en queda 1 és acceptable, perquè el soci ho anirà a comprovar al taulell de totes maneres.
Vista materialitzada enfront de taula de resum
| Vista materialitzada | Taula de resum | |
|---|---|---|
| Definició | Una sentència CREATE MATERIALIZED VIEW |
CREATE TABLE + procés de càrrega |
| Refresc | REFRESH, sempre complet (a PostgreSQL) |
A mida: només el mes en curs, només el que va canviar |
| Risc de divergència de la lògica | Cap: la consulta és a la definició | Existeix: l'INSERT es pot desviar del que es pretenia |
| Escalabilitat | Limitada: refrescar-ho tot costa cada vegada més | Bona: es refresca només el necessari |
| Esforç | Mínim | Mitjà |
Regla pràctica: comença sempre per una vista materialitzada. Si el refresc complet triga massa —i amb anys d'historial acabarà trigant— migra a una taula de resum amb refresc incremental. És l'ordre que minimitza la feina.
(Nota: SQLite no té vistes materialitzades. L'equivalent és una taula normal poblada per l'aplicació. És una de les diferències que cal tenir presents en triar entre PostgreSQL i SQLite, com vam veure a 01-02.)
- Tècnica 6: taules d'historial planes per a informes
Una variant de la taula de resum que no agrega, sinó que aplana: desa una fila per fet, però amb totes les columnes que calen ja resoltes, sense JOIN.
CREATE TABLE historial_prestecs_pla (
prestec_id INTEGER NOT NULL,
data_prestec DATE NOT NULL,
data_devolucio DATE,
dies_prestat INTEGER,
-- Dades del soci EN EL MOMENT del préstec
soci_id INTEGER NOT NULL,
soci_nom VARCHAR(140) NOT NULL,
soci_sucursal VARCHAR(60) NOT NULL,
-- Dades del material EN EL MOMENT del préstec
material_id INTEGER NOT NULL,
material_titol VARCHAR(200) NOT NULL,
material_tipus VARCHAR(20) NOT NULL,
autor_nom VARCHAR(140),
-- Dades de l'exemplar
exemplar_codi VARCHAR(10) NOT NULL,
sucursal_prestec VARCHAR(60) NOT NULL,
CONSTRAINT pk_historial_prestecs_pla PRIMARY KEY (prestec_id)
);Cada fila porta dotze columnes que a l'esquema normalitzat exigirien cinc JOIN. Qualsevol informe —préstecs per autor i any, per tipus de material i sucursal, per franja d'edat del soci— es resol amb un GROUP BY sobre una sola taula.
L'important d'aquesta tècnica és la frase "en el moment del préstec". Els valors es copien quan el préstec es tanca i no s'actualitzen mai més. Si la Marta Alsina es trasllada a la sucursal Sud el 2027, els préstecs que va fer el 2026 continuen dient "Nord", que és la veritat històrica. Un informe sobre l'activitat de la sucursal Nord el 2026 els ha de comptar.
Aquí hi ha la diferència clau amb les tècniques anteriors: això no és una còpia que calgui mantenir sincronitzada. És un registre del que era cert aleshores. No hi ha risc d'inconsistència perquè no hi ha res per propagar, i per això aquesta és una de les desnormalitzacions més segures que existeixen.
-- Es pobla quan el préstec es tanca, amb els valors vigents en aquell moment
INSERT INTO historial_prestecs_pla
SELECT p.prestec_id, p.data_prestec, p.data_devolucio,
p.data_devolucio - p.data_prestec,
s.soci_id, s.nom || ' ' || s.cognoms, ss.nom,
m.material_id, m.titol, m.tipus,
a.nom || ' ' || a.cognoms,
e.codi, es.nom
FROM prestecs p
JOIN socis s ON s.soci_id = p.soci_id
JOIN sucursals ss ON ss.sucursal_id = s.sucursal_id
JOIN exemplars e ON e.exemplar_id = p.exemplar_id
JOIN sucursals es ON es.sucursal_id = e.sucursal_id
JOIN materials m ON m.material_id = e.material_id
LEFT JOIN autors a ON a.autor_id = m.autor_id
WHERE p.prestec_id = 5001;
- Tècnica 7: l'esquema en estrella dels magatzems de dades
Totes les tècniques anteriors són desnormalitzacions puntuals sobre un esquema normalitzat. L'esquema en estrella és una altra cosa: és un model de dades complet dissenyat des del principi per desnormalitzar, de manera sistemàtica i per norma.
Reprenem aquí la distinció OLTP enfront d'OLAP de la lliçó 01-02:
| OLTP (BiblioRed operatiu) | OLAP (magatzem analític) | |
|---|---|---|
| Per a què | Registrar préstecs, donar d'alta socis | Analitzar deu anys d'activitat |
| Operacions | Moltes, petites, concurrents | Poques, enormes, seqüencials |
| Escriptures | Constants | Només la càrrega periòdica |
| Prioritat | Integritat, latència baixa | Rendiment de lectura massiva |
| Disseny | Normalitzat (3FN) | Desnormalitzat (estrella) |
Fets i dimensions
L'esquema en estrella organitza les dades en dos tipus de taula:
- Taula de fets (fact table): una fila per esdeveniment mesurable, amb les mètriques numèriques que s'agregaran i claus foranes a les dimensions. És enorme —milions o milers de milions de files— i molt estreta.
- Taules de dimensió: el context pel qual es vol filtrar i agrupar. Són petites, amples i deliberadament desnormalitzades: una dimensió no es descompon encara que tingui dependències transitives.
-- DIMENSIÓ: el material. Cal notar que autor, editorial i tipus estan
-- APLANATS aquí, en comptes de ser en taules separades. És intencionat.
CREATE TABLE dim_material (
material_key INTEGER PRIMARY KEY,
material_id INTEGER NOT NULL,
titol VARCHAR(200) NOT NULL,
tipus VARCHAR(20) NOT NULL,
autor_nom VARCHAR(140),
autor_nacionalitat VARCHAR(40), -- transitiva via autor: acceptat
editorial VARCHAR(80),
any_publicacio SMALLINT,
idioma VARCHAR(20)
);
-- DIMENSIÓ: la sucursal, amb la seva geografia aplanada
CREATE TABLE dim_sucursal (
sucursal_key INTEGER PRIMARY KEY,
sucursal_id INTEGER NOT NULL,
nom VARCHAR(60) NOT NULL,
ciutat VARCHAR(60) NOT NULL, -- transitiva via CP: acceptat
codi_postal VARCHAR(5) NOT NULL,
comarca VARCHAR(60)
);
-- DIMENSIÓ: el temps. Totes les formes de mirar una data, precalculades
CREATE TABLE dim_data (
data_key INTEGER PRIMARY KEY, -- 20260409
data DATE NOT NULL,
any_ SMALLINT NOT NULL,
trimestre SMALLINT NOT NULL,
mes SMALLINT NOT NULL,
mes_nom VARCHAR(12) NOT NULL,
dia_setmana SMALLINT NOT NULL,
es_festiu BOOLEAN NOT NULL DEFAULT FALSE
);
-- FET: un préstec. Estreta, llarguíssima, només claus i mètriques
CREATE TABLE fets_prestec (
prestec_key BIGINT PRIMARY KEY,
data_key INTEGER NOT NULL REFERENCES dim_data (data_key),
soci_key INTEGER NOT NULL REFERENCES dim_soci (soci_key),
material_key INTEGER NOT NULL REFERENCES dim_material (material_key),
sucursal_key INTEGER NOT NULL REFERENCES dim_sucursal (sucursal_key),
dies_prestat SMALLINT,
dies_retard SMALLINT NOT NULL DEFAULT 0,
import_recarrec NUMERIC(6,2) NOT NULL DEFAULT 0,
num_prestecs SMALLINT NOT NULL DEFAULT 1
);S'anomena "en estrella" perquè el diagrama té la taula de fets al centre i les dimensions al voltant:
flowchart TD
DF["dim_data"] --> H
DS["dim_soci"] --> H
DM["dim_material"] --> H
DSU["dim_sucursal"] --> H
H["<b>fets_prestec</b><br/>metriques + claus"]
I les consultes analítiques es tornen trivials d'escriure i rapidíssimes d'executar:
-- Préstecs i retards per nacionalitat de l'autor i trimestre, 2025
SELECT d.any_, d.trimestre, m.autor_nacionalitat,
SUM(h.num_prestecs) AS prestecs,
AVG(h.dies_retard) AS retard_mitja
FROM fets_prestec h
JOIN dim_data d ON d.data_key = h.data_key
JOIN dim_material m ON m.material_key = h.material_key
WHERE d.any_ = 2025
GROUP BY d.any_, d.trimestre, m.autor_nacionalitat
ORDER BY prestecs DESC;Un sol nivell de JOIN, sense cadenes. A l'esquema normalitzat, arribar d'un préstec a la nacionalitat de l'autor exigeix recórrer prestecs → exemplars → materials → autors.
Per què en analítica es desnormalitza per norma
Quatre raons, i les quatre són sòlides:
- No hi ha escriptures concurrents. El magatzem es carrega per lots des del sistema operatiu. El principal cost de la desnormalització —mantenir la coherència davant d'escriptures— senzillament no existeix.
- La font de veritat és en un altre lloc. Si el magatzem es corromp, es torna a carregar des de l'OLTP. La redundància no pot produir una pèrdua irrecuperable.
- Les dades són històriques i immutables. Un préstec del 2019 no canvia. I quan el context canvia —un material es recatalog— el correcte és conservar el valor antic per als fets antics, que és just el que la desnormalització dóna.
- El patró de consulta és conegut i estable. Se sap per endavant per què s'agruparà, i el model es dissenya per a això.
En resum: a OLAP es donen alhora totes les condicions que justifiquen desnormalitzar, i cap de les que ho desaconsellen. Per això allà és la norma i no l'excepció.
(Existeix una variant anomenada floc de neu que sí que normalitza les dimensions —traient autor de dim_material a la seva pròpia taula, per exemple—. Estalvia espai i complica les consultes. El criteri majoritari a la indústria és estrella tret que les dimensions siguin gegantines. El disseny de magatzems de dades és una disciplina pròpia; aquí només interessa reconèixer-lo com a desnormalització sistemàtica i entendre per què està justificada.)
- Com es manté la coherència d'allò desnormalitzat
Tota desnormalització que no sigui històrica congelada crea una obligació: mantenir la còpia sincronitzada amb la font. Hi ha tres formes de complir-la i cal triar conscientment.
Opció A: a l'aplicació
El codi que escriu el préstec actualitza també el comptador:
BEGIN;
INSERT INTO prestecs (soci_id, exemplar_id, data_prestec, data_devolucio_prevista)
VALUES (14, 3081, CURRENT_DATE, CURRENT_DATE + 21);
UPDATE socis
SET total_prestecs = total_prestecs + 1,
prestecs_oberts = prestecs_oberts + 1
WHERE soci_id = 14;
COMMIT;A favor: la lògica és on l'equip la veu, és fàcil de depurar i de provar.
En contra, i és un problema seriós: n'hi ha prou que un sol camí d'escriptura se n'oblidi perquè la còpia comenci a divergir. I els camins d'escriptura són més dels que sembla: l'aplicació web, l'aplicació de taulell, el procés d'importació nocturn, l'script de correcció que algú va executar a mà un dimarts a la tarda. Cadascun se n'ha de recordar.
Opció B: amb un activador (TRIGGER)
Un activador és una funció que el SGBD executa automàticament quan es produeix un esdeveniment sobre una taula. L'avantatge decisiu: tant se val qui escrigui i des d'on.
-- La funció que fa la feina
CREATE OR REPLACE FUNCTION fn_actualitzar_comptador_soci()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE socis
SET total_prestecs = total_prestecs + 1,
prestecs_oberts = prestecs_oberts
+ CASE WHEN NEW.data_devolucio IS NULL THEN 1 ELSE 0 END
WHERE soci_id = NEW.soci_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE socis
SET total_prestecs = total_prestecs - 1,
prestecs_oberts = prestecs_oberts
- CASE WHEN OLD.data_devolucio IS NULL THEN 1 ELSE 0 END
WHERE soci_id = OLD.soci_id;
ELSIF TG_OP = 'UPDATE' THEN
-- Devolució: passa d'obert a tancat
IF OLD.data_devolucio IS NULL AND NEW.data_devolucio IS NOT NULL THEN
UPDATE socis SET prestecs_oberts = prestecs_oberts - 1
WHERE soci_id = NEW.soci_id;
END IF;
END IF;
RETURN NULL; -- AFTER trigger: el valor retornat s'ignora
END;
$$ LANGUAGE plpgsql;
-- L'activador que la invoca
CREATE TRIGGER trg_comptador_soci
AFTER INSERT OR UPDATE OR DELETE ON prestecs
FOR EACH ROW
EXECUTE FUNCTION fn_actualitzar_comptador_soci();Lectura del codi, línia a línia, perquè és la primera vegada que apareix un activador al curs:
RETURNS TRIGGERmarca la funció com a apta per ser invocada per un activador.TG_OPés una variable especial que conté l'operació:'INSERT','UPDATE'o'DELETE'.NEWés la fila nova (existeix aINSERTiUPDATE);OLDés l'anterior (existeix aUPDATEiDELETE).AFTER ... FOR EACH ROWsignifica que s'executa una vegada per fila afectada, després d'aplicar el canvi. Si unUPDATEtoca 500 files, l'activador s'executa 500 vegades.- Tot passa dins de la mateixa transacció que l'operació original: si l'
UPDATEdel comptador falla, l'INSERTdel préstec es desfà també. Aquesta atomicitat és precisament el que fa fiable aquesta opció, i és matèria de la lliçó 06-01.
A favor: impossible de saltar-se'l. Coherència garantida pel SGBD.
En contra: és lògica de negoci amagada a la base de dades, invisible per a qui llegeix el codi de l'aplicació; complica la depuració; i costa a cada escriptura. Un UPDATE massiu de 100.000 files executa l'activador 100.000 vegades.
Opció C: per procés per lots
Una tasca programada recalcula la còpia periòdicament:
-- Cada nit a les 3:00
UPDATE socis s
SET total_prestecs = COALESCE(c.total, 0),
prestecs_oberts = COALESCE(c.oberts, 0)
FROM (
SELECT soci_id, COUNT(*) AS total,
COUNT(*) FILTER (WHERE data_devolucio IS NULL) AS oberts
FROM prestecs GROUP BY soci_id
) c
WHERE c.soci_id = s.soci_id;A favor: cost zero a les escriptures, codi senzill, i —això és valuós— corregeix per si sol qualsevol divergència, vingui d'on vingui.
En contra: les dades estan desfasades entre execucions. Cal decidir si això és acceptable, i dir-ho a la interfície ("dades a data d'ahir").
La comparativa
| Aplicació | Activador | Lot | |
|---|---|---|---|
| Garantia de coherència | Baixa: depèn que tots els camins ho facin | Alta: el SGBD la imposa | Mitjana: exacta després de cada execució |
| Latència | Immediata | Immediata | Fins al cicle següent |
| Cost en escriptura | Mitjà | Mitjà-alt | Nul |
| Cost en operació | Baix | Baix | Mitjà: hi ha un procés per vigilar |
| Visibilitat per a l'equip | Alta | Baixa: cal anar-lo a buscar | Mitjana |
| Se salta si escrius per una altra banda | Sí | No | No aplica: es corregeix sol |
| S'autocorregeix | No | No | Sí |
| Recomanat per a | Còpies poc crítiques amb un sol camí d'escriptura | Còpies crítiques que han de ser exactes sempre | Agregats i informes que toleren retard |
La combinació que millor funciona a la pràctica: tria una de les tres per al manteniment i afegeix sempre la de lot com a auditoria. Encara que facis servir un activador, programa la consulta de comprovació nocturna. Si algun dia retorna files, sabràs que hi ha un forat abans que ho descobreixi un usuari.
- El pont amb NoSQL: el modelatge documental és desnormalització elevada a mètode
Si en llegir la secció 7 has pensat "això s'assembla molt a allò d'incrustar documents", has vist exactament el que calia veure.
El modelatge documental de la lliçó 03-03 —incrustar en comptes de referenciar, duplicar camps expressament, dissenyar l'agregat al voltant de la consulta que el llegirà— és desnormalització. No és una tècnica semblant: és la mateixa decisió, presa pels mateixos motius, amb les mateixes conseqüències.
Compara-ho punt per punt:
| Al món relacional (aquesta lliçó) | Al món documental (03-03) |
|---|---|
| Duplicar un atribut per evitar un JOIN | Incrustar un subdocument per evitar una segona consulta |
| Taula de resum preagregada | Camps de recompte dins del document agregat |
| Columna redundant mantinguda per activador | Camp duplicat amb propagació en actualitzar l'original |
Dada històrica congelada (import de la multa) |
Camp duplicat de categoria B: històric congelat |
| Atribut immutable copiat (l'ISBN) | Camp duplicat de categoria A: immutable, gratis |
| Font de veritat enfront de còpia derivada | La col·lecció canònica enfront de l'agregat de lectura |
La diferència no és a la tècnica sinó al punt de partida per defecte. En un sistema relacional, el normal és normalitzar i desnormalitzar en casos concrets i justificats. En un sistema documental, el normal és agregar —desnormalitzar— i normalitzar (referenciar) en casos concrets i justificats. L'eix és el mateix; el que canvia és on és el punt neutre.
I la disciplina que exigeix és idèntica. A 03-03 vam establir que cada camp duplicat ha d'estar classificat —immutable, històric congelat, o viu amb propagació— i que si no s'escriu, d'aquí a sis mesos ningú no sabrà si cal actualitzar-lo. És exactament la documentació que la secció 5 d'aquesta lliçó exigeix per a cada columna redundant. La regla és la mateixa als dos mons: duplica el que es mostra, mai el que es fa servir per decidir; i escriu sempre qui és la font de veritat.
Això explica també una cosa que al mòdul 3 podia sonar contradictòria. Quan vam dir que MongoDB "no necessita JOIN" no estàvem dient que hagués desaparegut el problema que el JOIN resol: estàvem dient que es paga per endavant, a l'escriptura, en forma de duplicació mantinguda. És el mateix intercanvi de la secció 3 d'aquesta lliçó, amb els mateixos plats a la balança.
- Guia de decisió: les preguntes abans i els senyals per desfer-ho
Les set preguntes abans de desnormalitzar
Respon-les per escrit. Si alguna no té resposta, no desnormalitzis encara.
1. He mesurat el problema? Quant triga ara la consulta, amb dades de producció i volum real? Si no tens el número, no tens un problema: tens una sospita.
2. He provat amb un índex? EXPLAIN ANALYZE de la consulta, revisió dels índexs existents, ANALYZE de les taules. Això és la lliçó 06-03 i és obligatori abans de continuar.
3. Quin és l'objectiu concret? "Aquest informe s'ha d'obrir en menys de dos segons" és un objectiu. "Anar més ràpid" no ho és, perquè no es pot saber si s'ha complert.
4. Quina és la relació lectures/escriptures? Mesurada, no estimada. Per sota de 10:1, la desnormalització rarament compensa.
5. La còpia és immutable, històrica congelada o viva? És la pregunta que més estalvia. Les dues primeres són gairebé gratis. Només la tercera exigeix un mecanisme de propagació, i només aleshores cal respondre les dues següents.
6. Qui manté la còpia i què passa si falla? Aplicació, activador o lot (secció 12). I l'escenari de la fallada: un número malament en una pantalla, o un import malament en una factura? La gravetat decideix el mecanisme.
7. Com es detecta i es repara la divergència? La consulta d'auditoria i el procediment de recàlcul, escrits i programats. Si no els tens, la desnormalització no està acabada.
L'arbre de decisió
flowchart TD
A["Consulta lenta<br/>o dada que s'ha de congelar"] --> B{"Es una dada historica<br/>que s'ha de congelar?"}
B -->|Si| C["Desa-la. No es<br/>desnormalitzacio opcional:<br/>es el correcte"]
B -->|No| D{"Ho has mesurat<br/>amb EXPLAIN ANALYZE?"}
D -->|No| E["Mesura-ho primer<br/>→ 06-03"]
D -->|Si| F{"Ho arregla<br/>un index?"}
F -->|Si| G["Crea l'index.<br/>Fi del problema"]
F -->|No| H{"Es un agregat<br/>sobre moltes files?"}
H -->|Si| I["Vista materialitzada<br/>o taula de resum"]
H -->|No| J{"Ratio lectures/escriptures<br/>mes gran que 10:1?"}
J -->|No| K["No desnormalitzis.<br/>Revisa la consulta"]
J -->|Si| L{"La dada copiada<br/>es immutable?"}
L -->|Si| M["Duplica. Cost gairebe nul"]
L -->|No| N["Duplica + mecanisme de<br/>propagacio + auditoria.<br/>Documenta-ho"]
Els senyals que indiquen que cal desfer-ho
Una desnormalització no és per sempre. Aquests sis senyals indiquen que cal revisar-la, i probablement revertir-la:
1. Les consultes d'auditoria retornen files amb regularitat. El mecanisme de manteniment té un forat que no s'ha tancat. Cada divergència detectada és una dada que algú va veure malament abans que l'auditoria l'enxampés.
2. Ningú no recorda per què hi ha aquella columna. Si la documentació no existeix o ningú no la troba, la desnormalització ja no és deliberada: és deute.
3. La còpia s'ha convertit en font de veritat. El símptoma és que algú corregeix un valor a la còpia en comptes de a l'original. A partir d'aquí hi ha dos sistemes de dades que es contradiuen i no hi ha manera de decidir quin mana.
4. Les escriptures s'han convertit en el coll d'ampolla. El sistema va optimitzar les lectures i ara el taulell espera. Mesura una altra vegada: pot ser que l'equilibri hagi canviat.
5. El motiu original ha desaparegut. L'informe que justificava la taula de resum ja no el fa servir ningú. La versió nova de PostgreSQL executa aquell JOIN cent vegades més ràpid. S'hi va afegir un índex que resol el cas. Revisa les desnormalitzacions almenys una vegada l'any: algunes caduquen.
6. La lògica de manteniment s'ha tornat més complexa que el JOIN que evitava. Si l'activador té quaranta línies i tres casos especials per estalviar un JOIN de dues taules, l'intercanvi ha deixat de tenir sentit.
Com es desfà
Amb la mateixa disciplina amb què es va fer, i en ordre invers al de 05-03: es canvien primer les lectures perquè facin servir l'esquema normalitzat, es comprova que donen els mateixos resultats, es retira el mecanisme de manteniment, i només al final s'elimina la columna o la taula. Desant-ne una còpia abans, sempre.
Errors Habituals i Consells
Desnormalitzar sense haver mesurat. És l'error número u i la causa de la major part de la redundància innecessària que hi ha en producció. "Això anirà lent quan creixi" és una predicció, no un mesurament, i les prediccions sobre rendiment fallen constantment: el coll d'ampolla gairebé mai no és on s'esperava.
Desnormalitzar abans de provar un índex. És la secció 2 sencera. Un CREATE INDEX és reversible, gratuït en risc, i instantani; una desnormalització és permanent a la pràctica. Comença per 06-03.
Començar desnormalitzat "per si de cas". Trenca la regla d'or. Encara no saps quines consultes dominaran, ni amb quin volum, ni amb quin patró d'escriptura. I desfer-ho després és molt més car que fer-ho ara.
No documentar la font de veritat. Cada dada duplicada ha de tenir escrit quina de les dues còpies mana. Sense això, el dia que discrepin —i discreparan— ningú no sabrà quina corregir, i algú triarà malament.
Tractar la còpia com a font de veritat. El símptoma és un UPDATE directe sobre la taula de resum per "quadrar" una xifra. Aquell UPDATE no arregla res: crea una divergència permanent que el refresc següent esborrarà, o pitjor, no esborrarà.
Posar un activador que refresqui una vista materialitzada sencera a cada escriptura. Anul·la completament el benefici i converteix cada INSERT en un recàlcul global. Les vistes materialitzades es refresquen de manera programada.
Oblidar el procediment de recàlcul. Tota desnormalització necessita un UPDATE/INSERT documentat que la reconstrueixi des de zero. Algun dia caldrà executar-lo, amb pressa, i no serà el moment d'escriure'l.
Confondre "dada congelada" amb "dada desnormalitzada". No són el mateix i confondre-les porta a dos errors oposats: propagar un valor que havia de quedar-se quiet (i falsejar un històric), o deixar sense propagar una còpia viva (i mostrar dades obsoletes). La pregunta que les separa és a la secció 4: si l'original canvia demà, aquest ha de canviar també?
No revisar mai. Les desnormalitzacions caduquen. Una revisió anual de totes les que hi ha a l'esquema, amb els seus mesuraments repetits, sol trobar-ne almenys una que ja no cal.
Exercicis
Exercici 1: Decidir si desnormalitzar
Per a cadascun d'aquests quatre casos de BiblioRed, decideix si desnormalitzaries o no. Justifica-ho amb les preguntes de la secció 14 i indica, si desnormalitzes, quina tècnica faries servir i quin mecanisme de manteniment.
a) La pàgina de detall d'un esdeveniment mostra el nom de la sala i el seu aforament. Es consulta unes 300 vegades al dia. La consulta normalitzada triga 4 mil·lisegons.
b) L'informe anual de direcció creua els 84.000 préstecs amb socis, materials, autors i sucursals, agrupant per autor i any. Triga 38 segons i l'obren 15 persones cada matí durant el gener.
c) El rebut que s'imprimeix en cobrar una multa mostra el nom del soci, l'import i el motiu.
d) El catàleg web mostra, per a cada material, quants exemplars hi ha disponibles a cada sucursal. Es consulta 40.000 vegades al dia i les dades canvien amb cada préstec i cada devolució.
Exercici 2: Detectar i reparar una divergència
BiblioRed va afegir fa sis mesos la columna socis.total_prestecs, mantinguda per l'aplicació de taulell (opció A de la secció 12). Avui, la responsable de la sucursal Nord diu que la fitxa d'un soci mostra 12 préstecs i el seu historial només en té 9.
Es demana:
- a) Escriure la consulta d'auditoria que troba tots els socis amb el comptador descompassat, mostrant la diferència.
- b) Escriure l'
UPDATEque repara el comptador de tots ells. - c) Proposar tres causes plausibles de la divergència, tenint en compte que el manteniment és a l'aplicació.
- d) Proposar el canvi de mecanisme que evitaria que tornés a passar, i dir què es guanya i què es paga.
Exercici 3: Dissenyar una taula de resum
Direcció de BiblioRed demana un quadre de comandament de l'activitat d'esdeveniments amb aquestes xifres, per sucursal i mes: nombre d'esdeveniments celebrats, nombre total d'inscripcions confirmades, places ofertes, ocupació mitjana en percentatge, i valoració mitjana dels informes d'esdeveniment.
Es demana:
- a) Escriure el
CREATE TABLEde la taula de resum, amb clau primària i les columnes de control que recomana la secció 8. - b) Escriure l'
INSERT ... SELECTque la carrega a partir d'esdeveniments,sales,inscripcionsiinformes_esdeveniment, contemplant només els mesos tancats. - c) Decidir el mecanisme de refresc i justificar-lo.
- d) Escriure la consulta d'auditoria que comprova que una fila del resum quadra amb les dades operatives.
Solucions
Solució 1
a) No desnormalitzar. La pregunta 1 ja ho resol: 4 mil·lisegons no és un problema. Amb 300 consultes diàries, el temps total de CPU dedicat a aquell JOIN és de poc més d'un segon al dia. Introduir una còpia de sala_nom i sala_aforament a esdeveniments significaria a més duplicar una dada viva —l'aforament canvia si la sala es reforma, i de fet és l'exemple d'anomalia d'actualització que vam fer servir a 04-01—, així que exigiria un mecanisme de propagació complet. Cost alt, benefici nul.
b) Sí desnormalitzar: taula de resum o vista materialitzada. És el cas 2 de la secció 4 en estat pur. 38 segons × 15 persones = gairebé deu minuts diaris d'espera acumulada, sobre dades que no canvien: els préstecs d'anys tancats són immutables. A més, la pregunta 2 no el salvarà: cap índex no accelera significativament un GROUP BY que recorre les 84.000 files de totes maneres.
La tècnica adequada és una taula de resum amb granularitat autor-any i una columna tancat, refrescada una vegada al mes per procés per lots. Els anys passats es calculen una vegada a la vida. El cost de manteniment tendeix a zero i la consulta passa de 38 segons a uns pocs mil·lisegons.
c) Sí, però no és una desnormalització opcional: és correcció. És el cas 3 de la secció 4. El rebut emès el 12 de març diu el que diu i no pot canviar: ni si el soci es canvia el nom, ni si l'ordenança apuja la tarifa, ni si la multa es requalifica. Els tres valors s'han de copiar a la taula pagaments (o en una taula rebuts) en el moment d'emetre el rebut, i no tornar-se a tocar mai.
No cal mecanisme de manteniment —precisament perquè no es propaga res— i no hi ha risc d'inconsistència. És la desnormalització més segura i l'única de les quatre que seria un error no fer.
d) Sí desnormalitzar: vista materialitzada. És el cas 1 de la secció 4. La relació lectures/escriptures és aclaparadora: 40.000 consultes diàries enfront d'uns pocs centenars de préstecs i devolucions. I la consulta normalitzada és un COUNT agrupat sobre exemplars per a cada material, que a la pàgina del catàleg s'executa moltes vegades.
La tècnica és la mv_disponibilitat_material de la secció 9, amb refresc programat cada 5 o 10 minuts. La clau és acceptar el desfasament: que el catàleg web digui "2 disponibles" quan en queda 1 és tolerable, perquè el soci comprovarà la disponibilitat real en demanar-lo. El que no seria tolerable és fer servir aquella vista per decidir si es concedeix un préstec: per a això cal consultar exemplars, que és la font de veritat. És l'aplicació exacta de la regla de 03-03: duplica el que es mostra, mai el que es fa servir per decidir.
Solució 2
a) Consulta d'auditoria:
SELECT s.soci_id,
s.nom || ' ' || s.cognoms AS soci,
s.total_prestecs AS desat,
COUNT(p.prestec_id) AS real_,
s.total_prestecs - COUNT(p.prestec_id) AS diferencia
FROM socis s
LEFT JOIN prestecs p ON p.soci_id = s.soci_id
GROUP BY s.soci_id, s.nom, s.cognoms, s.total_prestecs
HAVING s.total_prestecs <> COUNT(p.prestec_id)
ORDER BY abs(s.total_prestecs - COUNT(p.prestec_id)) DESC;El LEFT JOIN és imprescindible: amb un JOIN intern, els socis sense cap préstec desapareixerien del resultat, i són precisament els que poden tenir un comptador positiu erroni.
b) Reparació:
BEGIN;
UPDATE socis s
SET total_prestecs = COALESCE(c.total, 0)
FROM (
SELECT s2.soci_id, COUNT(p.prestec_id) AS total
FROM socis s2
LEFT JOIN prestecs p ON p.soci_id = s2.soci_id
GROUP BY s2.soci_id
) c
WHERE c.soci_id = s.soci_id
AND s.total_prestecs <> COALESCE(c.total, 0);
-- Comprovar abans de confirmar: ha de retornar 0 files
SELECT COUNT(*) FROM (
SELECT s.soci_id FROM socis s
LEFT JOIN prestecs p ON p.soci_id = s.soci_id
GROUP BY s.soci_id, s.total_prestecs
HAVING s.total_prestecs <> COUNT(p.prestec_id)
) x;
COMMIT;El COALESCE cobreix els socis sense préstecs, el COUNT dels quals sobre el LEFT JOIN dóna 0 però el subconjunt dels quals podria no aparèixer. I la comprovació dins de la transacció, abans del COMMIT, és la pràctica correcta: si el número no és zero, es fa ROLLBACK.
c) Tres causes plausibles, totes característiques del manteniment a l'aplicació:
- Un camí d'escriptura que no actualitza el comptador. El procés nocturn d'importació de préstecs de la biblioteca veïna, o l'script de correcció que algú va executar a mà, van inserir a
prestecssense tocarsocis. És la causa més freqüent. - Un
DELETEno contemplat. Quan l'Ivan Pereda va demanar que s'esborrés el seu historial, es van esborrar les files deprestecsperò el codi només restava el comptador en el cas de la devolució, no en el de l'esborrament. - Una transacció parcialment confirmada. Si l'
INSERTi l'UPDATEno eren dins de la mateixa transacció, una fallada entre els dos deixa el préstec inserit i el comptador sense incrementar. És un error subtil i produeix divergències d'una unitat, molt difícils de rastrejar després.
d) Canvi de mecanisme: passar a activador (opció B), i afegir l'auditoria per lots.
L'activador de la secció 12 s'executa vingui l'escriptura d'on vingui: l'aplicació web, el taulell, el procés d'importació o el psql d'un dimarts a la tarda. Elimina d'arrel les causes 1 i 2. I com que s'executa dins de la mateixa transacció que l'operació original, elimina també la 3.
El que es guanya: coherència garantida pel SGBD, no per la disciplina de tots els equips que escriuen.
El que es paga: un cost afegit a cada escriptura sobre prestecs —notable si alguna vegada es fa un UPDATE massiu—; lògica de negoci que viu a la base de dades i no es veu llegint el codi de l'aplicació; i una funció plpgsql més per provar i mantenir.
I en qualsevol cas, la consulta de l'apartat a) es programa com a comprovació nocturna igualment. Fins i tot amb activador: si l'activador es desactiva alguna vegada per a una càrrega massiva i algú s'oblida de reactivar-lo, l'auditoria ho detectarà aquella mateixa nit.
Solució 3
a) La taula de resum:
CREATE TABLE resum_esdeveniments_mes (
any_ SMALLINT NOT NULL,
mes SMALLINT NOT NULL,
sucursal_id INTEGER NOT NULL,
esdeveniments_celebrats INTEGER NOT NULL DEFAULT 0,
inscripcions_conf INTEGER NOT NULL DEFAULT 0,
places_ofertes INTEGER NOT NULL DEFAULT 0,
ocupacio_mitjana_pct NUMERIC(5,2),
valoracio_mitjana NUMERIC(3,2),
tancat BOOLEAN NOT NULL DEFAULT FALSE,
actualitzat_el TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT pk_resum_esdeveniments_mes PRIMARY KEY (any_, mes, sucursal_id),
CONSTRAINT chk_resum_ev_mes CHECK (mes BETWEEN 1 AND 12),
CONSTRAINT chk_resum_ev_ocupacio
CHECK (ocupacio_mitjana_pct IS NULL OR ocupacio_mitjana_pct BETWEEN 0 AND 100),
CONSTRAINT chk_resum_ev_valoracio
CHECK (valoracio_mitjana IS NULL OR valoracio_mitjana BETWEEN 0 AND 5),
CONSTRAINT fk_resum_ev_sucursal FOREIGN KEY (sucursal_id)
REFERENCES sucursals (sucursal_id) ON UPDATE CASCADE
);Les columnes tancat i actualitzat_el són les de control que demanava l'enunciat. Els CHECK no són adorn: una taula derivada amb un percentatge d'ocupació del 340 % delata un error a la consulta de càrrega, i és millor que ho detecti l'INSERT que un directiu en una reunió.
b) La càrrega:
INSERT INTO resum_esdeveniments_mes
(any_, mes, sucursal_id, esdeveniments_celebrats, inscripcions_conf,
places_ofertes, ocupacio_mitjana_pct, valoracio_mitjana, tancat)
SELECT EXTRACT(YEAR FROM e.inici)::SMALLINT AS any_,
EXTRACT(MONTH FROM e.inici)::SMALLINT AS mes,
s.sucursal_id,
COUNT(DISTINCT e.esdeveniment_id),
COALESCE(SUM(i.confirmades), 0),
SUM(e.places_ofertes),
CASE WHEN SUM(e.places_ofertes) > 0
THEN 100.0 * COALESCE(SUM(i.confirmades), 0) / SUM(e.places_ofertes)
ELSE NULL END,
AVG(inf.valoracio_mitjana),
TRUE
FROM esdeveniments e
JOIN sales s ON s.sala_id = e.sala_id
LEFT JOIN LATERAL (
SELECT SUM(ins.places_ocupades) AS confirmades
FROM inscripcions ins
WHERE ins.esdeveniment_id = e.esdeveniment_id
AND ins.estat IN ('confirmada','assistida')
) i ON TRUE
LEFT JOIN informes_esdeveniment inf ON inf.esdeveniment_id = e.esdeveniment_id
WHERE e.estat = 'celebrat'
AND e.inici < date_trunc('month', CURRENT_DATE) -- només mesos tancats
GROUP BY 1, 2, s.sucursal_id
ON CONFLICT (any_, mes, sucursal_id) DO UPDATE
SET esdeveniments_celebrats = EXCLUDED.esdeveniments_celebrats,
inscripcions_conf = EXCLUDED.inscripcions_conf,
places_ofertes = EXCLUDED.places_ofertes,
ocupacio_mitjana_pct = EXCLUDED.ocupacio_mitjana_pct,
valoracio_mitjana = EXCLUDED.valoracio_mitjana,
actualitzat_el = now();Tres decisions que mereixen comentari:
- La subconsulta
LATERALper a les inscripcions evita l'error clàssic de multiplicar files en unir dues taules de detall (inscripcions i informes) contra la mateixa taula d'esdeveniments. Sense ella, un esdeveniment amb 20 inscripcions i un informe produiria 20 files i la valoració mitjana es comptaria 20 vegades. És exactament el problema d'"inventar files en reunir" de 05-03, en la seva versió d'agregació. e.estat = 'celebrat'exclou els cancel·lats i els programats, que no han de comptar com a activitat.places_ocupadesen comptes de comptar inscripcions: la columna generada d'inscripcionsja inclou els acompanyants, que és el que ocupa aforament de debò.
c) Mecanisme de refresc: procés per lots, mensual, el dia 1 de cada mes.
La justificació és a la naturalesa de la dada. Els mesos tancats no canvien mai: un esdeveniment celebrat al març amb les seves inscripcions i el seu informe és un fet consumat. Refrescar més sovint seria feina inútil. I com que el quadre de comandament és una eina de direcció que es mira mensualment, una dada "a tancament del mes passat" és exactament el que es necessita.
L'ON CONFLICT ... DO UPDATE permet a més reexecutar la càrrega sense risc si un informe d'esdeveniment s'omple amb retard.
Un activador aquí seria un error greu: recalcular agregats mensuals a cada inscripció és un cost permanent per a un benefici que es consumeix una vegada al mes.
d) Consulta d'auditoria per a una fila concreta (març del 2026, sucursal Nord):
WITH operatiu AS (
SELECT COUNT(DISTINCT e.esdeveniment_id) AS esdeveniments,
SUM(e.places_ofertes) AS places
FROM esdeveniments e
JOIN sales s ON s.sala_id = e.sala_id
WHERE s.sucursal_id = 2
AND e.estat = 'celebrat'
AND e.inici >= '2026-03-01' AND e.inici < '2026-04-01'
),
resum AS (
SELECT esdeveniments_celebrats AS esdeveniments, places_ofertes AS places
FROM resum_esdeveniments_mes
WHERE any_ = 2026 AND mes = 3 AND sucursal_id = 2
)
SELECT o.esdeveniments AS esdeveniments_operatiu, r.esdeveniments AS esdeveniments_resum,
o.places AS places_operatiu, r.places AS places_resum,
(o.esdeveniments = r.esdeveniments AND o.places = r.places) AS quadra
FROM operatiu o CROSS JOIN resum r; esdeveniments_operatiu | esdeveniments_resum | places_operatiu | places_resum | quadra
------------------------+---------------------+-----------------+--------------+--------
14 | 14 | 420 | 420 | tSi quadra és f, la taula de resum està desviada i cal reexecutar la càrrega d'aquell mes. Programar aquesta comprovació per al mes anterior, executada setmanalment, és suficient: les dades de mesos tancats no s'haurien de moure, i si es mouen és que algú està corregint dades històriques, cosa que convé saber.
Conclusió
Aquest mòdul va començar amb una promesa del mòdul 4: sotmetre l'esquema de BiblioRed a un examen formal que fins aleshores havíem evitat. Ja està fet, i convé mirar el recorregut sencer.
A 05-01 vam construir l'instrumental. Les tres anomalies —d'inserció, d'actualització i d'esborrament— van deixar de ser una nota al peu per convertir-se en tres fallades que vam provocar amb SQL sobre el full de préstecs. Vam aprendre a escriure'n la causa com a dependència funcional X → Y, a distingir les totals de les parcials i les transitives, a deduir amb els axiomes d'Armstrong, i a calcular la clausura X⁺ per trobar claus candidates amb un algorisme en comptes de amb intuïció.
A 05-02 vam recórrer el catàleg. Primera forma normal i l'atomicitat que depèn de l'ús; segona i les dependències parcials; tercera i les transitives; Boyce-Codd amb la seva definició d'una línia i la seva lletra petita sobre la conservació de dependències; quarta i les multivaluades independents; cinquena i l'honestedat de dir que gairebé mai no apareix. I el criteri que ho ordena tot: fins a 3FN/FNBC sempre, més enllà només si el cas ho demana.
A 05-03 vam fer la feina. D'un full de càlcul de tretze columnes a nou taules en FNBC, pas a pas, amb les dades al davant i el SQL de migració. Vam aprendre que INSERT ... SELECT DISTINCT és la forma canònica de migrar, que un error de clau duplicada és l'esquema nou fent la seva feina, que la condició de Heath és el que separa una descomposició correcta d'una que inventa files, i que en producció es normalitza per fases i no de cop. I vam trobar una fallada real a l'esquema del mòdul 4: multes violava la 3FN per prestec_id → soci_id, cosa que permetia cobrar a un soci la multa d'un altre.
I en aquesta lliçó hem tancat el cercle amb la decisió inversa. Desnormalitzar no és el contrari de normalitzar: és el que es fa després de normalitzar, sobre un esquema que ja és correcte, per aconseguir alguna cosa concreta que aquell esquema no donava. Hem vist les set tècniques —columna redundant, columna generada, atribut duplicat, taula de resum, vista materialitzada, historial pla, esquema en estrella—, les tres formes de mantenir la coherència amb les seves garanties i els seus costos, i les set preguntes que cal respondre per escrit abans de tocar res. Hem vist també que el cas més fort per desnormalitzar no és el rendiment sinó la correcció: l'import d'una multa, el nom d'un rebut i la tarifa d'una factura són dades històriques congelades, i desar-les no és una concessió, és l'única resposta correcta. I hem reconegut que el modelatge documental de 03-03 és aquesta mateixa disciplina portada al centre del mètode, amb les mateixes categories i les mateixes obligacions.
La regla d'or resumeix el mòdul sencer: primer normalitza, després desnormalitza expressament, mesurant, i mai a l'inrevés. Un esquema normalitzat del qual s'ha retrocedit en dos punts concrets, documentats, mesurats i auditats, és un bon esquema. Un esquema redundant que mai no va passar per la normalització no és un esquema desnormalitzat: és un esquema sense dissenyar.
Amb això es tanca el mòdul 5, Normalització. L'esquema de BiblioRed ha passat l'examen, amb una fallada detectada i corregida i quatre desnormalitzacions ara justificades i escrites. Sabem que l'estructura és correcta i que les dades no es poden contradir. El que encara no sabem és què passa quan dues persones del taulell registren un préstec del mateix exemplar en el mateix instant, ni què passa si el servidor s'apaga a mitja operació, ni quant triga de debò una consulta quan la taula té deu milions de files, ni qui pot llegir els telèfons dels socis. Al mòdul 6, Transaccions, Rendiment i Seguretat, deixem de mirar l'esquema i comencem a mirar el sistema en funcionament: les transaccions i les propietats ACID que garanteixen que una operació passa sencera o no passa (06-01); la concurrència i els nivells d'aïllament que decideixen què veu cada usuari mentre un altre escriu (06-02); els índexs i els plans d'execució, que són —recorda-ho— el primer que cal provar abans de desnormalitzar (06-03); i la seguretat, els permisos i les còpies de seguretat, que és el que separa una base de dades d'un accident esperant a passar (06-04).
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
