Il codice di un'applicazione si versiona con Git, si rivede, si testa. Lo schema del database, invece, viene ancora spesso modificato a mano, con un comando lanciato direttamente sul server di produzione. Il risultato è che nessuno sa con certezza quale struttura abbia ciascun ambiente, e che un aggiornamento può fallire perché in produzione manca una colonna presente nell'ambiente di sviluppo. Le migrazioni risolvono questo problema trattando le modifiche allo schema come codice.
Che cos'è una migrazione
Una migrazione è un file che descrive un singolo cambiamento allo schema: creare una tabella, aggiungere una colonna, creare un indice. I file sono numerati o datati, e vengono applicati in ordine:
migrazioni/
0001_crea_clienti.sql
0002_crea_ordini.sql
0003_aggiungi_telefono_clienti.sql
0004_indice_ordini_data.sql
Il database tiene una tabella di servizio in cui registra quali migrazioni sono già state applicate. Quando si esegue lo strumento di migrazione, questo confronta l'elenco dei file con la tabella e applica solo quelle mancanti. Così ogni ambiente, dallo sviluppo alla produzione, arriva allo stesso schema seguendo gli stessi passi.
Quasi tutti i framework web ne includono uno, e esistono strumenti indipendenti dal linguaggio come Flyway o Liquibase. Il principio è sempre lo stesso.
Le regole di base
- Una migrazione già applicata non si modifica. Se contiene un errore, si scrive una nuova migrazione che lo corregge. Modificare un file già eseguito altrove crea ambienti diversi con la stessa «versione».
- Le migrazioni stanno nel repository, insieme al codice che dipende da esse, e passano dalla revisione come qualsiasi altra modifica.
- Ogni migrazione fa una cosa sola, così è facile capire che cosa cambia e risalire al problema in caso di errore.
- Si provano su una copia realistica dei dati di produzione, non solo su un database vuoto: un'operazione istantanea su cento righe può richiedere ore su cento milioni.
Il problema delle modifiche in produzione
Quando l'applicazione è in uso continuo, una migrazione deve tenere conto di due fattori.
I blocchi sulle tabelle
Alcune operazioni bloccano la tabella per tutta la loro durata. A seconda del database e della versione, aggiungere una colonna con un valore predefinito, cambiare il tipo di una colonna o creare un indice possono impedire letture o scritture finché non terminano. Su una tabella grande, questo significa un'applicazione ferma.
Molti database offrono varianti non bloccanti. In PostgreSQL, per esempio, un indice si può creare senza bloccare le scritture:
CREATE INDEX CONCURRENTLY idx_ordini_data ON ordini (data_ordine);
È più lento e non può essere eseguito dentro una transazione, ma l'applicazione continua a lavorare. Prima di eseguire una migrazione su una tabella grande va sempre verificato nella documentazione del proprio database quale tipo di blocco richiede.
Codice vecchio e codice nuovo insieme
Durante un rilascio, per qualche tempo convivono la versione precedente dell'applicazione e quella nuova. Se la migrazione rinomina una colonna, la versione precedente smette di funzionare nel momento in cui la migrazione viene applicata.
Espandere e poi contrarre
La tecnica più usata per modifiche senza interruzione si chiama expand and contract: prima si espande lo schema in modo compatibile con entrambe le versioni, poi, quando la versione vecchia non è più in uso, lo si contrae.
Esempio: rinominare la colonna telefono in cellulare.
| Passo | Azione | Compatibilità |
|---|---|---|
| 1 | Aggiungere la colonna cellulare, facoltativa |
Il codice vecchio la ignora |
| 2 | Rilasciare codice che scrive in entrambe le colonne | Vecchio e nuovo funzionano |
| 3 | Copiare i dati storici da telefono a cellulare, a blocchi |
Nessun impatto |
| 4 | Rilasciare codice che legge e scrive solo cellulare |
La colonna vecchia non serve più |
| 5 | Eliminare la colonna telefono |
Contrazione completata |
Sono cinque passi invece di uno, ma in nessun momento l'applicazione si ferma o perde dati.
Copiare i dati a blocchi
Un singolo UPDATE su milioni di righe crea una transazione enorme, mantiene blocchi a lungo e genera un grande volume di registri. Meglio procedere a blocchi:
UPDATE clienti
SET cellulare = telefono
WHERE id BETWEEN 1 AND 10000 AND cellulare IS NULL;
ripetendo l'istruzione per gli intervalli successivi, con una breve pausa tra un blocco e l'altro.
E il ritorno indietro?
Molti strumenti permettono di scrivere per ogni migrazione anche l'operazione inversa. È utile in sviluppo, ma in produzione va usata con cautela: l'inverso di «elimina colonna» non può ripristinare i dati cancellati. Spesso la strategia più sicura è andare avanti, con una nuova migrazione correttiva, e fare un backup prima di ogni migrazione distruttiva.
In sintesi
- Le migrazioni trattano le modifiche allo schema come codice versionato e ordinato.
- Una migrazione applicata non si modifica: si corregge con una nuova.
- Su tabelle grandi vanno verificati i blocchi e usate le varianti non bloccanti.
- Rinomine e cambi di tipo si fanno con la tecnica espandere e poi contrarre.
- I dati si copiano a blocchi e le migrazioni distruttive si precedono con un backup.