async function backfillPhoneNumbers(batchSize = 2000) {
let lastId = 0;
let totalUpdated = 0;
while (true) {
// Batch update folosind keyset pagination pentru performanță maximă
const result = await db.query(
`WITH target_rows AS (
SELECT id, phone FROM users
WHERE id > $1 AND phone_number IS NULL
ORDER BY id ASC
LIMIT $2
)
UPDATE users u
SET phone_number = tr.phone
FROM target_rows tr
WHERE u.id = tr.id
RETURNING u.id`,
[lastId, batchSize]
);
if (result.rows.length === 0) break;
lastId = result.rows[result.rows.length - 1].id;
totalUpdated += result.rows.length;
// Throttling: lăsăm DB-ul să respire între batch-uri
await new Promise((resolve) => setTimeout(resolve, 100));
}
console.log(`Backfill complet. Total rânduri actualizate: ${totalUpdated}`);
}Dacă ai încercat vreodată să schimbi structura unui tabel cu zeci de milioane de rânduri în producție fără să oprești traficul, știi sentimentul de sudoare rece. Un simplu ALTER TABLE sau un UPDATE masiv îți poate bloca baza de date și-ți dă jos aplicația în câteva secunde. Am pățit-o acum vreo 5 ani când am blocat un Postgres 10 minute pentru că am crezut că un implicit default pe o coloană nouă "merge repede". De atunci, folosesc exclusiv pattern-ul în 4 pași (numit și Expand and Contract): Add, Backfill, Flip, Remove.
Am aplicat asta recent când am schimbat un câmp phone dintr-un VARCHAR vechi într-un format normalizat phone_number pe un DB cu 18 milioane de rânduri și ~2.5k req/sec. Latența pe p99 a crescut cu doar 3ms pe durata întregului proces.
Pasul 1: Add (Expand)
Prima regulă când faci o migrare fără downtime este că aplicația ta trebuie să fie compatibilă cu ambele versiuni ale schemei în același timp.
Adăugăm coloana nouă în DB, dar o lăsăm NULLABLE (sau cu DEFAULT fără lock de tabel, feature nativ în Postgres 11+). În codul aplicației, facem deploy la un release care scrie în AMBELE coloane (old + new), dar citește în continuare exclusiv din coloana veche.
Trade-off sincer: Dublezi I/O-ul pe scrieri pentru o perioadă, dar baza de date rămâne rapidă și nu blochezi nicio tranzacție.
Pasul 2: Backfill (Popularea datelor vechi)
Acum ai coloana nouă, iar rândurile create recent au date în ambele locuri. Rândurile vechi au însă NULL pe coloana nouă.
Aici greșesc mulți juniori: dau un UPDATE users SET phone_number = phone WHERE phone_number IS NULL;. Așa omori producția. Tranzacția masivă umple WAL-ul, generează table bloat și ține lock-uri lungi.
Soluția este un script de fundal sau un cron job care procesează în batch-uri mici (ex: 1.000 - 5.000 de rânduri) cu pauze între ele. La cele 18 milioane de rânduri ale noastre, backfill-ul a durat cam 4 ore, rulate noaptea, fără nicio alertă de la SRE.
Pasul 3: Flip (Schimbă sursa de citire)
După ce scriptul de backfill a terminat și verifici că WHERE phone_number IS NULL întoarce 0 rânduri, faci următorul deploy.
Acum aplicația citește din coloana NOUĂ (phone_number). Scrierea se face tot în ambele coloane, pentru că în caz de un rollback neprevăzut, coloana veche este încă la zi și nu pierzi date. Lași sistemul să ruleze așa 24-48 de ore. Dacă apare vreun bug ascuns, poți face revert la cod instant, fără nicio migrare de DB.
Pasul 4: Remove (Contract)
Totul e stabil în producție? Perfect. Tai scrierea în coloana veche din cod și faci deploy.
Abia la final, într-o migrare separată, dai ALTER TABLE users DROP COLUMN phone;. Pe Postgres, DROP COLUMN e instanțeu la nivel de metadata, dar atenție: tot ia un lock scurt de schemă (ACCESS EXCLUSIVE), așa că setează un lock_timeout scurt ca să nu blochezi query-urile care vin în paralel.
E un proces laborios și durează 2-3 zile în loc de 5 minute? Da. Dar dormi liniștit noaptea și ai uptime de 99.99%. Voi ce trucuri folosiți când aveți de modificat tabele mamut?