async function updateUserPhone(userId: string, newPhone: string): Promise<void> {
// Pasul 2: Double write activat în repository
await db.transaction(async (tx) => {
await tx.execute(
`UPDATE users
SET phone = $1, -- coloana veche
phone_number = $1 -- coloana noua (expand)
WHERE id = $2`,
[newPhone, userId]
);
});
}Să faci modificări de schemă pe o bază de date de producție cu trafic mare e ca și cum ai schimba roata la mașină în timp ce mergi cu 120 km/h pe autostradă. Am aplicat pattern-ul de migrare în 4 pași pe o tabelă cu 12 milioane de utilizatori și am scăpat fără nicio secundă de downtime. Dacă vrei să eviți lock-urile care îți blochează complet API-ul, asta e singura cale sigură.
De ce crapă migrările clasice?
Clasicul ALTER TABLE users RENAME COLUMN phone TO phone_number funcționează de minune când ai 100 de useri în baza de date. Când ai milioane de rânduri și sute de query-uri pe secundă, un astfel de query blochează tabela (table lock). Aplicația va încerca să scrie, conexiunile se vor acumula în pool și, în câteva secunde, ai un frumos ecran de 504 Gateway Timeout.
Pentru a evita asta, folosim pattern-ul Expand/Contract (sau Blue/Green database schema). Da, durează mai mult și necesită disciplină, dar dormi liniștit noaptea.
Cei 4 pași ai migrației
1. Expand (Adăugarea noii coloane)
Primul pas este să adaugi noua coloană, dar o pui ca nullable (chiar dacă în final vrei să fie NOT NULL). Codul tău vechi încă rulează în producție și habar n-are de noua coloană. Dacă ai pune NOT NULL direct din migrare fără un default, toate insert-urile din aplicație ar crăpa instantaneu pentru că versiunea curentă de cod nu trimite acea valoare.
2. Double Write și Backfill (Sincronizarea datelor)
Aici e magia. Faci deploy la o versiune de cod care face două lucruri:
- Citește în continuare din coloana veche (
phone). - Scrie (insert/update) în ambele coloane (
phoneșiphone_number).
Odată ce acest cod e live, toate înregistrările noi vor fi corecte în ambele locuri. Acum e momentul să aduci la zi datele vechi. Rulezi un script de backfill. Am pățit la începuturi să dau un UPDATE users SET phone_number = phone direct în consolă pe o tabelă mare. Baza de date a intrat în 100% CPU și a blocat tot.
Lecția învățată: backfill-ul se face mereu în batch-uri mici (de exemplu, câte 2000-5000 de rânduri o dată) cu o mică pauză de câteva milisecunde între ele ca să lași baza de date să respire.
3. Flip (Mutarea citirii)
Acum că ai toate datele sincronizate, faci un nou deploy. De data asta, aplicația va citi exclusiv din noua coloană (phone_number) și va scrie tot în ea.
Un sfat de aur: lasă scrierea dublă activă încă 24-48 de ore. Dacă descoperi vreun bug critic în noul cod și trebuie să faci rollback la versiunea anterioară, datele tale rămân intacte în ambele coloane. Dacă oprești scrierea în cea veche prematur și ești nevoit să dai rollback, vei pierde toate datele salvate în acel interval.
4. Contract (Curățenia)
După ce ești 100% sigur că totul e stabil, scoți codul de scriere dublă din aplicație. Acum poți să adaugi constrângerea de NOT NULL pe noua coloană (folosind VALIDATE CONSTRAINT în PostgreSQL ca să eviți lock-ul lung pe tabelă) și, în final, rulezi migrarea care șterge vechea coloană phone.
Trade-off-ul sincer
Merge excepțional pentru uptime de 99.99%, dar procesul este anevoios. Ai nevoie de cel puțin două sau trei deploy-uri de cod separate și trebuie să scrii logică temporară de double-write. Dacă ai o tabelă mică sau un serviciu intern necritic, nu te complica. Dă-i un downtime de 2 minute duminică noaptea și ai terminat.
Voi cum gestionați migrările astea dureroase? Folosiți instrumente automate la nivel de ORM sau scrieți logica manual în aplicație?