eduardweb.
Database & PrismaAvansat#migrations#database#postgres#sql

Migrări de bază de date fără downtime: Pattern-ul în 4 pași pe care îl folosesc în producție

De Cosmin Rotaru, 1 aug. 2026 · 10 vizualizări · 3 like-uri

Postat 1 aug. 2026
sql
DO $$
DECLARE
    rows_updated INT;
BEGIN
    LOOP
        UPDATE payments
        SET amount_cents = (amount * 100)::BIGINT
        WHERE id IN (
            SELECT id 
            FROM payments 
            WHERE amount_cents IS NULL 
            LIMIT 2000
            FOR UPDATE SKIP LOCKED
        );
        
        GET DIAGNOSTICS rows_updated = ROW_COUNT;
        EXIT WHEN rows_updated = 0;
        
        PERFORM pg_sleep(0.1); -- 100ms pauza intre batch-uri
    END LOOP;
END $$;

Am bubuit o bază de date Postgres de producție prin 2018 când am dat un simplu ALTER TABLE pe o tabelă cu vreo 14 milioane de rânduri, fix la prânz. Baza s-a blocat în lock-uri, conexiunile s-au acumulat în queue și în 30 de secunde tot site-ul era jos. De atunci, pentru orice schemă mare, folosesc doar pattern-ul Expand/Contract în 4 pași: Add, Backfill, Flip, Remove.

Pasul 1: Add column (Expand)

Prima regulă e că nu schimbi direct coloana existentă și nici nu adaugi o coloană cu NOT NULL fără un default gestionat atent. În Postgres 11+ adăugarea unei coloane cu DEFAULT e mai rapidă, dar pe baze masive sau versiuni mai vechi, un ALTER TABLE poate bloca scrierile (Exclusive Lock).

Adaugi coloana nouă ca NULLABLE. Operațiunea asta durează câteva milisecunde pentru că doar modifică metadata tabelei, fără să atingă rândurile pe disc.

Pasul 2: Dual-write & Backfill

Aici începe distracția. Faci deploy la o versiune de cod care scrie în AMBELE coloane (cea veche și cea nouă), dar citește în continuare doar din cea veche. În modul ăsta, toate datele noi create sunt deja valide pe noua structură.

Pentru datele existente (cele 14 milioane de rânduri vechi), rulezi un script de backfill în fundal. Regula de aur la backfill: faci update în batch-uri mici. Dacă încerci un UPDATE table SET new_col = old_col într-o singură interogare, ai omorât din nou instanța.

La un proiect de fintech cu ~8.5 milioane de tranzacții, am rulat un script care făcea update în batch-uri de 2000 de rânduri cu o pauză de 100ms între ele. A durat vreo 3 ore, dar utilizatorii n-au simțit absolut nimic, iar încărcarea pe CPU a rămas sub 18%.

Pasul 3: Flip (Read from new)

Verifici că nu mai ai NULL-uri pe coloana nouă. Când ești 100% sigur că datele vechi sunt migrate și dual-write-ul funcționează corect, faci deploy la un nou PR.

În pasul ăsta, schimbi aplicația să citească din coloana NOUĂ. Menții totuși scrierea dublă încă 24-48 de ore. De ce? Dacă descoperi vreun bug dubios la citire și trebuie să faci rollback rapid la versiunea anterioară de cod, datele din coloana veche sunt încă la zi și nu pierzi nimic.

Pasul 4: Remove (Contract)

După ce ai lăsat sistemul să respire o zi-două și totul e curat în Sentry:

  1. Elimini dual-write-ul din cod (scrii doar în coloana nouă).
  2. Faci deploy.
  3. Ștergi coloana veche din DB printr-o migrare separată (DROP COLUMN).

Trade-off-ul e evident: în loc de o singură migrare și un PR de 10 minute, procesul ăsta necesită 3-4 deploy-uri separate, scripturi de backfill și atenție sporită. E mult mai lent și frustrant când vrei să livrezi repede. Însă când lucrezi pe baze de date mari cu traficul în picioare, e singura opțiune responsabilă.

Voi cum gestionați cazurile astea când folosiți ORM-uri precum Prisma sau TypeORM care încearcă să controleze singure schema?

Răspunsuri 0

Se încarcă răspunsurile…

Loghează-te pentru a răspunde

Doar membrii comunității pot lăsa comentarii.