Migracje baz danych: zamienianie gąsienic w motyle
Opublikowany: 2022-03-11Użytkownicy nie dbają o to, co znajduje się w oprogramowaniu, z którego korzystają; po prostu działa płynnie, bezpiecznie i dyskretnie. Deweloperzy dążą do tego, a jednym z problemów, które próbują rozwiązać, jest upewnienie się, że magazyn danych jest w stanie odpowiednim dla aktualnej wersji produktu. Oprogramowanie ewoluuje, a jego model danych może również zmieniać się w czasie, np. w celu naprawienia błędów projektowych. Aby jeszcze bardziej skomplikować problem, możesz mieć wiele środowisk testowych lub klientów, którzy migrują do nowszych wersji produktu w różnym tempie. Nie możesz po prostu udokumentować struktury sklepu i manipulacji, które są potrzebne, aby korzystać z nowej, błyszczącej wersji z jednej perspektywy.
Kiedyś dołączyłem do projektu z kilkoma bazami danych ze strukturami, które były aktualizowane na żądanie, bezpośrednio przez programistów. Oznaczało to, że nie było oczywistego sposobu, aby dowiedzieć się, jakie zmiany należy zastosować, aby przenieść strukturę do najnowszej wersji i nie było w ogóle koncepcji wersjonowania! Działo się to w erze poprzedzającej DevOps i dziś byłoby to uważane za totalny bałagan. Postanowiliśmy stworzyć narzędzie, które posłużyłoby do zastosowania każdej zmiany w danej bazie danych. Miał migracje i dokumentował zmiany schematu. Dało nam to pewność, że nie nastąpią przypadkowe zmiany, a stan schematu będzie przewidywalny.
W tym artykule przyjrzymy się, jak zastosować migracje schematów relacyjnych baz danych i jak przezwyciężyć współistniejące problemy.
Przede wszystkim, czym są migracje baz danych? W kontekście tego artykułu migracja to zestaw zmian, które należy zastosować do bazy danych. Tworzenie lub usuwanie tabeli, kolumny lub indeksu to typowe przykłady migracji. Kształt schematu może z czasem ulec radykalnym zmianom, zwłaszcza jeśli rozwój został rozpoczęty, gdy wymagania były wciąż niejasne. Tak więc w ciągu kilku kamieni milowych na drodze do wydania Twój model danych będzie ewoluował i może stać się zupełnie inny niż na samym początku. Migracje to tylko kroki do stanu docelowego.
Na początek przyjrzyjmy się, co mamy w naszym zestawie narzędzi, aby uniknąć ponownego wymyślania tego, co już zostało dobrze zrobione.
Narzędzia
W każdym powszechnie używanym języku istnieją biblioteki, które ułatwiają migrację baz danych. Na przykład w przypadku Javy popularne opcje to Liquibase i Flyway. Będziemy częściej używać Liquibase w przykładach, ale koncepcje odnoszą się do innych rozwiązań i nie są powiązane z Liquibase.
Po co zawracać sobie głowę korzystaniem z oddzielnej biblioteki migracji schematów, jeśli niektóre ORM-y zapewniają już opcję automagicznego uaktualnienia schematu i dostosowania go do struktury zmapowanych klas? W praktyce takie automatyczne migracje wykonują tylko proste zmiany schematu, np. tworzenie tabel i kolumn, i nie mogą wykonywać potencjalnie destrukcyjnych rzeczy, takich jak usuwanie lub zmiana nazwy obiektów bazy danych. Dlatego rozwiązania nieautomatyczne (ale nadal zautomatyzowane) są zazwyczaj lepszym wyborem, ponieważ jesteś zmuszony samodzielnie opisać logikę migracji i wiesz, co dokładnie stanie się z Twoją bazą danych.
Bardzo złym pomysłem jest również łączenie automatycznych i ręcznych modyfikacji schematów, ponieważ możesz tworzyć unikalne i nieprzewidywalne schematy, jeśli ręczne zmiany zostaną zastosowane w złej kolejności lub w ogóle nie zostaną zastosowane, nawet jeśli są wymagane. Po wybraniu narzędzia użyj go, aby zastosować wszystkie migracje schematu.
Typowe migracje baz danych
Typowe migracje obejmują tworzenie sekwencji, tabel, kolumn, kluczy podstawowych i obcych, indeksów i innych obiektów bazy danych. W przypadku większości typowych zmian Liquibase zapewnia odrębne elementy deklaratywne do opisywania tego, co należy zrobić. Byłoby zbyt nudno czytać o każdej trywialnej zmianie obsługiwanej przez Liquibase lub inne podobne narzędzia. Aby zorientować się, jak wyglądają zestawy zmian, rozważmy następujący przykład, w którym tworzymy tabelę (deklaracje przestrzeni nazw XML są pominięte dla zwięzłości):
<?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>
Jak widać, changelog to zbiór zestawów zmian, a zestawy zmian składają się ze zmian. Proste zmiany, takie jak createTable
, można łączyć w celu wdrożenia bardziej złożonych migracji; Załóżmy na przykład, że musisz zaktualizować kod produktu dla wszystkich produktów. Można to łatwo osiągnąć dzięki następującej zmianie:
<sql>UPDATE product SET code = 'new_' || code</sql>
Wydajność ucierpi, jeśli masz miliony produktów. Aby przyspieszyć migrację, możemy przepisać ją w następujących krokach:
- Utwórz nową tabelę dla produktów za pomocą
createTable
, tak jak widzieliśmy wcześniej. Na tym etapie lepiej jest utworzyć jak najmniej ograniczeń. Nazwijmy nową tabelęPRODUCT_TMP
. - Wypełnij
PRODUCT_TMP
SQL w postaciINSERT INTO ... SELECT ...
używającsql
change. - Utwórz wszystkie potrzebne ograniczenia (
addNotNullConstraint
,addUniqueConstraint
,addForeignKeyConstraint
) i indeksy (createIndex
). - Zmień nazwę tabeli
PRODUCT
naPRODUCT_BAK
. Liquibase może to zrobić za pomocąrenameTable
. - Zmień nazwę
PRODUCT_TMP
naPRODUCT
(ponownie, używającrenameTable
). - Opcjonalnie usuń
PRODUCT_BAK
za pomocądropTable
.
Oczywiście lepiej jest unikać takich migracji, ale dobrze jest wiedzieć, jak je zaimplementować, na wypadek, gdybyś natrafił na jeden z tych rzadkich przypadków, w których jest to potrzebne.
Jeśli uważasz, że XML, JSON lub YAML są zbyt dziwaczne dla zadania opisywania zmian, po prostu użyj zwykłego SQL i wykorzystaj wszystkie funkcje specyficzne dla dostawcy bazy danych. Możesz także zaimplementować dowolną niestandardową logikę w zwykłej Javie.
Sposób, w jaki Liquibase zwalnia Cię z pisania rzeczywistego SQL specyficznego dla bazy danych, może prowadzić do nadmiernej pewności siebie, ale nie powinieneś zapominać o dziwactwach docelowej bazy danych; np. podczas tworzenia klucza obcego indeks może, ale nie musi, zostać utworzony, w zależności od używanego systemu zarządzania bazą danych. W rezultacie możesz znaleźć się w niezręcznej sytuacji. Liquibase pozwala określić, że zestaw zmian powinien być uruchamiany tylko dla określonego typu bazy danych, np. PostgreSQL, Oracle lub MySQL. Umożliwia to przy użyciu tych samych niezależnych od dostawcy zestawów zmian dla różnych baz danych, a dla innych zestawów zmian przy użyciu składni i funkcji specyficznych dla dostawcy. Następujący zestaw zmian zostanie wykonany tylko w przypadku korzystania z bazy danych Oracle:
<changeSet dbms="oracle" author="..."> ... </changeSet>
Oprócz Oracle, Liquibase obsługuje kilka innych baz danych po wyjęciu z pudełka.
Nazywanie obiektów bazy danych
Każdy tworzony obiekt bazy danych musi mieć nazwę. Nie musisz jawnie podawać nazwy dla niektórych typów obiektów, np. dla ograniczeń i indeksów. Ale to nie znaczy, że te obiekty nie będą miały nazw; ich nazwy i tak zostaną wygenerowane przez bazę danych. Problem pojawia się, gdy musisz odwołać się do tego obiektu, aby go usunąć lub zmienić. Więc lepiej nadać im wyraźne nazwy. Ale czy są jakieś zasady dotyczące nazw? Odpowiedź jest krótka: bądź konsekwentny; np. jeśli zdecydowałeś się nazwać indeksy w następujący sposób: IDX_<table>_<columns>
, to indeks dla wspomnianej kolumny CODE
powinien mieć nazwę IDX_PRODUCT_CODE
.
Konwencje nazewnictwa są niezwykle kontrowersyjne, więc nie zamierzamy podawać tutaj wyczerpujących instrukcji. Bądź konsekwentny, szanuj konwencje zespołu lub projektu lub po prostu wymyśl je, jeśli ich nie ma.
Organizowanie zmian
Pierwszą rzeczą, o której należy się zdecydować, jest miejsce przechowywania zestawów zmian. Zasadniczo istnieją dwa podejścia:
- Zachowaj zestawy zmian z kodem aplikacji. Jest to wygodne, ponieważ możesz jednocześnie zatwierdzać i przeglądać zestawy zmian i kod aplikacji.
- Przechowuj zestawy zmian i kod aplikacji oddzielnie , np. w osobnych repozytoriach VCS. Takie podejście jest odpowiednie, gdy model danych jest współużytkowany przez kilka aplikacji i wygodniej jest przechowywać wszystkie zestawy zmian w dedykowanym repozytorium i nie rozpraszać ich w wielu repozytoriach, w których znajduje się kod aplikacji.
Gdziekolwiek przechowujesz zestawy zmian, ogólnie rozsądne jest podzielenie ich na następujące kategorie:
- Niezależne migracje, które nie wpływają na działający system. Zwykle bezpiecznie jest tworzyć nowe tabele, sekwencje itp., jeśli aktualnie wdrożona aplikacja jeszcze o nich nie wie.
- Modyfikacje schematu zmieniające strukturę sklepu , np. dodawanie lub usuwanie kolumn i indeksów. Tych zmian nie należy stosować, gdy starsza wersja aplikacji jest nadal używana, ponieważ może to prowadzić do blokad lub dziwnego zachowania z powodu zmian w schemacie.
- Szybkie migracje, które wstawiają lub aktualizują niewielkie ilości danych. Jeśli wdrażanych jest wiele aplikacji, zestawy zmian z tej kategorii mogą być wykonywane jednocześnie bez obniżania wydajności bazy danych.
- Potencjalnie powolne migracje, które wstawiają lub aktualizują dużo danych. Te zmiany lepiej zastosować, gdy nie są wykonywane żadne inne podobne migracje.

Te zestawy migracji należy uruchamiać kolejno przed wdrożeniem nowszej wersji aplikacji. Takie podejście staje się jeszcze bardziej praktyczne, jeśli system składa się z kilku oddzielnych aplikacji, a niektóre z nich korzystają z tej samej bazy danych. W przeciwnym razie warto oddzielić tylko te zestawy zmian, które można zastosować bez wpływu na działające aplikacje, a pozostałe zestawy zmian można zastosować razem.
W przypadku prostszych aplikacji pełny zestaw niezbędnych migracji można zastosować podczas uruchamiania aplikacji. W takim przypadku wszystkie zestawy zmian należą do jednej kategorii i są uruchamiane za każdym razem, gdy aplikacja jest inicjowana.
Niezależnie od wybranego etapu, na którym ma zostać zastosowana migracja, warto wspomnieć, że używanie tej samej bazy danych dla wielu aplikacji może powodować blokady podczas stosowania migracji. Liquibase (podobnie jak wiele innych podobnych rozwiązań) wykorzystuje dwie specjalne tabele do rejestrowania swoich metadanych: DATABASECHANGELOG
i DATABASECHANGELOGLOCK
. Pierwsza służy do przechowywania informacji o zastosowanych zestawach zmian, a druga do zapobiegania równoczesnym migracjom w ramach tego samego schematu bazy danych. Jeśli więc wiele aplikacji musi z jakiegoś powodu korzystać z tego samego schematu bazy danych, lepiej jest używać nazw innych niż domyślne dla tabel metadanych, aby uniknąć blokad.
Teraz, gdy struktura wysokiego poziomu jest jasna, musisz zdecydować, jak zorganizować zestawy zmian w każdej kategorii.
Zależy to w dużej mierze od konkretnych wymagań aplikacji, ale zwykle rozsądne są następujące punkty:
- Przechowuj dzienniki zmian pogrupowane według wersji Twojego produktu. Utwórz nowy katalog dla każdego wydania i umieść w nim odpowiednie pliki dziennika zmian. Mieć główny dziennik zmian i dołączać dzienniki zmian, które odpowiadają wydaniom. W dziennikach zmian wydania dołącz inne dzienniki zmian składające się na to wydanie.
- Ustal konwencję nazewnictwa plików dziennika zmian i identyfikatorów zestawów zmian — i oczywiście przestrzegaj jej.
- Unikaj zestawów zmian z dużą ilością zmian. Preferuj wiele zestawów zmian zamiast jednego długiego zestawu zmian.
- Jeśli używasz procedur składowanych i musisz je zaktualizować, rozważ użycie
runOnChange="true"
zestawu zmian, w którym ta procedura składowana jest dodawana. W przeciwnym razie za każdym razem, gdy jest aktualizowany, musisz utworzyć nowy zestaw zmian z nową wersją procedury składowanej. Wymagania są różne, ale często nie można śledzić takiej historii. - Rozważ zmiażdżenie zbędnych zmian przed scaleniem gałęzi funkcji. Czasami zdarza się, że w gałęzi funkcji (zwłaszcza w tej długowiecznej) późniejsze zestawy zmian dopracowują zmiany dokonane we wcześniejszych zestawach zmian. Na przykład możesz utworzyć tabelę, a następnie zdecydować się na dodanie do niej większej liczby kolumn. Warto dodać te kolumny do początkowej zmiany
createTable
, jeśli ta gałąź funkcji nie została jeszcze połączona z gałęzią główną. - Użyj tych samych dzienników zmian, aby utworzyć testową bazę danych. Jeśli spróbujesz to zrobić, wkrótce może się okazać, że nie każdy zestaw zmian ma zastosowanie do środowiska testowego lub że potrzebne są dodatkowe zestawy zmian dla tego konkretnego środowiska testowego. Dzięki Liquibase ten problem można łatwo rozwiązać za pomocą kontekstów . Po prostu dodaj atrybut
context="test"
do zestawów zmian, które mają być wykonywane tylko z testami, a następnie zainicjuj Liquibase z włączonym kontekstemtest
.
Cofanie
Podobnie jak inne podobne rozwiązania, Liquibase obsługuje migrację schematu „w górę” i „w dół”. Ale uważaj: cofnięcie migracji może nie być łatwe i nie zawsze jest warte wysiłku. Jeśli zdecydowałeś się wspierać cofanie migracji dla swojej aplikacji, bądź spójny i rób to dla każdego zestawu zmian, który musiałby zostać cofnięty. W Liquibase cofnięcie zestawu zmian odbywa się poprzez dodanie tagu rollback
, który zawiera zmiany wymagane do wykonania wycofania. Rozważmy następujący przykład:
<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>
Wyraźne cofanie zmian jest tutaj zbędne, ponieważ Liquibase wykonałby te same akcje cofania. Liquibase jest w stanie automatycznie wycofać większość obsługiwanych typów zmian, np. createTable
, addColumn
lub createIndex
.
Naprawianie przeszłości
Nikt nie jest doskonały i wszyscy popełniamy błędy. Niektóre z nich mogą zostać odkryte zbyt późno, gdy zepsute zmiany zostały już zastosowane. Zobaczmy, co można zrobić, aby uratować sytuację.
Ręczna aktualizacja bazy danych
Polega na manipulowaniu DATABASECHANGELOG
i Twoją bazą danych w następujący sposób:
- Jeśli chcesz poprawić złe zestawy zmian i wykonać je ponownie:
- Usuń wiersze z
DATABASECHANGELOG
, które odpowiadają zestawom zmian. - Usuń wszystkie skutki uboczne, które zostały wprowadzone przez zestawy zmian; np. przywróć tabelę, jeśli została usunięta.
- Napraw złe zestawy zmian.
- Ponownie uruchom migracje.
- Usuń wiersze z
- Jeśli chcesz poprawić złe zestawy zmian, ale pomiń ich ponowne stosowanie:
- Zaktualizuj
DATABASECHANGELOG
, ustawiając wartość polaMD5SUM
naNULL
dla tych wierszy, które odpowiadają nieprawidłowym zestawom zmian. - Ręcznie napraw błędy w bazie danych. Na przykład, jeśli została dodana kolumna o niewłaściwym typie, wyślij zapytanie, aby zmodyfikować jej typ.
- Napraw złe zestawy zmian.
- Ponownie uruchom migracje. Liquibase obliczy nową sumę kontrolną i zapisze ją w
MD5SUM
. Poprawione zestawy zmian nie zostaną ponownie uruchomione.
- Zaktualizuj
Oczywiście łatwo jest wykonać te sztuczki podczas programowania, ale staje się znacznie trudniejsze, jeśli zmiany zostaną zastosowane do wielu baz danych.
Napisz zmiany naprawcze
W praktyce takie podejście jest zwykle bardziej odpowiednie. Możesz się zastanawiać, dlaczego po prostu nie edytować oryginalnego zestawu zmian? Prawda jest taka, że to zależy od tego, co należy zmienić. Liquibase oblicza sumę kontrolną dla każdego zestawu zmian i odmawia zastosowania nowych zmian, jeśli suma kontrolna jest nowa dla co najmniej jednego z poprzednio zastosowanych zestawów zmian. To zachowanie można dostosować na podstawie zestawu zmian, określając runOnChange="true"
. Suma kontrolna nie ulega zmianie, jeśli zmodyfikujesz warunki wstępne lub opcjonalne atrybuty zestawu zmian ( context
, runOnChange
, itp.).
Teraz możesz się zastanawiać, jak ostatecznie poprawić zestawy zmian z błędami?
- Jeśli chcesz, aby te zmiany nadal obowiązywały w nowych schematach, po prostu dodaj korygujące zestawy zmian. Na przykład, jeśli dodano kolumnę z niewłaściwym typem, zmodyfikuj jej typ w nowym zestawie zmian.
- Jeśli chcesz udawać, że te złe zestawy zmian nigdy nie istniały, wykonaj następujące czynności:
- Usuń zestawy zmian lub dodaj atrybut
context
z wartością gwarantującą, że nigdy więcej nie spróbujesz zastosować migracji z takim kontekstem, np.context="graveyard-changesets-never-run"
. - Dodaj nowe zestawy zmian, które albo cofną to, co zostało zrobione źle, albo to naprawią. Te zmiany powinny być stosowane tylko wtedy, gdy zastosowano złe zmiany. Można to osiągnąć za pomocą warunków wstępnych, takich jak
changeSetExecuted
. Nie zapomnij dodać komentarza wyjaśniającego, dlaczego to robisz. - Dodaj nowe zestawy zmian, które modyfikują schemat we właściwy sposób.
- Usuń zestawy zmian lub dodaj atrybut
Jak widać, naprawienie przeszłości jest możliwe, choć nie zawsze jest to proste.
Łagodzenie bólów wzrostowych
Wraz ze starzeniem się aplikacji rośnie również jej dziennik zmian, gromadząc każdą zmianę schematu na ścieżce. Jest to zgodne z projektem i nie ma w tym nic złego. Długie dzienniki zmian można skrócić, regularnie zgniatając migracje, np. po wydaniu każdej wersji produktu. W niektórych przypadkach przyspieszyłoby to inicjowanie nowego schematu.
Zgniatanie nie zawsze jest trywialne i może powodować regresje, nie przynosząc wielu korzyści. Inną świetną opcją jest użycie bazy danych nasion, aby uniknąć wykonywania wszystkich zestawów zmian. Jest dobrze przystosowany do środowisk testowych, jeśli potrzebujesz jak najszybciej przygotować bazę danych, być może nawet z niektórymi danymi testowymi. Możesz myśleć o tym jako o formie zgniatania zestawów zmian: W pewnym momencie (np. po wydaniu innej wersji) robisz zrzut schematu. Po przywróceniu zrzutu stosujesz migracje jak zwykle. Tylko nowe zmiany zostaną zastosowane, ponieważ starsze zostały już zastosowane przed wykonaniem zrzutu; dlatego zostały przywrócone z wysypiska.
Wniosek
Celowo unikaliśmy zagłębiania się w funkcje Liquibase, aby dostarczyć krótki i rzeczowy artykuł, skupiający się ogólnie na ewoluujących schematach. Mamy nadzieję, że jasne jest, jakie korzyści i problemy niesie ze sobą automatyczne stosowanie migracji schematów baz danych i jak dobrze to wszystko wpisuje się w kulturę DevOps. Ważne jest, aby nie zamieniać nawet dobrych pomysłów w dogmaty. Wymagania są różne, a nasze decyzje, jako inżynierów baz danych, powinny wspierać rozwój produktu, a nie tylko przestrzeganie zaleceń kogoś z Internetu.