backend

Liquibase: ce este, cum gestionezi schema bazei de date corect

Liquibase este unealta prin care ții schema bazei de date sub control de versiune, alături de cod. Cum funcționează, ce capcane evită, cum o folosim noi.

Cuprins

Liquibase este unealta prin care ții schema bazei de date sub control de versiune, exact ca și codul: în fișiere de tip changelog, comise în git, aplicate deterministic în toate mediile. Înlocuiește scriptele SQL ad-hoc care duc inevitabil la schema-drift între dev, staging și producție. Pentru orice proiect cu mai mult de un dezvoltator sau cu mai mult de un mediu, Liquibase (sau alternativa Flyway) nu este opțional, este disciplina de bază.

Proiectul este open-source, dezvoltat din 2006, în Java; suportă toate bazele relaționale majore (Postgres, MySQL, Oracle, SQL Server, SQLite) și se integrează cu Spring Boot, Maven, Gradle, GitLab CI. La crawlerra-backend folosim versiunea 4.24, livrată ca dependență tranzitivă de Spring Boot 3.2.

Ce este Liquibase mai exact?

O bibliotecă Java care citește un fișier changelog (XML, YAML, JSON sau SQL), îl compară cu istoricul aplicat în baza de date (tabela DATABASECHANGELOG), și aplică numai changeset-urile noi. Procesul este idempotent: poți rula Liquibase de o mie de ori, dacă nimic nu s-a schimbat, nu face nimic.

Conceptele de bază:

  • Changelog: fișierul-rădăcină care listează toate migrațiile în ordinea aplicării. Pe crawlerra: src/main/resources/db/changelog/db.changelog-master.xml.
  • Changeset: o singură migrație, cu identificator unic (id + author), una sau mai multe operații (create table, add column, custom SQL). Aplicat o singură dată; imutabil după aplicare.
  • DATABASECHANGELOG: tabela ținută de Liquibase în baza de date, cu istoricul changeset-urilor aplicate (id, author, checksum, dataTime). Modificată automat de Liquibase, niciodată direct de tine.
  • Rollback: instrucțiunile pentru a anula un changeset. Pentru DDL simplu, generate automat. Pentru data migration, scrise explicit.

Cum funcționează un changeset?

Un changeset XML clasic arată așa:

<changeSet id="001-create-article" author="crawlerra">
    <sql splitStatements="false"><![CDATA[
        CREATE TABLE article (
            id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
            slug VARCHAR(200) NOT NULL UNIQUE,
            ...
        );
    ]]></sql>
</changeSet>

Fluxul la boot:

  • Liquibase citește changelog-ul și iterează prin toate changeset-urile.
  • Pentru fiecare, calculează checksum-ul fișierului și compară cu cel din DATABASECHANGELOG.
  • Dacă changeset-ul nu există în tabelă: îl aplică, inserează rândul. Dacă există cu același checksum: îl sare. Dacă există cu checksum diferit: aruncă eroare („checksum mismatch") și nu pornește.
  • Toate operațiile dintr-un changeset sunt o singură tranzacție; dacă o operație eșuează, întregul changeset se anulează.

Detaliul critic este checksum-ul: orice modificare a changeset-ului după ce a fost aplicat (chiar și un spațiu sau o virgulă) duce la mismatch și boot-failure. Asta este intenționat; e protecția împotriva schemei-drift.

Care sunt capcanele frecvente?

  • Modificarea unui changeset deja aplicat. Vrei să schimbi o coloană dintr-un changeset vechi. Modifici fișierul. La următorul boot: checksum mismatch, aplicația nu pornește. Soluția corectă: scrii un changeset NOU care face modificarea. Nu atinge istoricul.
  • XSD version mismatch. Spring Boot 3.2 ține Liquibase 4.24, care are XSD-ul dbchangelog-4.24.xsd. Dacă referi dbchangelog-4.27.xsd din copy-paste, fail la boot cu secureParsing. Pinned la versiunea exactă a Liquibase din pom.xml1.
  • Rollback lipsă pentru data migration. Faci un changeset care șterge o coloană pe care credeai că n-o mai folosește nimeni. Pe staging merge perfect. Pe producție, datele din acea coloană erau folosite de un alt serviciu și acum trebuie repuse. Fără bloc <rollback>, restaurarea durează ore. Întotdeauna scrie rollback-ul, chiar dacă „nu vei avea nevoie de el".
  • Migrații cu impact asupra performanței. ALTER TABLE foo ADD COLUMN bar INTEGER NOT NULL DEFAULT 0 pe o tabelă de 50M de rânduri blochează producția zece minute. Folosește migrații în pași: adaugă coloana cu NULL, backfill în loturi, apoi adaugă constraint-ul NOT NULL.
  • customChange fără mecanism de retry. Java customChange (cum folosim pentru seed-ul de admin) trebuie să fie idempotent. Pe crawlerra-backend, SeedAdminChange.java folosește ON CONFLICT DO NOTHING ca să poată rula de mai multe ori fără erori.

Cum folosim Liquibase la crawlerra?

Backendul nostru are 14 changeset-uri în producție, organizate cronologic în src/main/resources/db/changelog/changes/001-create-article.xml până la 014-create-ticket-attachment.xml, plus un customChange Java (009-seed-admin) care folosește SeedAdminChange.java pentru a aplica hash bcrypt parolei admin-ului. Master-ul (db.changelog-master.xml) include explicit toate fișierele în ordinea de aplicare; nu folosim includeAll ca să evităm ambiguitatea ordinii lexicografice.

Spring Boot rulează Liquibase automat la boot, înainte de inițializarea JPA. Postgres-ul nostru este configurat cu ddl-auto: validate, ceea ce înseamnă că JPA nu modifică niciodată schema; Liquibase este singura sursă de schimbări. Asta oferă protecție dublă împotriva drift-ului: dacă o entitate JPA nu se potrivește cu schema generată de Liquibase, aplicația refuză să pornească, ceea ce e mult mai bun decât o nealiniere descoperită târziu în producție.

Pentru tabela refresh_token (folosită de JWT-urile noastre), schema este în 008-create-refresh-token.xml cu indici dedicați pentru lookup-uri pe token_id și pentru detectarea reutilizării prin parent_token_id. Detalii despre principiul „măsurăm tot" care ne face să verificăm schema înainte de fiecare deploy sunt în articolul nostru editorial.

Cum verifici că migrațiile sunt sigure?

Trei verificări înainte de orice merge pe main. Local + Testcontainers: testele de integrare pornesc Postgres în container, aplică Liquibase, validează schema cu JPA. Dacă tot ce face e ./mvnw verify green, schema este consistentă; dacă e roșu cu „schema validation failed", entity-urile JPA nu se potrivesc cu changelog-ul.

Dry-run pe staging: rulează Liquibase cu updateSQL în loc de update, generând SQL-ul fără să-l execute. Citește output-ul, asigură-te că nu apar surprize (recreare de tabelă, lock-uri lungi, modificări destructive nepreconizate). Pentru schimbări mari, fă asta și pe producție cu un dump recent.

Monitorizare după deploy: după aplicarea unei migrații, monitorizează baza de date. Locks lungi, conexiuni blocate, query-uri brusc lente sunt semnale că migrația a făcut mai mult decât crezi. Alertele de pe pool-ul de conexiuni Postgres trebuie să fie acoperite în stack-ul de observabilitate; impactul migrațiilor pe SLA este real, deși invizibil când totul merge bine.

  1. Pin-ul Liquibase 4.24 vine de la Spring Boot 3.2 BOM; orice referință la versiuni mai noi în XSD trebuie evitată. [crawlerra.liquibase_version]

Întrebări frecvente

Liquibase vs Flyway, care e alegerea bună?

Liquibase pentru migrații complexe + rollback automat, Flyway pentru simplitate SQL pură. Flyway are sintaxă mai simplă (doar SQL numerotate), Liquibase suportă XML / YAML / JSON + DSL pentru rollback automat, customChange Java și constraint-uri exprimate semantic. Pentru un Spring Boot mic, Flyway e suficient; pentru un proiect cu integrări complexe sau cu echipă care vrea diff-uri semantice, Liquibase câștigă.

Pot folosi SQL pur cu Liquibase?

Da, prin tag-ul <sql> sau prin fișiere .sql referite din changelog. Nu trebuie să accepți DSL-ul Liquibase peste tot; majoritatea proiectelor combină <createTable> pentru schema clară cu <sql> pentru operații specifice motorului (index parțial, JSONB, funcții). Important e ca fișierul de changelog să rămână declarativ și deterministic.

Pot șterge un changeset după ce l-am aplicat?

Nu, niciodată. Liquibase ține o tabelă DATABASECHANGELOG cu istoricul aplicat. Dacă ștergi un changeset, la următorul boot Liquibase nu îl va găsi în fișiere și va da eroare („checksum mismatch" sau „missing changeset"). Dacă vrei să retragi un schimb, scrii un changeset NOU care îl inversează. Imutabilitatea istoricului e principala disciplină Liquibase.

Cum dau rollback unei migrații?

Cu liquibase rollbackCount 1, dacă changeset-ul are bloc <rollback> definit. Pentru DDL simplu (create table, add column), Liquibase generează automat rollback-ul. Pentru operații complexe (data migration, schimbare tip coloană), trebuie să scrii rollback-ul explicit. Recomandare: scrie-l mereu; data migration fără rollback înseamnă „pe producție nu putem da înapoi".

Liquibase rulează doar la boot-ul aplicației?

Nu, poate rula și standalone (CLI, Maven goal, GitLab CI step). Spring Boot îl rulează automat la pornire prin spring-boot-starter-liquibase, ceea ce e convenabil pentru dev. În producție, mulți preferă să-l ruleze ca pas separat în CI/CD: build, apply migrations, deploy. Asta face deploy-ul mai predictibil și permite review explicit al schimbărilor de schema.