Kto tu rządzi schematem? Migracje bazy danych, gdy wiele usług dzieli jedną bazę
Ile wersji Twojej aplikacji działa w tej chwili na produkcji? Jeśli kilka usług w Kubernetesie dzieli jedną bazę danych, odpowiedź „jedna” jest prawie na pewno błędna. Opisujemy strategię, w której schemat i kod ewoluują razem — z jednym właścicielem migracji, zasadą fail fast, wzorcem expand/contract i mechanizmem, który nie pozwoli usunąć kolumny, dopóki ktoś jeszcze jej używa.
Zacznijmy od pytania kontrolnego: ile wersji Twojej aplikacji działa w tej chwili na produkcji?
Jeśli odpowiedź brzmi „jedna”, warto ją sprawdzić. Rolling update w Kubernetesie przez kilka minut utrzymuje obok siebie stare i nowe pody. Wydanie kanaryjskie robi to celowo przez kilka godzin. CronJob, który generuje raport raz w miesiącu, uruchomi się z obrazem sprzed trzech tygodni. Do tego dochodzi zapomniany skrypt eksportu u partnera. Kod ma wiele wersji naraz. Baza danych ma zawsze dokładnie jedną.
To jest sedno problemu, o którym piszemy. Gdy kilka deploymentów — backoffice, API, procesy w tle, zadania cykliczne — dzieli jedną bazę, każda migracja schematu jest zmianą kontraktu z wieloma stronami naraz, z których część jeszcze nie wie, że kontrakt się zmienia. Poniżej opisujemy strategię pracy, w której ewolucja danych i ewolucja kodu są jednym procesem, a nie dwoma, które ktoś ręcznie synchronizuje. Opieramy się na Flyway, ale zasady przenoszą się na Liquibase, migracje Django, Alembic czy narzędzia wbudowane w frameworki.
Ręcznie? Przy dwóch bazach już nie
Zanim przejdziemy do wielu usług, warto odpowiedzieć na prostsze pytanie: po co w ogóle wersjonowane migracje?
Wystarczą dwie instancje tych samych danych — test i produkcja — żeby ręczne zarządzanie schematem przestało działać. Ktoś doda indeks na produkcji „bo było wolno”, ktoś inny zmieni typ kolumny na teście „do sprawdzenia”. Po pół roku środowiska różnią się w sposób, którego nikt nie potrafi opisać. A testy, które przechodzą na środowisku, które nie jest kopią produkcji, niczego nie dowodzą.
Narzędzie typu Flyway zamienia historię schematu w uporządkowany ciąg plików V1__…, V2__…, które leżą w repozytorium obok kodu i są wykonywane dokładnie raz, w ustalonej kolejności. Korzyści są szersze, niż się zwykle wymienia:
- Odtwarzalność środowisk. Nowe środowisko to pusta baza i
migrate. Nie „dump z produkcji i poprawki ręczne”, tylko deterministyczny ciąg kroków. - Schemat przechodzi review. Zmiana struktury danych trafia do merge requesta razem z kodem, który jej potrzebuje. Recenzent widzi obie strony kontraktu jednocześnie.
- Audyt. Tabela
flyway_schema_historymówi, która migracja, kiedy, z jaką sumą kontrolną i czy zakończyła się sukcesem. Na pytanie „od kiedy ta kolumna istnieje?” odpowiada zapytanie, a nie archeologia. - Wykrywanie dryfu. Sumy kontrolne wykrywają zmodyfikowane po fakcie pliki migracji, a polecenie
validate— rozjazd między tym, co kod zna, a tym, co jest w bazie. - Migracje danych, nie tylko DDL. Przeniesienie wartości do nowej kolumny, normalizacja, uzupełnienie słowników — wersjonowane tak samo jak struktura.
- Testy na prawdziwym schemacie. Te same migracje budują bazę w testach integracyjnych (np. Testcontainers), więc testy sprawdzają schemat, który naprawdę trafi na produkcję.
- Wdrożenie nowej osoby w zespole. Historia modelu danych jest czytelna jak changelog. Nowa osoba nie musi pytać, „skąd się wzięła ta tabela”.
- Przewidywalny kierunek zmian. Migracje idą do przodu. Błąd naprawia kolejna migracja, a nie ręczne „cofnięcie” na produkcji.
Bez tego trudno sobie wyobrazić wielokrotne wdrażanie nowych wersji aplikacji na kilku środowiskach. Ale przy wielu usługach na jednej bazie same migracje to dopiero początek.
Teza pierwsza: migruje dokładnie jeden
Najprostsza konfiguracja — każda usługa ma Flyway z migrate przy starcie — wygląda niewinnie. Flyway zakłada blokadę na czas migracji, więc dwie repliki nie wykonają tego samego skryptu jednocześnie. Problem leży gdzie indziej: kto i w jakiej wersji decyduje o schemacie?
Jeśli migracje uruchamia każda usługa, to o stanie bazy decyduje ta, która akurat wystartowała pierwsza. Wdrożenie nowej wersji API zmieni schemat pod backoffice'em, który o niczym nie wie. Wdrożenie hotfixa w starej gałęzi innej usługi może spróbować dołożyć migrację w miejscu, które w głównej linii jest już zajęte. Odpowiedzialność rozmywa się, a wraz z nią przewidywalność.
Dlatego zasada: w systemie jest dokładnie jeden „migration master”. Tylko on wykonuje migrate. Wszystkie pozostałe deploymenty migracji nie wykonują — co najwyżej sprawdzają, czy baza jest w stanie, którego oczekują.
Kto powinien nim być? Z naszej praktyki: deployment z GUI administracyjnym, backoffice'em. To aplikacja, która i tak zmienia się razem z modelem danych (każde nowe pole trzeba gdzieś edytować), ma najmniejszy ruch zewnętrzny i jej chwilowa niedostępność w trakcie dłuższej migracji boli najmniej. Klienci API nie czekają, aż zakończy się ALTER TABLE.
Alternatywą jest osobny Job Kubernetesa (np. hook Helma uruchamiany przed aktualizacją deploymentów) albo initContainer. To dobre rozwiązanie, gdy żadna z aplikacji nie jest naturalnym właścicielem modelu. Zasada pozostaje ta sama: jeden wykonawca, jedna linia historii.

Teza druga: reszta sprawdza i natychmiast upada
Skoro tylko master migruje, co robią pozostali? Sprawdzają przy starcie, czy wersja schematu zgadza się z tą, której oczekuje ich kod. Jeśli nie — kończą działanie z błędem. Natychmiast, bez prób „jakoś sobie poradzić”.
Brzmi groźnie? W Kubernetesie to jest najbezpieczniejsze, co pod może zrobić. Nowa wersja, która nie przejdzie startu, nigdy nie zgłosi gotowości (readiness), więc nie dostanie ruchu. Rolling update się zatrzyma, a stara wersja dalej obsługuje użytkowników — dokładnie tak, jak przed wdrożeniem. Awaria nowej wersji nie jest katastrofą. Jest bezpiecznikiem.
Flyway ma do tego gotowe narzędzie: validate. Porównuje migracje znane aplikacji (z jej classpath) z tabelą historii w bazie. Zachowanie, które sprawdziliśmy na Flyway 10 i PostgreSQL 17, jest dokładnie takie, jakiego potrzebujemy:
- Nowy kod na starej bazie — kod zna migrację, której w bazie nie ma.
validatekończy się błędem Detected resolved migration not applied to database. Pod upada. Dobrze: master jeszcze nie zmigrował. - Stary kod na nowej bazie — w bazie jest migracja, której kod nie zna. Domyślne ustawienie
ignoreMigrationPatternsto*:future, więc migracje „z przyszłości” są ignorowane ivalidateprzechodzi. Też dobrze: to jest dokładnie ta sytuacja, którą chcemy dopuścić w fazie expand.
Ta asymetria jest kluczowa i rzadko się o niej mówi. Gdyby ktoś „dla porządku” wyczyścił ignoreMigrationPatterns, stary kod zacząłby upadać po każdej migracji mastera — i rolling update zamieniłby się w przestój.
W Spring Boot wystarczy zostawić Flyway włączony we wszystkich usługach, ale poza masterem podmienić strategię migrate na validate:
1@Configuration2@Profile("!migration-master")3class SchemaVersionCheck {45 // Usługa nie migruje. Sprawdza tylko, czy baza ma6 // wszystkie migracje znane jej kodowi. Brak którejś7 // = wyjątek przy starcie = fail fast.8 @Bean9 FlywayMigrationStrategy validateOnly() {10 return flyway -> flyway.validate();11 }12}
Z tej zasady wynika kolejność wdrożeń: najpierw master, potem cała reszta. Jeśli pipeline zrobi to w odwrotnej kolejności, nic złego się nie stanie — nowe pody pozostałych usług upadną, stare będą działać, a po wdrożeniu mastera wystarczy ponowić rollout. Fail fast zamienia błąd kolejności z incydentu w opóźnienie.
Teza trzecia: migracje w jednym miejscu
Wiele deploymentów to zwykle wiele projektów albo podprojektów. I tu pojawia się pokusa: każdy zespół trzyma migracje „swoich” tabel u siebie. To prosta droga do sytuacji, w której nikt nie zna pełnej historii schematu, numery wersji kolidują, a kolejność zależy od tego, kto wdrożył pierwszy.
Migracji nie wolno rozrzucać. Muszą być zebrane systematycznie, w jednym miejscu, w jednej linii wersji.
U nas dobrze sprawdza się wspólny moduł, który oprócz migracji zawiera warstwę dostępu do danych: encje, mapowanie obiektowo-relacyjne, repozytoria. Wszystkie usługi go linkują i od niego zależą. Konsekwencje są bardzo praktyczne:
- wersja modułu jest jednocześnie wersją schematu, którą usługa rozumie, więc
validatema z czym porównać, - zmiana encji i migracja, która ją umożliwia, są w tym samym commicie i tym samym review,
- usługa, która nie podbiła zależności, po prostu dalej widzi stary model — i dzięki tolerancji migracji „z przyszłości” dalej działa.
Uczciwie trzeba powiedzieć, ile to kosztuje. Wspólny moduł danych to świadome sprzężenie usług. Nie jest to architektura mikroserwisów w podręcznikowym sensie („każda usługa ma swoją bazę”). Ale wspólna baza jest w wielu systemach faktem — historycznym, kosztowym albo wynikającym z potrzeby spójności transakcyjnej. Skoro już jest, lepiej, żeby sprzężenie było jawne i wersjonowane, niż ukryte w kopiach tych samych klas w pięciu repozytoriach.
Teza czwarta: baza musi być hermetyczna
Jedno „szybkie ALTER TABLE” w piątek wieczorem, wykonane ręcznie na produkcji, bo „migracja to za dużo zachodu na jeden indeks”. Co się dzieje dalej? Za tydzień migracja, która tworzy ten sam indeks, wyłoży się na produkcji, a na teście przejdzie. Za pół roku nikt nie wie, dlaczego test nie jest produkcją.
Jeśli wchodzimy na ścieżkę automatycznych migracji, musimy zablokować ręczny dostęp do DDL. Nie zaleceniem w wiki, tylko uprawnieniami:
1-- migrator: jedyna rola, która zmienia strukturę2CREATE ROLE app_migrator LOGIN;3GRANT CREATE, USAGE ON SCHEMA app TO app_migrator;45-- aplikacje: tylko dane, bez DDL6CREATE ROLE app_runtime LOGIN;7GRANT USAGE ON SCHEMA app TO app_runtime;8ALTER DEFAULT PRIVILEGES FOR ROLE app_migrator IN SCHEMA app9 GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_runtime;1011-- ludzie: tylko odczyt; zmiany wyłącznie migracjami12CREATE ROLE app_readonly LOGIN;13GRANT USAGE ON SCHEMA app TO app_readonly;14ALTER DEFAULT PRIVILEGES FOR ROLE app_migrator IN SCHEMA app15 GRANT SELECT ON TABLES TO app_readonly;
Do tego spring.jpa.hibernate.ddl-auto=none (o tym niżej, w antywzorcach) i okresowe validate na każdym środowisku jako wykrywacz dryfu. Hermetyczność nie jest biurokracją. To warunek, bez którego cała historia migracji jest fikcją.
Rozszerzać łatwo. Zwężać — to już inna rozmowa
Teraz najciekawsza część. Większość zmian schematu to rozszerzenia: nowa tabela, nowa kolumna, nowy indeks. Dobrze zaprojektowane rozszerzenie jest zgodne wstecz — stary kod go nie zauważa, bo nie pyta o nowe kolumny. Dlatego master może migrować, a pozostałe usługi mogą być aktualizowane w swoim tempie.
Trudniej jest ze zmianami, które zwężają schemat: usunięcie kolumny, zmiana jej typu, zmiana nazwy, rozbicie tabeli. Stary kod, który wciąż czyta customer.legacy_code, po usunięciu kolumny po prostu przestanie działać. Przed taką migracją musimy mieć pewność, że wszyscy konsumenci starej struktury zostali już zaktualizowani.
Kto to są „wszyscy”? Usługi, które wdrażamy codziennie — łatwo sprawdzić. Ale też:
- CronJob, który uruchamia się raz w miesiącu i od trzech tygodni nikt nie widział jego poda,
- raport w narzędziu BI z własnym zapytaniem SQL,
- integracja partnera, która czyta jeden widok,
- skrypt, który ktoś „tymczasowo” uruchamia z laptopa przed zamknięciem kwartału.
Wzorzec, który porządkuje takie zmiany, nazywa się parallel change albo expand/contract. Zmiana zwężająca rozkłada się na kroki, z których każdy jest bezpieczny dla wszystkich działających wersji kodu:
- Expand — dodajemy nową strukturę obok starej (nowa kolumna, nowa tabela). Master migruje, nic się nie psuje.
- Migrate — kod zapisuje do obu struktur i czyta z nowej, dane historyczne przenosi osobny, porcjowany backfill. Kolejne usługi przechodzą na nową wersję.
- Contract — gdy nikt nie używa starej struktury, usuwamy ją.
Zmiana nazwy kolumny to więc w praktyce trzy wydania, a nie jedno RENAME. Brzmi jak dużo pracy? Owszem. Ale alternatywą jest okno serwisowe i nadzieja, że nie zapomnieliśmy o żadnym konsumencie.
Czy coś może zablokować contract, póki żyje stary kod?
To pytanie, które warto zadać wprost, bo intuicja podpowiada, że takie zabezpieczenie „pewnie gdzieś jest”. Sprawdziliśmy.
Flyway sam nie wie, kto czyta bazę. Tabela flyway_schema_history zapisuje, co zostało zastosowane, a nie kto z tego korzysta. validate chroni nowy kod przed starą bazą, ale nie chroni starego kodu przed migracją, która coś mu odbierze. Flyway daje za to haki:
- Callbacki (
beforeMigrate,beforeEachMigratei inne), w SQL albo w Javie. Błąd w callbacku przerywa migrację — sprawdziliśmy to doświadczalnie. Java API dostaje informację o migracji, która ma się wykonać, więc może reagować tylko na wybrane. - Sama migracja może zaczynać się od warunku, który zgłasza błąd. W PostgreSQL DDL jest transakcyjny, więc przerwana migracja nie zostawia niczego w połowie.
W Liquibase odpowiednikiem są preconditions — np. sqlCheck z onFail="HALT" zatrzymuje cały changelog, jeśli zapytanie nie zwróci oczekiwanej wartości. To ten sam mechanizm w innym opakowaniu: narzędzie da się zatrzymać, ale samo nie wie, kiedy.
Najdalej idzie pgroll dla PostgreSQL. Tam expand/contract jest operacją samego narzędzia: pgroll start tworzy nową wersję schematu jako zestaw widoków nad fizycznymi tabelami, a triggery przenoszą zapisy między starą i nową kolumną. Stary kod widzi stary schemat, nowy kod — nowy (wybór przez search_path). Usunięcie starych struktur wykonuje dopiero jawny krok pgroll complete, uruchamiany wtedy, gdy nikt nie używa poprzedniej wersji. Pytanie „czy ktoś jeszcze używa starej wersji” wciąż jednak zostaje po naszej stronie.
Wniosek: brakującym elementem nie jest narzędzie, tylko wiedza o konsumentach. Można ją zbudować prostym, lokalnym wzorcem.
Wzorzec: rejestr konsumentów schematu
Każdy uczestnik — usługa, CronJob, worker — przy starcie (i potem okresowo) zapisuje, kim jest i jaką wersję schematu rozumie:
1CREATE TABLE schema_consumers (2 consumer text PRIMARY KEY, -- np. 'api', 'monthly-report'3 schema_version integer NOT NULL, -- znana kodowi4 last_seen timestamptz NOT NULL5);67-- przy starcie każdej usługi i każdego uruchomienia CronJoba8INSERT INTO schema_consumers9 (consumer, schema_version, last_seen)10VALUES ('monthly-report', 5, now())11ON CONFLICT (consumer) DO UPDATE12 SET schema_version = EXCLUDED.schema_version,13 last_seen = now();
Migracja zwężająca zaczyna się od strażnika, który sprawdza rejestr w oknie czasowym dłuższym niż okres najrzadszego zadania:
1-- V6__contract_drop_customer_legacy_code.sql2DO $$3BEGIN4 IF EXISTS (SELECT 1 FROM schema_consumers5 WHERE schema_version < 56 AND last_seen > now() - interval '35 days') THEN7 RAISE EXCEPTION 'contract zablokowany: stary kod aktywny';8 END IF;9END $$;1011ALTER TABLE customer DROP COLUMN legacy_code;
Sprawdziliśmy ten scenariusz na Flyway 10 i PostgreSQL 17. Dopóki w rejestrze jest konsument ze starszą wersją, migrate kończy się błędem, a kolumna pozostaje nietknięta. Po aktualizacji ostatniego konsumenta ta sama migracja przechodzi. W MySQL, gdzie DDL nie jest transakcyjny i nie ma bloków DO, strażnika lepiej umieścić w callbacku Javy albo w migracji Javy — reguła jest ta sama.
Dwa uzupełnienia, które robią z tego praktykę, a nie tylko trik:
- Konwencja nazw. Migracje zwężające mają w nazwie
contract, a pipeline wymaga dla nich ręcznego zatwierdzenia. Rozszerzenia idą automatycznie, zwężenia — świadomie. - Okno czasowe to decyzja, nie stała. 35 dni dla miesięcznego crona, ale co z raportem rocznym? Rejestr pokazuje, kto się zgłaszał — nie pokaże tego, kto nie zgłosił się nigdy.
I tu zaczynają się prawdziwe pytania. Co z konsumentami, których nie kontrolujemy — narzędziem BI, partnerem? Czy zmuszać ich do zgłaszania się, czy dać im stabilny widok jako jawne API? Ile zaufania pokładamy w oknie czasowym? Nie mamy na to jednej odpowiedzi i chętnie poznamy Wasze.
Martwe dusze
Najprostsze rozwiązanie problemu contract? Nie robić contract. Zostawić kolumnę legacy_code na zawsze, nikomu nie przeszkadza.
Czy na pewno? Każda martwa kolumna to fałszywa informacja o modelu domeny. Nowa osoba w zespole zobaczy ją i założy, że coś znaczy. Raport ją policzy. Ktoś zacznie do niej pisać „bo jest”. Zaśmiecony schemat jest jak dokumentacja z nieaktualnymi rozdziałami: tym groźniejszy, im bardziej wygląda na kompletny.
Naszym zdaniem schemat powinien być w całości aktualny. Martwe dane przeczą tej aktualności. Kontrapunkt jest oczywisty: contract kosztuje i niesie ryzyko, a zysk z usunięcia jednej kolumny bywa iluzoryczny. Kompromis, który stosujemy: contract jako zaplanowany dług z terminem. Expand trafia do repozytorium razem z zadaniem „usuń starą strukturę” i datą, po której rejestr konsumentów powinien być czysty. Nie „kiedyś”, tylko konkretnego dnia.
Galeria antywzorców
Część z nich już opisaliśmy. Zbieramy je w jednym miejscu, bo w praktyce rzadko występują pojedynczo.
Każda usługa migruje sama. O schemacie decyduje ta, która wystartowała pierwsza. Lekarstwo: jeden master, reszta tylko validate.
Migracje rozrzucone po repozytoriach. Kolizje numerów i brak pełnej historii. Lekarstwo: jeden moduł z migracjami i warstwą dostępu do danych.
Ręczny DDL „na szybko”. Środowiska przestają być kopiami. Lekarstwo: uprawnienia, nie dobre chęci.
Contract przed ostatnim konsumentem. Miesięczny raport pada w nocy z 31-go na 1-go. Lekarstwo: rejestr konsumentów i strażnik w migracji.
Martwe dusze bez terminu. Lekarstwo: dług z datą.
A teraz te, o których mówi się rzadziej.
Expand, który nie jest expandem
„Przecież tylko dodaliśmy kolumnę.” A jednak stary kod pada. Jak to możliwe? Kilka scenariuszy:
- nowa kolumna jest
NOT NULLbez wartości domyślnej — stary kod, który jej nie zna, nie potrafi już wstawić wiersza, - nowy indeks unikalny — stary kod dalej zapisuje duplikaty, które wcześniej były dozwolone,
- stary kod robi
INSERT INTO t VALUES (…)bez listy kolumn albo mapujeSELECT *pozycyjnie — każda nowa kolumna przesuwa mu świat.
Rozszerzenie jest zgodne wstecz tylko wtedy, gdy stary kod może dalej czytać i pisać dokładnie tak jak wcześniej. Test, który to wykrywa, jest prosty: stara wersja aplikacji uruchomiona na nowym schemacie w CI.
Długa migracja w starcie poda
Migracja dodaje kolumnę z wartością domyślną do tabeli z setkami milionów wierszy. W PostgreSQL ALTER TABLE bierze blokadę ACCESS EXCLUSIVE, w MySQL — blokadę metadanych. Stoją wszystkie zapytania do tabeli. A liveness probe Kubernetesa widzi pod, który od trzech minut „nie odpowiada”, i go restartuje — w połowie migracji. W MySQL DDL nie jest transakcyjny, więc schemat zostaje w stanie pośrednim, a historia Flyway z wpisem o porażce.
Lekarstwo: dłuższe migracje poza startem aplikacji (osobny Job) albo startupProbe z wyraźnym zapasem. Do tego lock_timeout w PostgreSQL, CREATE INDEX CONCURRENTLY, a w MySQL narzędzia online DDL (gh-ost, pt-online-schema-change). Pytanie kontrolne przed każdą migracją: ile wierszy ma ta tabela na produkcji?
DDL i backfill w jednym pliku
Nowa kolumna i UPDATE uzupełniający ją dla 50 milionów wierszy w jednej transakcji. Replikacja zostaje w tyle, transakcja trzyma blokady, timeouty sypią się w aplikacjach. Backfill to osobny, porcjowany krok — najlepiej proces w aplikacji, który można zatrzymać i wznowić.
Pozostałe
- Edycja zastosowanej migracji. Zmiana sumy kontrolnej,
flyway repair„żeby przeszło” — i historia przestaje być historią. Zastosowanej migracji się nie zmienia; błędy poprawia kolejna. - Dwóch właścicieli schematu.
ddl-auto=updateobok Flyway: ORM po cichu „poprawia” schemat, inaczej na każdym środowisku. Właściciel może być tylko jeden. - Migracje Javy zależne od bieżących encji. Migracja sprzed dwóch lat używa klasy, która dziś wygląda inaczej — i na pustej bazie robi coś innego niż wtedy. Migracja musi być zamrożona w czasie: czysty SQL albo JDBC.
- Kolizje wersji z równoległych gałęzi. Dwie gałęzie tworzą
V2_1_30. StałeoutOfOrder=trueleczy objaw. Lepiej: sprawdzenie w CI przy merge'u i konwencja numeracji odporna na równoległość. - Testy na innym schemacie. H2 z automatycznie generowanym schematem zamiast migracji na prawdziwym silniku: zielone testy, czerwona produkcja.
- Wyłączone bezpieczniki.
validateOnMigrate=falsealbobaselineOnMigrate=trueustawione „na chwilę, żeby ruszyło”, i zostawione na lata. To drugie potrafi po cichu oznaczyć jako zmigrowaną bazę, o której nic nie wiemy. - Migracje warunkowe per środowisko. „Jeśli produkcja, to…” — i środowiska przestają być kopiami z definicji.
- Obiekty poza migracjami. Widoki, procedury, triggery, uprawnienia zakładane ręcznie. We Flyway są na to migracje powtarzalne
R__, a uprawnienia też są częścią schematu. - Wiara w undo. Migracja w dół daje iluzję bezpieczeństwa: po contract usuniętych danych nie przywróci. Strategią jest roll-forward, a siatką bezpieczeństwa — backup z odtwarzaniem do punktu w czasie.
- Niejawne API. Raport, eksport, integracja czytają tabele bezpośrednio, a nikt o tym nie wie. Wspólna baza po cichu stała się interfejsem publicznym — bez wersji, bez dokumentacji, bez właściciela.
Krótko
- Jeden migration master; najlepiej backoffice, który i tak zmienia się razem z modelem.
- Pozostali sprawdzają przy starcie (
validate) i upadają natychmiast; stara wersja w Kubernetesie dalej działa. - Kolejność wdrożeń: najpierw master, potem reszta.
- Migracje w jednym module razem z warstwą dostępu do danych.
- Brak ręcznego DDL — wymuszony uprawnieniami.
- Expand teraz, contract później — i nigdy w tym samym wydaniu.
- Rejestr konsumentów i strażnik w migracji zwężającej.
- Contract z terminem, a nie martwe dusze.
Do dyskusji
Zostawiamy kilka pytań, na które nie mamy jednej odpowiedzi:
- Czy wspólna baza dla wielu usług to błąd architektury, który trzeba naprawić, czy rozsądny kompromis, który trzeba dobrze obsłużyć?
- Gdzie przebiega granica między „martwą duszą” a polem, które może się jeszcze przydać?
- Jak traktować konsumentów, których nie kontrolujemy — wymuszać rejestrację, czy dawać im stabilny widok jako jawne API?
- Czy narzędzia w stylu pgroll, które przenoszą expand/contract do samej bazy, to przyszłość, czy kolejna warstwa złożoności?
Masz inne doświadczenia, kontrprzykład albo pomysł, którego tu nie ma? Napisz do nas — najciekawsze komentarze opublikujemy razem z artykułem, za zgodą autorów.
Źródła
- Flyway: ignoreMigrationPatterns, zdarzenia callbacków
- Liquibase: Preconditions
- pgroll: repozytorium i dokumentacja
- Martin Fowler / Danilo Sato: Parallel Change
Powiązane teksty
2026-10-18
Kubernetes to nie tylko nowy sposób wdrażania. To zmiana nawyków operacyjnych
Przeniesienie aplikacji z serwerów do kontenerów to mniejsza część pracy. Większa to porzucenie przyzwyczajeń — monitoringu hostów, logów w /var/log, założenia, że aplikacja działa w jednym egzemplarzu, i tego, że wiadomo, na której maszynie działa.
2026-09-25
Migracja kilkunastu aplikacji na własny klaster k3s — problemy i korzyści
Nasz trzeci klaster Kubernetes na bare metal — pierwszy produkcyjny u klienta. Działa od listopada 2023 roku i obsługuje około 20 aplikacji w czterech środowiskach. Opisujemy architekturę, siedem problemów, które nas zaskoczyły, i to, co realnie zyskaliśmy.