Migrațiile bazei de date: Transformarea omizilor în fluturi

Publicat: 2022-03-11

Utilizatorilor nu le pasă ce este în interiorul software-ului pe care îl folosesc; doar că funcționează fără probleme, în siguranță și fără probleme. Dezvoltatorii se străduiesc să facă acest lucru, iar una dintre problemele pe care încearcă să le rezolve este cum să se asigure că depozitul de date este într-o stare adecvată pentru versiunea curentă a produsului. Software-ul evoluează, iar modelul său de date se poate schimba, de asemenea, în timp, de exemplu, pentru a remedia greșelile de proiectare. Pentru a complica și mai mult problema, este posibil să aveți o serie de medii de testare sau clienți care migrează la versiuni mai noi ale produsului în ritmuri diferite. Nu puteți documenta doar structura magazinului și ce manipulări sunt necesare pentru a utiliza noua versiune strălucitoare dintr-o singură perspectivă.

Migrațiile bazei de date: Transformarea omizilor în fluturi

Odată m-am alăturat unui proiect cu câteva baze de date cu structuri care au fost actualizate la cerere, direct de dezvoltatori. Acest lucru însemna că nu exista o modalitate evidentă de a afla ce modificări trebuiau aplicate pentru a migra structura la cea mai recentă versiune și nu exista deloc conceptul de versiuni! Acest lucru a fost în perioada pre-DevOps și ar fi considerat o mizerie totală în zilele noastre. Am decis să dezvoltăm un instrument care să fie folosit pentru a aplica fiecare modificare în baza de date dată. A avut migrații și ar documenta modificările de schemă. Acest lucru ne-a făcut încrezători că nu vor exista modificări accidentale și că starea schemei va fi previzibilă.

În acest articol, vom arunca o privire asupra modului de aplicare a migrărilor de scheme de baze de date relaționale și cum să depășim problemele concomitente.

În primul rând, ce sunt migrațiile de baze de date? În contextul acestui articol, o migrare este un set de modificări care ar trebui aplicate unei baze de date. Crearea sau eliminarea unui tabel, coloană sau index sunt exemple comune de migrare. Forma schemei dvs. se poate schimba dramatic în timp, mai ales dacă dezvoltarea a fost începută când cerințele erau încă vagi. Așadar, de-a lungul mai multor etape în drumul către o lansare, modelul dvs. de date va fi evoluat și este posibil să fi devenit complet diferit de ceea ce era la început. Migrațiile sunt doar pași către starea țintă.

Pentru a începe, haideți să explorăm ce avem în cutia noastră de instrumente pentru a evita reinventarea a ceea ce a făcut deja bine.

Instrumente

În fiecare limbă utilizată pe scară largă, există biblioteci care facilitează migrarea bazelor de date. De exemplu, în cazul Java, opțiunile populare sunt Liquibase și Flyway. Vom folosi Liquibase mai mult în exemple, dar conceptele se aplică altor soluții și nu sunt legate de Liquibase.

De ce să vă deranjați să utilizați o bibliotecă separată de migrare a schemei dacă unele ORM-uri oferă deja o opțiune de a actualiza automat o schemă și de a o face să se potrivească cu structura claselor mapate? În practică, astfel de migrări automate fac doar schimbări simple de schemă, de exemplu, crearea de tabele și coloane și nu pot face lucruri potențial distructive, cum ar fi eliminarea sau redenumirea obiectelor bazei de date. Deci soluțiile neautomate (dar tot automate) sunt de obicei o alegere mai bună, deoarece ești forțat să descrii singur logica de migrare și știi ce se va întâmpla exact cu baza ta de date.

De asemenea, este o idee foarte proastă să amestecați modificările automate și manuale ale schemei, deoarece puteți produce scheme unice și imprevizibile dacă modificările manuale sunt aplicate în ordine greșită sau nu sunt aplicate deloc, chiar dacă sunt necesare. Odată ce instrumentul este ales, utilizați-l pentru a aplica toate migrările de schemă.

Migrații tipice de baze de date

Migrațiile tipice includ crearea de secvențe, tabele, coloane, chei primare și externe, indici și alte obiecte de bază de date. Pentru cele mai comune tipuri de modificări, Liquibase oferă elemente declarative distincte pentru a descrie ceea ce ar trebui făcut. Ar fi prea plictisitor să citești despre fiecare schimbare banală susținută de Liquibase sau alte instrumente similare. Pentru a vă face o idee despre modul în care arată seturile de modificări, luați în considerare următorul exemplu în care creăm un tabel (declarațiile de spațiu de nume XML sunt omise pentru concizie):

 <?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>

După cum puteți vedea, jurnalul de modificări este un set de seturi de modificări, iar seturile de modificări constau din modificări. Modificările simple precum createTable pot fi combinate pentru a implementa migrări mai complexe; de exemplu, să presupunem că trebuie să actualizați codul produsului pentru toate produsele. Se poate realiza cu ușurință cu următoarea modificare:

 <sql>UPDATE product SET code = 'new_' || code</sql>

Performanța va avea de suferit dacă aveți miliarde de produse. Pentru a accelera migrarea, o putem rescrie în următorii pași:

  1. Creați un tabel nou pentru produse cu createTable , așa cum am văzut mai devreme. În această etapă, este mai bine să creați cât mai puține constrângeri posibil. Să denumim noul tabel PRODUCT_TMP .
  2. Populați PRODUCT_TMP cu SQL sub forma INSERT INTO ... SELECT ... folosind sql change.
  3. Creați toate constrângerile ( addNotNullConstraint , addUniqueConstraint , addForeignKeyConstraint ) și indecșii ( createIndex ) de care aveți nevoie.
  4. Redenumiți tabelul PRODUCT în ceva de genul PRODUCT_BAK . Liquibase o poate face cu renameTable .
  5. Redenumiți PRODUCT_TMP în PRODUCT (din nou, folosind renameTable ).
  6. Opțional, eliminați PRODUCT_BAK cu dropTable .

Desigur, este mai bine să eviți astfel de migrări, dar e bine să știi cum să le implementezi în cazul în care te confrunți cu unul dintre acele cazuri rare în care ai nevoie.

Dacă considerați că XML, JSON sau YAML sunt prea bizare pentru sarcina de a descrie modificări, atunci utilizați doar SQL simplu și utilizați toate caracteristicile specifice furnizorului de baze de date. De asemenea, puteți implementa orice logică personalizată în Java simplu.

Modul în care Liquibase vă scutește de la scrierea SQL-ului specific bazei de date poate duce la exces de încredere, dar nu ar trebui să uitați de ciudateniile bazei de date țintă; de exemplu, atunci când creați o cheie străină, un index poate fi sau nu creat, în funcție de sistemul specific de gestionare a bazei de date utilizat. Drept urmare, s-ar putea să te trezești într-o situație incomodă. Liquibase vă permite să specificați că un set de modificări ar trebui să fie rulat numai pentru un anumit tip de bază de date, de exemplu, PostgreSQL, Oracle sau MySQL. Face acest lucru posibil folosind aceleași seturi de modificări independente de furnizor pentru diferite baze de date și pentru alte seturi de modificări, folosind sintaxa și caracteristicile specifice furnizorului. Următorul set de modificări va fi executat numai dacă se utilizează o bază de date Oracle:

 <changeSet dbms="oracle" author="..."> ... </changeSet>

Pe lângă Oracle, Liquibase acceptă câteva alte baze de date din cutie.

Denumirea obiectelor bazei de date

Fiecare obiect de bază de date pe care îl creați trebuie să fie numit. Nu vi se cere să furnizați în mod explicit un nume pentru anumite tipuri de obiecte, de exemplu, pentru constrângeri și indici. Dar nu înseamnă că acele obiecte nu vor avea nume; numele lor vor fi oricum generate de baza de date. Problema apare atunci când trebuie să faceți referință la acel obiect pentru a-l scăpa sau a-l modifica. Deci este mai bine să le dai nume explicite. Dar există reguli cu privire la ce nume să dai? Răspunsul este scurt: Fii consecvent; de exemplu, dacă ați decis să denumiți indecși astfel: IDX_<table>_<columns> , atunci un index pentru coloana CODE menționată mai sus ar trebui să fie numit IDX_PRODUCT_CODE .

Convențiile de numire sunt incredibil de controversate, așa că nu ne vom presupune că oferim instrucțiuni cuprinzătoare aici. Fii consecvent, respectă-ți echipa sau convențiile proiectului sau inventează-le dacă nu există.

Organizarea seturilor de modificări

Primul lucru pe care trebuie să vă decideți este unde să stocați seturile de modificări. Practic, există două abordări:

  1. Păstrați seturile de modificări cu codul aplicației. Este convenabil să faceți acest lucru, deoarece puteți comite și revizui seturile de modificări și codul aplicației împreună.
  2. Păstrați seturile de modificări și codul aplicației separate , de exemplu, în depozite VCS separate. Această abordare este potrivită atunci când modelul de date este partajat în mai multe aplicații și este mai convenabil să stocați toate seturile de modificări într-un depozit dedicat și să nu le împrăștiați în mai multe depozite în care se află codul aplicației.

Oriunde stocați seturile de modificări, este, în general, rezonabil să le împărțiți în următoarele categorii:

  1. Migrații independente care nu afectează sistemul care rulează. De obicei, este sigur să creați noi tabele, secvențe etc., dacă aplicația implementată în prezent nu le cunoaște încă.
  2. Modificări ale schemei care modifică structura magazinului, de exemplu, adăugarea sau eliminarea coloanelor și a indecșilor. Aceste modificări nu ar trebui să fie aplicate în timp ce o versiune mai veche a aplicației este încă în uz, deoarece acest lucru poate duce la blocări sau un comportament ciudat din cauza modificărilor schemei.
  3. Migrații rapide care inserează sau actualizează cantități mici de date. Dacă sunt implementate mai multe aplicații, seturile de modificări din această categorie pot fi executate simultan fără a degrada performanța bazei de date.
  4. Migrații potențial lente care inserează sau actualizează o mulțime de date. Aceste modificări sunt mai bine să fie aplicate atunci când nu se execută alte migrări similare.

reprezentarea grafică a celor patru categorii

Aceste seturi de migrare ar trebui să fie rulate consecutiv înainte de implementarea unei versiuni mai noi a unei aplicații. Această abordare devine și mai practică dacă un sistem este compus din mai multe aplicații separate, iar unele dintre ele folosesc aceeași bază de date. În caz contrar, merită să separăm doar acele seturi de modificări care ar putea fi aplicate fără a afecta aplicațiile care rulează, iar seturile de modificări rămase pot fi aplicate împreună.

Pentru aplicații mai simple, setul complet de migrări necesare poate fi aplicat la pornirea aplicației. În acest caz, toate seturile de modificări se încadrează într-o singură categorie și sunt rulate ori de câte ori aplicația este inițializată.

Indiferent de stadiul în care se alege să se aplice migrările, merită menționat faptul că utilizarea aceleiași baze de date pentru mai multe aplicații poate provoca blocări atunci când se aplică migrările. Liquibase (ca multe alte soluții similare) utilizează două tabele speciale pentru a-și înregistra metadatele: DATABASECHANGELOG și DATABASECHANGELOGLOCK . Primul este folosit pentru stocarea informațiilor despre seturile de modificări aplicate, iar cel de-al doilea pentru a preveni migrările concurente în cadrul aceleiași scheme de bază de date. Deci, dacă mai multe aplicații trebuie să folosească aceeași schemă a bazei de date dintr-un anumit motiv, este mai bine să folosiți nume care nu sunt implicite pentru tabelele de metadate pentru a evita blocările.

Acum că structura de nivel înalt este clară, trebuie să decideți cum să organizați seturile de modificări în cadrul fiecărei categorii.

eșantion de organizare a seturilor de modificări

Depinde foarte mult de cerințele specifice aplicației, dar următoarele puncte sunt de obicei rezonabile:

  1. Păstrați jurnalele de modificări grupate în funcție de versiunile produsului dvs. Creați un director nou pentru fiecare ediție și plasați fișierele de jurnal de modificări corespunzătoare în el. Aveți un jurnal de modificări rădăcină și includeți jurnalele de modificări care corespund versiunilor. În jurnalele de modificări ale versiunii, includeți alte jurnalele de modificări care cuprind această ediție.
  2. Aveți o convenție de denumire pentru fișierele jurnal de modificări și identificatorii setului de modificări - și respectați-o, desigur.
  3. Evitați seturile de modificări cu multe modificări. Preferați mai multe seturi de modificări unui singur set lung de modificări.
  4. Dacă utilizați proceduri stocate și trebuie să le actualizați, luați în considerare utilizarea runOnChange="true" al setului de modificări în care este adăugată procedura stocată. În caz contrar, de fiecare dată când este actualizat, va trebui să creați un nou set de modificări cu o nouă versiune a procedurii stocate. Cerințele variază, dar este adesea acceptabil să nu urmăriți un astfel de istoric.
  5. Luați în considerare eliminarea modificărilor redundante înainte de a îmbina ramurile caracteristicilor. Uneori, se întâmplă ca într-o ramură de caracteristici (mai ales într-o ramură de lungă durată) seturile de modificări ulterioare să rafineze modificările făcute în seturile de modificări anterioare. De exemplu, puteți crea un tabel și apoi decideți să adăugați mai multe coloane la acesta. Merită să adăugați acele coloane la modificarea inițială createTable dacă această ramură caracteristică nu a fost încă îmbinată cu ramura principală.
  6. Utilizați aceleași jurnalele de modificări pentru a crea o bază de date de testare. Dacă încercați să faceți acest lucru, este posibil să aflați în curând că nu fiecare set de modificări este aplicabil mediului de testare sau că sunt necesare seturi de modificări suplimentare pentru acel mediu de testare specific. Cu Liquibase, această problemă este ușor de rezolvat folosind contexte . Doar adăugați atributul context="test" la seturile de modificări care trebuie executate numai cu teste și apoi inițializați Liquibase cu contextul de test activat.

Rolling Back

Ca și alte soluții similare, Liquibase acceptă migrarea schemei „în sus” și „jos”. Dar fiți avertizat: anularea migrațiilor poate să nu fie ușoară și nu merită întotdeauna efortul. Dacă ați decis să acceptați anularea migrărilor pentru aplicația dvs., atunci fiți consecvent și faceți-o pentru fiecare set de modificări care ar trebui anulat. Cu Liquibase, anularea unui set de modificări se realizează prin adăugarea unei etichete rollback care conține modificările necesare pentru a efectua o rollback. Luați în considerare următorul exemplu:

 <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>

Rollback explicit este redundant aici, deoarece Liquibase ar efectua aceleași acțiuni de rollback. Liquibase este capabil să anuleze automat majoritatea tipurilor de modificări acceptate, de exemplu createTable , addColumn sau createIndex .

Repararea Trecutului

Nimeni nu este perfect și toți facem greșeli. Unele dintre ele pot fi descoperite prea târziu când au fost deja aplicate modificări stricate. Să explorăm ce s-ar putea face pentru a salva ziua.

Actualizați manual baza de date

Implică jocul cu DATABASECHANGELOG și baza ta de date în următoarele moduri:

  1. Dacă doriți să corectați seturile de modificări proaste și să le executați din nou:
    • Eliminați rândurile din DATABASECHANGELOG care corespund seturilor de modificări.
    • Eliminați toate efectele secundare care au fost introduse de seturile de modificări; de exemplu, restaurați un tabel dacă a fost abandonat.
    • Remediați seturile de modificări proaste.
    • Rulați din nou migrațiile.
  2. Dacă doriți să corectați seturile de modificări proaste, dar săriți să le aplicați din nou:
    • Actualizați DATABASECHANGELOG setând valoarea câmpului MD5SUM la NULL pentru acele rânduri care corespund seturilor de modificări greșite.
    • Remediați manual ceea ce a fost greșit în baza de date. De exemplu, dacă a fost adăugată o coloană cu tipul greșit, atunci lansați o interogare pentru a modifica tipul acesteia.
    • Remediați seturile de modificări proaste.
    • Rulați din nou migrațiile. Liquibase va calcula noua sumă de control și o va salva în MD5SUM . Seturile de modificări corectate nu vor fi rulate din nou.

Evident, este ușor să faci aceste trucuri în timpul dezvoltării, dar devine mult mai greu dacă modificările sunt aplicate mai multor baze de date.

Scrieți seturi de modificări corective

În practică, această abordare este de obicei mai adecvată. S-ar putea să vă întrebați, de ce nu editați setul de modificări inițial? Adevărul este că depinde de ceea ce trebuie schimbat. Liquibase calculează o sumă de control pentru fiecare set de modificări și refuză să aplice noi modificări dacă suma de control este nouă pentru cel puțin unul dintre seturile de modificări aplicate anterior. Acest comportament poate fi personalizat pe bază de set de modificări prin specificarea runOnChange="true" . Suma de control nu este afectată dacă modificați precondițiile sau atributele opționale ale seturilor de modificări ( context , runOnChange etc.).

Acum, s-ar putea să vă întrebați, cum corectați în cele din urmă seturile de modificări cu greșeli?

  1. Dacă doriți ca acele modificări să fie aplicate în continuare pentru scheme noi, atunci adăugați seturi de modificări corective. De exemplu, dacă a fost adăugată o coloană cu tipul greșit, modificați-i tipul în noul set de modificări.
  2. Dacă doriți să pretindeți că acele seturi de modificări proaste nu au existat niciodată, atunci faceți următoarele:
    • Eliminați seturile de modificări sau adăugați atributul context cu o valoare care garantează că nu veți mai încerca niciodată să aplicați migrări cu un astfel de context, de exemplu, context="graveyard-changesets-never-run" .
    • Adăugați seturi de modificări noi care fie vor anula ceea ce a fost făcut greșit, fie îl vor remedia. Aceste modificări ar trebui aplicate numai dacă s-au aplicat modificări proaste. Poate fi realizat cu precondiții, cum ar fi changeSetExecuted . Nu uita să adaugi un comentariu explicând de ce faci asta.
    • Adăugați noi seturi de modificări care modifică schema în mod corect.

După cum vedeți, este posibil să remediați trecutul, deși s-ar putea să nu fie întotdeauna simplu.

Atenuarea durerilor de creștere

Pe măsură ce aplicația dvs. îmbătrânește, jurnalul de modificări crește și el, acumulând fiecare modificare a schemei de-a lungul căii. Este prin design și nu este nimic inerent în neregulă cu asta. Jurnalele lungi de modificări pot fi scurtate prin eliminarea regulată a migrațiilor, de exemplu, după lansarea fiecărei versiuni a produsului. În unele cazuri, ar face inițializarea noii scheme mai rapidă.

ilustrare a jurnalelor de modificări strivite

Squashingul nu este întotdeauna banal și poate provoca regresii fără a aduce multe beneficii. O altă opțiune excelentă este utilizarea unei baze de date semințe pentru a evita executarea tuturor seturilor de modificări. Este foarte potrivit pentru medii de testare dacă aveți nevoie să aveți o bază de date pregătită cât mai repede posibil, poate chiar cu câteva date de testare. S-ar putea să vă gândiți la asta ca la o formă de squashing pentru seturi de modificări: la un moment dat (de exemplu, după lansarea unei alte versiuni), faceți un dump a schemei. După restaurarea dump-ului, aplicați migrarea ca de obicei. Se vor aplica doar modificări noi, deoarece cele mai vechi au fost deja aplicate înainte de a face dump-ul; prin urmare, acestea au fost restaurate din groapă.

ilustrare a unei baze de date de semințe

Concluzie

Am evitat în mod intenționat să ne scufundăm mai adânc în funcțiile Liquibase pentru a livra un articol scurt și la obiect, axat pe schemele în evoluție în general. Sperăm că este clar ce beneficii și probleme sunt aduse de aplicarea automată a migrărilor schemei bazei de date și cât de bine se încadrează totul în cultura DevOps. Este important să nu transformi nici măcar ideile bune în dogme. Cerințele variază și, în calitate de ingineri de baze de date, deciziile noastre ar trebui să încurajeze avansarea unui produs și nu doar aderarea la recomandările cuiva de pe internet.