Naucz się wykonywać zmiany schematu bez przestojów przy użyciu wzorca rozszerzanie/zwężanie: dodawaj kolumny bezpiecznie, backfilluj partiami, wdrażaj kompatybilny kod, a potem usuwaj stare ścieżki.

Przestoje spowodowane zmianą bazy danych nie zawsze są widocznym, oczywistym outage'em. Dla użytkowników może to wyglądać jak strona, która ładuje się bez końca, płatność, która się nie powiedzie, albo aplikacja nagle pokazująca „coś poszło nie tak”. Dla zespołów objawia się to alertami, rosnącym wskaźnikiem błędów i zaległością nieudanych zapisów do posprzątania.
Zmiany schematu są ryzykowne, bo baza danych jest współdzielona przez wszystkie uruchomione wersje aplikacji. Podczas wydania często mamy równocześnie stare i nowe wersje kodu (rolling deployy, wiele instancji, zadania w tle). Migracja, która wygląda poprawnie, może mimo to złamać jedną z tych wersji.
Typowe tryby awaryjne to:
Nawet gdy kod jest w porządku, wydania są blokowane, bo prawdziwy problem to timing i zgodność między wersjami. Zmiany schematu bez przestojów sprowadzają się do jednej zasady: każdy stan pośredni musi być bezpieczny dla starego i nowego kodu. Zmieniasz bazę bez łamania odczytów i zapisów, wdrażasz kod, który obsłuży obie struktury, i usuwasz starą ścieżkę dopiero, gdy nic już od niej nie zależy.
Dodatkowy wysiłek opłaca się przy realnym ruchu, ścisłych SLA lub wielu instancjach i workerach. Dla małego narzędzia wewnętrznego z cichą bazą proste okno konserwacyjne może być łatwiejsze.
Większość incydentów związanych z pracami na bazie wynika z oczekiwania, że zmiana w bazie nastąpi natychmiast, podczas gdy zmiana ta zajmuje czas. Wzorzec rozszerzanie/zwężanie rozbija ryzykowną operację na mniejsze, bezpieczne kroki.
Przez krótki czas system obsługuje dwie „dialekty” jednocześnie. Najpierw wprowadzasz nową strukturę, utrzymujesz działanie starej, stopniowo przenosisz dane, a potem sprzątasz.
Wzorzec jest prosty:
To dobrze współgra z rolling deployami. Jeśli aktualizujesz 10 serwerów po kolei, przez chwilę będziesz mieć stare i nowe wersje razem. Wzorzec expand/contract pozwala obu pracować z tą samą bazą w tym okresie.
Ułatwia też rollbacky. Jeśli nowe wydanie ma błąd, możesz cofnąć aplikację bez cofania bazy, bo stare struktury istnieją przez okno expand.
Przykład: chcesz rozdzielić kolumnę PostgreSQL full_name na first_name i last_name. Dodajesz nowe kolumny (expand), wysyłasz kod, który potrafi zapisywać i czytać obie wersje, backfillujesz stare wiersze, a potem usuwasz full_name gdy upewnisz się, że nikt już go nie używa (contract).
Faza expand polega na dodaniu nowych opcji, a nie usuwaniu starych.
Częstym pierwszym krokiem jest dodanie nowej kolumny. W PostgreSQL zwykle najbezpieczniej dodać ją jako nullable i bez domyślnej wartości. Dodanie kolumny NOT NULL z domyślną może wywołać przepisanie tabeli lub cięższe blokady, w zależności od wersji Postgresa i dokładnej zmiany. Bezpieczniejsza sekwencja to: dodać nullable, wdrożyć tolerancyjny kod, backfillować, a dopiero później wymusić NOT NULL.
Indeksy też wymagają uwagi. Tworzenie zwykłego indeksu może blokować zapisy dłużej niż oczekujesz. Tam, gdzie to możliwe, twórz indeksy równolegle (concurrent), żeby odczyty i zapisy działały dalej. Trwa to dłużej, ale unika blokady zatrzymującej wydanie.
Expand może też oznaczać dodanie nowych tabel. Jeśli przechodzisz od pojedynczej kolumny do relacji wiele-do-wielu, możesz dodać tabelę łączącą, zostawiając starą kolumnę. Stara ścieżka nadal działa, podczas gdy nowa zaczyna zbierać dane.
W praktyce expand często obejmuje:
Po expand stare i nowe wersje aplikacji powinny móc działać jednocześnie bez niespodzianek.
Większość problemów z wydaniem pojawia się w środku: niektóre serwery mają nowy kod, inne nadal stary kod, podczas gdy baza już się zmienia. Cel jest prosty: każda wersja w rollout powinna działać zarówno ze starym, jak i z rozbudowanym schematem.
Częstym podejściem jest dual-write. Jeżeli dodajesz nową kolumnę, nowa aplikacja zapisuje do obu — starej i nowej kolumny. Stare wersje nadal robią zapisy tylko do starego pola, co jest w porządku, bo ono nadal istnieje. Nową kolumnę trzymaj opcjonalną na początku i odłóż restrykcje na później, aż wszyscy writerzy się zaktualizują.
Odczyty zwykle przełączają się ostrożniej niż zapisy. Przez pewien czas trzymaj odczyty na starym polu (tym, które wiesz, że jest w pełni wypełnione). Po backfillu i weryfikacji przełącz odczyty tak, by preferowały nowe pole, z fallbackiem do starego, jeśli nowe jest puste.
Utrzymuj też stabilny kształt API podczas zmian w bazie. Nawet jeśli wprowadzasz nowe wewnętrzne pole, unikaj zmiany kształtu odpowiedzi, dopóki wszyscy konsumenci (web, mobile, integracje) nie będą gotowi.
Przy rolloutach przyjaznych rollbackowi zwykle stosuje się sekwencję:
Kluczową ideą jest to, że pierwszy nieodwracalny krok to usunięcie starej struktury, więc odkładasz go na koniec.
Backfill to miejsce, gdzie wiele „zmian schematu bez przestojów” się sypie. Chcesz wypełnić nową kolumnę dla istniejących wierszy bez długich blokad, wolnych zapytań czy niespodziewanych skoków obciążenia.
Batchowanie ma znaczenie. Celuj w batchy, które kończą się szybko (sekundy, nie minuty). Jeśli każdy batch jest mały, możesz wstrzymać, wznowić i dostroić pracę bez blokowania wydań.
Do śledzenia postępu używaj stabilnego kursora. W PostgreSQL często jest to klucz główny. Przetwarzaj wiersze po kolei i zapisuj ostatnie id, które ukończyłeś, albo pracuj w zakresach id. Unika to kosztownych skanów całej tabeli przy restarcie joba.
Oto prosty wzorzec:
UPDATE my_table
SET new_col = ...
WHERE new_col IS NULL
AND id > $last_id
ORDER BY id
LIMIT 1000;
Zrób update warunkowy (np. WHERE new_col IS NULL), żeby zadanie było idempotentne. Ponowne uruchomienia dotykają tylko wierszy, które nadal wymagają pracy, co redukuje niepotrzebne zapisy.
Planuj też napływ nowych danych podczas backfillu. Zwykła kolejność to:
Dobry backfill jest nudny: równomierny, mierzalny i łatwy do zatrzymania, jeśli baza zaczyna pracować zbyt ciężko.
Najbardziej ryzykowny moment to nie dodanie nowej kolumny, a decyzja, że możesz już na niej polegać.
Zanim przystąpisz do contract, udowodnij dwie rzeczy: nowe dane są kompletne i produkcja czyta je bezpiecznie.
Zacznij od szybkich, powtarzalnych kontroli kompletności:
Jeśli stosujesz dual-write, dodaj kontrolę spójności, żeby wyłapać ciche błędy. Na przykład uruchamiaj zapytanie co godzinę, które znajduje wiersze, gdzie old_value <> new_value i alarmuj, jeśli liczba jest różna od zera. To często najszybszy sposób, by odkryć, że jakiś writer dalej aktualizuje tylko stare pole.
Obserwuj podstawowe sygnały produkcyjne podczas migracji. Jeśli czas zapytań lub oczekiwania na blokady rośnie, nawet twoje „bezpieczne” zapytania weryfikujące mogą dokładać obciążenia. Monitoruj wskaźniki błędów dla ścieżek kodu czytających nowe pole, zwłaszcza tuż po deployach.
Jak długo trzymaj obie ścieżki? Długo wystarczająco, by przetrwać co najmniej jeden pełny cykl wydania i jedno ponowne uruchomienie backfillu. Wiele zespołów używa 1–2 tygodni lub dopóki nie są pewni, że żadna stara wersja aplikacji nie działa.
Contract to moment, który wywołuje nerwowość, bo wydaje się punktem bez powrotu. Jeśli expand został wykonany poprawnie, contract to w większości sprzątanie i nadal można go wykonać małymi, niskiego ryzyka krokami.
Wybierz moment ostrożnie. Nie usuwaj niczego tuż po zakończeniu backfillu. Odczekaj przynajmniej jeden pełny cykl wydania, żeby zadania opóźnione i przypadki brzegowe miały czas się wykazać.
Bezpieczna sekwencja contract wygląda zwykle tak:
Jeśli możesz, rozbij contract na dwa wydania: jedno usuwa referencje w kodzie (z dodatkowym logowaniem), a późniejsze usuwa obiekty bazy. To rozdzielenie ułatwia rollback i debugowanie.
Szczegóły PostgreSQL są tu istotne. Usunięcie kolumny to zwykle zmiana metadanych, ale wciąż wymaga krótkiej blokady ACCESS EXCLUSIVE. Zaplanuj spokojny moment i trzymaj migrację krótką. Jeśli stworzyłeś dodatkowe indeksy, preferuj DROP INDEX CONCURRENTLY, żeby nie blokować zapisów (nie można tego wykonać wewnątrz transakcji, więc narzędzia migracyjne muszą to wspierać).
Migracje bez przestojów zawodzą, gdy baza i aplikacja przestają się zgadzać co do dozwolonych operacji. Wzorzec działa tylko wtedy, gdy każdy stan pośredni jest bezpieczny dla starego i nowego kodu.
Te błędy występują często:
Realistyczny scenariusz: zaczynasz zapisywać full_name z API, ale zadanie w tle tworzące użytkowników dalej ustawia tylko first_name i last_name. W nocy dodaje wiersze z full_name = NULL, a późniejszy kod zakłada, że full_name zawsze istnieje.
Traktuj każdy krok jak wydanie, które może trwać dni:
Powtarzalna lista kontrolna chroni przed wysłaniem kodu, który działa tylko w jednym stanie bazy.
Przed wdrożeniem potwierdź, że baza ma już rozszerzone elementy (nowe kolumny/tabele, indeksy utworzone mało blokująco). Następnie upewnij się, że aplikacja jest tolerancyjna: powinna działać ze starym kształtem, z rozszerzonym kształtem i w stanie częściowo backfillowanym.
Zachowaj listę krótką:
Migracja jest zakończona dopiero wtedy, gdy odczyty używają nowych danych, zapisy już nie utrzymują starych danych i zweryfikowałeś backfill przynajmniej jednym prostym checkiem (liczby lub próbki).
Załóżmy, że masz tabelę PostgreSQL customers z kolumną phone trzymającą niejednolite wartości (różne formaty, czasem puste). Chcesz ją zastąpić phone_e164, ale nie możesz blokować wydań ani zamykać aplikacji.
Czysta sekwencja expand/contract wygląda tak:
phone_e164 jako nullable, bez domyślnej wartości i bez surowych constraintów.phone, i do phone_e164, ale trzymaj odczyty na phone, żeby nic się nie zmieniło dla użytkowników.phone_e164 najpierw, a jeśli jest NULL, wraca do phone.phone_e164, usuń fallback, dropnij phone, a potem dodaj ostrzejsze ograniczenia jeśli są potrzebne.Rollback pozostaje prosty, jeśli każdy krok jest wstecznie zgodny. Jeśli przełączenie odczytów powoduje problemy, cofnij aplikację, a baza nadal ma obie kolumny. Jeśli backfill generuje skoki obciążenia, wstrzymaj job, zmniejsz batch i kontynuuj później.
Jeśli chcesz, żeby zespół się trzymał planu, udokumentuj wszystko w jednym miejscu: dokładny SQL, które wydanie przełącza odczyty, jak mierzysz ukończenie (np. procent non-NULL phone_e164) i kto odpowiada za każdy krok.
Wzorzec expand/contract działa najlepiej, gdy staje się rutyną. Napisz krótki runbook, którego zespół będzie używać przy każdej zmianie schematu — najlepiej jedna strona i wystarczająco szczegółowa, żeby nowy członek mógł ją wykonać.
Praktyczny szablon obejmuje:
Ustal właścicieli z góry. „Wszyscy myśleli, że ktoś inny zrobi contract” to powód, dla którego stare kolumny i flagi funkcji żyją miesiącami.
Nawet jeśli backfill działa online, planuj go na niższy ruch — łatwiej trzymać małe batchy, obserwować obciążenie i szybko zatrzymać pracę, jeśli opóźnienia rosną.
Jeśli budujesz i wdrażasz z Koder.ai (koder.ai), Planning Mode może pomóc rozrysować fazy i punkty kontrolne przed dotknięciem produkcji. Te same zasady zgodności nadal obowiązują, ale zapisanie kroków utrudnia pominięcie „nudnych” części, które zapobiegają outage'om.
Ponieważ baza danych jest współdzielona przez wszystkie uruchomione wersje aplikacji. Podczas rolling deployów i prac w tle stare i nowe wersje mogą działać jednocześnie, a migracja zmieniająca nazwy, usuwająca kolumny lub dodająca ograniczenia może przerwać działanie wersji, która nie była przygotowana na dany stan schematu.
Oznacza to zaprojektowanie migracji tak, żeby każdy pośredni stan bazy działał poprawnie zarówno dla starego, jak i nowego kodu. Najpierw dodajesz nowe struktury, działasz przez pewien czas z oboma ścieżkami, a dopiero gdy nic już nie zależy od starych elementów, usuwasz je.
Faza rozszerzania (expand) dodaje nowe kolumny, tabele lub indeksy bez usuwania niczego, czego potrzebuje aktualna aplikacja. Faza zwężania (contract) to etap porządkowania — usuwasz stare kolumny, stare odczyty/zapisy i tymczasową logikę synchronizacji po tym, jak nowa ścieżka działa poprawnie.
Bezpieczniej jest dodać kolumnę jako nullable bez domyślnej wartości — zwykle to najmniej inwazyjny krok, bo unikasz kosztownych blokad i przepisania tabeli. Potem wdrażasz kod tolerancyjny na brak wartości, backfillujesz stopniowo i dopiero później możesz zaostrzyć ograniczenia, np. dodać NOT NULL.
Dual-write stosuje się podczas przejścia, kiedy nowa wersja aplikacji zapisuje jednocześnie do starego i nowego pola. Dzięki temu dane pozostają spójne, gdy nadal działają starsze instancje aplikacji i zadania, które znają tylko stare pole.
Backfille rób w małych partiach, które szybko się kończą, i upewnij się, że każdy batch jest idempotentny, tak aby ponowne uruchomienie dotykało tylko wierszy nadal wymagających pracy. Monitoruj czas zapytań, oczekiwania na blokady i opóźnienie replikacji, i bądź gotów zatrzymać lub zmniejszyć batch, gdy baza zaczyna się nagrzewać.
Najpierw sprawdź kompletność — ile wierszy nadal ma NULL w nowej kolumnie. Wykonaj też kontrolę spójności porównując stare i nowe wartości na próbce (lub cały zbiór, jeśli koszt jest niski). Monitoruj błędy produkcyjne po wdrożeniach, żeby wykryć ścieżki kodu wciąż używające nieprawidłowego schematu.
Ograniczenia typu NOT NULL lub inne nowe constrainty mogą zablokować zapisy podczas walidacji tabeli, a zwykłe tworzenie indeksu może trzymać blokady dłużej niż się spodziewasz. Również zmiany nazw i usunięcia są ryzykowne, bo starszy kod może nadal odnosić się do starych nazw podczas rolling deployu.
Dopiero gdy przestaniesz zapisywać do starego pola, przeniesiesz odczyty na nowe pole bez fallbacku i odczekasz wystarczająco długo, by upewnić się, że żadna stara wersja aplikacji czy worker nie działa — wtedy można bezpiecznie wykonać contract. Wiele zespołów traktuje to jako osobne wydanie, żeby rollback pozostał prosty.
Jeśli możesz pozwolić sobie na okno konserwacyjne i masz mały ruch, prosta jednorazowa migracja może wystarczyć. Gdy jednak masz realnych użytkowników, wiele instancji aplikacji, zadania w tle lub SLA, wzorzec expand/contract zwykle się opłaca, bo upraszcza rollbacky i zmniejsza ryzyko; w Koder.ai Planning Mode zapisanie etapów i checków z wyprzedzeniem pomaga nie pominąć nudnych, ale istotnych kroków.