Migrazioni del database: trasformare i bruchi in farfalle
Pubblicato: 2022-03-11Agli utenti non importa cosa c'è dentro il software che usano; solo che funziona in modo fluido, sicuro e discreto. Gli sviluppatori si impegnano affinché ciò accada e uno dei problemi che cercano di risolvere è come garantire che l'archivio dati sia in uno stato appropriato per la versione corrente del prodotto. Il software si evolve e il suo modello di dati può anche cambiare nel tempo, ad esempio per correggere errori di progettazione. Per complicare ulteriormente il problema, potresti avere diversi ambienti di test o clienti che migrano a versioni più recenti del prodotto a ritmi diversi. Non puoi semplicemente documentare la struttura del negozio e quali manipolazioni sono necessarie per utilizzare la nuova versione brillante da un'unica prospettiva.
Una volta ho aderito a un progetto con alcuni database con strutture che venivano aggiornate su richiesta, direttamente dagli sviluppatori. Ciò significava che non c'era un modo ovvio per scoprire quali modifiche dovevano essere applicate per migrare la struttura all'ultima versione e non c'era affatto il concetto di controllo delle versioni! Questo era durante l'era pre-DevOps e al giorno d'oggi sarebbe considerato un disastro totale. Abbiamo deciso di sviluppare uno strumento che sarebbe stato utilizzato per applicare ogni modifica al database specificato. Aveva migrazioni e avrebbe documentato le modifiche allo schema. Questo ci ha reso fiduciosi che non ci sarebbero state modifiche accidentali e lo stato dello schema sarebbe stato prevedibile.
In questo articolo, daremo un'occhiata a come applicare le migrazioni di schemi di database relazionali e come superare i problemi concomitanti.
Innanzitutto, cosa sono le migrazioni di database? Nel contesto di questo articolo, una migrazione è un insieme di modifiche da applicare a un database. La creazione o l'eliminazione di una tabella, colonna o indice sono esempi comuni di migrazioni. La forma del tuo schema potrebbe cambiare drasticamente nel tempo, soprattutto se lo sviluppo è stato avviato quando i requisiti erano ancora vaghi. Quindi, nel corso di diverse pietre miliari sulla strada per un rilascio, il tuo modello di dati si sarà evoluto e potrebbe essere diventato completamente diverso da quello che era all'inizio. Le migrazioni sono solo passaggi verso lo stato di destinazione.
Per iniziare, esploriamo ciò che abbiamo nella nostra cassetta degli attrezzi per evitare di reinventare ciò che è già stato fatto bene.
Utensili
In ogni linguaggio ampiamente utilizzato, ci sono librerie che aiutano a semplificare le migrazioni di database. Ad esempio, nel caso di Java, le opzioni popolari sono Liquibase e Flyway. Useremo più Liquibase negli esempi, ma i concetti si applicano ad altre soluzioni e non sono legati a Liquibase.
Perché preoccuparsi di utilizzare una libreria di migrazione dello schema separata se alcuni ORM forniscono già un'opzione per aggiornare automaticamente uno schema e farlo corrispondere alla struttura delle classi mappate? In pratica, tali migrazioni automatiche eseguono solo semplici modifiche allo schema, ad esempio la creazione di tabelle e colonne, e non possono eseguire operazioni potenzialmente distruttive come eliminare o rinominare gli oggetti del database. Quindi le soluzioni non automatiche (ma comunque automatizzate) sono solitamente una scelta migliore perché sei costretto a descrivere tu stesso la logica di migrazione e sai cosa accadrà esattamente al tuo database.
È anche una pessima idea combinare modifiche automatiche e manuali dello schema perché potresti produrre schemi unici e imprevedibili se le modifiche manuali vengono applicate nell'ordine sbagliato o non vengono applicate affatto, anche se sono necessarie. Una volta scelto lo strumento, utilizzalo per applicare tutte le migrazioni dello schema.
Tipiche migrazioni di database
Le migrazioni tipiche includono la creazione di sequenze, tabelle, colonne, chiavi primarie ed esterne, indici e altri oggetti di database. Per i tipi più comuni di modifiche, Liquibase fornisce elementi dichiarativi distinti per descrivere cosa dovrebbe essere fatto. Sarebbe troppo noioso leggere di ogni banale cambiamento supportato da Liquibase o altri strumenti simili. Per avere un'idea dell'aspetto dei set di modifiche, considera il seguente esempio in cui creiamo una tabella (le dichiarazioni dello spazio dei nomi XML sono omesse per brevità):
<?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog> <changeSet author="demo"> <createTable tableName="PRODUCT"> <column name="ID" type="BIGINT"> <constraints primaryKey="true" primaryKeyName="PK_PRODUCT"/> </column> <column name="CODE" type="VARCHAR(50)"> <constraints nullable="false" unique="true" uniqueConstraintName="UC_PRODUCT_CODE"/> </column> </createTable> </changeSet> </databaseChangeLog> Come puoi vedere, il log delle modifiche è un insieme di insiemi di modifiche e gli insiemi di modifiche sono costituiti da modifiche. Semplici modifiche come createTable possono essere combinate per implementare migrazioni più complesse; ad esempio, supponiamo di dover aggiornare il codice prodotto per tutti i prodotti. Può essere facilmente ottenuto con la seguente modifica:
<sql>UPDATE product SET code = 'new_' || code</sql>Le prestazioni ne risentiranno se hai miliardi di prodotti. Per velocizzare la migrazione, possiamo riscriverla nei seguenti passaggi:
- Crea una nuova tabella per i prodotti con
createTable, proprio come abbiamo visto in precedenza. In questa fase, è meglio creare il minor numero di vincoli possibile. Diamo un nome alla nuova tabellaPRODUCT_TMP. - Popolare
PRODUCT_TMPcon SQL sotto forma diINSERT INTO ... SELECT ...usandosqlchange. - Crea tutti i vincoli (
addNotNullConstraint,addUniqueConstraint,addForeignKeyConstraint) e gli indici (createIndex) di cui hai bisogno. - Rinomina la tabella
PRODUCTin qualcosa di simile aPRODUCT_BAK. Liquibase può farlo conrenameTable. - Rinomina
PRODUCT_TMPinPRODUCT(di nuovo, usandorenameTable). - Facoltativamente, rimuovi
PRODUCT_BAKcondropTable.
Certo, è meglio evitare tali migrazioni, ma è bene sapere come implementarle nel caso in cui ti imbatti in uno di quei rari casi in cui ne hai bisogno.
Se ritieni che XML, JSON o YAML siano troppo bizzarri per il compito di descrivere le modifiche, usa semplicemente SQL e utilizza tutte le funzionalità specifiche del fornitore di database. Inoltre, puoi implementare qualsiasi logica personalizzata in Java semplice.
Il modo in cui Liquibase ti esenta dalla scrittura di un SQL specifico per il database può portare a un'eccessiva sicurezza, ma non dovresti dimenticare le stranezze del tuo database di destinazione; ad esempio, quando si crea una chiave esterna, un indice può essere creato o meno, a seconda del sistema di gestione del database specifico utilizzato. Di conseguenza, potresti trovarti in una situazione imbarazzante. Liquibase consente di specificare che un changeset deve essere eseguito solo per un particolare tipo di database, ad esempio PostgreSQL, Oracle o MySQL. Ciò rende possibile l'utilizzo degli stessi set di modifiche indipendenti dal fornitore per database diversi e per altri set di modifiche, utilizzando la sintassi e le funzionalità specifiche del fornitore. Il seguente set di modifiche verrà eseguito solo se si utilizza un database Oracle:
<changeSet dbms="oracle" author="..."> ... </changeSet>Oltre a Oracle, Liquibase supporta alcuni altri database pronti all'uso.
Denominazione degli oggetti del database
Ogni oggetto di database che crei deve essere nominato. Non è necessario fornire esplicitamente un nome per alcuni tipi di oggetti, ad esempio per vincoli e indici. Ma non significa che quegli oggetti non avranno nomi; i loro nomi verranno comunque generati dal database. Il problema sorge quando è necessario fare riferimento a quell'oggetto per eliminarlo o modificarlo. Quindi è meglio dare loro nomi espliciti. Ma ci sono delle regole sui nomi da dare? La risposta è breve: sii coerente; ad esempio, se hai deciso di nominare indici come questo: IDX_<table>_<columns> , allora un indice per la sopracitata colonna CODE dovrebbe essere chiamato IDX_PRODUCT_CODE .
Le convenzioni di denominazione sono incredibilmente controverse, quindi non abbiamo la presunzione di fornire istruzioni complete qui. Sii coerente, rispetta le convenzioni del tuo team o del progetto o semplicemente inventale se non ce ne sono.
Organizzare i Changeset
La prima cosa da decidere è dove archiviare i changeset. Ci sono fondamentalmente due approcci:
- Conserva i set di modifiche con il codice dell'applicazione. È conveniente farlo perché è possibile eseguire il commit e la revisione dei set di modifiche e del codice dell'applicazione insieme.
- Mantieni i set di modifiche e il codice dell'applicazione separati , ad esempio in repository VCS separati. Questo approccio è adatto quando il modello di dati è condiviso tra più applicazioni ed è più conveniente archiviare tutti i set di modifiche in un repository dedicato e non disperderli su più repository in cui risiede il codice dell'applicazione.
Ovunque memorizzi i set di modifiche, è generalmente ragionevole suddividerli nelle seguenti categorie:
- Migrazioni indipendenti che non influiscono sul sistema in esecuzione. Di solito è sicuro creare nuove tabelle, sequenze e così via, se l'applicazione attualmente distribuita non ne è ancora a conoscenza.
- Modifiche allo schema che alterano la struttura del negozio , ad esempio aggiungendo o eliminando colonne e indici. Queste modifiche non devono essere applicate mentre è ancora in uso una versione precedente dell'applicazione, poiché ciò potrebbe causare blocchi o comportamenti strani a causa di modifiche allo schema.
- Migrazioni rapide che inseriscono o aggiornano piccole quantità di dati. Se vengono distribuite più applicazioni, i set di modifiche di questa categoria possono essere eseguiti contemporaneamente senza compromettere le prestazioni del database.
- Migrazioni potenzialmente lente che inseriscono o aggiornano molti dati. È meglio applicare queste modifiche quando non vengono eseguite altre migrazioni simili.
Questi set di migrazioni devono essere eseguiti consecutivamente prima di distribuire una versione più recente di un'applicazione. Questo approccio diventa ancora più pratico se un sistema è composto da diverse applicazioni separate e alcune di esse utilizzano lo stesso database. In caso contrario, vale la pena separare solo i set di modifiche che potrebbero essere applicati senza influire sulle applicazioni in esecuzione e i restanti set di modifiche potrebbero essere applicati insieme.

Per le applicazioni più semplici, è possibile applicare l'intero set di migrazioni necessarie all'avvio dell'applicazione. In questo caso, tutti i set di modifiche rientrano in un'unica categoria e vengono eseguiti ogni volta che l'applicazione viene inizializzata.
Qualunque sia la fase scelta per applicare le migrazioni, vale la pena ricordare che l'utilizzo dello stesso database per più applicazioni può causare blocchi durante l'applicazione delle migrazioni. Liquibase (come molte altre soluzioni simili) utilizza due tabelle speciali per registrare i suoi metadati: DATABASECHANGELOG e DATABASECHANGELOGLOCK . Il primo viene utilizzato per archiviare informazioni sui set di modifiche applicati e il secondo per impedire migrazioni simultanee all'interno dello stesso schema di database. Pertanto, se più applicazioni devono utilizzare lo stesso schema di database per qualche motivo, è meglio utilizzare nomi non predefiniti per le tabelle di metadati per evitare blocchi.
Ora che la struttura di alto livello è chiara, devi decidere come organizzare i changeset all'interno di ciascuna categoria.
Dipende molto dai requisiti specifici dell'applicazione, ma i seguenti punti sono generalmente ragionevoli:
- Mantieni i log delle modifiche raggruppati per versioni del tuo prodotto. Crea una nuova directory per ogni versione e inserisci i file di log delle modifiche corrispondenti in essa. Avere un log delle modifiche di root e includere i log delle modifiche che corrispondono ai rilasci. Nei registri delle modifiche delle versioni, includi altri registri delle modifiche che compongono questa versione.
- Avere una convenzione di denominazione per i file di registro delle modifiche e gli identificatori di serie di modifiche e seguirla, ovviamente.
- Evita i set di modifiche con molte modifiche. Preferisci più changeset a un unico lungo changeset.
- Se si utilizzano le procedure memorizzate ed è necessario aggiornarle, considerare l'utilizzo
runOnChange="true"del set di modifiche in cui viene aggiunta la procedura memorizzata. Altrimenti, ogni volta che viene aggiornato, dovrai creare un nuovo changeset con una nuova versione della stored procedure. I requisiti variano, ma è spesso accettabile non tenere traccia di tale cronologia. - Prendi in considerazione l'eliminazione delle modifiche ridondanti prima di unire i rami delle funzionalità. A volte capita che in un ramo di funzionalità (soprattutto in uno di lunga durata) i set di modifiche successivi perfezionino le modifiche apportate nei set di modifiche precedenti. Ad esempio, puoi creare una tabella e poi decidere di aggiungervi più colonne. Vale la pena aggiungere quelle colonne alla modifica iniziale di
createTablese questo ramo di funzionalità non è stato ancora unito al ramo principale. - Usa gli stessi log delle modifiche per creare un database di test. Se provi a farlo, potresti presto scoprire che non tutti i set di modifiche sono applicabili all'ambiente di test o che sono necessari set di modifiche aggiuntivi per quello specifico ambiente di test. Con Liquibase, questo problema è facilmente risolvibile utilizzando i contesti . Basta aggiungere l'attributo
context="test"ai changeset che devono essere eseguiti solo con i test, quindi inizializzare Liquibase con il contesto ditestabilitato.
Rotolando indietro
Come altre soluzioni simili, Liquibase supporta la migrazione dello schema "su" e "giù". Ma attenzione: annullare le migrazioni potrebbe non essere facile e non sempre ne vale la pena. Se hai deciso di supportare l'annullamento delle migrazioni per la tua applicazione, sii coerente e fallo per ogni set di modifiche che dovrebbe essere annullato. Con Liquibase, l'annullamento di un changeset si ottiene aggiungendo un tag di rollback che contiene le modifiche necessarie per eseguire un rollback. Considera il seguente esempio:
<changeSet author="..."> <createTable tableName="PRODUCT"> <column name="ID" type="BIGINT"> <constraints primaryKey="true" primaryKeyName="PK_PRODUCT"/> </column> <column name="CODE" type="VARCHAR(50)"> <constraints nullable="false" unique="true" uniqueConstraintName="UC_PRODUCT_CODE"/> </column> </createTable> <rollback> <dropTable tableName="PRODUCT"/> </rollback> </changeSet> Il rollback esplicito è ridondante in questo caso perché Liquibase eseguirebbe le stesse azioni di rollback. Liquibase è in grado di ripristinare automaticamente la maggior parte dei tipi di modifiche supportati, ad esempio createTable , addColumn o createIndex .
Riparare il passato
Nessuno è perfetto e tutti commettiamo errori. Alcuni di essi potrebbero essere scoperti troppo tardi quando sono già state applicate modifiche non valide. Esploriamo cosa si potrebbe fare per salvare la situazione.
Aggiorna manualmente il database
Implica il pasticcio con DATABASECHANGELOG e il tuo database nei seguenti modi:
- Se desideri correggere i set di modifiche errati ed eseguirli di nuovo:
- Rimuovere le righe da
DATABASECHANGELOGche corrispondono ai set di modifiche. - Rimuovere tutti gli effetti collaterali introdotti dai changeset; ad esempio, ripristinare una tabella se è stata eliminata.
- Correggi i set di modifiche errati.
- Esegui di nuovo le migrazioni.
- Rimuovere le righe da
- Se desideri correggere i set di modifiche errati ma saltare di nuovo l'applicazione:
- Aggiorna
DATABASECHANGELOGimpostando il valore del campoMD5SUMsuNULLper quelle righe che corrispondono ai set di modifiche errati. - Correggi manualmente ciò che è stato sbagliato nel database. Ad esempio, se è stata aggiunta una colonna con il tipo errato, eseguire una query per modificarne il tipo.
- Correggi i set di modifiche errati.
- Esegui di nuovo le migrazioni. Liquibase calcolerà il nuovo checksum e lo salverà in
MD5SUM. I set di modifiche corretti non verranno più eseguiti.
- Aggiorna
Ovviamente, è facile eseguire questi trucchi durante lo sviluppo, ma diventa molto più difficile se le modifiche vengono applicate a più database.
Scrivi le modifiche correttive
In pratica, questo approccio è generalmente più appropriato. Potresti chiederti, perché non modificare semplicemente il changeset originale? La verità è che dipende da cosa deve essere cambiato. Liquibase calcola un checksum per ogni changeset e rifiuta di applicare nuove modifiche se il checksum è nuovo per almeno uno dei changeset applicati in precedenza. Questo comportamento può essere personalizzato in base al set di modifiche specificando l' runOnChange="true" . Il checksum non viene influenzato se si modificano le precondizioni o gli attributi facoltativi del set di modifiche ( context , runOnChange , ecc.).
Ora, ti starai chiedendo, in che modo alla fine correggi i set di modifiche con errori?
- Se desideri che tali modifiche vengano comunque applicate ai nuovi schemi, aggiungi semplicemente i set di modifiche correttive. Ad esempio, se è stata aggiunta una colonna con il tipo errato, modificarne il tipo nel nuovo changeset.
- Se desideri fingere che quei set di modifiche errati non siano mai esistiti, procedi come segue:
- Rimuovi i changeset o aggiungi l'attributo
contextcon un valore che garantisca che non avresti mai più tentato di applicare le migrazioni con un tale contesto, ad esempiocontext="graveyard-changesets-never-run". - Aggiungi nuovi set di modifiche che ripristineranno ciò che è stato fatto di sbagliato o lo risolveranno. Queste modifiche dovrebbero essere applicate solo se sono state applicate modifiche errate. Può essere ottenuto con precondizioni, ad esempio con
changeSetExecuted. Non dimenticare di aggiungere un commento che spieghi il motivo per cui lo stai facendo. - Aggiungi nuovi set di modifiche che modifichino lo schema nel modo giusto.
- Rimuovi i changeset o aggiungi l'attributo
Come vedi, correggere il passato è possibile, anche se potrebbe non essere sempre semplice.
Mitigazione dei dolori della crescita
Man mano che la tua applicazione invecchia, anche il suo log delle modifiche cresce, accumulando ogni modifica dello schema lungo il percorso. È di progettazione e non c'è nulla di intrinsecamente sbagliato in questo. I log delle modifiche lunghi possono essere ridotti schiacciando regolarmente le migrazioni, ad esempio dopo aver rilasciato ciascuna versione del prodotto. In alcuni casi, renderebbe più veloce l'inizializzazione del nuovo schema.
Lo schiacciamento non è sempre banale e può causare regressioni senza portare molti benefici. Un'altra ottima opzione è usare un database seed per evitare di eseguire tutti i changeset. È adatto per testare ambienti se è necessario disporre di un database pronto il più velocemente possibile, magari anche con alcuni dati di test. Potresti pensarlo come una forma di compressione per i set di modifiche: ad un certo punto (ad esempio, dopo aver rilasciato un'altra versione), esegui un dump dello schema. Dopo aver ripristinato il dump, si applicano le migrazioni come di consueto. Verranno applicate solo le nuove modifiche perché quelle precedenti erano già state applicate prima di eseguire il dump; pertanto, sono stati ripristinati dalla discarica.
Conclusione
Abbiamo intenzionalmente evitato di approfondire le caratteristiche di Liquibase per fornire un articolo breve e al punto, incentrato sull'evoluzione degli schemi in generale. Si spera che sia chiaro quali vantaggi e problemi sono determinati dall'applicazione automatizzata delle migrazioni di schemi di database e quanto tutto si adatta bene alla cultura DevOps. È importante non trasformare nemmeno le buone idee in dogmi. I requisiti variano e, in quanto ingegneri di database, le nostre decisioni dovrebbero favorire lo sviluppo di un prodotto e non solo il rispetto dei consigli di qualcuno su Internet.
