Tornem a mirar el plànol complet, tal com vam prometre en tancar el mòdul 7. Durant set mòduls has construït BiblioRed a base de peces: una taula aquí, un JOIN allà, un índex quan alguna cosa anava lenta. Cada peça s'explicava per separat perquè calia aprendre-la per separat. En un projecte real ningú no et lliura les peces ordenades: et lliuren una conversa amb un client i has d'arribar tu sol des d'allà fins a un sistema en producció.
Això és el que fa aquesta lliçó. L'ajuntament de Vallmar, satisfet amb la xarxa de biblioteques, acaba d'encarregar un segon sistema: VallBici, el servei municipal de bicicletes compartides. No és una ampliació de BiblioRed; és un domini nou, amb les seves pròpies regles, els seus propis problemes de concurrència i els seus propis informes. BiblioRed apareixerà de tant en tant com a terme de comparació —«això ja ho vam resoldre així, aquí canvia perquè…»—, però la feina és nova de cap a peus.
El recorregut és el d'un projecte real i en el seu ordre real: l'encàrrec, els requisits escrits, el model conceptual, l'esquema físic, la comprovació de normalització, la càrrega de dades, les transaccions crítiques, els informes, el rendiment i l'operació. A cada punt on cal decidir alguna cosa, la decisió va acompanyada de la seva justificació i de les alternatives descartades. Aquest és el contingut de la lliçó: no el SQL final —que es copia en deu minuts— sinó el raonament que hi porta.
I acaba on ha d'acabar un cas honest: enumerant les tres coses que aquest esquema resol malament, que són exactament el material de la lliçó 08-02.
Contingut
- L'encàrrec: la conversa inicial
- Document de requisits VallBici v1.0
- Model conceptual: entitats, relacions i tres decisions difícils
- El diagrama ER
- Esquema físic: el
CREATE TABLEcomentat - Les restriccions que codifiquen les regles de negoci
- Comprovació de normalització i dues desnormalitzacions deliberades
- Càrrega de dades i volum realista
- Les dues transaccions crítiques: desbloquejar i ancorar
- Consultes d'explotació: els informes de l'ajuntament
- Rendiment: les dues consultes que es degraden
- Operació: rols, dades personals i còpies
- Què resol malament aquest esquema
- Errors Habituals i Consells
- Exercicis
- Conclusió
- L'encàrrec: la conversa inicial
La primera reunió amb l'àrea de Mobilitat de l'ajuntament produeix això, transcrit gairebé literalment:
«Tenim 60 estacions repartides pels cinc districtes de la ciutat i 900 bicicletes, unes de mecàniques i unes altres d'elèctriques. Cada estació té un nombre fix d'ancoratges; una bici ocupa un ancoratge quan està aparcada. Hi ha 24.000 persones abonades. Una persona desbloqueja una bici en una estació, fa el seu trajecte i l'ancora en una altra. Hi ha tres tipus d'abonament —anual, mensual i turístic de tres dies— i si et passes del temps inclòs es cobra un recàrrec. Les bicis passen revisions, s'avarien i es retiren al taller. L'aplicació del mòbil ha de mostrar la disponibilitat en temps real, recordar la sessió, deixar buscar estacions per nom o per adreça i guardar el rastre GPS de cada trajecte. I volem informes mensuals d'ús per districte i franja horària.»
Apliquem la tècnica de 04-01: subratllar els substantius (candidats a entitat), els verbs (candidats a relació) i les frases amb "no pot", "sempre", "cada" (candidates a restricció). Però abans cal fer una cosa que a 04-01 vam insistir molt: tornar a preguntar. Un encàrrec de dos paràgrafs sempre amaga decisions que el client dona per evidents i no ho són.
| Pregunta al client | Resposta | Conseqüència de disseny |
|---|---|---|
| Un ancoratge pot estar avariat sense que ho estigui l'estació? | Sí, passa sovint | L'ancoratge necessita estat propi |
| Pot una persona tenir dos abonaments alhora? | No, mai solapats; sí consecutius | Restricció de no solapament |
| I dos trajectes oberts alhora? | Impossible: només es desbloqueja amb l'app | Índex únic parcial |
| Si puja la tarifa, canvia el preu de trajectes ja cobrats? | Mai. Seria il·legal | Tarifa congelada al trajecte |
| Què passa si una bici desapareix? | Es dona de baixa, però el seu historial es conserva | ON DELETE RESTRICT + baixa lògica |
| L'elèctrica i la mecànica es cobren igual? | L'elèctrica porta un recàrrec fix per desbloqueig | La jerarquia afecta el preu |
| Cada quant voleu els informes? | Mensual, però el tauler d'operacions és diari | Dos perfils de consulta diferents |
Aquestes set respostes valen més que les cent línies de SQL següents. La quarta, en particular, és la que evita l'error car: si la tarifa no es congela, el primer canvi de preus reescriu la facturació de dos anys.
- Document de requisits VallBici v1.0
Requisits funcionals
| Id | Requisit |
|---|---|
| RF1 | Registrar persones abonades amb les seves dades de contacte i el seu districte de residència |
| RF2 | Vendre abonaments anuals, mensuals i turístics de 3 dies, amb el seu període de vigència |
| RF3 | Mantenir l'inventari d'estacions, ancoratges i bicicletes, amb el seu estat |
| RF4 | Registrar el desbloqueig d'una bicicleta i el seu ancoratge posterior, amb instants exactes |
| RF5 | Calcular l'import de cada trajecte segons la tarifa vigent en el moment del desbloqueig |
| RF6 | Registrar revisions i avaries, i retirar bicicletes al taller |
| RF7 | Publicar la disponibilitat de cada estació |
| RF8 | Produir els informes d'explotació de l'apartat següent |
Regles de negoci
| Id | Regla | On es codifica |
|---|---|---|
| RN1 | Un ancoratge allotja com a màxim una bicicleta | PRIMARY KEY d'ancoratges |
| RN2 | Una bicicleta és com a màxim en un ancoratge | UNIQUE (bicicleta_id) |
| RN3 | Una persona no pot tenir dos abonaments amb vigències solapades | EXCLUDE USING gist |
| RN4 | Una bicicleta no pot tenir dos trajectes oberts | Índex únic parcial |
| RN5 | Una persona no pot tenir dos trajectes oberts | Índex únic parcial |
| RN6 | L'instant d'ancoratge és posterior al de desbloqueig | CHECK |
| RN7 | Un trajecte tancat té estació destí, ancoratge destí, instant de fi i import; un d'obert no en té cap dels quatre | CHECK conjunt |
| RN8 | El nombre de bicis en una estació mai no supera el seu nombre d'ancoratges | CHECK |
| RN9 | La tarifa aplicada a un trajecte no canvia encara que canviïn les tarifes | Còpia de tarifa a trajectes |
| RN10 | Un trajecte només es pot iniciar amb un abonament en vigor | Lògica de la transacció |
Consultes que l'ajuntament vol poder respondre
Aquesta llista és part del requisit, no un extra. És el que a 04-01 anomenàvem el criteri d'èxit de l'esquema: un model que no les pot respondre és un model fallit, per elegant que sigui.
- C1 — Trajectes per districte i franja horària, mes a mes.
- C2 — Els deu parells d'estacions origen→destí més freqüents.
- C3 — Estacions que es buiden o s'omplen sistemàticament, per franja (el problema del reequilibratge: és el que costa diners de debò, perquè obliga a moure bicis en furgoneta).
- C4 — Ingressos per tipus d'abonament, separant quotes de recàrrecs.
- C5 — Bicicletes amb més avaries per hora d'ús (no en termes absoluts: una bici molt usada s'avaria més i això no la fa dolenta).
- Model conceptual: entitats, relacions i tres decisions difícils
Del text surten sense discussió: districte, estació, ancoratge, bicicleta, model de bicicleta, persona abonada, tipus d'abonament, tarifa, abonament, trajecte, ordre de taller i cobrament. El que sí que té discussió són tres decisions, i són les que separen un model que aguanta d'un que no.
Decisió 1 — L'ancoratge és una entitat o és un número dins de l'estació?
La temptació: guardar a estacions una columna num_ancoratges i una altra bicis_disponibles, i no modelar l'ancoratge. És més simple i aparentment suficient: per pintar l'app n'hi ha prou de saber quantes bicis hi ha.
Per què es descarta. Tres motius, en ordre de pes:
- El client va dir que un ancoratge es pot avariar tot sol. Un atribut no té estat; una entitat sí. Sense
ancoratges, una estació amb 20 ancoratges i 3 de trencats continua "tenint capacitat 20" i el sistema promet places que no existeixen. - L'app ha de dir a la persona en quin ancoratge és la bici que ha reservat i en quin l'ha de deixar. Aquesta dada no existeix si l'ancoratge no existeix.
- RN1 i RN2 —"un ancoratge, una bici; una bici, un ancoratge"— són restriccions d'integritat, i al mòdul 4 vam fixar el principi: una restricció que l'esquema pot imposar no es delega al codi. Amb
ancoratgescom a taula són una clau primària i unUNIQUE; sense ella, són codi d'aplicació i confiança.
Com es modela: l'ancoratge és una entitat feble de l'estació (regla 8 de 04-03), amb clau primària composta (estacio_id, numero). El número 7 només significa alguna cosa dins d'una estació concreta, exactament igual que el número de rebut d'una multa depenia de la multa a BiblioRed.
Decisió 2 — El trajecte és una relació o una entitat?
Un trajecte connecta una persona (via el seu abonament), una bicicleta i dues estacions. En termes de 04-02 seria una relació de grau 4, i les relacions de grau alt són gairebé sempre un símptoma que falta una entitat.
Es modela com a entitat, i per quatre raons:
- Té atributs propis i abundants: dos instants, durada, import, tarifa congelada.
- Té identitat: la mateixa persona pot fer el mateix trajecte entre les mateixes dues estacions amb la mateixa bici dues vegades el mateix dia, i són dos fets diferents. Una taula d'unió amb clau composta els confondria.
- Neix incomplet. Quan es desbloqueja la bici només es coneix la meitat del trajecte. Una relació que existeix a mitges és una entitat amb columnes nul·les, no una relació.
- Altres coses el referencien: el cobrament del recàrrec apunta al trajecte.
Les dues estacions són dues relacions 1:N diferents cap a estacions (regla 5 de 04-03): estacio_origen i estacio_desti. No és un cas rar: és el mateix patró que "vol amb aeroport de sortida i d'arribada", i l'única precaució és no oblidar que les dues claus foranes apunten a la mateixa taula, cosa que obliga a posar àlies a totes les consultes que les facin servir.
Detall fi: la clau forana no apunta a estacions sinó a ancoratges(estacio_id, numero), perquè ens interessa saber de quin ancoratge concret va sortir i en quin va entrar. L'estació queda determinada per l'ancoratge.
Decisió 3 — La jerarquia mecànica / elèctrica
Tota bicicleta és mecànica o elèctrica (jerarquia total i disjunta). Les elèctriques tenen tres atributs que a les mecàniques no tenen sentit: capacitat de bateria, autonomia i número de sèrie de la bateria. A 04-03, regla 10, vam veure les tres estratègies. Repassem la decisió amb els criteris d'allà:
| Criteri | Taula única | Taula per subclasse | Taula per classe concreta |
|---|---|---|---|
| Atributs específics | 3 columnes nul·les en 900 files | Sense nuls | Sense nuls |
trajectes pot referenciar la superclasse? |
Sí | Sí | No — necessitaria dues FK |
| "Totes les bicis de l'estació 12" | Trivial | Trivial | UNION de dues branques |
n_serie_bateria obligatori i únic només en elèctriques |
Impossible amb NOT NULL |
Directe | Directe |
| Cost de consulta típica | Cap | Un LEFT JOIN ocasional |
UNION sempre |
Triem taula per subclasse (estratègia 2), igual que vam fer amb els materials de BiblioRed i pel mateix motiu dominant: hi ha taules que referencien la superclasse. trajectes, ancoratges i ordres_taller apunten a "una bicicleta", sense importar de quin tipus, i l'estratègia 3 faria això impossible sense duplicar totes les claus foranes.
L'estratègia 1 (taula única) era defensable —només són tres columnes— i amb 900 files el malbaratament és irrellevant. Es descarta pel quart criteri: n_serie_bateria ha de ser obligatori i únic en les elèctriques, i en una taula única només podria ser opcional. És exactament l'argument que a BiblioRed ens va fer separar materials_llibre per l'isbn.
I com a 04-03, l'estratègia 2 arrossega el seu forat conegut: res no impedeix per si sol que una bici amb tipus = 'mecanica' tingui fila a bicicletes_electriques. Es tapa amb el truc de la clau forana discriminada, que veuràs a l'esquema.
- El diagrama ER
erDiagram
DISTRICTE ||--o{ ESTACIO : agrupa
DISTRICTE ||--o{ PERSONA_ABONADA : "resideix a"
ESTACIO ||--|{ ANCORATGE : conte
ANCORATGE |o--o| BICICLETA : allotja
MODEL_BICI ||--o{ BICICLETA : "es del model"
BICICLETA ||--o| BICICLETA_ELECTRICA : "especialitza a"
BICICLETA ||--o{ TRAJECTE : "es fa servir a"
BICICLETA ||--o{ ORDRE_TALLER : "passa per"
PERSONA_ABONADA ||--o{ ABONAMENT : contracta
TIPUS_ABONAMENT ||--o{ ABONAMENT : classifica
TIPUS_ABONAMENT ||--o{ TARIFA : "es tarifa amb"
TARIFA ||--o{ ABONAMENT : "fixa preu de"
TARIFA ||--o{ TRAJECTE : "congelada a"
ABONAMENT ||--o{ TRAJECTE : autoritza
ANCORATGE ||--o{ TRAJECTE : "es origen de"
ANCORATGE ||--o{ TRAJECTE : "es desti de"
PERSONA_ABONADA ||--o{ COBRAMENT : paga
ABONAMENT ||--o{ COBRAMENT : "genera quota"
TRAJECTE ||--o{ COBRAMENT : "genera recarrec"
Llegeix-lo amb la notació de 04-02: || és participació total i cardinalitat 1, o{ és cardinalitat N amb participació parcial, |{ és N amb participació total. Que ESTACIO ||--|{ ANCORATGE sigui total als dos costats diu una cosa certa i no trivial: una estació sense cap ancoratge no és una estació, i un ancoratge sense estació no existeix. Que ANCORATGE |o--o| BICICLETA sigui parcial en tots dos costats diu el contrari: hi ha ancoratges buits i hi ha bicis fora de tot ancoratge (circulant o al taller).
- Esquema físic: el
CREATE TABLE comentat
CREATE TABLE comentatDues extensions abans de començar. btree_gist permet barrejar en un EXCLUDE columnes d'igualtat (un enter) amb columnes de solapament (un rang), que és just el que demanen RN3 i les tarifes.
Inventari
CREATE TABLE districtes (
districte_id SMALLINT PRIMARY KEY, -- 5 files: clau natural, estable
nom VARCHAR(40) NOT NULL UNIQUE
);
CREATE TABLE estacions (
estacio_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
codi CHAR(6) NOT NULL UNIQUE, -- 'VB-012', el que va retolat
nom VARCHAR(80) NOT NULL,
adreca VARCHAR(120) NOT NULL,
districte_id SMALLINT NOT NULL REFERENCES districtes ON DELETE RESTRICT,
latitud NUMERIC(9,6) NOT NULL CHECK (latitud BETWEEN -90 AND 90),
longitud NUMERIC(9,6) NOT NULL CHECK (longitud BETWEEN -180 AND 180),
num_ancoratges SMALLINT NOT NULL CHECK (num_ancoratges BETWEEN 8 AND 40),
estat VARCHAR(14) NOT NULL DEFAULT 'activa'
CHECK (estat IN ('activa','manteniment','retirada')),
data_alta DATE NOT NULL DEFAULT CURRENT_DATE,
-- Desnormalització deliberada núm. 1 (es justifica a l'apartat 7)
bicis_disponibles SMALLINT NOT NULL DEFAULT 0 CHECK (bicis_disponibles >= 0),
CONSTRAINT ck_hi_cap_a_estacio CHECK (bicis_disponibles <= num_ancoratges) -- RN8
);Per què aquests tipus, un a un:
| Columna | Tipus triat | Alternativa descartada i per què |
|---|---|---|
districte_id |
SMALLINT natural |
IDENTITY: cinc districtes que no canvien mai no necessiten clau subrogada |
estacio_id |
INTEGER IDENTITY |
SERIAL: obsolet des de PostgreSQL 10; IDENTITY és estàndard SQL i no deixa seqüències òrfenes |
codi |
CHAR(6) |
Seria la clau natural, però un rètol es repinta: es queda com a UNIQUE, no com a PK |
latitud/longitud |
NUMERIC(9,6) |
FLOAT: precisió decimal exacta i ≈11 cm de resolució n'hi ha prou. Quan calgui geometria de debò, PostGIS (ho discutim a 08-02) |
num_ancoratges |
SMALLINT |
INTEGER: cap estació no tindrà 33.000 ancoratges |
estat |
VARCHAR + CHECK |
ENUM: afegir un valor a un ENUM requereix ALTER TYPE; un CHECK es canvia amb ALTER TABLE i es llegeix al \d |
CREATE TABLE models_bici (
model_id SMALLINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
fabricant VARCHAR(40) NOT NULL,
nom VARCHAR(40) NOT NULL,
tipus VARCHAR(10) NOT NULL CHECK (tipus IN ('mecanica','electrica')),
pes_kg NUMERIC(4,1) NOT NULL CHECK (pes_kg > 0),
UNIQUE (fabricant, nom)
);
CREATE TABLE bicicletes (
bicicleta_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
matricula CHAR(7) NOT NULL UNIQUE, -- 'VB-0417'
model_id SMALLINT NOT NULL REFERENCES models_bici ON DELETE RESTRICT,
tipus VARCHAR(10) NOT NULL CHECK (tipus IN ('mecanica','electrica')),
data_alta DATE NOT NULL DEFAULT CURRENT_DATE,
estat VARCHAR(10) NOT NULL DEFAULT 'ancorada'
CHECK (estat IN ('ancorada','en_us','taller','baixa')),
km_acumulats NUMERIC(10,2) NOT NULL DEFAULT 0 CHECK (km_acumulats >= 0),
-- Redundant com a clau, però necessària: és l'àncora del discriminant
CONSTRAINT uq_bici_tipus UNIQUE (bicicleta_id, tipus)
);
CREATE TABLE bicicletes_electriques (
bicicleta_id INTEGER PRIMARY KEY,
tipus VARCHAR(10) NOT NULL DEFAULT 'electrica' CHECK (tipus = 'electrica'),
capacitat_wh SMALLINT NOT NULL CHECK (capacitat_wh > 0),
autonomia_km SMALLINT NOT NULL CHECK (autonomia_km BETWEEN 10 AND 200),
n_serie_bateria VARCHAR(24) NOT NULL UNIQUE,
FOREIGN KEY (bicicleta_id, tipus)
REFERENCES bicicletes (bicicleta_id, tipus) ON DELETE CASCADE
);El truc del discriminant, explicat. La clau forana composta (bicicleta_id, tipus) obliga que la fila referenciada de bicicletes tingui tipus = 'electrica', perquè el CHECK d'aquesta taula fixa tipus a aquell valor. Resultat: és impossible donar d'alta una bateria per a una bici mecànica. El forat que 04-03 deixava obert a l'estratègia 2 queda tapat en la meitat que importa. L'altra meitat —que una elèctrica no tingui fila aquí— continua sense poder-se imposar declarativament i es controla en el procés d'alta.
CREATE TABLE ancoratges (
estacio_id INTEGER NOT NULL REFERENCES estacions ON DELETE CASCADE,
numero SMALLINT NOT NULL CHECK (numero > 0),
estat VARCHAR(10) NOT NULL DEFAULT 'operatiu'
CHECK (estat IN ('operatiu','avariat','bloquejat')),
bicicleta_id INTEGER REFERENCES bicicletes ON DELETE SET NULL,
PRIMARY KEY (estacio_id, numero), -- RN1: entitat feble
CONSTRAINT uq_bici_en_un_ancoratge UNIQUE (bicicleta_id) -- RN2
);Les dues regles més importants del sistema són dues línies d'esquema. La clau primària composta impedeix que l'ancoratge 7 de l'estació 12 allotgi dues bicis. L'UNIQUE (bicicleta_id) impedeix que la bici 417 sigui simultàniament en dos ancoratges; funciona perquè a PostgreSQL UNIQUE admet tants NULL com vulgui, així que tots els ancoratges buits conviuen sense conflicte.
ON DELETE CASCADE d'ancoratges cap a estacions està justificat: un ancoratge no té vida pròpia fora de la seva estació (és l'acció que 02-06 reservava precisament per a entitats febles). ON DELETE SET NULL cap a bicicletes és el correcte per al contrari: si una bici desaparegués del sistema, l'ancoratge ha de quedar lliure, no desaparèixer.
Persones, abonaments i tarifes
CREATE TABLE persones_abonades (
persona_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
document_hash CHAR(64) NOT NULL UNIQUE, -- SHA-256 amb sal; veure apartat 12
nom VARCHAR(60) NOT NULL,
cognoms VARCHAR(80) NOT NULL,
email VARCHAR(120) NOT NULL UNIQUE,
telefon VARCHAR(20),
data_naix DATE NOT NULL,
districte_id SMALLINT REFERENCES districtes ON DELETE SET NULL,
data_registre TIMESTAMPTZ NOT NULL DEFAULT now(),
data_baixa DATE,
CONSTRAINT ck_edat_minima CHECK (data_naix <= CURRENT_DATE - INTERVAL '14 years'),
CONSTRAINT ck_baixa_posterior CHECK (data_baixa IS NULL
OR data_baixa >= data_registre::date)
);
CREATE TABLE tipus_abonament (
tipus_abonament VARCHAR(12) PRIMARY KEY
CHECK (tipus_abonament IN ('anual','mensual','turistic')),
descripcio VARCHAR(60) NOT NULL,
durada INTERVAL NOT NULL -- '1 year', '1 mon', '3 days'
);
CREATE TABLE tarifes (
tarifa_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tipus_abonament VARCHAR(12) NOT NULL REFERENCES tipus_abonament ON DELETE RESTRICT,
vigencia DATERANGE NOT NULL,
quota NUMERIC(6,2) NOT NULL CHECK (quota >= 0),
minuts_inclosos SMALLINT NOT NULL CHECK (minuts_inclosos >= 0),
minuts_fraccio SMALLINT NOT NULL CHECK (minuts_fraccio > 0),
preu_fraccio NUMERIC(5,2) NOT NULL CHECK (preu_fraccio >= 0),
recarrec_electrica NUMERIC(5,2) NOT NULL DEFAULT 0 CHECK (recarrec_electrica >= 0),
-- Dues tarifes del mateix tipus d'abonament no poden estar vigents alhora
EXCLUDE USING gist (tipus_abonament WITH =, vigencia WITH &&)
);
CREATE TABLE abonaments (
abonament_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
persona_id INTEGER NOT NULL REFERENCES persones_abonades ON DELETE RESTRICT,
tipus_abonament VARCHAR(12) NOT NULL REFERENCES tipus_abonament ON DELETE RESTRICT,
tarifa_id INTEGER NOT NULL REFERENCES tarifes ON DELETE RESTRICT,
vigencia DATERANGE NOT NULL,
import NUMERIC(6,2) NOT NULL CHECK (import >= 0),
estat VARCHAR(10) NOT NULL DEFAULT 'actiu'
CHECK (estat IN ('actiu','suspes','anullat')),
CONSTRAINT ck_vigencia_acotada CHECK (NOT lower_inf(vigencia) AND NOT upper_inf(vigencia)),
-- RN3: res d'abonaments solapats de la mateixa persona
EXCLUDE USING gist (persona_id WITH =, vigencia WITH &&) WHERE (estat <> 'anullat')
);Sobre NUMERIC per als diners no hi ha debat i convé repetir-ho perquè és l'error més car que es comet amb els tipus: FLOAT no representa exactament 0,10, i un sistema que suma 1,6 milions d'imports amb FLOAT produeix un descompte comptable que ningú no sabrà explicar. NUMERIC(6,2) dona fins a 9.999,99 €, de sobres per a una quota anual.
DATERANGE en lloc de dues columnes data_inici/data_fi és el que fa possible l'EXCLUDE. Amb dues columnes soltes, "aquests dos abonaments se solapen" és una consulta amb quatre comparacions que cal escriure bé cada vegada; amb un rang és l'operador && i ho imposa el motor.
Trajectes, taller i cobraments
CREATE TABLE trajectes (
trajecte_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
abonament_id INTEGER NOT NULL REFERENCES abonaments ON DELETE RESTRICT,
bicicleta_id INTEGER NOT NULL REFERENCES bicicletes ON DELETE RESTRICT,
estacio_origen INTEGER NOT NULL,
ancoratge_origen SMALLINT NOT NULL,
ts_inici TIMESTAMPTZ NOT NULL DEFAULT now(),
estacio_desti INTEGER,
ancoratge_desti SMALLINT,
ts_fi TIMESTAMPTZ,
durada INTERVAL GENERATED ALWAYS AS (ts_fi - ts_inici) STORED,
-- Desnormalització deliberada núm. 2: la tarifa congelada (RN9)
tarifa_id INTEGER NOT NULL REFERENCES tarifes ON DELETE RESTRICT,
minuts_inclosos SMALLINT NOT NULL,
minuts_fraccio SMALLINT NOT NULL,
preu_fraccio NUMERIC(5,2) NOT NULL,
recarrec_electrica NUMERIC(5,2) NOT NULL DEFAULT 0,
import NUMERIC(6,2) CHECK (import >= 0),
FOREIGN KEY (estacio_origen, ancoratge_origen)
REFERENCES ancoratges (estacio_id, numero) ON DELETE RESTRICT,
FOREIGN KEY (estacio_desti, ancoratge_desti)
REFERENCES ancoratges (estacio_id, numero) ON DELETE RESTRICT,
CONSTRAINT ck_ordre_temporal CHECK (ts_fi IS NULL OR ts_fi > ts_inici), -- RN6
CONSTRAINT ck_tancament_complet CHECK ( -- RN7
(ts_fi IS NULL AND estacio_desti IS NULL
AND ancoratge_desti IS NULL AND import IS NULL)
OR (ts_fi IS NOT NULL AND estacio_desti IS NOT NULL
AND ancoratge_desti IS NOT NULL AND import IS NOT NULL))
);
-- RN4 i RN5: ni la bici ni la persona poden tenir dos trajectes oberts
CREATE UNIQUE INDEX uq_trajecte_obert_bici ON trajectes (bicicleta_id) WHERE ts_fi IS NULL;
CREATE UNIQUE INDEX uq_trajecte_obert_abon ON trajectes (abonament_id) WHERE ts_fi IS NULL;Tres tipus que mereixen un comentari:
TIMESTAMPTZ, noTIMESTAMP. Vallmar canvia d'hora dues vegades l'any. El diumenge d'octubre en què els rellotges endarrereixen, unTIMESTAMPsense zona converteix "02:30" en un instant ambigu, i els trajectes d'aquella matinada poden sortir amb durada negativa.TIMESTAMPTZguarda un instant absolut i l'ambigüitat desapareix. El preu és recordar-se de convertir a hora local quan s'agrupa per franja horària, cosa que farem explícitament a C1.INTERVALgenerada.duradano s'emmagatzema a mà: és una columna generada (04-04). Compleix la regla 4 de 04-03 —els derivats no s'emmagatzemen— sense renunciar a poder indexar-la, perquèSTOREDsí que ocupa disc però es calcula sola i mai no pot discrepar de les seves fonts.BIGINTatrajecte_id. Amb 1,6 milions de trajectes l'any,INTEGER(2.147 milions) trigaria més de mil anys a esgotar-se. Tot i així es fa servirBIGINT: canviar el tipus d'una clau primària en producció és una de les migracions més doloroses que hi ha, i el cost avui són quatre bytes per fila.
CREATE TABLE ordres_taller (
ordre_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
bicicleta_id INTEGER NOT NULL REFERENCES bicicletes ON DELETE RESTRICT,
tipus VARCHAR(10) NOT NULL CHECK (tipus IN ('revisio','avaria')),
motiu VARCHAR(60) NOT NULL,
ts_obertura TIMESTAMPTZ NOT NULL DEFAULT now(),
ts_tancament TIMESTAMPTZ,
cost NUMERIC(7,2) CHECK (cost >= 0),
CONSTRAINT ck_tancament_taller CHECK (ts_tancament IS NULL OR ts_tancament >= ts_obertura)
);
CREATE UNIQUE INDEX uq_ordre_oberta ON ordres_taller (bicicleta_id) WHERE ts_tancament IS NULL;
CREATE TABLE cobraments (
cobrament_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
persona_id INTEGER NOT NULL REFERENCES persones_abonades ON DELETE RESTRICT,
concepte VARCHAR(10) NOT NULL CHECK (concepte IN ('abonament','recarrec')),
abonament_id INTEGER REFERENCES abonaments ON DELETE RESTRICT,
trajecte_id BIGINT REFERENCES trajectes ON DELETE RESTRICT,
import NUMERIC(6,2) NOT NULL CHECK (import > 0),
ts_cobrament TIMESTAMPTZ NOT NULL DEFAULT now(),
estat VARCHAR(10) NOT NULL DEFAULT 'pendent'
CHECK (estat IN ('pendent','cobrat','fallit','retornat')),
CONSTRAINT ck_origen_del_cobrament CHECK (
(concepte = 'abonament' AND abonament_id IS NOT NULL AND trajecte_id IS NULL)
OR (concepte = 'recarrec' AND trajecte_id IS NOT NULL AND abonament_id IS NULL))
);ck_origen_del_cobrament és un exemple del patró que 04-04 anomenava restricció de coherència entre columnes: no n'hi ha prou que cada columna sigui vàlida per separat; la combinació ha de tenir sentit. Un cobrament de concepte abonament amb trajecte_id emplenat és una dada incoherent, i l'esquema la rebutja.
- Les restriccions que codifiquen les regles de negoci
Val la pena veure les tres més interessants fallant, perquè d'una restricció que mai no has vist saltar no saps si funciona.
-- RN2: intentar posar la bici 417, que ja és a l'ancoratge 3 de l'estació 12,
-- també a l'ancoratge 5 de l'estació 12
UPDATE ancoratges SET bicicleta_id = 417 WHERE estacio_id = 12 AND numero = 5;ERROR: duplicate key value violates unique constraint "uq_bici_en_un_ancoratge" DETAIL: Key (bicicleta_id)=(417) already exists.
-- RN3: la persona 8801 ja té un abonament anual del 2026-01-01 al 2027-01-01
INSERT INTO abonaments (persona_id, tipus_abonament, tarifa_id, vigencia, import)
VALUES (8801, 'mensual', 7, daterange('2026-06-01','2026-07-01'), 12.00);ERROR: conflicting key value violates exclusion constraint
"abonaments_persona_id_vigencia_excl"
DETAIL: Key (persona_id, vigencia)=(8801, [2026-06-01,2026-07-01)) conflicts
with existing key (persona_id, vigencia)=(8801, [2026-01-01,2027-01-01)).-- RN4: la bici 417 ja té un trajecte obert
INSERT INTO trajectes (abonament_id, bicicleta_id, estacio_origen, ancoratge_origen,
tarifa_id, minuts_inclosos, minuts_fraccio, preu_fraccio)
VALUES (10233, 417, 12, 3, 7, 30, 15, 0.60);ERROR: duplicate key value violates unique constraint "uq_trajecte_obert_bici" DETAIL: Key (bicicleta_id)=(417) already exists.
Aquest últim és el més valuós dels tres. La regla "una bici no pot estar en dos trajectes oberts" sembla codi d'aplicació pur, i en el 90 % dels sistemes ho és —amb el resultat que, sota càrrega, dues peticions simultànies la violen alegrement—. Un índex únic parcial la converteix en una garantia del motor que cap condició de cursa no pot burlar.
- Comprovació de normalització i dues desnormalitzacions deliberades
Repassem l'esquema amb el mètode de 05-03. Per a cada taula: identificar la clau, llistar les dependències funcionals i comprovar que tot atribut no primer depèn de la clau completa i de res més.
| Taula | Clau | Dependències problemàtiques | Veredicte |
|---|---|---|---|
districtes |
districte_id |
Cap | FNBC |
estacions |
estacio_id |
bicis_disponibles és derivable |
3FN trencada a propòsit (veure a sota) |
ancoratges |
(estacio_id, numero) |
Cap: estat i bicicleta_id depenen del parell complet |
FNBC |
bicicletes |
bicicleta_id |
tipus també és a models_bici |
Veure nota |
abonaments |
abonament_id |
tipus_abonament és deduïble via tarifa_id |
Veure nota |
trajectes |
trajecte_id |
Les quatre columnes de tarifa depenen de tarifa_id |
2FN/3FN trencada a propòsit |
cobraments |
cobrament_id |
Cap | FNBC |
La nota sobre bicicletes.tipus. Existeix la dependència model_id → tipus, i model_id no és clau: és una dependència transitiva i per tant una violació de 3FN de manual. Es conserva per una raó concreta i verificable: és la columna que fa funcionar el discriminant de la jerarquia, i una clau forana no pot apuntar a un valor que s'ha d'anar a buscar a una altra taula. I no genera anomalies, perquè un model no canvia de tipus mai: una bici mecànica no es converteix en elèctrica. És el cas que 05-02 descrivia com a "dependència transitiva sobre un atribut immutable", on el risc d'anomalia d'actualització és zero. Tot i així, es blinda amb un activador de verificació en l'alta.
La nota sobre abonaments.tipus_abonament. Mateix raonament i mateixa conclusió: tarifa_id → tipus_abonament. Es conserva perquè les consultes d'explotació agrupen per tipus d'abonament constantment i evitar un JOIN en el 80 % dels informes ho justifica. Es blinda amb una clau forana composta:
ALTER TABLE tarifes ADD CONSTRAINT uq_tarifa_tipus UNIQUE (tarifa_id, tipus_abonament);
ALTER TABLE abonaments ADD CONSTRAINT fk_abonament_tarifa_coherent
FOREIGN KEY (tarifa_id, tipus_abonament) REFERENCES tarifes (tarifa_id, tipus_abonament);Ara la redundància és impossible de contradir, que és l'única forma acceptable de conviure amb una redundància. És exactament el mateix patró del discriminant de les bicis.
Desnormalització 1 — estacions.bicis_disponibles
Què es trenca: la dada és derivable amb SELECT COUNT(*) FROM ancoratges WHERE estacio_id = ? AND bicicleta_id IS NOT NULL.
Per què s'accepta: l'app mòbil demana la disponibilitat de les 60 estacions cada vegada que algú obre el mapa, i amb 24.000 persones abonades això són desenes de milers de peticions al dia que degeneren en un recompte sobre ancoratges cadascuna. És el cas de llibre de 05-04: lectura massiva, escriptura poc freqüent, dada petita.
Com es manté: amb un activador sobre ancoratges, no a mà des de l'aplicació. Que ho mantingui l'aplicació és el que garanteix que algun dia discrepi.
CREATE OR REPLACE FUNCTION trg_recalcular_disponibles() RETURNS TRIGGER AS $$
BEGIN
IF TG_OP IN ('UPDATE','DELETE') AND OLD.bicicleta_id IS NOT NULL THEN
UPDATE estacions SET bicis_disponibles = bicis_disponibles - 1
WHERE estacio_id = OLD.estacio_id;
END IF;
IF TG_OP IN ('UPDATE','INSERT') AND NEW.bicicleta_id IS NOT NULL THEN
UPDATE estacions SET bicis_disponibles = bicis_disponibles + 1
WHERE estacio_id = NEW.estacio_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_ancoratges_disponibles
AFTER INSERT OR UPDATE OF bicicleta_id OR DELETE ON ancoratges
FOR EACH ROW EXECUTE FUNCTION trg_recalcular_disponibles();I, com mana 05-04, una consulta d'auditoria que s'executa cada nit i avisa si el comptador ha derivat:
SELECT e.codi, e.bicis_disponibles AS comptador,
COUNT(a.bicicleta_id) AS real_
FROM estacions e
JOIN ancoratges a USING (estacio_id)
GROUP BY e.estacio_id, e.codi, e.bicis_disponibles
HAVING e.bicis_disponibles <> COUNT(a.bicicleta_id);Desnormalització 2 — la tarifa congelada a trajectes
Què es trenca: minuts_inclosos, minuts_fraccio, preu_fraccio i recarrec_electrica depenen de tarifa_id, no de trajecte_id.
Per què s'accepta: no és rendiment, és correcció. És la resposta a la quarta pregunta de la reunió inicial. Si el trajecte només guardés tarifa_id, qualsevol recàlcul posterior aplicaria la tarifa actual; el dia que l'ajuntament apugi el preu de la fracció, tot l'històric canviaria d'import. Les dades històriques han de ser reproduïbles: és un requisit comptable i de vegades legal.
Com es manté: copiant la tarifa en el moment del desbloqueig, dins de la transacció, i no tornant-la a tocar mai. Un activador BEFORE UPDATE que rebutgi canvis en aquelles quatre columnes és una protecció barata i assenyada.
Fixa't en la diferència entre les dues: la primera es desnormalitza per velocitat i cal vigilar-la; la segona es desnormalitza per semàntica i vigilar-la seria un error, perquè el seu valor ha de diferir de l'actual.
- Càrrega de dades i volum realista
INSERT INTO districtes VALUES
(1,'Port'), (2,'Eixample'), (3,'Vallmar Alta'), (4,'Ribera'), (5,'Industrial');
INSERT INTO tipus_abonament VALUES
('anual', 'Abonament anual amb 30 min inclosos per trajecte', INTERVAL '1 year'),
('mensual', 'Abonament mensual amb 30 min inclosos', INTERVAL '1 mon'),
('turistic','Abonament de 3 dies amb 15 min inclosos', INTERVAL '3 days');
INSERT INTO tarifes (tipus_abonament, vigencia, quota, minuts_inclosos,
minuts_fraccio, preu_fraccio, recarrec_electrica) VALUES
('anual', daterange('2026-01-01','2027-01-01'), 45.00, 30, 15, 0.60, 0.35),
('mensual', daterange('2026-01-01','2027-01-01'), 9.50, 30, 15, 0.60, 0.35),
('turistic', daterange('2026-01-01','2027-01-01'), 15.00, 15, 15, 1.10, 0.50);
-- 60 estacions repartides pels cinc districtes
INSERT INTO estacions (codi, nom, adreca, districte_id, latitud, longitud, num_ancoratges)
SELECT 'VB-' || lpad(n::text, 3, '0'),
'Estació ' || n,
'Carrer Fictici ' || n,
1 + (n % 5),
40.100000 + (n % 12) * 0.004,
-3.200000 + (n % 9) * 0.005,
12 + (n % 5) * 4
FROM generate_series(1, 60) AS n;
-- Els ancoratges de cada estació (entitat feble: es generen a partir de num_ancoratges)
INSERT INTO ancoratges (estacio_id, numero)
SELECT e.estacio_id, g
FROM estacions e, LATERAL generate_series(1, e.num_ancoratges) AS g;Aquest 1.512 és la primera dada de dimensionament útil: 1.512 ancoratges per a 900 bicicletes, un 68 % d'ocupació mitjana. És una folgança sana; per sota del 80 % el reequilibratge es torna un malson operatiu.
| Taula | Files inicials | Creixement anual | Notes |
|---|---|---|---|
districtes |
5 | 0 | Fixa |
estacions |
60 | +5 | Creixement per pla municipal |
ancoratges |
1.512 | +120 | Derivada de les estacions |
models_bici |
6 | +1 | |
bicicletes |
900 | +90 / −60 | Altes i baixes |
persones_abonades |
24.000 | +4.000 | |
abonaments |
26.500 | +30.000 | Els turístics roten molt |
trajectes |
0 | +1.600.000 | ≈4.400/dia; és la taula del sistema |
ordres_taller |
0 | +7.000 | |
cobraments |
0 | +180.000 | Quotes + recàrrecs |
Tota decisió de rendiment de l'apartat 11 es refereix a aquests 1,6 milions anuals de trajectes. Les altres taules són irrellevants a efectes de pla d'execució, i confondre això és la manera més habitual de perdre una tarda indexant el que no toca.
- Les dues transaccions crítiques: desbloquejar i ancorar
Aquí és on el mòdul 6 deixa de ser teoria. L'escenari que cal resoldre és concret: són les 08:12, l'estació del Port té una sola bici lliure i dues persones premen "desbloquejar" amb 40 mil·lisegons de diferència.
sequenceDiagram
participant A as App persona A
participant B as App persona B
participant PG as PostgreSQL
A->>PG: BEGIN · SELECT ancoratge amb bici FOR UPDATE SKIP LOCKED
PG-->>A: ancoratge 3 (bici 417) - fila bloquejada
B->>PG: BEGIN · SELECT ancoratge amb bici FOR UPDATE SKIP LOCKED
PG-->>B: 0 files (salta la 3, no queda cap mes)
A->>PG: UPDATE ancoratges · INSERT trajectes · COMMIT
B->>PG: ROLLBACK - "no queden bicicletes"
Desbloqueig
BEGIN;
-- 1. Verificar que l'abonament està en vigor (RN10). Sense bloqueig: llegir-lo n'hi ha prou.
SELECT abonament_id, tarifa_id
FROM abonaments
WHERE persona_id = 8801 AND estat = 'actiu'
AND vigencia @> CURRENT_DATE;
-- 2. Prendre UNA bici ancorada de l'estació 12, bloquejant només aquella fila.
-- ORDER BY km_acumulats reparteix el desgast de la flota.
SELECT a.estacio_id, a.numero, a.bicicleta_id, b.tipus
FROM ancoratges a
JOIN bicicletes b ON b.bicicleta_id = a.bicicleta_id
WHERE a.estacio_id = 12
AND a.estat = 'operatiu'
AND b.estat = 'ancorada'
ORDER BY b.km_acumulats
FOR UPDATE OF a SKIP LOCKED
LIMIT 1;
-- 3. Alliberar l'ancoratge i marcar la bici en ús
UPDATE ancoratges SET bicicleta_id = NULL WHERE estacio_id = 12 AND numero = 3;
UPDATE bicicletes SET estat = 'en_us' WHERE bicicleta_id = 417;
-- 4. Obrir el trajecte congelant la tarifa vigent
INSERT INTO trajectes (abonament_id, bicicleta_id, estacio_origen, ancoratge_origen,
tarifa_id, minuts_inclosos, minuts_fraccio,
preu_fraccio, recarrec_electrica)
SELECT 10233, 417, 12, 3,
t.tarifa_id, t.minuts_inclosos, t.minuts_fraccio,
t.preu_fraccio,
CASE WHEN b.tipus = 'electrica' THEN t.recarrec_electrica ELSE 0 END
FROM tarifes t
JOIN bicicletes b ON b.bicicleta_id = 417
WHERE t.tipus_abonament = 'anual' AND t.vigencia @> CURRENT_DATE
RETURNING trajecte_id;
COMMIT;Per què SKIP LOCKED i no FOR UPDATE a seques. Amb FOR UPDATE, la sessió B es queda esperant que A confirmi, i després reavalua: com que l'ancoratge ja no té bici, obté 0 files. El resultat final és correcte, però B ha esperat sense necessitat. Amb SKIP LOCKED, B ignora la fila bloquejada i continua buscant una altra bici a la mateixa estació; només si de debò no en queda cap retorna 0 files. En una estació amb 8 bicis i 8 persones desbloquejant alhora, la diferència és que les 8 ho aconsegueixen en paral·lel en comptes de fer cua. És exactament el patró de cua que vam veure a 06-02, aplicat a un inventari.
Per què FOR UPDATE OF a. Sense l'OF a, PostgreSQL bloquejaria també la fila de bicicletes, i no cal: la fila que decideix qui guanya és la de l'ancoratge. Bloquejar de més multiplica els interbloqueigs.
El nivell d'aïllament és READ COMMITTED, el predeterminat. No cal pujar a REPEATABLE READ: no hi ha cap lectura que s'hagi de repetir de manera estable, i el bloqueig explícit ja resol la cursa. Pujar l'aïllament "per si de cas" només afegeix errors de serialització que caldria reintentar.
Ancoratge
BEGIN;
-- 1. Reservar un ancoratge lliure i operatiu a l'estació de destí
SELECT estacio_id, numero
FROM ancoratges
WHERE estacio_id = 34 AND estat = 'operatiu' AND bicicleta_id IS NULL
ORDER BY numero
FOR UPDATE SKIP LOCKED
LIMIT 1;
-- 2. Tancar el trajecte calculant l'import amb la tarifa CONGELADA
UPDATE trajectes t
SET ts_fi = now(),
estacio_desti = 34,
ancoratge_desti = 7,
import = t.recarrec_electrica
+ t.preu_fraccio
* GREATEST(0, ceil(
(EXTRACT(EPOCH FROM (now() - t.ts_inici)) / 60
- t.minuts_inclosos) / t.minuts_fraccio))
WHERE t.trajecte_id = 884213
AND t.ts_fi IS NULL -- idempotència: un segon ancoratge no fa res
RETURNING import;
-- 3. Ocupar l'ancoratge i tornar la bici a estat 'ancorada'
UPDATE ancoratges SET bicicleta_id = 417 WHERE estacio_id = 34 AND numero = 7;
UPDATE bicicletes SET estat = 'ancorada',
km_acumulats = km_acumulats + 3.40
WHERE bicicleta_id = 417;
-- 4. Si hi ha recàrrec, generar el cobrament
INSERT INTO cobraments (persona_id, concepte, trajecte_id, import)
SELECT a.persona_id, 'recarrec', t.trajecte_id, t.import
FROM trajectes t JOIN abonaments a USING (abonament_id)
WHERE t.trajecte_id = 884213 AND t.import > 0;
COMMIT;Un trajecte de 68 minuts amb abonament anual: 68 − 30 inclosos = 38 minuts excedits, ceil(38/15) = 3 fraccions × 0,60 € = 1,80 €… més el recàrrec d'elèctrica 0,35 €. El resultat de dalt, 1,55 €, correspon a 2 fraccions (1,20 €) més 0,35 €, és a dir a un trajecte de 55 minuts. Comprova tu el càlcul amb els dos casos: verificar a mà el primer import que produeix un sistema de cobrament és un costum que estalvia disgustos.
Els tres detalls que fan robusta aquesta transacció:
AND t.ts_fi IS NULLalWHEREde l'UPDATEla fa idempotent. Si l'app reintenta l'ancoratge perquè va perdre la resposta per xarxa, el segon intent afecta 0 files i no torna a cobrar.- El
RETURNINGpermet a l'aplicació comprovar quantes files va canviar. Zero files no és un èxit silenciós: és un error que cal tractar. - El comptador
bicis_disponiblesno es toca aquí: l'actualitza l'activador d'ancoratges. Si es toqués també a mà, el comptador pujaria de dos en dos, i aquesta és la fallada de desnormalització més comuna que hi ha.
- Consultes d'explotació: els informes de l'ajuntament
C1 — Trajectes per districte i franja horària.
SELECT d.nom AS districte,
COUNT(*) FILTER (WHERE h BETWEEN 7 AND 9) AS punta_mati,
COUNT(*) FILTER (WHERE h BETWEEN 10 AND 16) AS vall,
COUNT(*) FILTER (WHERE h BETWEEN 17 AND 20) AS punta_tarda,
COUNT(*) FILTER (WHERE h > 20 OR h < 7) AS nocturn,
COUNT(*) AS total
FROM (SELECT t.estacio_origen,
EXTRACT(HOUR FROM t.ts_inici AT TIME ZONE 'Europe/Madrid')::int AS h
FROM trajectes t
WHERE t.ts_inici >= DATE '2026-06-01'
AND t.ts_inici < DATE '2026-07-01') x
JOIN estacions e ON e.estacio_id = x.estacio_origen
JOIN districtes d USING (districte_id)
GROUP BY d.nom
ORDER BY total DESC;districte | punta_mati | vall | punta_tarda | nocturn | total --------------+------------+-------+-------------+---------+-------- Eixample | 14820 | 11340 | 16905 | 2115 | 45180 Port | 9640 | 14210 | 12880 | 3410 | 40140 Ribera | 7115 | 6320 | 8090 | 1145 | 22670 Vallmar Alta | 5980 | 4110 | 6240 | 705 | 17035 Industrial | 4210 | 1890 | 4560 | 380 | 11040
Allà s'hi llegeix un patró real: l'Eixample i l'Industrial són clarament pendulars (dues puntes, poca vall) mentre que el Port té el seu màxim a la vall — és turisme, no desplaçament a la feina. L'AT TIME ZONE 'Europe/Madrid' no és decoratiu: sense ell, a l'estiu les franges sortirien desplaçades dues hores.
C2 — Els parells origen→destí més freqüents, amb el seu rànquing dins del districte.
SELECT * FROM (
SELECT eo.nom AS origen, ed.nom AS desti, dd.nom AS districte_desti,
COUNT(*) AS viatges,
ROUND(AVG(EXTRACT(EPOCH FROM t.durada) / 60)::numeric, 1) AS min_mitja,
RANK() OVER (PARTITION BY dd.districte_id ORDER BY COUNT(*) DESC) AS posicio
FROM trajectes t
JOIN estacions eo ON eo.estacio_id = t.estacio_origen
JOIN estacions ed ON ed.estacio_id = t.estacio_desti
JOIN districtes dd ON dd.districte_id = ed.districte_id
WHERE t.ts_fi IS NOT NULL
AND t.ts_inici >= DATE '2026-06-01' AND t.ts_inici < DATE '2026-07-01'
GROUP BY eo.estacio_id, eo.nom, ed.estacio_id, ed.nom,
dd.districte_id, dd.nom
) r
WHERE posicio <= 2
ORDER BY districte_desti, posicio;origen | desti | districte_desti | viatges | min_mitja | posicio ---------------------+--------------------+-----------------+---------+-----------+--------- Estació 7 | Estació 22 | Eixample | 1284 | 14.2 | 1 Estació 41 | Estació 22 | Eixample | 967 | 18.6 | 2 Estació 3 | Estació 18 | Industrial | 712 | 21.4 | 1 ...
El RANK() OVER (PARTITION BY ...) sobre un COUNT(*) agregat és el patró "top N per grup" que vas practicar a 07-04. Nota que la funció de finestra s'avalua després del GROUP BY, per això pot ordenar per COUNT(*).
C3 — El problema del reequilibratge. Aquesta és la consulta que estalvia diners de debò, i la que millor demostra per què vam modelar el trajecte com a entitat amb dos extrems.
WITH moviments AS (
SELECT estacio_origen AS estacio_id, ts_inici AS ts, -1 AS delta
FROM trajectes
WHERE ts_inici >= DATE '2026-06-01' AND ts_inici < DATE '2026-07-01'
UNION ALL
SELECT estacio_desti, ts_fi, +1
FROM trajectes
WHERE ts_fi IS NOT NULL
AND ts_fi >= DATE '2026-06-01' AND ts_fi < DATE '2026-07-01'
),
per_franja AS (
SELECT estacio_id,
EXTRACT(HOUR FROM ts AT TIME ZONE 'Europe/Madrid')::int / 4 AS bloc,
SUM(delta) AS net
FROM moviments
GROUP BY 1, 2
)
SELECT e.codi, e.nom, e.num_ancoratges,
SUM(net) FILTER (WHERE bloc = 1) AS "04-08",
SUM(net) FILTER (WHERE bloc = 2) AS "08-12",
SUM(net) FILTER (WHERE bloc = 4) AS "16-20",
SUM(net) AS net_mes,
CASE WHEN SUM(net) < -300 THEN 'es buida · reposar'
WHEN SUM(net) > 300 THEN 'es plena · retirar'
ELSE 'equilibrada' END AS diagnostic
FROM per_franja JOIN estacions e USING (estacio_id)
GROUP BY e.estacio_id, e.codi, e.nom, e.num_ancoratges
ORDER BY ABS(SUM(net)) DESC
LIMIT 5;codi | nom | num_ancoratges | 04-08 | 08-12 | 16-20 | net_mes | diagnostic --------+-------------+----------------+-------+-------+-------+---------+-------------------- VB-022 | Estació 22 | 24 | -18 | +892 | -774 | +611 | es plena · retirar VB-007 | Estació 7 | 16 | +31 | -845 | +698 | -498 | es buida · reposar VB-041 | Estació 41 | 20 | +12 | -602 | +515 | -402 | es buida · reposar VB-018 | Estació 18 | 28 | -8 | +498 | -401 | +377 | es plena · retirar VB-003 | Estació 3 | 12 | +22 | -344 | +266 | -281 | equilibrada
Els signes expliquen la història sencera: la 7 i la 41 són residencials (es buiden al matí, es reomplen a la tarda) i la 22 és un destí de feina. La columna net_mes diu quantes bicis cal moure en furgoneta cada mes, i el 08-12 davant del 16-20 diu a quina hora s'han de moure. La tècnica —convertir dues columnes d'una fila en dues files amb signe mitjançant UNION ALL— és la forma canònica de tractar qualsevol entitat amb dos extrems.
C4 — Ingressos per tipus d'abonament.
SELECT ta.tipus_abonament,
COUNT(*) FILTER (WHERE c.concepte = 'abonament') AS n_quotes,
SUM(c.import) FILTER (WHERE c.concepte = 'abonament') AS eur_quotes,
SUM(c.import) FILTER (WHERE c.concepte = 'recarrec') AS eur_recarrecs,
SUM(c.import) AS eur_total,
ROUND(100.0 * SUM(c.import) FILTER (WHERE c.concepte = 'recarrec')
/ NULLIF(SUM(c.import), 0), 1) AS pct_recarrec
FROM cobraments c
JOIN persones_abonades p USING (persona_id)
JOIN abonaments ab ON ab.persona_id = p.persona_id
AND ab.vigencia @> c.ts_cobrament::date
JOIN tipus_abonament ta ON ta.tipus_abonament = ab.tipus_abonament
WHERE c.estat = 'cobrat'
AND c.ts_cobrament >= DATE '2026-01-01'
GROUP BY ta.tipus_abonament
ORDER BY eur_total DESC;tipus_abonament | n_quotes | eur_quotes | eur_recarrecs | eur_total | pct_recarrec -----------------+----------+------------+---------------+-----------+-------------- anual | 9840 | 442800.00 | 38215.40 | 481015.40 | 7.9 turistic | 8120 | 121800.00 | 71430.80 | 193230.80 | 36.9 mensual | 6410 | 60895.00 | 19204.20 | 80099.20 | 24.0
El pct_recarrec de l'abonament turístic (36,9 %) és la mena de troballa que justifica l'informe: les persones visitants es passen del temps inclòs gairebé la meitat de les vegades. O bé 15 minuts són pocs, o bé l'app no avisa. És una conversa de negoci que només existeix perquè la consulta la va fer possible.
C5 — Avaries per hora d'ús.
WITH us AS (
SELECT bicicleta_id,
SUM(EXTRACT(EPOCH FROM durada)) / 3600 AS hores
FROM trajectes
WHERE ts_fi IS NOT NULL AND ts_inici >= DATE '2026-01-01'
GROUP BY bicicleta_id
),
avaries AS (
SELECT bicicleta_id, COUNT(*) AS n
FROM ordres_taller
WHERE tipus = 'avaria' AND ts_obertura >= DATE '2026-01-01'
GROUP BY bicicleta_id
)
SELECT b.matricula, m.fabricant, m.nom AS model, b.tipus,
ROUND(u.hores::numeric, 1) AS hores_us,
COALESCE(a.n, 0) AS avaries,
ROUND((COALESCE(a.n,0) * 100 / u.hores)::numeric, 2) AS avaries_per_100h
FROM us u
JOIN bicicletes b USING (bicicleta_id)
JOIN models_bici m USING (model_id)
LEFT JOIN avaries a USING (bicicleta_id)
WHERE u.hores >= 50 -- descarta bicis amb mostra insuficient
ORDER BY avaries_per_100h DESC
LIMIT 5;matricula | fabricant | model | tipus | hores_us | avaries | avaries_per_100h -----------+-----------+---------+-----------+----------+---------+------------------ VB-0417 | Norvent | Urbana2 | mecanica | 214.6 | 9 | 4.19 VB-0388 | Norvent | Urbana2 | mecanica | 188.2 | 7 | 3.72 VB-0512 | Ciclmar | E-Vall | electrica | 341.9 | 11 | 3.22 VB-0041 | Norvent | Urbana2 | mecanica | 255.0 | 8 | 3.05 VB-0733 | Ciclmar | E-Vall | electrica | 298.4 | 9 | 3.02
El WHERE u.hores >= 50 és la part important i la que gairebé ningú no posa: sense ell, una bici amb 2 hores d'ús i una avaria encapçala la llista amb 50 avaries/100 h i l'informe no té cap valor. Tota taxa necessita un llindar mínim de denominador.
- Rendiment: les dues consultes que es degraden
Amb la taula buida tot va ràpid. El dia que trajectes té 1,6 milions de files, dues coses es trenquen. Seguim l'ordre d'intervenció de 06-03: mesurar primer, entendre el pla, i només llavors tocar.
Degradació 1 — l'informe mensual C1
EXPLAIN (ANALYZE, BUFFERS)
SELECT COUNT(*) FROM trajectes
WHERE ts_inici >= DATE '2026-06-01' AND ts_inici < DATE '2026-07-01'; Finalize Aggregate (cost=98421.30..98421.31 rows=1) (actual time=2184.402..2189.117 rows=1)
-> Gather (...)
-> Partial Aggregate (...)
-> Parallel Seq Scan on trajectes (actual time=0.041..2050.883 rows=45180 loops=3)
Filter: ((ts_inici >= '2026-06-01') AND (ts_inici < '2026-07-01'))
Rows Removed by Filter: 488153
Buffers: shared read=31204
Planning Time: 0.198 ms
Execution Time: 2189.204 msDiagnòstic: Rows Removed by Filter: 488153 per a cadascun dels 3 processos. Es llegeixen 1,6 milions de files per quedar-se amb 45.180. És el símptoma clàssic de 06-03: un filtre molt selectiu (2,8 %) resolt amb recorregut seqüencial.
Intervenció. Aquí hi ha una elecció real entre dos índexs:
| Índex | Mida | Cost d'escriptura | Quan guanya |
|---|---|---|---|
B-tree (ts_inici) |
≈34 MB | Alt: 4.400 insercions/dia l'actualitzen | Rangs petits, i també serveix per a "l'últim trajecte de X" |
BRIN (ts_inici) |
≈48 KB | Gairebé nul | Rangs grans sobre dades correlacionades amb l'ordre físic |
trajectes s'insereix sempre en ordre cronològic i mai no es reordena: la correlació física de ts_inici és pràcticament 1. És el cas exacte per al qual existeix BRIN.
CREATE INDEX idx_trajectes_ts_brin ON trajectes USING brin (ts_inici)
WITH (pages_per_range = 64);
ANALYZE trajectes; Aggregate (actual time=118.940..118.941 rows=1)
-> Bitmap Heap Scan on trajectes (actual time=3.117..114.226 rows=45180 loops=1)
Recheck Cond: ((ts_inici >= '2026-06-01') AND (ts_inici < '2026-07-01'))
Rows Removed by Index Recheck: 2841
Heap Blocks: lossy=1408
Buffers: shared hit=1412 read=3
-> Bitmap Index Scan on idx_trajectes_ts_brin (actual time=0.402..0.402 rows=14080 loops=1)
Planning Time: 0.211 ms
Execution Time: 118.987 msDe 2.189 ms a 119 ms, amb un índex de 48 KB. Rows Removed by Index Recheck: 2841 és normal en BRIN: l'índex treballa per blocs, així que retorna algun bloc de més i el motor descarta les files sobrants. A canvi ocupa mil vegades menys que el B-tree i el seu manteniment en les insercions és menyspreable.
Degradació 2 — "quin trajecte tinc obert?"
És la consulta més freqüent del sistema: l'executa l'app cada vegada que algú l'obre amb una bici en curs.
EXPLAIN ANALYZE
SELECT trajecte_id, ts_inici, estacio_origen
FROM trajectes WHERE abonament_id = 10233 AND ts_fi IS NULL;Seq Scan on trajectes (actual time=1893.221..1893.223 rows=1 loops=1) Filter: ((ts_fi IS NULL) AND (abonament_id = 10233)) Rows Removed by Filter: 1599999 Execution Time: 1893.244 ms
I aquí hi ha el detall bonic: l'índex que ho arregla ja existeix. És uq_trajecte_obert_abon, l'índex únic parcial que vam crear a l'apartat 5 per imposar RN5. Un índex parcial sobre WHERE ts_fi IS NULL cobreix, amb 1,6 milions de files a la taula, només les 900 com a màxim que poden estar obertes simultàniament. L'única cosa que faltava era un ANALYZE:
Index Scan using uq_trajecte_obert_abon on trajectes (actual time=0.031..0.033 rows=1 loops=1) Index Cond: (abonament_id = 10233) Buffers: shared hit=3 Execution Time: 0.049 ms
De 1.893 ms a 0,049 ms. La lliçó és la que 06-03 repetia: una restricció ben triada és també un índex, i moltes vegades l'índex que necessites ja l'has creat sense adonar-te'n.
Els tres índexs restants que sí que cal crear a mà:
CREATE INDEX idx_trajectes_origen_ts ON trajectes (estacio_origen, ts_inici);
CREATE INDEX idx_trajectes_desti_ts ON trajectes (estacio_desti, ts_fi)
WHERE ts_fi IS NOT NULL;
CREATE INDEX idx_ordres_bici_tipus ON ordres_taller (bicicleta_id, tipus, ts_obertura);L'ordre de les columnes en els compostos segueix la regla de 06-03 —igualtat primer, rang després— i respon a C2 i C3. El segon és parcial perquè els trajectes oberts no tenen destí i no aporten res a l'índex.
- Operació: rols, dades personals i còpies
Rols i permisos mínims
CREATE ROLE vallbici_app LOGIN PASSWORD '...'; -- l'API que atén l'app mòbil
CREATE ROLE vallbici_taller LOGIN PASSWORD '...'; -- l'equip de manteniment
CREATE ROLE vallbici_analista LOGIN PASSWORD '...'; -- informes de l'ajuntament
GRANT SELECT, INSERT, UPDATE ON ancoratges, trajectes, bicicletes TO vallbici_app;
GRANT SELECT ON estacions, tarifes, abonaments TO vallbici_app;
GRANT INSERT ON cobraments TO vallbici_app;
-- L'API NO pot esborrar res, en cap taula. Ni tan sols les seves pròpies files.
GRANT SELECT, INSERT, UPDATE ON ordres_taller TO vallbici_taller;
GRANT SELECT, UPDATE (estat) ON bicicletes TO vallbici_taller;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO vallbici_analista;
REVOKE SELECT ON persones_abonades, cobraments FROM vallbici_analista;
GRANT SELECT ON v_trajectes_anonims TO vallbici_analista;El GRANT SELECT, UPDATE (estat) ON bicicletes és l'aplicació literal del principi de mínim privilegi de 06-04: el taller canvia l'estat d'una bici, i només l'estat. No pot tocar km_acumulats ni matricula.
Dades personals
Els informes de l'ajuntament no necessiten saber qui va fer cada trajecte. La vista que consumeix el perfil analista trenca el vincle:
CREATE VIEW v_trajectes_anonims AS
SELECT t.trajecte_id, t.ts_inici, t.ts_fi, t.durada, t.import,
t.estacio_origen, t.estacio_desti,
ab.tipus_abonament,
p.districte_id AS districte_residencia,
date_part('year', age(p.data_naix))::int / 10 * 10 AS decada_edat
FROM trajectes t
JOIN abonaments ab USING (abonament_id)
JOIN persones_abonades p USING (persona_id);Dues observacions honestes sobre això:
- La vista no anonimitza de debò. Amb districte de residència, dècada d'edat i patró de trajectes, una persona amb recorregut singular pot ser reidentificable. Reduir el risc requereix agregació mínima (no publicar cel·les amb menys de N persones) i això és una decisió de política, no de SQL.
- El compliment normatiu no el decideix qui dissenya la base de dades. Base legal del tractament, terminis de conservació de la telemetria, avaluació d'impacte, dret de supressió: tot això ho revisa un professional de protecció de dades o compliance. El que sí que és responsabilitat tècnica és que l'esquema permeti complir-lo: per això el document es guarda com a
document_hashi no en clar, per aixòON DELETE RESTRICTobliga a un procediment explícit de supressió en lloc d'esborrar en cascada l'històric comptable, i per això existeix la vista.
Còpies
| Element | Estratègia | Freqüència | Objectiu |
|---|---|---|---|
| Còpia base física | pg_basebackup |
Diària, 02:00 | Restauració completa |
| WAL arxivat | archive_command a emmagatzematge extern |
Continu | PITR: restaurar a qualsevol instant |
| Bolcat lògic | pg_dump -Fc |
Setmanal | Recuperar una taula solta sense restaurar-ho tot |
| Prova de restauració | Restaurar en màquina a part i executar l'auditoria del comptador | Mensual | Que la còpia serveixi de debò |
L'última fila és la que més se salta i l'única que garanteix alguna cosa. En paraules de 06-04: una còpia que no s'ha restaurat mai no és una còpia, és una esperança. Amb PITR configurat, l'escenari "a les 11:40 algú va executar un UPDATE sense WHERE sobre tarifes" es resol restaurant a les 11:39.
- Què resol malament aquest esquema
El sistema funciona, compleix els vuit requisits funcionals i respon a les cinc consultes. I tot i així hi ha tres coses de l'encàrrec inicial que aquest esquema resol malament. Val la pena ser precisos, perquè són exactament el material de la lliçó següent.
1. La telemetria GPS. L'encàrrec demanava guardar el rastre de cada trajecte: posicions cada 5 segons, bateria de les elèctriques, incidències. Un trajecte mitjà de 14 minuts són ~170 punts. Amb 1,6 milions de trajectes anuals, això és una taula posicions de 272 milions de files l'any, cadascuna amb trajecte_id, ts, lat, lon i poca cosa més. Es pot fer —PostgreSQL ho aguanta— però és un mal encaix: són dades que s'escriuen massivament, es llegeixen sempre senceres i per trajecte, no s'actualitzen mai, no necessiten integritat referencial estricta i caduquen al cap de pocs mesos. Estàs pagant el preu del model relacional (índex per fila, WAL per fila, visibilitat per fila) per a una dada que no fa servir cap dels seus avantatges.
2. La fitxa enriquida de l'estació. La nostra taula estacions té 11 columnes planes. La fitxa que vol l'app té fotos, horaris d'accés que varien per estació, accessibilitat, si està sota coberta, si té bomba d'aire, notes de l'empresa mantenidora, etiquetes de punts d'interès propers… i cada estació té un subconjunt diferent d'aquests atributs. Modelar-ho en el relacional porta a una de tres sortides, totes dolentes: 40 columnes nul·les, una taula clau-valor genèrica (l'antipatró EAV que 04-01 va marcar en vermell) o una taula nova per cada atribut que se li acudeixi a algú.
3. Les incidències amb estructura variable. ordres_taller té un motiu VARCHAR(60). Però una incidència de "frens" necessita registrar quin fre i quina mesura; una de "bateria" necessita cicles de càrrega i voltatge; una de "vandalisme" necessita fotos i número d'atestat. Són estructures diferents per tipus, i afegir un tipus nou no hauria de requerir un ALTER TABLE en producció.
Les tres tenen una cosa en comú: estructura variable o volum alt sense necessitat de transaccions. I les tres tenen una altra cosa en comú, encara més important: cap d'elles no és el nucli transaccional. Ningú no cobra diners a partir d'un punt GPS. Això és el que permet treure-les de PostgreSQL sense posar en risc el que importa, i és just el que fa la lliçó 08-02.
Errors Habituals i Consells
Error 1: començar pel CREATE TABLE. És l'error del qual es deriven gairebé tots els altres. Les set preguntes de l'apartat 1 van costar vint minuts de reunió i van determinar la meitat de l'esquema. Si no les haguéssim fet, hauríem descobert la necessitat de la tarifa congelada el dia de la primera pujada de preus, amb dos anys de facturació ja emesa.
Error 2: no modelar l'ancoratge "perquè amb un comptador n'hi ha prou". Funciona fins al primer ancoratge avariat. La regla general: si el client diu que alguna cosa té estat propi, és una entitat, encara que sembli un número.
Error 3: FLOAT per als diners. Continua passant. NUMERIC per a tot el que es cobra, es factura o se suma.
Error 4: TIMESTAMP sense zona en un sistema amb canvi horari. El diumenge d'octubre produirà trajectes de durada negativa i la restauració d'aquella nit serà un trencaclosques.
Error 5: mantenir a mà un comptador desnormalitzat. Si bicis_disponibles s'actualitza des de l'aplicació i des de l'activador, es duplica l'increment. Un dels dos, i millor l'activador.
Error 6: pujar el nivell d'aïllament en comptes de bloquejar la fila correcta. SERIALIZABLE no és una solució màgica: converteix una cursa en un error de serialització que l'aplicació ha de reintentar. Un FOR UPDATE SKIP LOCKED sobre la fila que decideix és més barat i més predictible.
Consell 1: escriu la llista de consultes abans que l'esquema. És el millor detector d'entitats que falten. C3 va ser la que va confirmar que el trajecte necessitava els seus dos extrems com a claus foranes independents.
Consell 2: cada restricció que escriguis, prova-la fallant. D'una restricció que mai no has vist rebutjar un INSERT no saps si està ben escrita.
Consell 3: mira si l'índex ja existeix abans de crear-lo. La degradació 2 es va resoldre sola. Els índexs únics, parcials o no, són índexs complets.
Consell 4: documenta les desnormalitzacions en el mateix esquema. El comentari -- Desnormalització deliberada núm. 1 evita que d'aquí a tres anys algú "arregli" l'esquema traient-la. Millor encara: COMMENT ON COLUMN.
Exercicis
Exercici 1 — Reserves de bicicleta
L'ajuntament vol permetre reservar una bicicleta des de l'app: la persona reserva una bici concreta d'una estació i té 10 minuts per arribar-hi i desbloquejar-la; passat aquest temps, la reserva caduca i la bici torna a estar disponible. Una persona només pot tenir una reserva activa.
- Decideix si la reserva és una entitat nova o un estat d'alguna cosa existent, i justifica-ho.
- Escriu el
CREATE TABLE(o l'ALTER TABLE) amb totes les restriccions necessàries. - Escriu la transacció de reserva, amb el seu control de concurrència.
- Explica com caduquen les reserves i per què aquesta decisió.
Exercici 2 — Tarifa reduïda per bo social
S'introdueix una tarifa reduïda per a persones amb bo social municipal. La condició s'acredita cada any i pot deixar de complir-se. Un trajecte es cobra amb la tarifa reduïda si la persona tenia el bo social vigent en el moment del desbloqueig.
- Modela l'acreditació del bo social sense duplicar la informació de
persones_abonades. - Explica per què la restricció
EXCLUDEd'abonamentsno n'hi ha prou aquí. - Escriu la consulta que calcula, per al mes passat, quant ha deixat d'ingressar l'ajuntament per les tarifes reduïdes.
Exercici 3 — Detectar bicicletes "fantasma"
Una bicicleta fantasma és la que porta més de 48 hores en un trajecte obert: l'han robat, s'ha trencat el sistema d'ancoratge o l'app ha fallat en tancar-lo. Escriu la consulta que les llista amb l'última estació coneguda, quant temps porten i el nom de contacte de la persona que la va desbloquejar, ordenades de més antiga a més recent. Afegeix l'índex que la faci eficient, o explica per què no en cal cap.
Solucions
Solució 1
1. Entitat nova. Tres arguments: (a) té atributs propis —instant de reserva, instant de caducitat, resultat—; (b) té historial: voldrem saber quantes reserves caduquen sense fer-se servir, i un estat sobreescrit no deixa rastre; (c) relaciona tres coses (persona, bici i ancoratge) amb temporalitat pròpia. Un estat = 'reservada' a bicicletes no permetria cap de les tres.
2. L'esquema:
CREATE TABLE reserves (
reserva_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
abonament_id INTEGER NOT NULL REFERENCES abonaments ON DELETE RESTRICT,
bicicleta_id INTEGER NOT NULL REFERENCES bicicletes ON DELETE RESTRICT,
estacio_id INTEGER NOT NULL,
ancoratge SMALLINT NOT NULL,
ts_reserva TIMESTAMPTZ NOT NULL DEFAULT now(),
ts_limit TIMESTAMPTZ NOT NULL
GENERATED ALWAYS AS (ts_reserva + INTERVAL '10 minutes') STORED,
resultat VARCHAR(10) NOT NULL DEFAULT 'activa'
CHECK (resultat IN ('activa','usada','caducada','cancellada')),
FOREIGN KEY (estacio_id, ancoratge) REFERENCES ancoratges (estacio_id, numero)
);
CREATE UNIQUE INDEX uq_reserva_activa_abon ON reserves (abonament_id) WHERE resultat = 'activa';
CREATE UNIQUE INDEX uq_reserva_activa_bici ON reserves (bicicleta_id) WHERE resultat = 'activa';
CREATE INDEX idx_reserves_caducar ON reserves (ts_limit) WHERE resultat = 'activa';Els dos índexs únics parcials imposen "una reserva activa per persona" i "una reserva activa per bici" — el mateix patró que RN4/RN5. El tercer és de servei, per al procés de caducitat.
3. La transacció:
BEGIN;
SELECT a.estacio_id, a.numero, a.bicicleta_id
FROM ancoratges a
JOIN bicicletes b ON b.bicicleta_id = a.bicicleta_id
WHERE a.estacio_id = 12 AND a.estat = 'operatiu' AND b.estat = 'ancorada'
AND NOT EXISTS (SELECT 1 FROM reserves r
WHERE r.bicicleta_id = a.bicicleta_id AND r.resultat = 'activa')
ORDER BY b.km_acumulats
FOR UPDATE OF a SKIP LOCKED
LIMIT 1;
INSERT INTO reserves (abonament_id, bicicleta_id, estacio_id, ancoratge)
VALUES (10233, 417, 12, 3);
COMMIT;L'anti-join NOT EXISTS exclou les bicis ja reservades; l'índex únic parcial és la xarxa de seguretat si dues peticions l'esquiven. Fixa't que no es marca la bici com a reservada a bicicletes: l'estat viu a reserves, en un sol lloc.
4. La caducitat. Dues opcions i una de triada.
Descartada: un procés que cada minut marqui com a caducada les vençudes. Funciona, però introdueix una finestra en què la reserva està vençuda i encara figura activa, i depèn que el procés no caigui.
Triada: caducitat implícita en la lectura. La reserva es considera activa si resultat = 'activa' AND ts_limit > now(); el NOT EXISTS de dalt hi afegeix aquesta condició. El procés de neteja continua existint, però només per deixar l'històric ordenat, i que es retardi no afecta la correcció. La regla general: quan el temps determina la validesa d'una dada, la veritat ha d'estar a la consulta, no en un procés.
Solució 2
1. El model. Una taula d'acreditacions amb vigència, no una columna booleana a persones_abonades:
CREATE TABLE bons_socials (
bo_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
persona_id INTEGER NOT NULL REFERENCES persones_abonades ON DELETE RESTRICT,
vigencia DATERANGE NOT NULL,
expedient VARCHAR(20) NOT NULL UNIQUE,
EXCLUDE USING gist (persona_id WITH =, vigencia WITH &&)
);
ALTER TABLE tarifes ADD COLUMN bo_social BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE tarifes DROP CONSTRAINT tarifes_tipus_abonament_vigencia_excl;
ALTER TABLE tarifes ADD EXCLUDE USING gist
(tipus_abonament WITH =, bo_social WITH =, vigencia WITH &&);Una columna te_bo_social BOOLEAN seria l'error clàssic: no guarda des de quan, no guarda l'expedient i, sobretot, no permet reconstruir si en tenia el dia del trajecte. L'acreditació és un fet amb data, i els fets amb data van en files, no en columnes.
2. Per què l'EXCLUDE d'abonaments no n'hi ha prou. Aquell EXCLUDE garanteix que no hi hagi dos abonaments solapats de la mateixa persona; no diu res sobre quina tarifa s'aplica. El bo social és una condició ortogonal a l'abonament i amb la seva pròpia vigència: una persona pot tenir un abonament anual de l'1 de gener al 31 de desembre i un bo social que caduca el 30 de juny. A partir de l'1 de juliol, el mateix abonament ha de generar trajectes amb tarifa normal. Com que el trajecte ja congela la seva tarifa (RN9), el sistema queda correcte sense tocar res més: n'hi ha prou que la transacció de desbloqueig triï la tarifa consultant bons_socials amb vigencia @> CURRENT_DATE.
3. La consulta de cost fiscal:
SELECT COUNT(*) AS trajectes_reduits,
SUM(t.import) AS ingressat,
SUM(t.import * (tn.preu_fraccio / NULLIF(t.preu_fraccio, 0)))
AS hauria_ingressat,
SUM(t.import * (tn.preu_fraccio / NULLIF(t.preu_fraccio, 0)))
- SUM(t.import) AS cost_bonificacio
FROM trajectes t
JOIN tarifes tr ON tr.tarifa_id = t.tarifa_id AND tr.bo_social
JOIN tarifes tn ON tn.tipus_abonament = tr.tipus_abonament
AND NOT tn.bo_social
AND tn.vigencia && tr.vigencia
WHERE t.ts_fi IS NOT NULL
AND t.ts_inici >= date_trunc('month', CURRENT_DATE - INTERVAL '1 month')
AND t.ts_inici < date_trunc('month', CURRENT_DATE); trajectes_reduits | ingressat | hauria_ingressat | cost_bonificacio
-------------------+-----------+------------------+------------------
8412 | 2184.60 | 4369.20 | 2184.60El NULLIF protegeix del zero: si la tarifa reduïda tingués preu_fraccio = 0, la divisió rebentaria.
Solució 3
SELECT b.matricula, b.tipus,
e.codi AS ultima_estacio, e.nom,
t.ts_inici,
date_trunc('minute', now() - t.ts_inici) AS temps_obert,
p.nom || ' ' || p.cognoms AS contacte, p.telefon
FROM trajectes t
JOIN bicicletes b USING (bicicleta_id)
JOIN estacions e ON e.estacio_id = t.estacio_origen
JOIN abonaments ab USING (abonament_id)
JOIN persones_abonades p USING (persona_id)
WHERE t.ts_fi IS NULL
AND t.ts_inici < now() - INTERVAL '48 hours'
ORDER BY t.ts_inici;matricula | tipus | ultima_estacio | nom | ts_inici | temps_obert | contacte -----------+-----------+----------------+-------------+------------------------+---------------+---------- VB-0233 | mecanica | VB-041 | Estació 41 | 2026-06-09 19:12:04+02 | 5 days 03:41 | ... VB-0781 | electrica | VB-007 | Estació 7 | 2026-06-12 08:33:51+02 | 2 days 14:19 | ...
Sobre l'índex: no en cal cap de nou. El filtre ts_fi IS NULL és tremendament selectiu —com a màxim 900 files d'1,6 milions— i uq_trajecte_obert_bici ja cobreix exactament aquell predicat parcial. El planificador el pot recórrer sencer (900 entrades) i filtrar després per ts_inici; afegir un índex sobre (ts_inici) WHERE ts_fi IS NULL milloraria marginalment una consulta que s'executa una vegada al dia i afegiria manteniment a 4.400 insercions diàries. No és rendible. Aquest és el raonament que 06-03 demanava: un índex es justifica per la freqüència de la consulta i el cost d'escriptura, no per la mida de la taula.
Conclusió
Has recorregut un projecte relacional sencer: d'una transcripció de reunió de dos paràgrafs a un sistema amb onze taules, vint restriccions que codifiquen regles de negoci reals, dues transaccions concurrents correctes, cinc informes d'explotació i un pla d'operació.
El que convé endur-se no és l'esquema —el del teu pròxim projecte serà un altre— sinó la forma de les decisions. Cadascuna de les importants va tenir el mateix format: una temptació simple, una pregunta al client que la va desmuntar, dues o tres alternatives posades en una taula i un criteri explícit per triar. L'ancoratge és una entitat perquè té estat propi. El trajecte és una entitat perquè neix incomplet i té identitat. La jerarquia fa servir taula per subclasse perquè altres taules referencien la superclasse i perquè el número de sèrie de la bateria ha de ser obligatori. bicis_disponibles trenca la 3FN per velocitat i es vigila; la tarifa congelada la trenca per semàntica i vigilar-la seria un error. Cap d'aquestes frases no és una preferència estètica: totes són arguments que es poden discutir i, si calgués, rebatre.
També has vist que les eines del curs no es fan servir d'una en una. L'EXCLUDE USING gist de 04-04 imposa una regla de negoci de 04-01; l'índex únic parcial que imposa RN5 resulta ser, sense canviar una línia, l'índex que arregla la consulta més freqüent del sistema; la desnormalització de 05-04 necessita l'activador de 05-04 i l'auditoria nocturna de 05-04, les tres coses o cap. Un sistema en producció és això: peces del temari funcionant alhora i sostenint-se entre si.
I acaba amb una llista de tres fracassos, que és la part més honesta del cas. La telemetria GPS, la fitxa enriquida de l'estació i les incidències d'estructura variable no encaixen bé en aquest esquema, i forçar-les-hi produiria columnes nul·les, taules EAV i migracions cada vegada que algú inventa un tipus d'incidència. A la lliçó 08-02 agafem aquestes tres peces exactes —ni una més— i les modelem a MongoDB amb el mètode de 03-03, comprovant què s'hi guanya, què s'hi perd i per què la temptació de "migrar-ho tot a Mongo" és la resposta equivocada. El nucli transaccional que acabes de construir es queda on és: és ell qui cobra els diners.
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
