Queda un cap solt que arrosseguem des de 04-02. spring.jpa.hibernate.ddl-auto: update continua creant i modificant taules pel seu compte cada vegada que arrenca l'aplicació. Ha estat còmode mentre dissenyàvem entitats i relacions, però ningú no sap exactament quin SQL s'ha executat sobre la base de dades, no hi ha registre dels canvis, no hi ha manera de reproduir l'esquema en un altre entorn i no hi ha manera de desfer res. Això no pot arribar a producció.
Aquesta lliçó tanca el mòdul substituint aquest automatisme per migracions versionades: l'esquema deixa de ser un efecte secundari de les classes Java i passa a ser codi font, escrit, revisat i versionat a Git com qualsevol altre. Escriurem l'esquema inicial complet de CicloUrbana en SQL de PostgreSQL, la migració que jubila el CarregadorEstacionsDemo del mòdul 1, i aprendrem a fer evolucionar un esquema en producció sense aturar el servei.
Contingut
- Per què
ddl-autono val en producció - L'esquema com a codi versionat
- Flyway enfront de Liquibase
- Integració amb Spring Boot
- Convenció de noms dels scripts
- La taula
flyway_schema_history V1: l'esquema inicial de CicloUrbanaV2: les dades de Ribalta- Migracions i desplegament continu
- Callbacks i migracions Java
- Migracions per entorn
- Validar la coherència amb les entitats
- Errors Comuns i Consells
- Exercicis
- Per què
ddl-auto no val en producció
ddl-auto no val en produccióRepassem els cinc valors de 04-02 amb el criteri d'un entorn real:
| Valor | Risc en producció |
|---|---|
create / create-drop |
Esborra tota la base de dades. Catastròfic |
update |
Canvis no revisats, incomplets i sense registre |
validate |
Cap: només comprova. El correcte |
none |
Cap: no fa res |
El problema amb update és més profund que «podria esborrar dades» —de fet no esborra columnes—. Són cinc limitacions estructurals: no pot modificar el que ja existeix (canviar varchar(80) a varchar(40), afegir un NOT NULL a una taula amb files o alterar el tipus d'una columna); no esborra res, així que reanomenar capacitat a places_totals produeix les dues columnes, amb dades a la vella i nuls a la nova; no deixa registre de què es va aplicar, quan ni qui ho va revisar; no és reproduïble, perquè l'esquema depèn de l'ordre històric d'arrencades i no d'un estat declarat; i no sap migrar dades, com omplir una columna nova a partir d'una altra.
I hi ha un problema humà pitjor que els cinc tècnics: el canvi d'esquema deixa de ser una decisió i passa a ser un efecte secundari. Algú afegeix un camp a una entitat per a una funcionalitat i, en desplegar, la base de dades de producció canvia sense que ningú hagi revisat aquell ALTER TABLE.
La comparació resumeix el mòdul:
ddl-auto: update |
Flyway | |
|---|---|---|
| Qui decideix el SQL | Hibernate | Tu |
| Revisable a Git | No | Sí |
| Registre del que s'ha aplicat | No | Taula d'historial |
| Reproduïble | No | Sí, determinista |
| Migració de dades | No | Sí |
| Reanomenar columnes | No | Sí |
| Reversible | No | Amb scripts de desfer |
- L'esquema com a codi versionat
La idea central és simple: cada canvi de l'esquema és un fitxer SQL numerat que s'aplica una sola vegada i en ordre.
src/main/resources/db/migration/
├── V1__crear_esquema_inicial.sql ├── V3__afegir_taula_incidencies.sql
├── V2__carregar_estacions_ribalta.sql └── V4__index_lloguers_per_data.sqlFlyway manté a la mateixa base de dades una taula amb les migracions ja aplicades. En arrencar compara, executa només les noves en ordre de versió i registra el resultat.
graph TD
A["L'aplicació arrenca"] --> B["Flyway llegeix flyway_schema_history"]
B --> C["Escaneja db/migration"]
C --> D{"Hi ha versions sense aplicar?"}
D -->|No| E["Valida checksums i continua"]
D -->|Sí| F["Executa en ordre: V1, V2, V3..."]
F --> G["Registra cadascuna amb el seu checksum"]
G --> E
E --> H["Hibernate valida entitats contra l'esquema"]
H --> I["Aplicació llesta"]
Els avantatges que això desbloqueja: qualsevol entorn es reconstrueix des de zero executant les migracions en ordre; el canvi d'esquema passa per revisió de codi, com qualsevol altre; l'historial de Git explica l'evolució del model de dades; i desenvolupament i producció convergeixen, perquè executen el mateix SQL.
- Flyway enfront de Liquibase
Són les dues eines de referència a l'ecosistema Java, i Spring Boot autoconfigura totes dues.
| Aspecte | Flyway | Liquibase |
|---|---|---|
| Format | SQL natiu (o Java) | XML, YAML, JSON o SQL |
| Corba d'aprenentatge | Baixa: és SQL | Mitjana: cal aprendre el seu llenguatge |
| Abstracció del motor | Cap: escrius SQL del motor | Alta: genera SQL per motor |
| Desfer | De pagament a l'edició Pro | Gratuït |
| Refactoritzacions predefinides | No | Sí (renameColumn, etc.) |
| Filosofia | Explícita i minimalista | Declarativa i completa |
| Adequada quan | Un sol motor i equip còmode amb SQL | Diversos motors o necessitat de desfer |
CicloUrbana fa servir Flyway per tres raons concretes: la base de dades és PostgreSQL i no canviarà, així que l'abstracció de Liquibase no hi aporta res; escriure SQL directe fa que el fitxer sigui exactament el que s'executa, sense traduccions intermèdies; i qualsevol que sàpiga SQL pot revisar una migració sense aprendre un format nou. Si el teu context és diferent —un producte que s'instal·la sobre Oracle, SQL Server o PostgreSQL segons el client—, Liquibase és probablement millor.
- Integració amb Spring Boot
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>La segona dependència és obligatòria des de Flyway 10, que va separar el suport de cada motor en mòduls propis. Oblidar-la produeix un error molt característic:
Amb les dependències al classpath, l'autoconfiguració fa la resta: crea un bean Flyway, l'apunta al DataSource i executa les migracions abans que Hibernate inicialitzi l'EntityManagerFactory. Aquest ordre és essencial: quan Hibernate validi les entitats, les taules ja existeixen.
La configuració de CicloUrbana:
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: false
validate-on-migrate: true
clean-disabled: true
table: flyway_schema_history
jpa:
hibernate:
ddl-auto: validate # ja no update!| Propietat | Què fa | Valor recomanat |
|---|---|---|
enabled |
Activa Flyway | true |
locations |
On buscar migracions | classpath:db/migration |
baseline-on-migrate |
Marca una BD existent com a línia base | false, llevat d'adopció en un projecte existent |
baseline-version |
Versió d'aquesta línia base | 1 |
validate-on-migrate |
Verifica els checksums en arrencar | true |
clean-disabled |
Impedeix flyway clean |
true. No el posis mai a false |
out-of-order |
Permet aplicar versions anteriors tardanes | false |
table |
Nom de la taula d'historial | flyway_schema_history |
clean-disabled: true és innegociable. L'ordre clean esborra tots els objectes de l'esquema: taules, dades i índexs. Executar-lo per error contra producció és una d'aquelles històries que s'expliquen durant anys; des de Flyway 9 ve desactivat per defecte, i convé deixar-ho així explícitament.
El canvi a ddl-auto: validate és l'altre moment clau: a partir d'aquí Hibernate no toca l'esquema, només comprova que coincideix amb les entitats i falla en arrencar si no. És la xarxa de seguretat que impedeix que migracions i classes Java es desincronitzin.
- Convenció de noms dels scripts
V2__carregar_estacions_ribalta.sql
│ │ ││
│ │ │└── Descripció (els _ es converteixen en espais)
│ │ └─── Separador: DOS guions baixos, obligatori
│ └───── Versió
└─────── Prefix| Prefix | Tipus | Quan s'executa |
|---|---|---|
V |
Versionada | Una sola vegada, en ordre de versió |
R |
Repetible | Cada cop que canvia el seu checksum, després de les versionades |
U |
De desfer | Només amb flyway undo (edició de pagament) |
Migracions versionades (V). Són el 95 % de la feina: crear taules, afegir columnes, inserir dades de referència. Les versions poden ser 1, 2, 2.1 o 20260901.1; en un equip gran, un esquema de data i hora evita que dues branques reclamin la mateixa versió.
Migracions repetibles (R). No porten versió (R__vista_ocupacio_estacions.sql) i es reexecuten quan el seu contingut canvia, cosa que les fa ideals per a objectes que es redefineixen sencers: vistes, funcions i procediments. En lloc de V5__crear_vista, V9__modificar_vista i V14__modificar_vista_un_altre_cop, hi ha un únic fitxer l'historial del qual a Git és l'historial de la vista.
-- R__vista_ocupacio_estacions.sql
CREATE OR REPLACE VIEW vista_ocupacio_estacions AS
SELECT e.id, e.nom, e.capacitat,
COUNT(b.id) FILTER (WHERE b.estat = 'DISPONIBLE') AS bicicletes_disponibles
FROM estacions e
LEFT JOIN bicicletes b ON b.estacio_id = e.id
WHERE e.activa = true
GROUP BY e.id, e.nom, e.capacitat;Anomena bé la descripció: apareix a la taula d'historial i als missatges d'error. V7__afegir_columna_nivell_bateria_bicicletes.sql és informatiu; V7__canvis.sql no diu res.
- La taula
flyway_schema_history
flyway_schema_historyFlyway crea aquesta taula a la seva primera execució: és el registre de tot el que s'ha aplicat.
| Columna | Contingut |
|---|---|
installed_rank |
Ordre d'aplicació |
version |
Versió (NULL a les repetibles) |
description |
Descripció llegible |
type |
SQL, JDBC, BASELINE |
script |
Nom del fitxer |
checksum |
Empremta del contingut de l'script |
installed_by |
Usuari de base de dades |
installed_on |
Moment d'aplicació |
execution_time |
Mil·lisegons que va trigar |
success |
Si va acabar correctament |
SELECT version, description, success, installed_on, execution_time
FROM flyway_schema_history ORDER BY installed_rank; version | description | success | installed_on | execution_time
---------+---------------------------+---------+---------------------+----------------
1 | crear esquema inicial | t | 2026-09-01 09:14:22 | 184
2 | carregar estacions ribalta| t | 2026-09-01 09:14:22 | 12El checksum és el mecanisme central. En arrencar, Flyway recalcula l'empremta de cada script i la compara amb la registrada; si un script ja aplicat ha canviat, l'arrencada falla:
FlywayValidateException: Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 1
-> Applied to database : 1554682513
-> Resolved locally : 987654321És una fallada deliberada i valuosa: significa que algú va editar una migració ja aplicada, trencant la premissa fonamental que les migracions són immutables. El fitxer diu una cosa i les bases de dades on es va aplicar en diuen una altra: ningú no sap ja quin és l'esquema real.
Què fer si et passa. Si l'script només es va aplicar al teu entorn local, esborra la base de dades i torna a migrar. Si va arribar a un entorn compartit, la resposta és una de sola: crea una migració nova amb el canvi addicional i reverteix el fitxer anterior al seu contingut original. flyway repair reescriu els checksums, però és un últim recurs: emmascara el problema en lloc de resoldre'l.
V1: l'esquema inicial de CicloUrbana
V1: l'esquema inicial de CicloUrbanaAquest és l'esquema complet, coherent amb les entitats de 04-03 i les relacions de 04-04.
-- V1__crear_esquema_inicial.sql
-- Esquema inicial de CicloUrbana: xarxa de bicicletes elèctriques de Ribalta.
-- ============================================================
-- Seqüències (allocationSize = 50 a les entitats, INCREMENT BY 50 aquí)
-- ============================================================
CREATE SEQUENCE estacions_id_seq START WITH 1 INCREMENT BY 50;
CREATE SEQUENCE bicicletes_id_seq START WITH 1 INCREMENT BY 50;
CREATE SEQUENCE usuaris_id_seq START WITH 1 INCREMENT BY 50;
CREATE SEQUENCE lloguers_id_seq START WITH 1 INCREMENT BY 50;
-- ============================================================
-- estacions
-- ============================================================
CREATE TABLE estacions (
id BIGINT NOT NULL,
nom VARCHAR(80) NOT NULL,
adreca VARCHAR(200) NOT NULL,
capacitat INTEGER NOT NULL,
latitud NUMERIC(9,6) NOT NULL,
longitud NUMERIC(9,6) NOT NULL,
activa BOOLEAN NOT NULL DEFAULT TRUE,
versio BIGINT NOT NULL DEFAULT 0,
creat_el TIMESTAMPTZ NOT NULL,
modificat_el TIMESTAMPTZ NOT NULL,
CONSTRAINT pk_estacions PRIMARY KEY (id),
CONSTRAINT uk_estacions_nom UNIQUE (nom),
CONSTRAINT ck_estacions_capacitat CHECK (capacitat > 0 AND capacitat <= 200),
CONSTRAINT ck_estacions_latitud CHECK (latitud BETWEEN -90 AND 90),
CONSTRAINT ck_estacions_longitud CHECK (longitud BETWEEN -180 AND 180)
);
CREATE INDEX idx_estacions_activa ON estacions (activa);
CREATE INDEX idx_estacions_ubicacio ON estacions (latitud, longitud);
-- ============================================================
-- bicicletes
-- ============================================================
CREATE TABLE bicicletes (
id BIGINT NOT NULL,
matricula VARCHAR(10) NOT NULL,
estat VARCHAR(20) NOT NULL,
nivell_bateria INTEGER NOT NULL,
estacio_id BIGINT,
versio BIGINT NOT NULL DEFAULT 0,
creat_el TIMESTAMPTZ NOT NULL,
modificat_el TIMESTAMPTZ NOT NULL,
CONSTRAINT pk_bicicletes PRIMARY KEY (id),
CONSTRAINT uk_bicicletes_matricula UNIQUE (matricula),
CONSTRAINT fk_bicicletes_estacio FOREIGN KEY (estacio_id)
REFERENCES estacions (id) ON DELETE SET NULL,
CONSTRAINT ck_bicicletes_estat CHECK (estat IN
('DISPONIBLE', 'EN_US', 'MANTENIMENT', 'RETIRADA')),
CONSTRAINT ck_bicicletes_bateria CHECK (nivell_bateria BETWEEN 0 AND 100),
CONSTRAINT ck_bicicletes_matricula CHECK (matricula ~ '^RB-[0-9]{4}$')
);
CREATE INDEX idx_bicicletes_estat ON bicicletes (estat);
CREATE INDEX idx_bicicletes_estacio ON bicicletes (estacio_id);
-- ============================================================
-- usuaris
-- ============================================================
CREATE TABLE usuaris (
id BIGINT NOT NULL,
correu VARCHAR(120) NOT NULL,
nom VARCHAR(120) NOT NULL,
tipus_tarifa VARCHAR(20) NOT NULL DEFAULT 'ESTANDARD',
data_alta DATE NOT NULL,
actiu BOOLEAN NOT NULL DEFAULT TRUE,
versio BIGINT NOT NULL DEFAULT 0,
creat_el TIMESTAMPTZ NOT NULL,
modificat_el TIMESTAMPTZ NOT NULL,
CONSTRAINT pk_usuaris PRIMARY KEY (id),
CONSTRAINT uk_usuaris_correu UNIQUE (correu),
CONSTRAINT ck_usuaris_tarifa CHECK (tipus_tarifa IN
('ESTANDARD', 'ESTUDIANT', 'JUBILAT'))
);
-- ============================================================
-- lloguers
-- ============================================================
CREATE TABLE lloguers (
id BIGINT NOT NULL,
usuari_id BIGINT NOT NULL,
bicicleta_id BIGINT NOT NULL,
estacio_origen_id BIGINT NOT NULL,
estacio_desti_id BIGINT,
inici TIMESTAMPTZ NOT NULL,
fi TIMESTAMPTZ,
import_total NUMERIC(8,2),
estat VARCHAR(20) NOT NULL DEFAULT 'EN_CURS',
versio BIGINT NOT NULL DEFAULT 0,
creat_el TIMESTAMPTZ NOT NULL,
modificat_el TIMESTAMPTZ NOT NULL,
CONSTRAINT pk_lloguers PRIMARY KEY (id),
CONSTRAINT fk_lloguers_usuari FOREIGN KEY (usuari_id)
REFERENCES usuaris (id),
CONSTRAINT fk_lloguers_bicicleta FOREIGN KEY (bicicleta_id)
REFERENCES bicicletes (id),
CONSTRAINT fk_lloguers_estacio_origen FOREIGN KEY (estacio_origen_id)
REFERENCES estacions (id),
CONSTRAINT fk_lloguers_estacio_desti FOREIGN KEY (estacio_desti_id)
REFERENCES estacions (id),
CONSTRAINT ck_lloguers_estat CHECK (estat IN
('EN_CURS', 'FINALITZAT', 'CADUCAT', 'CANCELLAT')),
CONSTRAINT ck_lloguers_fi CHECK (fi IS NULL OR fi >= inici),
CONSTRAINT ck_lloguers_import CHECK (import_total IS NULL OR import_total >= 0)
);
CREATE INDEX idx_lloguers_usuari ON lloguers (usuari_id);
CREATE INDEX idx_lloguers_bicicleta ON lloguers (bicicleta_id);
CREATE INDEX idx_lloguers_inici ON lloguers (inici DESC);
-- Un usuari no pot tenir dos lloguers en curs alhora
CREATE UNIQUE INDEX uk_lloguers_usuari_en_curs
ON lloguers (usuari_id) WHERE fi IS NULL;Cinc decisions de l'script mereixen comentari:
INCREMENT BY 50 a les seqüències, que ha de coincidir exactament amb l'allocationSize = 50 de les entitats (04-03); si no coincideixen, Hibernate genera ids que col·lideixen.
Restriccions CHECK sobre els enumerats. ck_bicicletes_estat garanteix a la base de dades el que @Enumerated(EnumType.STRING) garanteix en Java, i defensa davant d'escriptures que no passin per l'aplicació. La seva contrapartida: afegir un valor a l'enumerat exigeix una migració que actualitzi la restricció.
ON DELETE SET NULL a bicicletes.estacio_id, que reflecteix la decisió de domini de 04-04: esborrar una estació no esborra les seves bicicletes, les deixa sense estació assignada.
Índexs sobre les claus foranes, perquè PostgreSQL no els crea automàticament a diferència de MySQL: sense idx_lloguers_usuari, la consulta «els meus lloguers» recorreria seqüencialment tota la taula.
L'índex únic parcial uk_lloguers_usuari_en_curs. És la joia de l'script. WHERE fi IS NULL fa que la unicitat s'apliqui només als lloguers en curs: un usuari pot tenir centenars de lloguers finalitzats, però com a màxim un d'obert. És la regla de negoci central de CicloUrbana garantida per la base de dades, immune a condicions de cursa i a qualsevol fallada de la lògica d'aplicació. Cap comprovació en Java no ofereix aquesta garantia.
V2: les dades de Ribalta
V2: les dades de RibaltaAquesta migració jubila el CarregadorEstacionsDemo del mòdul 1, que portava des de 01-05 recreant les quatre estacions a cada arrencada.
-- V2__carregar_estacions_ribalta.sql
-- Estacions inicials de la xarxa municipal de Ribalta.
INSERT INTO estacions (id, nom, adreca, capacitat, latitud, longitud,
activa, versio, creat_el, modificat_el)
VALUES
(1, 'Plaça Major', 'Plaça Major, 1', 24, 40.416775, -3.703790,
TRUE, 0, NOW(), NOW()),
(2, 'Estació Nord', 'Avinguda Estació 3', 30, 40.428900, -3.698120,
TRUE, 0, NOW(), NOW()),
(3, 'Parc del Riu', 'Passeig Fluvial 12', 18, 40.409330, -3.712450,
TRUE, 0, NOW(), NOW()),
(4, 'Universitat', 'Campus Sud, accés B', 36, 40.435210, -3.689870,
TRUE, 0, NOW(), NOW());
-- La seqüència ha de quedar per damunt dels ids inserits a mà
SELECT setval('estacions_id_seq', 100, false);
-- Tarifes de l'ajuntament
INSERT INTO tarifes (codi, descripcio, preu_minut, import_minim)
VALUES ('ESTANDARD', 'Tarifa general', 0.15, 0.50),
('ESTUDIANT', 'Tarifa estudiant (-40%)', 0.09, 0.30),
('JUBILAT', 'Tarifa jubilat (-50%)', 0.075, 0.25);Dos punts crítics:
setval sobre la seqüència. En inserir ids explícits, la seqüència no avança. Si no l'ajustes, la primera alta des de l'API demanarà l'id 1 i xocarà amb «Plaça Major». setval(..., 100, false) la deixa començant a 100, amb marge de sobres.
Idempotència. Una migració V s'executa una sola vegada, així que no cal que sigui idempotent; però escriure-la amb ON CONFLICT (id) DO NOTHING permet reutilitzar el mateix SQL en altres contextos sense risc.
Quines dades van en una migració i quines no. Les dades de referència —tarifes, tipus d'incidència, la xarxa inicial d'estacions— són part de l'esquema funcional i van en migracions. Les dades de prova —usuaris ficticis, lloguers d'exemple— no han d'aparèixer en producció: van en migracions separades per entorn (apartat 11).
Amb això, CarregadorEstacionsDemo s'elimina del projecte: les seves dades ja no depenen de l'arrencada de l'aplicació, viuen a la base de dades i sobreviuen als reinicis. Les quatre estacions de Ribalta han sobreviscut a un reinici.
- Migracions i desplegament continu
Aquí hi ha la part que separa un projecte petit d'un en producció real. Durant un desplegament sense aturada, conviuen dues versions de l'aplicació contra una sola base de dades:
graph TD
A["v1.4 en 3 rèpliques"] --> B["Migració V8"]
B --> C["v1.4 (2 rèpliques) + v1.5 (1 rèplica)"]
C --> D["v1.5 en 3 rèpliques"]
style C fill:#ffe6cc
Durant la fase intermèdia, la versió antiga continua executant consultes contra l'esquema ja migrat. D'aquí la regla d'or: tota migració ha de ser compatible cap enrere amb la versió anterior de l'aplicació.
| Canvi | És compatible? | Per què |
|---|---|---|
| Afegir una taula | Sí | La versió antiga la ignora |
| Afegir una columna nul·lable | Sí | Els INSERT antics la deixen nul·la |
| Afegir un índex | Sí | Transparent (amb CONCURRENTLY) |
Afegir una columna NOT NULL amb DEFAULT |
Sí | Els INSERT antics prenen el valor per defecte |
Afegir una columna NOT NULL sense DEFAULT |
No | Els INSERT de la versió antiga fallen |
| Eliminar una columna | No | La versió antiga continua llegint-la |
| Reanomenar una columna | No | Equival a eliminar i afegir |
Reduir la longitud d'un VARCHAR |
No | Les dades existents poden no cabre-hi |
Afegir una restricció NOT NULL |
No | Trenca els INSERT que l'ometien |
El patró expand/contract és la tècnica per fer un canvi incompatible en passos compatibles. Reanomenar capacitat a places_totals sense aturar CicloUrbana:
Fase 1 — Expand (V8): afegir la columna nova i copiar les dades.
-- V8__expand_places_totals.sql
ALTER TABLE estacions ADD COLUMN places_totals INTEGER;
UPDATE estacions SET places_totals = capacitat;
-- Un disparador manté totes dues columnes sincronitzades durant la transició
CREATE OR REPLACE FUNCTION sincronitzar_places() RETURNS TRIGGER AS $$
BEGIN
IF NEW.places_totals IS DISTINCT FROM OLD.places_totals THEN
NEW.capacitat := NEW.places_totals;
ELSE
NEW.places_totals := NEW.capacitat;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sincronitzar_places
BEFORE INSERT OR UPDATE ON estacions
FOR EACH ROW EXECUTE FUNCTION sincronitzar_places();Fase 2 — Desplegar la versió que fa servir places_totals. Totes dues columnes existeixen i el disparador les manté coherents, així que la versió antiga i la nova conviuen sense problema.
Fase 3 — Contract (V9), després de confirmar que la versió antiga ja no està desplegada:
-- V9__contract_eliminar_capacitat.sql
DROP TRIGGER trg_sincronitzar_places ON estacions;
DROP FUNCTION sincronitzar_places();
ALTER TABLE estacions DROP COLUMN capacitat;
ALTER TABLE estacions ALTER COLUMN places_totals SET NOT NULL;Són tres desplegaments per a un canvi de nom. Sembla excessiu, i és exactament el que separa un sistema desplegable a qualsevol hora d'un que necessita una finestra de manteniment.
La regla inviolable: mai no s'edita una migració ja aplicada. Si V5 té un error, es corregeix amb V6; editar-la trenca el checksum a tots els entorns i deixa l'esquema real en estat desconegut.
Dos consells operatius de PostgreSQL: fes servir CREATE INDEX CONCURRENTLY en taules grans, perquè un CREATE INDEX normal bloqueja les escriptures durant tota la construcció (necessita la seva pròpia migració, en no poder anar dins d'una transacció); i recorda que ADD COLUMN ... DEFAULT és instantani des de PostgreSQL 11, però ALTER COLUMN ... TYPE reescriu la taula sencera.
- Callbacks i migracions Java
Callbacks. Flyway executa fitxers SQL en moments concrets del cicle si segueixen la convenció de noms: beforeMigrate.sql (abans de totes), afterMigrate.sql (després de totes), beforeEachMigrate.sql (abans de cadascuna) i afterMigrateError.sql (si alguna falla). Un afterMigrate.sql amb ANALYZE estacions; ANALYZE bicicletes; ANALYZE lloguers; recalcula les estadístiques del planificador de PostgreSQL, especialment útil després d'una migració que hagi mogut moltes dades.
Migracions en Java. Quan la transformació no cap en SQL —cal desxifrar valors, cridar un servei o processar per lots amb lògica complexa—, s'escriu una classe que estengui BaseJavaMigration:
package db.migration; // paquet obligatori
public class V7__normalitzar_matricules extends BaseJavaMigration {
@Override
public void migrate(Context context) throws Exception {
try (Statement lectura = context.getConnection().createStatement();
ResultSet files = lectura.executeQuery(
"SELECT id, matricula FROM bicicletes WHERE matricula !~ '^RB-[0-9]{4}$'")) {
try (PreparedStatement escriptura = context.getConnection()
.prepareStatement("UPDATE bicicletes SET matricula = ? WHERE id = ?")) {
while (files.next()) {
escriptura.setString(1, normalitzar(files.getString("matricula")));
escriptura.setLong(2, files.getLong("id"));
escriptura.addBatch();
}
escriptura.executeBatch();
}
}
}
private String normalitzar(String original) { // "rb142" -> "RB-0142"
return "RB-" + String.format("%04d",
Integer.parseInt(original.replaceAll("\\D", "")));
}
}Quatre requisits: la classe ha d'estar al paquet db.migration; el seu nom segueix la mateixa convenció (V7__descripcio); fa servir JDBC directe, mai els repositoris de l'aplicació, que encara no estan inicialitzats; i no ha de gestionar la transacció, de la qual ja se n'encarrega Flyway. Fes-les servir amb moderació: el SQL és més transparent i revisable, i una migració Java només es justifica quan la lògica és inexpressable en SQL.
- Migracions per entorn
Les dades de prova no poden arribar a producció. spring.flyway.locations accepta diverses rutes:
src/main/resources/db/
├── migration/ # esquema i dades de referència: TOTS els entorns
│ ├── V1__crear_esquema_inicial.sql
│ └── V2__carregar_estacions_ribalta.sql
└── dades-dev/ # dades de prova: NOMÉS desenvolupament
└── V900__usuaris_i_lloguers_prova.sqlLes versions altes (V900) mantenen les dades de prova sempre al final i eviten col·lisions amb les migracions reals de l'esquema.
Advertiment important: cada entorn té la seva pròpia taula d'historial, així que no hi ha conflicte entre ells. El que sí que passa és que si un desenvolupador executa el perfil prod sobre la seva base de dades local, Flyway detectarà que V900 està aplicada però ja no apareix a locations i avisarà amb Detected applied migration not resolved locally: és un avís, no un error. Els perfils s'estudien a fons a 07-02.
- Validar la coherència amb les entitats
Amb Flyway gestionant l'esquema, ddl-auto: validate cobra tot el seu sentit: Hibernate compara cada entitat amb les taules reals i falla en arrencar si alguna cosa no quadra.
Aquest missatge significa que algú va afegir un camp a l'entitat i va oblidar la migració. És exactament la fallada que vols, i arriba abans que cap petició la pateixi.
validate comprova l'existència de taules i columnes, els seus tipus i nul·labilitat, i les seqüències. No comprova índexs, restriccions CHECK ni claus foranes, així que no substitueix la revisió del SQL.
El flux de treball complet en afegir un camp a CicloUrbana: afegir el camp a l'entitat amb les seves anotacions; escriure la migració V<n>__...sql corresponent; arrencar, amb Flyway aplicant i Hibernate validant; i si validate falla, corregir la discrepància amb una altra migració nova, mai editant l'anterior.
Les ordres de Flyway des de Maven, útils fora de l'arrencada de l'aplicació:
./mvnw flyway:info # estat de cada migració: aplicada, pendent, fallida
./mvnw flyway:validate # comprova els checksums sense aplicar res
./mvnw flyway:migrate # aplica les pendents
./mvnw flyway:baseline # marca una BD existent com a línia base
./mvnw flyway:repair # repara checksums i neteja entrades fallidesRequereixen configurar el plugin al pom.xml amb la URL, l'usuari i la contrasenya presos de variables d'entorn, com a 04-02. flyway:info és especialment útil en integració contínua, per comprovar l'estat d'un entorn abans de desplegar (mòdul 8).
Errors Comuns i Consells
Editar una migració ja aplicada. Trenca el checksum i l'arrencada falla a tots els entorns on es va aplicar. Corregeix sempre amb una migració nova.
Oblidar flyway-database-postgresql. Des de Flyway 10 és obligatòria: Unsupported Database: PostgreSQL 16.
Deixar ddl-auto: update amb Flyway actiu. Tots dos competeixen per l'esquema i el resultat és impredictible. Amb Flyway, sempre validate o none.
Oblidar setval després d'inserir ids explícits. La primera alta des de l'API col·lideix amb les dades precarregades.
Posar dades de prova a db/migration. Acabaran en producció. Separa-les per locations.
Afegir una columna NOT NULL sense DEFAULT en desplegament continu. Els INSERT de la versió antiga fallen durant la transició.
Habilitar flyway clean. Esborra l'esquema complet. clean-disabled: true, sempre.
No indexar les claus foranes. PostgreSQL no ho fa sol, i les consultes per relació acaben en recorreguts seqüencials.
Consell: una migració, un canvi lògic. És més fàcil de revisar i, si falla, més fàcil de diagnosticar. I prova cada migració contra una còpia de producció abans de desplegar: un ALTER TABLE que triga 2 segons amb 4 estacions pot trigar 20 minuts amb 2 milions de lloguers.
Consell: fes servir restriccions i índexs únics parcials per a les regles de negoci. L'uk_lloguers_usuari_en_curs garanteix a la base de dades una cosa que cap comprovació en Java no pot assegurar davant de condicions de cursa.
Exercicis
Exercici 1: la migració de les incidències
Escriu V3__crear_taula_incidencies.sql per al model d'herència SINGLE_TABLE de 04-04: una taula incidencies amb id per seqüència, clau forana a bicicletes amb esborrat en cascada, columna discriminadora tipus, descripció obligatòria, data de report, estat, columnes d'auditoria, i els camps específics nivell_detectat (bateria) i denuncia_policial (vandalisme). Inclou-hi restriccions CHECK i índexs, i justifica per què les columnes específiques admeten nuls.
Exercici 2: eliminar una columna sense aturar el servei
CicloUrbana té a usuaris una columna telefon VARCHAR(20) NOT NULL que ja no es fa servir. Hi ha 3 rèpliques en producció amb desplegament progressiu. Descriu la seqüència completa de migracions i desplegaments per eliminar-la sense tallar el servei, i explica què passaria si es fes ALTER TABLE usuaris DROP COLUMN telefon directament.
Exercici 3: diagnosticar tres incidents
Diagnostica i resol cada situació.
- En arrencar en preproducció:
Migration checksum mismatch for migration version 3. - Un desenvolupador executa
V4en local i funciona; en integració contínua falla ambcolumn "activa" of relation "estacions" already exists. - Després de desplegar, l'aplicació arrenca però falla en crear estacions:
duplicate key value violates unique constraint "pk_estacions".
Solucions
Solució 1.
-- V3__crear_taula_incidencies.sql
CREATE SEQUENCE incidencies_id_seq START WITH 1 INCREMENT BY 50;
CREATE TABLE incidencies (
id BIGINT NOT NULL,
tipus VARCHAR(20) NOT NULL,
bicicleta_id BIGINT NOT NULL,
descripcio VARCHAR(500) NOT NULL,
estat VARCHAR(20) NOT NULL DEFAULT 'OBERTA',
reportada_el TIMESTAMPTZ NOT NULL,
resolta_el TIMESTAMPTZ,
-- Camps específics de les subclasses: NUL·LABLES per necessitat
nivell_detectat INTEGER,
denuncia_policial VARCHAR(40),
versio BIGINT NOT NULL DEFAULT 0,
creat_el TIMESTAMPTZ NOT NULL,
modificat_el TIMESTAMPTZ NOT NULL,
CONSTRAINT pk_incidencies PRIMARY KEY (id),
CONSTRAINT fk_incidencies_bicicleta FOREIGN KEY (bicicleta_id)
REFERENCES bicicletes (id) ON DELETE CASCADE,
CONSTRAINT ck_incidencies_tipus CHECK (tipus IN ('BATERIA', 'VANDALISME', 'AVARIA')),
CONSTRAINT ck_incidencies_estat CHECK (estat IN ('OBERTA', 'EN_CURS', 'RESOLTA')),
CONSTRAINT ck_incidencies_nivell CHECK (nivell_detectat IS NULL
OR nivell_detectat BETWEEN 0 AND 100),
CONSTRAINT ck_incidencies_resolta CHECK (resolta_el IS NULL
OR resolta_el >= reportada_el),
-- Coherència entre el discriminador i els seus camps propis
CONSTRAINT ck_incidencies_bateria CHECK (
tipus <> 'BATERIA' OR nivell_detectat IS NOT NULL)
);
CREATE INDEX idx_incidencies_bicicleta ON incidencies (bicicleta_id);
CREATE INDEX idx_incidencies_estat ON incidencies (estat)
WHERE estat <> 'RESOLTA';Per què les columnes específiques admeten nuls. És la contrapartida inevitable de SINGLE_TABLE: totes les subclasses comparteixen taula, i una incidència de vandalisme no té nivell_detectat. Declarar-les NOT NULL faria impossible inserir qualsevol tipus que no tingués tots els camps.
La restricció ck_incidencies_bateria recupera part d'aquesta integritat perduda: si el tipus és BATERIA, el nivell és obligatori. És la tècnica que compensa la principal debilitat de SINGLE_TABLE, i una raó més per haver triat JOINED si els camps propis fossin molts (exercici 3 de 04-04).
L'índex parcial sobre estat és una optimització deliberada: només indexa les incidències no resoltes, que són les que es consulten a diari, mantenint l'índex petit encara que l'històric creixi sense límit.
Solució 2. La seqüència correcta té tres passos:
Pas 1 — V10: relaxar la restricció.
És un canvi compatible cap enrere: la versió antiga continua enviant el telèfon i funciona; la nova podrà ometre'l.
Pas 2 — Desplegar la versió que ja no fa servir telefon, eliminant el camp de l'entitat Usuari, del DTO i del mapejador. Durant el desplegament progressiu conviuen rèpliques antigues —que escriuen el telèfon— i noves —que no—, i totes dues funcionen perquè la columna existeix i admet nuls.
Pas 3 — V11: eliminar la columna, en un desplegament posterior, després de confirmar que cap rèplica antiga continua viva.
Què passaria amb el DROP COLUMN directe. Durant la fase intermèdia, les rèpliques antigues continuen executant INSERT INTO usuaris (..., telefon, ...) i SELECT ... telefon ..., i cadascuna d'aquestes consultes fallaria amb column "telefon" does not exist. Pitjor encara, amb ddl-auto: validate qualsevol rèplica antiga que es reiniciés no arribaria a arrencar: una caiguda parcial de CicloUrbana durant el desplegament, exactament el que el desplegament progressiu pretenia evitar. I una consideració addicional: el pas 3 és irreversible, així que convé arxivar les dades abans d'esborrar-les, perquè cap migració de desfer no recupera informació que ja no existeix.
Solució 3.
1. Migration checksum mismatch a la versió 3. Algú va editar V3__...sql després que s'hagués aplicat a preproducció. El checksum del fitxer ja no coincideix amb el registrat. Diagnòstic: SELECT version, checksum, installed_on FROM flyway_schema_history WHERE version = '3'; i comparar amb l'historial de Git del fitxer. Solució: revertir V3 al seu contingut original —el que es va aplicar— i crear V6 amb el canvi que es pretenia introduir. Només si el canvi era purament cosmètic (un comentari, un espai) i s'ha verificat que el SQL efectiu és idèntic, flyway:repair recalcula els checksums. No és l'opció per defecte: emmascara el problema.
2. column "activa" already exists només en integració contínua. La base de dades de CI no està neta: conserva l'esquema d'una execució anterior en què la columna ja es va crear, probablement per un ddl-auto: update que va quedar actiu o per una migració prèvia que ja l'afegia; en local funcionava perquè la base es creava de zero. Solució: fer que CI parteixi d'una base de dades efímera —Testcontainers (06-05) és exactament això—, comprovar que cap migració anterior no crea ja aquesta columna i confirmar que ddl-auto està en validate a tots els entorns.
3. duplicate key value violates unique constraint "pk_estacions". Falta el setval de V2. Es van inserir les quatre estacions amb ids explícits 1-4, però la seqüència estacions_id_seq continua al seu valor inicial, així que la primera estació creada des de l'API demana l'id 1 i xoca amb «Plaça Major». Solució: una migració nova que ajusti la seqüència per damunt del màxim real.
-- V12__ajustar_sequencia_estacions.sql
SELECT setval('estacions_id_seq',
GREATEST((SELECT COALESCE(MAX(id), 0) FROM estacions) + 50, 100),
false);GREATEST amb el màxim real la fa segura sigui quin sigui l'estat de l'entorn, i el marge de 50 respecta l'allocationSize. La lliçó general: sempre que una migració insereixi ids explícits en una taula amb seqüència, ha d'ajustar la seqüència al mateix script.
Conclusió
El mòdul 4 es tanca amb CicloUrbana funcionant sobre persistència real i amb l'esquema sota control. Saps per què ddl-auto: update no pot arribar a producció —no modifica el que existeix, no esborra, no deixa registre, no és reproduïble i no migra dades— i, sobretot, per què el seu pitjor defecte és humà: converteix el canvi d'esquema en un efecte secundari en lloc d'una decisió revisada. Has comparat Flyway amb Liquibase i entens per què aquest projecte tria SQL natiu sobre un únic motor. Has integrat Flyway amb les seves dues dependències, configurat spring.flyway.* amb clean-disabled: true com a línia vermella, i fet el canvi decisiu del mòdul: ddl-auto passa d'update a validate, de manera que Hibernate ja no toca l'esquema i es limita a comprovar que les entitats i les taules coincideixen, fallant en arrencar quan no. Domines la convenció V/R/U, saps per a què serveix una migració repetible i entens la taula flyway_schema_history i el seu checksum, l'empremta que fa de l'arrencada un guardià contra l'edició de migracions ja aplicades.
Has escrit l'esquema inicial complet de Ribalta en SQL de PostgreSQL: quatre seqüències amb INCREMENT BY 50 que casen amb l'allocationSize de 04-03, quatre taules amb les seves claus primàries, claus foranes anomenades, restriccions CHECK que repliquen a la base de dades el que els enumerats garanteixen en Java, índexs explícits sobre les claus foranes —perquè PostgreSQL no els crea sol— i columnes d'auditoria i versio. I amb elles l'índex únic parcial uk_lloguers_usuari_en_curs, que converteix la regla de negoci central de CicloUrbana en una garantia del motor, immune a condicions de cursa. La migració V2 va carregar les quatre estacions i les tres tarifes, va ajustar la seqüència amb setval i va jubilar definitivament el CarregadorEstacionsDemo del mòdul 1. Coneixes el patró expand/contract per reanomenar una columna en tres desplegaments sense tallar el servei, la taula de canvis compatibles i incompatibles, els callbacks, les migracions Java amb BaseJavaMigration i la separació de dades de prova per locations.
Mira on és CicloUrbana. Va començar sent un main que imprimia un missatge. Té una API REST de tretze endpoints dissenyada sobre les restriccions de REST, amb verbs i codis d'estat correctes, validació declarativa, DTOs que separen domini i contracte, errors uniformes en RFC 7807 i un contracte OpenAPI publicable. I ara, a més, un model de dades persistent sobre PostgreSQL 16, amb entitats ben mapejades, relacions mandroses, repositoris de Spring Data, consultes que no pateixen el problema N+1, transaccions a la capa de servei i un esquema versionat a Git que qualsevol pot reconstruir des de zero. Les quatre estacions de Ribalta sobreviuen a un reinici, i també els lloguers, les bicicletes i els usuaris.
I precisament per això hi ha ara un problema que abans no importava: l'API està completament oberta. Qualsevol que conegui la URL pot crear estacions, donar de baixa bicicletes, consultar les dades personals dels ciutadans de Ribalta o finalitzar el lloguer d'una altra persona. Mentre tot vivia en memòria i es perdia en reiniciar, era una demo; ara hi ha dades reals i persistents d'una xarxa municipal, i no hi ha ni una sola comprovació de qui hi ha a l'altra banda. El mòdul 5, Seguretat a Spring Boot, ho resol: veurem què és Spring Security i com la seva cadena de filtres s'insereix davant del DispatcherServlet que vam conèixer a 03-01; la configurarem amb SecurityFilterChain substituint la contrasenya generada per defecte; distingirem autenticació d'autorització i modelarem els rols de CicloUrbana —ciutadà, operari, administrador— sobre l'entitat Usuari que acabem de crear; implementarem autenticació sense estat amb JWT, adequada per a una API consumida per una aplicació mòbil; i baixarem la seguretat al nivell de mètode amb @PreAuthorize perquè un ciutadà només pugui finalitzar els seus lloguers. La xarxa de Ribalta està a punt de tenir portes.
Curs de Spring Boot
Mòdul 1: Introducció a Spring Boot
- Què és Spring Boot?
- Configuració del teu entorn de desenvolupament
- Creant la teva primera aplicació Spring Boot
- Entenent l'estructura del projecte
- L'arrencada i el cicle de vida de l'aplicació
Mòdul 2: Conceptes bàsics de Spring Boot
- Anotacions de Spring Boot
- Injecció de dependències a Spring Boot
- Àmbit i cicle de vida dels beans
- Configuració de Spring Boot
- Propietats de Spring Boot
- Autoconfiguració i starters per dins
Mòdul 3: Construint serveis web RESTful
- Introducció als serveis web RESTful
- Creant controladors REST
- Gestió dels mètodes HTTP
- Validació de dades d'entrada
- DTOs i mapatge entre capes
- Gestió d'excepcions a REST
- Documentar l'API amb OpenAPI
Mòdul 4: Accés a dades amb Spring Boot
- Introducció a Spring Data JPA
- Configuració de fonts de dades
- Creació d'entitats JPA
- Relacions entre entitats
- Ús de repositoris de Spring Data
- Mètodes de consulta a Spring Data JPA
- Transaccions i gestió de la persistència
- Migracions d'esquema amb Flyway
Mòdul 5: Seguretat a Spring Boot
- Introducció a Spring Security
- Configuració de Spring Security
- Autenticació i autorització d'usuaris
- Implementació d'autenticació JWT
- Seguretat a nivell de mètode i enduriment de l'API
Mòdul 6: Proves a Spring Boot
- Introducció a les proves
- Proves unitàries amb JUnit
- Simulació amb Mockito
- Proves d'integració
- Proves amb Testcontainers
Mòdul 7: Funcions avançades de Spring Boot
- Spring Boot Actuator
- Perfils de Spring Boot
- Tasques programades i execució asíncrona
- Spring Boot amb Docker
- Spring Boot i microserveis
- Comunicació entre serveis i tolerància a fallades
Mòdul 8: Desplegament d'aplicacions Spring Boot
- Introducció al desplegament
- Desplegant a Heroku
- Desplegant a AWS
- Desplegant a Kubernetes
- Integració i lliurament continus
Mòdul 9: Rendiment i monitoratge
- Ajust de rendiment
- Memòria cau amb Spring Cache
- Monitoratge amb Spring Boot Actuator
- Ús de Prometheus i Grafana
- Gestió de registres i logs
- Traçabilitat distribuïda
