Koncept migracji bazodanowych i Doctrine 2

gildia-developerow.pl 11 miesięcy temu

Podczas tworzenia aplikacji biznesowych, nie sposób nie poruszyć tematu aktualizacji bazy danych – elementu, z którym spotykamy się w codziennej pracy. Niezależnie od typu środowiska, zawsze trafimy na temat migracji bazy danych. Na szczęście Doctrine posiada specjalnie do tego wyspecjalizowany mechanizm, którego najważniejsze elementy poruszymy w tym wpisie.

Przykład w stylu: co może pójść nie tak?

Schemat pracy nad aplikacją zwykle jest bardzo podobny. Tworzymy brancha, który zawiera nasz nowy kod, w którym m.in. zmieniamy encje, a następnie dorzucamy coś nowego do mapowań. Dalej nasz branch przechodzi proces code review, po czym zostaje zmergowany (do mastera, developa albo innego zbiorczego brancha). Podczas deploya produkcyjnego odpalamy komendę doctrine:schema:update i wszystko działa. Bajka. Niestety, tylko teoretycznie.

Już podczas tworzenia prostych aplikacji może okazać się, iż to nie zapyka. Wyobraźmy sobie taką sytuację, kiedy kilkuosobowym zespołem (5-10 backendowców) pracujemy nad nowym zestawem funkcjonalności. Na potrzeby dalszej części wpisu, nazwijmy ten zespół „Team X”. Ponieważ pracujemy nad czymś dużym i spójnym, pracujemy nad tym ponad pół roku. Jeden pull request się zamyka, trzy nowe otwierają się na jego miejsce. Jeden coś dorzuca, drugi coś modyfikuje. Konteksty PRek są bardzo różne. Ponieważ działamy zgodnie z GitFlow, to wrzucamy wszystko do brancha develop. W międzyczasie prac dochodzimy do wniosku, iż kilka już istniejących elementów systemu jest do remontu i oczywiście – zasadą dobrego skauta – planujemy remont w trakcie.

Przychodzi dzień deploya produkcyjnego. Wszyscy nastawiają się na świętowanie – w końcu wszystko na branchu releasowym działa, jest choćby potwierdzone przez naszych QA. Mamy choćby fajną procedurę na deploy:

  1. Wrzucamy zaślepkę na serwer, iż za chwilę wrócimy.
  2. Wrzucamy kodzik – pewnie merge pull requesta oraz wykonanie polecenia git pull origin master na serwerze.
  3. Dokonfigurowujemy nowe serwisy – bo przykładowo dodaliśmy redisa – i czyścimy cache.
  4. Odpalamy przygotowaną wcześniej komendę dostosowującą bazie danych do nowej schemy. Bo coś tam się zmieniło – wartość jednego pola zaczęliśmy traktować jako JSON i trzeba tam gdzieś przemigrować wartości.
  5. Odpalamy bin/console doctrine:schema:update --force. Nie zapominamy oczywiście o dołożeniu parametru --dump-sql, co by zobaczyć, co rzeczywiście się stanie.
  6. Wyrzucamy zaślepkę i pijemy szampana.

Procedura wygląda na spójną. Dla utrzymania dramaturgii całej sytuacji załóżmy, iż przychodzi piątek, godzina 14 (1). Pierwsze cztery punkty przeszły gładko. Jeszcze tylko zmiana Schemy i możemy świętować. A tu zonk. Ku naszemu zdumieniu, pojawia nam się komunikat informujący, iż nie możemy wykonać zapytania zmieniającego indeks na unikalny, ponieważ w kolumnie znajdują się wartości nieunikalne. Załóżmy, iż są to identyfikatory UUID.

Cała sytuacja wygląda następująco:

  • Mamy kilka milionów wierszy, po wykonaniu odpowiedniego zapytania na bazie stwierdzamy, iż około 3500 wpisów ma błędne identyfikatory.
  • Wygenerować nowych identyfikatorów nie możemy, ponieważ mamy zewnętrzne serwisy, które po tych identyfikatorach są sparowane.
  • Indeks na unikalną wartość jest nam potrzebny, ponieważ dalsza część aplikacji bazuje na regule unikalności tej kolumny.

Próba dowiedzenia się, dlaczego mamy błędne identyfikatory będzie trwała. Podczas, kiedy system nie działa, nie możemy się tym zająć. Niestety, nie możemy zignorować tego problemu, ponieważ zrobi nam się spory bałagan na poziomie mikroserwisów (a nie wszystkie są przez nas zarządzane). Jednym słowem: release nie powiódł się.

Pada decyzja w drugą stronę: cofamy wszystkie zmiany i na chłodno dochodzimy do tego, co się stało. Tutaj pojawia się drugi zonk: napisaliśmy komendę, która przemigruje wartości na JSON (punkt nr 4 deploya), ale nie mamy nic, co by odwróciło cały proces. choćby o ile cofniemy zmiany w kodzie i przywrócimy starą schemę, to i tak posypią się 500ki. Sytuacja jest patowa, rozbijamy obóz na weekend.

Koncept migracji bazodanowych

Migracją bazy danych nazywamy jej modyfikację, która ma służyć wdrożeniu nowej wersji aplikacji. Możemy podzielić ją na dwa rodzaje:

  • Migracja struktury bazy danych – dodajemy, modyfikujemy bądź usuwamy w tym miejscu tabele, indeksy oraz inne struktury bazodanowe. W tym miejscu nie ma zbytnio miejsca na logikę biznesową. Ten etap zwykle trwa krótko.
  • Migracja danych – polega na dostosowaniu danych znajdujących się w tabelach, konieczne do poprawnej pracy nowej wersji aplikacji. Ze względu na potencjalną konieczność przeliczania wartości każdego wiersza zgodnie z logiką biznesową, ten etap migracji potrafi trwać bardzo długo.

Przed każdym release powinniśmy zweryfikować oraz oszacować czas trwania migracji. Niektóre, teoretycznie proste zabiegi, mogą spowodować, iż zablokujemy aplikację choćby na kilka godzin. o ile proces migracyjny miałby trwać długo, to dobrze by było zaplanować go na czas, kiedy jak najmniejsza liczba użytkowników korzysta z aplikacji. No i nie można zapomnieć o odpowiednim komunikacie dla użytkowników serwisu.

To, co jest bardzo istotne w związku z migracjami, to konieczność dwustronnego działania. Oznacza to, iż o ile dodajemy tabelę, to powinniśmy również przewidzieć mechanizm na usunięcie jej. o ile dodajemy kolumny do tabeli, to powinniśmy zapewnić mechanizm odwrotny. Mówiąc „mechanizm odwrotny”, mam na myśli proces, który będzie w stanie całkowicie odwrócić efekt migracji.

No i backup. Nie zapominajmy o backupie. W sytuacjach kryzysowych, ten może okazać się bezcenny. Nawet, o ile zebranie go będzie trwało dłużej, niż cały proces migracyjny.

Migracje bazodanowe a komunikacja zewnętrzna

Migracje struktury danych są procesem, podczas którego pracuje sama baza danych. Podczas ich działania nie widzę możliwości, aby aplikacja wyprodukowała jakiekolwiek artefakty, które mogłyby zostać udostępnione na zewnątrz.

Nieco inaczej jest w temacie migracji danych. o ile zdecydujemy się na wykorzystanie istniejących w aplikacji serwisów, to istnieje prawdopodobieństwo wyprodukowania maili, wiadomości na kolejkach oraz requestów do zewnętrznych systemów. Powinniśmy mieć to na uwadze i na wszelki wypadek wyłączyć, na czas migracji danych, wszystkie połączenia, przez które mogłyby uciec artefakty.

Dodatkowo, o ile my udostępniamy API bądź jesteśmy wpięci w system kolejkowy, to inni mogą chcieć skomunikować się z nami w trakcie trwania migracji. Powinniśmy więc zadbać również o to, aby:

  • wyłączyć dostęp do API. Najlepiej do tego celu wykorzystać odpowiedni kod HTTP, np. 503 Service Unavailable. Ustawienie takiego kodu w naszym API zwiększy szansę na to, iż obcy system wróci do nas z requestem za jakiś czas.kolejkę, to
  • wyłączyć wszystkie skrypty konsumujące wiadomości z systemu kolejkowego. Zaletą systemu kolejkowego jest to, iż te wiadomości będą czekały na nas, dopóki sytuacja na froncie się nie ustabilizuje.

Migracje wielo-bazodanowe

W dobie aplikacji SaaS (ang. System-as-a-Service), czasami aplikacja jest projektowana w ten sposób, iż jeden klient = jedna baza danych. Z doświadczenia mogę powiedzieć, iż jest to sytuacja, w której nie można działać w trybie YOLO. Zakładając, iż mamy kilka tysięcy baz danych, które (o zgrozo) działają z aplikacją rozdysponowaną pod tylko jeden serwer aplikacyjny, to nie może się odbyć bez ofiar.

Nie róbcie tak!

Kiedy mamy jeden serwer aplikacyjny, na który wpuścimy nowy kod, to dopóki nie zostaną uruchomione migracje na każdej bazie danych z osobna, dopóty będziemy mieli klientów, którym aplikacja nie działa. Policzmy sobie, iż mając 3600 klientów (w domyśle 3600 baz danych), gdzie jedna baza migruje się 1 sekundę, cały proces migracji zajmie nam 1 godzinę. o ile połączymy to ze scenariuszem z pierwszej części wpisu, to wychodzi nam duży problem.

Kontynuując, o ile dobierzemy proces migracyjny w sposób nieodpowiedni, to może dojść do sytuacji, kiedy migracje, jedna po drugiej, zaczną sypać błędami. Będziemy stali wtedy przed dylematem:

  • Zatrzymać proces migracyjny, co będzie skutkowało częścią źle zmigrowanych oraz częścią dobrze zmigrowanych baz danych. o ile będzie można w sposób bezproblemowy przywrócić kod sprzed release, to będziemy mieli część klientów z działającymi aplikacjami, a część z niedziałającymi. Problematyczne może być znalezienie wszystkich niedziałających aplikacji. Może nie tyle problematyczne, co – zajmie to czas.
  • Kontynuować proces migracyjny, choćby o ile ten jest uszkodzony. Będziemy wtedy w sytuacji, kiedy wszystkie aplikacje przestaną działać. To, co zyskamy, to – mimo wszystko – spójność procesu. W trakcie trwania tej „popsutej” migracji, my przygotujemy nową, która będzie kolejno naprawiała aplikacje.

Powyższa historia wydarzyła się na prawdę. I w sumie, to cieszę się z tego doświadczenia, bo dzięki temu mogę przekazać je innym, między innymi Wam – moim czytelnikom.

Metoda adekwatna

Jeżeli mamy do czynienia z wieloma bazami danych, to musimy ważyć zmiany w aplikacji. Poprawny proces migracyjny będzie wymagał od nas nieco więcej zabawy. Zabawy, do której będziemy potrzebowali dwóch maszyn aplikacyjnych – „starej” i „nowej”.

Jako „starą” maszynę, mam na myśli obecny serwer aplikacyjny, na którym nic się nie zmieni. Będziemy tam mieli stary kod, nie będzie tam żadnego deploya. Ta „nowa” jednostka to będzie miejsce dla już zmigrowanych aplikacji, z nowym kodem. Całość pracy będzie polegała na stopniowym migrowaniu każdej aplikacji, wraz z przenoszeniem jej na nowy serwer aplikacyjny. Z początku dobrze by było cały proces przeprowadzać pojedynczo.

Kiedy upewnimy się, iż wszystko wygląda bardzo spójnie, to możemy podjąć decyzję o „paczkowaniu”, czyli migrowaniu po 10, 100 aplikacji jednocześnie. Jest to metoda, która zmaksymalizuje nasze bezpieczeństwo.

Dochodzimy do migracji w Doctrine 2:

Doctrine, jako system klasy ORM, posiada swój system migracji. Główną zasadą tego systemu jest praca na generowalnych plikach źródłowych znajdujących się w konfigurowalnym katalogu w projekcie. Każdy z tych plików posiada następującą strukturę:

<?php declare(strict_types=1); namespace App\Migrations; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; use Symfony\Component\DependencyInjection\ContainerAwareInterface; use Symfony\Component\DependencyInjection\ContainerInterface; class Version20230520153137 extends AbstractMigration { public function up(Schema $schema): void { $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); $this->addSql('...'); } public function down(Schema $schema): void { $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); $this->addSql('...'); } }

Aby mieć dostęp do odpowiednich metod, musimy dziedziczyć poklasie AbstractMigration. Wewnątrz zdefiniowanej klasy widzimy dwie metody: up(...) oraz down(...). Pierwsza z nich definiuje zapytania (oraz potencjalnie logikę), która ma zostać wykonana podczas przeprowadzania migracji. Druga służy do przeprowadzenia akcji totalnie odwrotnej.

Podczas prac na projekcie zbieramy grupę plików migracyjnych (nazywanych potocznie wersjami), które następnie służą nam kolejno w procesie migracji. Oznacza to, iż w momencie wywołania komendy

$ bin/console doctrine:migrations:migrate

na każdej ze zdefiniowanych wersji zostanie kolejno uruchomiona metoda up(...). Po uruchomieniu każdej wersji, w bazie danych (w tabeli doctrine_migrations) zostanie umieszczony wpis informujący o tym, iż ta konkretna wersja została wykonana. Jest to mechanizm zapobiegający ponownej migracji wersji, która została już uruchomiona w obrębie środowiska.

Doctrine definiuje pewien zakres komend, który pozwala nam na sprawne poruszanie się między wersjami. Aby dostać się do definicji każdej z nich, należy wykonać polecenie:

$ bin/console doctrine:migrations:

Jako wyjście, w okienku konsoli otrzymamy spis wszystkich dostępnych komend związanych z migracjami:

// bin/console doctrine:migrations: Command "doctrine:migrations:" is ambiguous. Did you mean one of these? doctrine:migrations:diff Generate a migration by comparing your current database to your mapping information. doctrine:migrations:sync-metadata-storage Ensures that the metadata storage is at the latest version. doctrine:migrations:list Display a list of all available migrations and their status. doctrine:migrations:current Outputs the current version doctrine:migrations:dump-schema Dump the schema for your database to a migration. doctrine:migrations:execute Execute one or more migration versions up or down manually. doctrine:migrations:generate Generate a blank migration class. doctrine:migrations:latest Outputs the latest version doctrine:migrations:migrate Execute a migration to a specified version or the latest available version. doctrine:migrations:rollup Rollup migrations by deleting all tracked versions and insert the one version that exists. doctrine:migrations:status View the status of a set of migrations. doctrine:migrations:up-to-date Tells you if your schema is up-to-date. doctrine:migrations:version Manually add and delete migration versions from the version table..

Z powyższego mogę wyróżnić:

  • doctrine:migrations:generate – generujemy pusty plik migracji
  • doctrine:migrations:diff – działa podobnie do doctrine:schema:update, z tą różnicą, iż zamiast wykonywać zapytania, to dorzuca je do świeżo wygenerowanego pliku wersji
  • doctrine:migrations:exacute – służące do uruchamiania pojedynczych wersji

Uwagę należy również zwrócić na główną komendę bin/console doctrine:migration:migrate . Jej bardzo przydatnym parametrem jest --dry-run, który pozwoli uruchomić migrację „na sucho”. Czyli bez jakiegokolwiek inputu do samej bazy danych.

Rozszerzanie klasy AbstractMigration

Klasa AbstractMigration posiada kilka metod, z których możemy skorzystać w trakcie komponowania pliku migracji. Do dyspozycji mamy metody:

// https://github.com/doctrine/migrations/blob/3.6.x/lib/Doctrine/Migrations/AbstractMigration.php public function preUp(Schema $schema): void; public function postUp(Schema $schema): void; public function preDown(Schema $schema): void; public function postDown(Schema $schema): void;

W definicji klasy są to puste metody, które możemy nadpisać. Są one uruchamiane przed/po uruchomieniu metod up(...) oraz down(...). Dzięki nim możemy uzyskać lepsze logowanie, operować na migrowanych strukturach oraz danych itp.

Do tego dochodzą metody, których możemy używać w trakcie trwania metody, a dzięki którym możemy sterować procesem migracji:

// https://github.com/doctrine/migrations/blob/3.6.x/lib/Doctrine/Migrations/AbstractMigration.php public function warnIf(bool $condition, string $message = 'Unknown Reason'): void; public function abortIf(bool $condition, string $message = 'Unknown Reason'): void; public function skipIf(bool $condition, string $message = 'Unknown Reason'): void;

Eventy

Dodatkowo, Doctrine daje nam możliwość podpięcia się pod Eventy, których listę możecie znaleźć poniżej:

  • onMigrationsMigrating – uruchamiane przed startem migratora, pod warunkiem, iż mamy do zmigrowania co najmniej jedną wersję,
  • onMigrationsVersionExecuting – uruchamiane przed wykonaniem pojedynczej wersji,
  • onMigrationsVersionExecuted – uruchomione po wykonaniu pojedynczej wersji,
  • onMigrationsVersionSkipped – uruchomione, kiedy wersja jest pominięta,
  • onMigrationsMigrated – uruchomione po zakończeniu pracy migratora.

Ktoś zapyta, jaka jest różnica pomiędzy Eventami oraz metodami z klasy AbstractMigration? Okazuje się, iż istnieją pewne różnice:

  • Metody klasy AbstractMigration dają nam możliwość różnej logiki, w zależności od potrzeb konkwetnej wersji migracji. Eventy dają nam opcję na scentralizowane, generyczne działanie, niezależnie od tego, co jest w danej chwili migrowane.
  • Metody klasy AbstractMigration posiadają inny zestaw parametrów niż Eventy. W pojedynczej migracji mamy do dyspozycji jedynie obiekt klasy Schema. Eventy mają dostęp do obiektów klas MigrationConfigurator oraz MigrationPlanList.

To daje nam potencjał na zupełnie inne wykorzystanie tych dwóch, podobnych do siebie, mechanizmów.

Transakcje

Bardzo ważną, moim zdaniem, kwestią jest transakcyjność procesu migracji. Bardzo ciekawą opcją komendy doctrine:migrations:migrate jest --all-or-nothing. Jest to konfiguracja, która otacza transakcją cały proces migracji. Oznacza to, iż o ile choćby najmniejszy błąd po stronie bazy danych się wydarzył – nic nie zostanie zmodyfikowane po stronie bazy danych. To samo tyczy się innych rzeczy, które mogą wysypać proces, do momentu wysłania zapytania robiącego commit transakcji. Łapcie kawałek kodu odpowiedzialny za ten mechanizm.

Oprócz wyżej wspomnianej opcji należy wspomnieć, iż każda wersja jest z góry traktowana jako objęta transakcją bazodanową. Odpowiedzialne za to są metody startMigration(…) oraz fragment metody executeMigration(…):

// https://github.com/doctrine/migrations/blob/3.6.x/lib/Doctrine/Migrations/Version/DbalExecutor.php#L117 private function startMigration( MigrationPlan $plan, MigratorConfiguration $configuration ): void { $this->sql = []; $this->dispatcher->dispatchVersionEvent( Events::onMigrationsVersionExecuting, $plan, $configuration ); if (! $plan->getMigration()->isTransactional()) { <--------- TUTAJ return; } // only start transaction if in transactional mode $this->connection->beginTransaction(); } // https://github.com/doctrine/migrations/blob/3.6.x/lib/Doctrine/Migrations/Version/DbalExecutor.php#L213 private function executeMigration( MigrationPlan $plan, ExecutionResult $result, MigratorConfiguration $configuration ): ExecutionResult { // ... if ($migration->isTransactional()) { <--------- TUTAJ TransactionHelper::commitIfInTransaction($this->connection); } $plan->markAsExecuted($result); }

Jeżeli zechcemy wyłączyć możliwość transakcyjności dla konkretnej wersji (bądź wielu z nich), należy nadpisać metodę isTransactional(...) z klasy AbstractMigration.

Kolejność ma znaczenie – Version number

Na sam koniec należy wspomnieć o kolejności wykonywania migracji. Nieprzypadkowo, pliki nowych wersji generowane są z nazwami o wzorcu yyyymmddhhiiss (data, wiecie). W dokumentacji Doctrine możemy przeczytać, iż wersje sortowane są właśnie po nazwie, jako ciągi znakowe. Stąd ten charakterystyczny sposób automatycznego nazewnictwa plików. o ile mamy potrzebę w jakiś sposób manipulować kolejnością wykonywania poszczególnych wersji, to własnie poprzez zabawę ich nazwami.

Czy samo wykorzystanie migracji Doctrine pomogłoby Teamowi X?

Odpowiedź brzmi: i tak i nie. Wszystko zależy od tego, w jaki sposób zostałyby one wykorzystane.

Jeżeli wszystkie migracje wynikające z modyfikacji systemu połączylibyśmy w jedną, to mielibyśmy wszystko objęte transakcją. Czyli do bazy danych poszłoby wszystko, albo nic. Na pewno mielibyśmy wtedy łatwiej, gdybyśmy cofali się z deployem. o ile nie połączylibyśmy tych migracji w jedną, to musielibyśmy zadbać o poprawną implementację metody down(...). Mechanizmu migracji Doctrine jest tak skonstruowany, iż na pewno komuś zaświeciłaby się lampka z pytaniem: „A co, jak się nie uda?”.

(1) – Sam nie popieram tego typu działań. Punkt o dramaturgii wprowadziłem jedynie, abyśmy mogli wszyscy się nieco pośmiać

Idź do oryginalnego materiału