async function backfillPhoneNumbers() {
const batchSize = 5000;
let lastId = 0;
let hasMore = true;
while (hasMore) {
// Folosim id-ul pentru keyset pagination ca să evităm OFFSET-urile lente
const result = await db.query(
`UPDATE users
SET phone_number = phone
WHERE id IN (
SELECT id FROM users
WHERE id > $1 AND phone_number IS NULL AND phone IS NOT NULL
ORDER BY id ASC
LIMIT $2
)
RETURNING id`,
[lastId, batchSize]
);
const rows = result.rows;
if (rows.length === 0) {
hasMore = false;
} else {
lastId = rows[rows.length - 1].id;
console.log(`Procesat batch până la ID: ${lastId}`);
// Lăsăm DB-ul să respire între batch-uri
await new Promise(resolve => setTimeout(resolve, 200));
}
}
}Toți am fost acolo. Ai o tabelă de utilizatori destul de mare — să zicem peste 5-10 milioane de rânduri. Vine un ticket simplu: trebuie să redenumim coloana phone în phone_number sau să schimbăm tipul unei coloane. Rulezi un ALTER TABLE simplu într-o seară de marți și, brusc, baza de date nu mai răspunde. Aplicația începe să dea 504. De ce? Pentru că baza de date pune un lock exclusiv pe tabelă ca să facă modificarea, iar toate request-urile web stau la coadă până când conexiunile ating timeout-ul.
Am pățit asta acum vreo patru ani la un serviciu de livrări cu vreo 12 milioane de tranzacții în PostgreSQL. Am blocat baza de date timp de 18 minute. De atunci, regula de aur în echipă a devenit clară: orice modificare de schemă pe tabele mari se face prin pattern-ul expand/contract în patru pași.
Cei 4 pași magici: Add, Write-both, Flip, Remove
Ideea de bază e simplă: nu modifici niciodată o coloană existentă direct. În schimb, creezi una nouă și muți datele treptat.
1. Add (Adăugarea coloanei noi)
Rulezi o migrare care doar adaugă noua coloană. Regula de aur aici: coloana nouă trebuie să fie neapărat nullable. Dacă pui NOT NULL din prima, migrarea va eșua sau va bloca tabela pentru că baza de date trebuie să scrie o valoare default pentru toate rândurile existente în timp ce ține lock-ul.
2. Dual Write și Backfill
Acesta este pasul unde mulți se grăbesc și o sfeclesc. În faza asta, modifici codul aplicației astfel încât:
- Să citească în continuare din coloana veche (
phone). - Să scrie orice update sau insert nou în ambele coloane (
phoneșiphone_number).
După ce ai făcut deploy la codul de dual-write, pornești un script de backfill. Acesta ia datele vechi și le copiază în coloana nouă, în background. Secretul e să faci asta în batch-uri mici și cu o mică pauză (sleep 100-200ms) între ele, ca să nu blochezi IOPS-ul bazei de date.
3. Flip (Comutarea citirilor)
După ce scriptul de backfill s-a terminat și ai verificat că datele coincid în ambele coloane, faci următorul deploy. Modifici aplicația să citească acum exclusiv din noua coloană (phone_number), dar continui să scrii în ambele.
De ce menținem scrierea dublă? E plasa noastră de siguranță. Dacă descoperi un bug în producție după flip, poți face rollback instant la codul vechi fără să pierzi nicio dată salvată în acest interval.
4. Remove (Curățenia de după petrecere)
Dacă totul e stabil timp de câteva zile, poți să respiri ușurat. Faci ultimul deploy: elimini scrierea dublă din cod, apoi rulezi o migrare care șterge coloana veche (phone). Tabela ta e curată, iar utilizatorii n-au simțit absolut nimic.
Trade-off-ul sincer: Siguranță contra timp
Hai să fim realiști. Metoda asta merge excelent pentru siguranță și uptime de 99.99%, dar e extrem de enervantă ca proces de dezvoltare. Ce făceai înainte într-un singur query de 5 secunde acum îți ia cel puțin 3 deployment-uri separate, un script de migrare scris manual și monitorizare atentă.
La tabele mici (sub 100k rânduri), este overkill total și doar pierzi timp. Dar când baza de date e inima business-ului și fiecare secundă de downtime costă mii de euro, merită fiecare linie de cod în plus.
Voi cum gestionați migrările astea sensibile? Ați încercat instrumente automate ca gh-ost sau preferați să controlați totul direct din codul aplicației?