-- Pasul 1: Adaugă coloana fără lock masiv
ALTER TABLE users ADD COLUMN full_name_v2 VARCHAR(255);
-- Pasul 4 (după backfill & flip): Adaugă NOT NULL safe în Postgres
-- Fără a bloca tabelul la scanare completă
ALTER TABLE users
ADD CONSTRAINT check_full_name_not_null
CHECK (full_name_v2 IS NOT NULL) NOT VALID;
-- Validarea se face cu SHARE UPDATE EXCLUSIVE lock (nu blochează scrierile)
ALTER TABLE users
VALIDATE CONSTRAINT check_full_name_not_null;
-- Cleanup final
ALTER TABLE users DROP COLUMN old_name;Prin 2017 am reușit performanța să pun în cap o producție cu vreo 18 milioane de rânduri. Am rulat un ALTER TABLE orders ADD COLUMN status_v2 VARCHAR NOT NULL DEFAULT 'pending' direct într-o migrare clasică de Django. În 30 de secunde, pool-ul de conexiuni Postgres era la 100%, healthcheck-urile picau pe capete, iar aplicația a fost indisponibilă vreo 12 minute până când am omorât tranzacția.
Dacă ai tabele mici, nu-ți bate capul. Dar când treci de câteva milioane de înregistrări sau ai trafic susținut de câteva mii de request-uri pe secundă, orice migrare destructivă te taxează.
De ce crapă baza de date?
Problema nu e neapărat mărimea discului, ci nivelul de lock. O modificare structurală cere de obicei AccessExclusiveLock. Chiar dacă operațiunea în sine ar dura 200ms în mod ideal, dacă prinzi o tranzacție lungă de citire deschisă în acel moment, migrarea ta se pune la coadă. În spatele ei se blochează instant toate celelalte query-uri, iar aplicația crapă prin timeout de conexiuni.
Soluția pe care o folosesc de atunci este pattern-ul Expand/Contract (sau Parallel Change), împărțit în 4 etape distincte.
Cei 4 pași: Add, Backfill, Flip, Remove
1. Add (Expand)
Adaugi coloana nouă ca fiind NULLABLE, fără valori default calculate la nivel de tabel și fără constrângeri stricte (de tip CHECK sau FOREIGN KEY validate pe loc).
În Postgres 11+, un default static pe o coloană nouă nu mai rescrie tabelul, dar dacă vrei să transformi tipul de date sau să spargi un câmp (ex: name în first_name și last_name), ai nevoie de o coloană nouă curată.
2. Backfill & Dual-write
Faci deploy la codul de aplicație care începe să scrie în ambele coloane: cea veche și cea nouă. Totuși, aplicația citește în continuare din coloana veche.
În paralel, pornești un worker de backfill care migrează datele vechi în loturi mici (de exemplu, batch-uri de 2.000 - 5.000 de rânduri, cu un sleep de 50-100ms între ele). Niciodată să nu faci UPDATE tabel SET col_noua = col_veche dintr-un foc, fiindcă vei sugruma IOPS-ul și vei distruge replicarea către read-replicas din cauza WAL lag-ului.
3. Flip
După ce worker-ul a terminat și ai verificat cu un query de comparație că ambele coloane sunt sincronizate 1:1, schimbi codul aplicației:
- Citești exclusiv din coloana nouă.
- Continui să scrii în coloana nouă.
- Oprești scrierea în coloana veche.
Lași pasul ăsta în producție măcar 24-48 de ore. Dacă apare vreun bug logic, poți oricând să dai rollback la citire pe coloana veche.
4. Remove (Contract)
După ce totul e stabil, adaugi constrângerea de NOT NULL (dacă e nevoie, folosind NOT VALID urmat de VALIDATE CONSTRAINT asincron) și faci drop la coloana veche printr-o ultimă migrare.
Trade-off-ul sincer
Metoda asta e plictisitoare și frustrantă pentru cine vrea să livreze repede. În loc de 5 minute și o singură migrare în PR, ai nevoie de minim 3 deploy-uri separate, monitorizare pe WAL lag și 2-3 zile de atenție.
Merge impecabil când ai tabele mari și SLA strict, dar e un overhead masiv dacă lucrezi la un MVP cu 10k useri unde un downtime de 3 secunde noaptea la ora 03:00 nu deranjează pe nimeni.
Voi cum gestionați migrările grele? Mergeți pe cod la nivel de app sau preferați triggere temporare în baza de date pentru sincronizare?