eduardweb.
Database & PrismaAvansat#architecture#devops#database#postgres#sql

Migrări DB fără downtime: Cum modifici 20M de rânduri fără să pici producția

De Radu Grigore, 10 aug. 2026 · 2 vizualizări · 2 like-uri

Postat acum 6 zile
sql
-- Exemplu de backfill procesat în batch-uri mici pentru a evita table lock și replication lag
DO $$
DECLARE
    batch_size INT := 5000;
    rows_updated INT;
BEGIN
    LOOP
        UPDATE users
        SET canonical_email = LOWER(TRIM(email))
        WHERE id IN (
            SELECT id FROM users
            WHERE canonical_email IS NULL
            LIMIT batch_size
            FOR UPDATE SKIP LOCKED
        );

        GET DIAGNOSTICS rows_updated = ROW_COUNT;
        EXIT WHEN rows_updated = 0;

        -- Pauză scurtă pentru a lăsa I/O-ul și replica să respire
        PERFORM pg_sleep(0.1);
    END LOOP;
END $$;

Am picat odată o producție întreagă marți la prânz pentru că am dat un simplu ALTER TABLE users RENAME COLUMN pe un Postgres cu 22 de milioane de rânduri. Table-lock instant, pool-ul de conexiuni plin în 3 secunde și 502-uri pe bandă rulantă. Dacă lucrezi pe baze de date cu trafic real, pattern-ul "Expand and Contract" (add, backfill, flip, remove) e singura cale sănătoasă.

Pasul 1: Add (Coloana nouă, complet permisivă)

Prima regulă când adaugi o coloană pe o masă mare: zero constrângeri blocate. Dacă pui NOT NULL fără un DEFAULT calculat instant, baza de date va rescrie tot tabelul pe disc și va bloca scrierile.

Adaugi coloana ca NULLable. În același deploy, modifici codul aplicației astfel încât orice INSERT sau UPDATE nou să scrie în AMBELE coloane (cea veche și cea nouă). Aplicația continuă să citească EXCLUSIV din coloana veche. Dacă ai microservicii multiple care ating direct tabela, poți pune un trigger SQL temporar pentru dual-write.

Pasul 2: Backfill (Scrii istoricul în batch-uri)

Acum ai datele noi populate doar pentru tranzacțiile curente. Cele 22 de milioane de rânduri vechi au încă NULL pe coloana nouă.

Greșeala clasică e să dai un UPDATE users SET new_col = old_col WHERE new_col IS NULL; direct în consola de psql. Vei omorî I/O-ul, crești replica lag-ul la minute bune și umpli WAL-ul. Faci backfill programatic, în scripturi de fundal, pe batch-uri mici (2.000 - 5.000 de rânduri per tranzacție). Pui o pauză de 100ms între batch-uri. La un proiect recent am migrat 45M de înregistrări în vreo 4 ore, menținând replica lag sub 50ms și CPU-ul bazei la sub 15%.

Pasul 3: Flip (Muți citirea)

Când scriptul de backfill a terminat și nu mai ai rânduri neprocesate, faci următorul deploy.

Acum comuți citirea: codul citește din coloana nouă. Păstrezi încă scrierea dublă pentru siguranță. Lași sistemul să ruleze așa 24-48 de ore. Dacă ceva crapă, rollback-ul înseamnă doar să schimbi un feature flag și să citești iar din coloana veche, fără pierdere de date.

Dacă totul e OK, faci un deploy intermediar în care oprești complet scrierea pe coloana veche.

Pasul 4: Remove (Curățenia finală)

Doar după ce ești 100% sigur că nicio instanță de aplicație nu mai atinge coloana veche, trimiți o migrare ce o șterge (DROP COLUMN old_col). Tot acum adaugi constrângerea NOT NULL pe coloana nouă (folosind NOT VALID și apoi VALIDATE CONSTRAINT în Postgres pentru a evita lock-ul pe citiri).

Trade-off-uri reale

Merge brici pentru uptime de 99.99%, dar vine cu un cost clar. În loc de o singură migrare și un deploy, faci 3-4 deploy-uri separate de cod. Dublezi temporar logica din ORM și, dacă ai o echipă nesincronizată, un coleg poate uita pasul 4 și rămâi cu coloane "fantomă" în schemă.

Voi cum gestionați refactoring-ul de schemă când aveți tabele de zeci de milioane de rânduri? Folosiți triggeri în DB sau gestionați dual-write direct în aplicație?

Răspunsuri 0

Se încarcă răspunsurile…

Loghează-te pentru a răspunde

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