Struktura bazy danych aplikacji nie jest wyryta w kamieniu. Dodawanie nowych funkcji czy refaktoryzacje prędzej, czy później wymuszą zmiany w danych przechowywanych w bazie danych lub w ich strukturze, czyli migrację (schema migration/database migration). Proces ten może generować szereg poważnych problemów takich jak:
- tymczasowe problemy z użytkowaniem aplikacji. Mogą wynikać np. z faktu, że niektóre operacje na bazie danych blokują możliwość zapisu/edycji/usunięcia danych;
- problemy wydajnościowe wynikające np. z niepoprawnych indeksów;
- niemożliwość wykonania rollbacka aplikacji w razie problemów. Co w przypadku gdy nowa wersja aplikacji zawiera migrację usuwającą kolumnę, która była używana w wersji poprzedniej? Rollback aplikacji spowoduje, że w przypadku próby odpytania o nieistniejącą kolumnę aplikacja zwróci błąd;
- utrata danych wynikająca z usunięcia pozornie niepotrzebnych kolumn lub tabel.
W artykule przedstawię Ci wiedzę, którą pozyskałem w trakcie procesu refaktoryzacji mechanizmu zarządzania migracjami baz danych. Przedstawię Ci kilka pułapek, na jakie możesz trafić tworząc migracje, a także rekomendacji i praktyk, które wypracowaliśmy z zespołem podczas pracy nad wspomnianym mechanizmem. Warto zaznaczyć, że są to jedynie rekomendacje. Z pewnością będzie można znaleźć sytuacje, gdzie postąpienie wbrew nim będzie miało sens lub będzie konieczne. Warto jednak robić to z głową i w świadomości potencjalnych konsekwencji.
Pisząc ten artykuł, tworzyłem go z myślą o systemie zarządzania bazą danych MySQL, jednak wiele z tych pułapek będzie też czyhać w innych relacyjnych bazach danych.
Nieco teorii
Aby być w stanie lepiej zrozumieć temat konieczne będzie krótkie wprowadzenie teoretyczne. Przez pojęcie migracji w tym artykule będę rozumiał kod SQL modyfikujący bazę danych dostarczany wraz z nową wersją aplikacji. Aplikacja zawierająca nową migrację w momencie deploymentu lub przed nim uruchamia kod migracji. Przykładowo, wersja 1.0
aplikacji łączy się z bazą zawierającą jedną tabelę z kilkoma kolumnami. Wersja 1.1
zawiera nową funkcję, która do poprawnego działania wymaga nowej kolumny. W tym celu wersja 1.1
dostarcza migrację, czyli fragment kodu dodający brakującą kolumnę.
W dalszej części artykułu będę posługiwał się skrótami, których zapamiętanie bardzo ułatwi czytanie artykułu:
- DDL — Data Definition Language — instrukcje definiujące i manipulujące strukturą danych. Są to np. operacje
CREATE
,ALTER
,DROP
; - DML — Data Manipulation Language — instrukcje manipulujące danymi (nie strukturą!). Są to operacje takie jak
INSERT
,DELETE
,UPDATE
; - DQL — Data Query Language — instrukcje odpytujące o dane, z których najważniejszą jest
SELECT
.
Będę również wykorzystywał pojęcia migracji up
oraz down
. Świetnie definicje tych migracji przedstawia poniższa definicja:
The up method should describe the transformation you’d like to make to your schema, and the down method of your migration should revert the transformations done by the up method. In other words, the database schema should be unchanged if you do an up followed by a down.
Podejścia do migracji
W kontekście zagrożeń, na które będę zwracał uwagę w tym artykule, interesować nas będą dwa podziały. Pierwszym z nich jest podział związany z dostępnością aplikacji wykorzystującej bazę danych:
- migracje powodujące downtime usługi. Czasami takie podejście pozwala przeprowadzić migrację szybciej i prościej, lecz skutkować będzie to czasową niedostępnością usługi;
- migracje bez downtime (zero downtime migration). Migracja ta pozostaje niewidoczna dla użytkownika aplikacji. Często będzie wymagać więcej czasu i nieszablonowego podejścia do problemu.
Drugi podział migracji, jaki będzie ważny z punktu widzenia zagrożeń, które przedstawię to podział związany z utratą danych:
- migracje bez ryzyka utraty danych;
- migracje, które powodują ryzyko utraty danych. Będą to takie migracje jak modyfikacje przeprowadzane na kolumnach oraz migracje usuwające dane.
Efektem, do jakiego moim zdaniem warto dążyć tworząc migracje, jest tworzenie migracji bez downtime, które nie tworzą ryzyka utraty danych.
Jakich migracji unikać?
Migracje, których warto unikać, zwykle będą łamały co najmniej jeden z dwóch warunków, które przedstawiłem przed chwilą.
Migracje usuwające dane
W pierwszej kolejności warto zastanowić się, czy migracje jawnie usuwające dane z tabel, kolumn lub bazy danych są konieczne. Będą to zapytania zawierające takie klauzule jak:
DELETE FROM ...
DROP TABLE ...
ALTER TABLE ... DROP COLUMN ...
DROP DATABASE ...
Zanim zdecydujesz się na usunięcie danych, zastanów się, czy jest to konieczne. Migracje usuwające dane powodują dwa problemy. Po pierwsze, dla migracji usuwających dane często nie jest możliwe napisanie migracji cofającej bazę do stanu poprzedniego. Innymi słowy, migracje up usuwające dane nie pozwalają na napisanie migracji down. Konieczne będzie wtedy przywrócenie bazy z backupu. Nawet jeśli Twój zespół ma przećwiczone przywracanie bazy z backupu, to chwilę to może zająć, co może skutkować downtimem aplikacji lub błędami.
Drugim problemem są same backupy. Zakładam, że robisz backupy i nawet pewnie masz to zautomatyzowane, ale:
- Kiedy ostatnio sprawdzałeś/aś, czy backup istnieje?
- Kiedy ostatnio sprawdzałeś/aś czy backup działa?
- Jak często wykonuje się backup? Czy utrata danych np. z jednego dnia jest dla Ciebie akceptowalna?
- Ile czasu trwa przywrócenie bazy z backupu?
- Jeśli trwa to np. kilka godzin, to czy kilkugodzinny downtime jest akceptowalny?
- Co w przypadku usług, które nie mogą sobie na to pozwolić?
- Co, jeśli wiąże nas określone SLA? Co w przypadku jego niespełnienia?
- Jak downtime wpłynie na relacje z klientami i ich opinię o firmie/aplikacji?
W perspektywie przedstawiony potencjalnych problemów warto zastanowić się, czy koszt przechowywania zbędnych danych nie będzie akceptowalny. Oczywiście to zależy od konkretnego przypadku. Na przykład koszt trzymania zbędnej kolumny w tabeli zawierającej 100 tysięcy wierszy z danymi typu VARCHAR(32)
będzie marginalny przy koszcie przechowywania np. bazy zawierającej terabajty danych analitycznych. W przypadku takiej kolumny, jej usuwanie moim zdaniem mija się z celem. Jeśli bardzo nam zależy na jej usunięciu, można to zrobić jakiś czas po upewnieniu się, że żaden fragment kodu jej nie używa, a aplikacja działa stabilnie i rollback do wersji sprzed zaaplikowania migracji nie będzie konieczny.
W drugim opisanym przypadku może pojawić się pokusa usunięcia danych starszych niż x
. Dane analityczne sprzed x
lat pewnie będą znacznie mniej przydatne niż te najnowsze. Decydując się na usunięcie takich danych, istnieje ryzyko popełnienia zwykłego błędu ludzkiego. Szansa na to jest mała, ale … znaki <
oraz >
są bardzo podobne, szczególnie w piątki po 16. Klauzula WHERE
również niekiedy potrafi się zgubić. Nie twierdzę, by tego nigdy nie robić. Wskazuję jednak, że zachowałbym tu szczególną ostrożność 😉
Migracją, którą coś usuwa, ale którą warto rozważyć, jest migracja usuwająca nieefektywny indeks. Zły indeks może być gorszy niż brak indeksu. Nieco bardziej ostrożnie podchodziłbym do indeksów, które wyglądają na nieużywane. Zanim usuniesz taki indeks, upewnij się kilkukrotnie, że jest nieużywany np. wykorzystując klauzulę EXPLAIN
i sprawdzając wartość w kolumnie key
. W MySQL 8.0 istnieje również możliwość ukrycia indeksu bez jego usuwania. Ponieważ usuwanie i dodawanie indeksów może trwać bardzo długo, bezpieczniej jest go ukryć. Ewentualnie po upewnieniu się, że faktycznie jest nieużywany można dodać migrację usuwającą go.
ALTER TABLE t1 ALTER INDEX i_idx INVISIBLE;
ALTER TABLE t1 ALTER INDEX i_idx VISIBLE;
Modyfikacja kolumn
W kontekście usuwania danych migracje modyfikujące kolumny również stanowią zagrożenie. Modyfikacja kolumny również może być migracją usuwającą dane. Przykładowo, jeśli kolumna o długości x
znaków ma zostać zmniejszona do y
znaków, to wymagane będzie przycięcie łańcuchów znaków do pożądanej długości. Taka migracja również nie umożliwia napisanie odpowiadającej jej migracji down.
CREATE TABLE test_table (
test_column VARCHAR(36)
);
INSERT INTO test_table (test_column) VALUES
('123456789012345678901234567890123456'); -- długość 36
UPDATE test_table
SET test_column = SUBSTRING(test_column, 1, 24);
ALTER TABLE test_table
MODIFY test_column VARCHAR(24);
SELECT * FROM test_table;
Potencjalne zagrożenie stanowią również migracje ALTER TABLE ... MODIFY
i ALTER TABLE ... CHANGE
, które nie powodują modyfikacji danych. Niektóre z instrukcji DDL powodują zablokowanie możliwości wykonywania w trakcie instrukcji DML. Do instrukcji DML należy m.in. INSERT
, co oznacza, że w trakcie wykonywania migracji blokującej niemożliwe będzie dodawanie, modyfikowanie i usuwanie zasobów co czyni go zasobem read-only na czas migracji. O ile wcześniej nie nastąpi timeout, operacje zostaną wykonane dopiero, gdy migracja się zakończy. Listę operacji DDL blokujących jednoczesne wykonywanie instrukcji DML można znaleźć w dokumentacji MySQL. Przeglądając dokumentację, skup się na załączonych tabelach i kolumnie Permits Concurrent DML. Wartość No
mają takie operacje jak zmiana typu danych czy typu kodowania znaków. Przed uruchomieniem migracji zachęcam do sprawdzenia wspomnianej dokumentacji. W zależności od wykorzystanej wersji MySQL oraz użytego algorytmu (słowo kluczowe ALGORITHM
) baza może dla danej migracji zachować się inaczej.
Przykładowo mając zadanie zmiany typu kolumny z VARCHAR
na TEXT
, zamiast blokować DML, warto rozważyć alternatywne podejście. Dodawanie nowej kolumny nie blokuje DML, podobnie jak operacja UPDATE
. Korzystając z tej właściwości, można stworzyć nową kolumnę o pożądanym typie, a następnie przekopiować do niej dane ze starej kolumny. Nie zawsze będzie to jednak możliwe. Jeśli np. migrujemy kolumnę typu VARCHAR
do INT
, to należy się spodziewać, że kopiowanie do nowej kolumny może się nie udać. W takim przypadku rozwiązaniem jest operowanie na dwóch kolumnach i obsłużenie kompatybilności wstecznej z poziomu kodu aplikacji. W opisanym przypadku mogłoby to wyglądać następująco. Przykład został przygotowany w TypeScript.
// Before
async function getValue(): Promise<string> {
const { value } = await query( someQuery );
return value;
}
// After
async function getValueV2(): Promise<number> {
const { valueNumber, valueString } = await query( someQueryV2 );
if ( valueNumber ) {
return valueNumber;
}
if ( isNaN( valueString ) ) {
throw new Error( 'Numeric value not found!' );
}
return parseInt( valueString, 10 );
}
Modyfikacją tabel i kolumn, jakie według mnie powodują więcej problemów niż dają korzyści, to ich rename. Nie niesie to praktycznie żadnej wartości biznesowej, a konieczne jest przygotowanie migracji up
, migracji down
, poprawek w kodzie aplikacji i przetestowania czy wszystko działa. Moim zdaniem to jest jeden z tych nielicznych przypadków, gdzie wolałbym zostać przy nieodpowiedniej nazwie i dodać komentarz wyjaśniający, co przechowuje dana kolumna lub tabela. Zostawiam tu jednak małą gwiazdkę i zaraz się dowiesz dlaczego.
ALTER TABLE ... MODIFY
bez blokowania DML – podejście alternatywne
Jeśli koniecznie chcemy dokonać zmiany, która spowoduje blokowanie operacji DML, to można ominąć problem blokowania, wykorzystując podejście alternatywne. Lista kroków w takim podejściu wygląda następująco:
- Stwórz nową (pustą) tabelę, której struktura będzie kopią oryginalnej tabeli.
- Zaimplementuj migrację na pustej tabeli.
- Dodaj triggery na oryginalną tabelę, które w przypadku operacji
INSERT
,UPDATE
iDELETE
zreplikują operację na klonie. - Skopiuj dane z oryginalnej tabeli do klona.
- Usuń oryginalną tabelę.
- Zmień nazwę klona na nazwę oryginalnej tabeli.
- Usuń niepotrzebne triggery.
W praktyce ręczne migrowanie danych w przedstawiony sposób jest uciążliwe. Na szczęście istnieją narzędzia automatyzujące ten proces. Śmiało mogę polecić narzędzie od Percony pt-online-schema-change. Instalacja narzędzia sprowadza się do wywołania prostej komendy i nie wymaga dodatkowej konfiguracji.
Po instalacji możesz przetestować narzędzie, wykonując dry run. Przekazanie flagi print spowoduje zwrócenie w konsoli listy wykonanych zapytań, które będą odtwarzały kroki opisane przeze mnie wcześniej. Użycie narzędzia w trybie dry run wygląda następująco.
pt-online-schema-change --alter "QUERY CONTENT HERE" D=database,t=table --user=username --host=host --password=password --dry-run --print
Po usunięciu flagi --dry-run
i ponownym wywołaniu narzędzie dokona procesu migracji. W przypadku długich migracji narzędzie będzie informowało o procentowym progresie w migracji i estymowanym czasie do końca migracji (czas ten niestety często jest niedoszacowany). Po pełną listę flag i listę możliwości narzędzia odsyłam do dokumentacji Percona Toolkit.
Podsumowanie
W ramach podsumowania zostawiam kilka dodatkowych podpowiedzi jak efektywniej pracować z migracjami w relacyjnych bazach danych:
- Jeśli masz wątpliwości czy dana migracja jest bezpieczna, to napisz do niej migrację
down
. Jeśli nie umożliwia ona przywrócenia bazy do stanu sprzed migracji, to prawdopodobnie masz odpowiedź; - Jeśli migracja blokująca DML robi to na milisekundy lub pojedyncze sekundy to wysiłek włożony w zapewnienie operacyjności bazy moim zdaniem jest niewspółmierny do korzyści w większości przypadków. O ile nie rozwijasz aplikacji na skalę Facebooka, downtime liczony w sekundach prawdopodobnie pozostanie niezauważony;
- Jeśli jest to możliwe, to sklonuj sobie bazę i zaimportuj ją na lokalnej instancji serwera baz danych. Nie pozwoli Ci to oszacować, ile dokładnie migracja potrwa na środowisku produkcyjnym, ale da Ci ogląd na sytuację czy mowa o wykonaniu migracji w kilka sekund, czy w kilkadziesiąt minut.
- Jeśli nie masz pewności czy migracja blokuje DML, to możesz lokalnie uruchomić proces migracji, a jednocześnie korzystając z drugiego połączenia do bazy próbować dodać, zaktualizować lub usunąć rekord w bazie. Jeśli operacja zostanie odwleczona do momentu zakończenia migracji, oznacza to, że migracja zablokowała DML.
- Jeśli chcesz się upewnić, że Twoja migracja usunie tylko to, co powinno być usunięte, to również warto przetestować to wcześniej na lokalnej kopii bazy danych.
Mam nadzieję, że dzięki temu artykułowi dodawanie migracji do baz danych będzie dla Ciebie nieco mniej straszne. Opisane typy migracji są realnymi przypadkami, z którymi miałem styczność w swojej pracy. Domyślam się jednak, że nie wyczerpuje to w pełni tematu, dlatego jestem ciekaw Twoich doświadczeń! Daj znać w komentarzu, z jakimi problematycznymi migracjami Ty się spotkałeś/aś i jak udało Ci się z nimi uporać.
Źródła i materiały dodatkowe
- Wikipedia – Schema migration
- Operacje DQL, DML, DCL, DDL w MsSQL
- Using the up/down Methods
- Data migration strategies – Trickle Vs Big Bang
- An Introduction to Database Migration Strategy and Best Practices
- The Secret Life of Your Database, Part 1: Migrations
- Poor database indexing – a SQL query performance killer – recommendations
- MySQL Docs – EXPLAIN Output Format
- What is Database Schema Migration?
- Online DDL Operations
- Alter table without locking the entire table
- Percona pt-online-schema-change
- Avoiding downtime in migrations – GitLab Docs
- Strong Migrations – Catch unsafe migrations in development
- Common Challenges in Schema Migration & How To Overcome Them
Zapisz się na mailing i odbierz e-booka
Odbierz darmowy egzemplarz e-booka 106 Pytań Rekrutacyjnych Junior JavaScript Developer i realnie zwiększ swoje szanse na rozmowie rekrutacyjnej! Będziesz też otrzymywać wartościowe treści i powiadomienia o nowych wpisach na skrzynkę e-mail.