async function backfillColumn(batchSize = 2500) {
let lastId = 0;
let updatedCount = 0;
while (true) {
const result = await db.query(sql`
WITH batch AS (
SELECT id FROM users
WHERE id > ${lastId} AND new_phone IS NULL
ORDER BY id ASC
LIMIT ${batchSize}
)
UPDATE users u
SET new_phone = phone_number
FROM batch
WHERE u.id = batch.id
RETURNING u.id;
`);
if (result.rows.length === 0) break;
lastId = result.rows[result.rows.length - 1].id;
updatedCount += result.rows.length;
// Pauză mică pentru a nu bloca I/O-ul pe DB
await new Promise((resolve) => setTimeout(resolve, 50));
}
return updatedCount;
}Am văzut prea mulți juniori și mid-level care dau ALTER TABLE RENAME COLUMN direct pe producție și apoi se miră de ce pică API-ul timp de 10 minute. Dacă ai o bază de date mică, îți permiți nebunia asta. Când ajungi la 12 milioane de rânduri și câteva mii de query-uri pe secundă, un lock de câteva secunde înseamnă cascading failures și alerte la 3 dimineața.
Pattern-ul Expand/Contract (sau Parallel Change) e tehnica pe care o folosesc de vreo 6 ani pentru orice refactoring de schemă. Procesul are 4 pași clari și necesită cel puțin două deployment-uri separate de aplicație.
Pasul 1: Add (Expand)
Adaugi coloana nouă în bază, dar o lași nullable sau cu o valoare default non-blocking. Nu pui constrângeri grele încă — un NOT NULL fără default va scana toată tabela și va pune ACCESS EXCLUSIVE lock.
În Postgres 11+, adăugarea unei coloane cu DEFAULT e instantanee pentru că scrie doar în metadata, dar ai grijă la versiunile mai vechi sau la alte motoare de DB.
Pasul 2: Dual Write & Backfill
Aici faci primul deploy de cod. Aplicația începe să scrie în AMBELE coloane (cea veche și cea nouă), dar citește tot din cea veche. Orice record nou sau update va actualiza ambele locuri.
Odată ce codul e live, lansezi un script de backfill în fundal care migrează datele vechi pe noua coloană. Aici e șmecheria: faci backfill-ul în batch-uri mici (2000-5000 de rânduri per tranzacție) cu pauze de câteva milisecunde între ele. La ultimul refactoring am economisit 70% din load-ul de I/O făcând batching inteligent cu WHERE id > last_processed_id în loc de OFFSET.
Pasul 3: Flip
După ce backfill-ul s-a terminat și ai verificat că ambele coloane au date identice, faci al doilea deploy. Schimbi aplicația să citească din coloana NOUĂ.
Păstrezi scrierea duală activă pentru încă 24-48 de ore. De ce? În caz că descoperi vreun bug ciudat în producție, poți face rollback instant la citirea din coloana veche fără pierderi de date.
Pasul 4: Remove (Contract)
Dacă totul e stabil de două zile, faci al treilea deploy: elimini scrierea pe coloana veche. De abia acum rulezi ultimul migration script SQL: adaugi constrângerile NOT NULL (dacă e cazul) folosind CHECK (col IS NOT NULL) NOT VALID urmat de VALIDATE CONSTRAINT ca să eviți table lock-ul, și la final dai DROP COLUMN pe coloana veche.
Trade-off-uri reale
Nimic nu e gratuit în engineering. Trade-off-ul principal e că dublezi timpul de livrare pentru o simplă schimbare de coloană și adaugi ceva boilerplate în codul de aplicație pentru câteva zile. În plus, dacă ai servicii terțe care citesc din DB fără controlul tău, trebuie să le coordonezi pe toate.
Dar la 12M de rânduri, prefer 3 zile de proces controlat decât 15 minute de downtime neplanificat și clienți furioși.
Voi cum gestionați migrările grele? Folosiți instrumente dedicate ca gh-ost / pg_repack sau tot pe pattern-ul ăsta mergeți?