Migracje bez przestoju na żywym sklepie
Na ruchliwym sklepie nieostrożna migracja kładzie witrynę, blokując tabelę. Przewodnik po bezpiecznych zmianach schematu — blokady, które gryzą, bezpieczne dodawanie kolumn i indeksów oraz wzorzec expand-contract dla zmian nazw i NOT NULL.
Na projekcie hobbystycznym migracja jest nieszkodliwa: chwila przestoju, gdy schemat się zmienia, nikt nie zauważa. Na żywym sklepie robiącym transakcje co sekundę ta sama niewinnie wyglądająca migracja może zablokować tabelę i położyć całą witrynę na czas trwania — a niektóre migracje, uruchomione naiwnie, blokują ruchliwą tabelę na minuty. Bolesne jest to, że te migracje działają dobrze w developmencie wobec pustej bazy i gryzą dopiero na produkcji wobec dużej, ruchliwej tabeli. Oto jak zmieniać schematy na działającym sklepie tak, by nikt nie zauważył.
Wróg: blokady trzymane zbyt długo
Niemal każdy problem bez-przestoju sprowadza się do jednego: migracja bierze blokadę na tabelę i trzyma ją, wykonując wolną pracę, a każde zapytanie potrzebujące tej tabeli ustawia się w kolejce za blokadą. Krótkie blokady są w porządku; zabójcami są operacje trzymające silną blokadę podczas przepisywania dużej tabeli albo budowania indeksu. Zadaniem jest wiedzieć, które operacje to robią, i unikać groźnej formy.
Najpierw praktyczna siatka bezpieczeństwa: ustaw krótki timeout blokady w migracjach, by migracja, która zablokowałaby się, czekając na blokadę, zawiodła szybko, zamiast spiętrzać kolejkę zatrzymanych zapytań za sobą (tak jedna wolna migracja kaskaduje w pełną awarię):
# zawiedź szybko, zamiast ustawiać całą aplikację w kolejce za blokadą
execute "SET lock_timeout = '5s'"
I rozważ gem strong_migrations, który łapie groźne migracje w developmencie i mówi ci
bezpieczną alternatywę, zanim je wyślesz — najtańsza ubezpieczenie, jakie istnieje.
Dodawanie kolumny: zwykle bezpieczne, z jedną pułapką
Dodanie kolumny nullable jest szybkie i bezpieczne — to zmiana metadanych, bez przepisywania tabeli:
add_column :products, :featured, :boolean # ok
Historyczną pułapką było dodawanie kolumny z wartością domyślną: na starszym Postgresie przepisywało to całą tabelę, trzymając blokadę, co na dużej tabeli oznaczało minuty przestoju. (Postgres 11 uczyni to natychmiastowym dla stałych defaultów, ale w 2016 musisz zakładać, że przepisuje.) Bezpiecznym wzorcem jest taniec expand-contract:
- Dodaj kolumnę bez defaultu (szybko).
- Ustaw default dla przyszłych wierszy w osobnym kroku.
- Wypełnij istniejące wiersze w partiach, byś nigdy nie aktualizował milionów wierszy w jednym długo blokującym poleceniu:
# wypełnianie w partiach, poza krytyczną ścieżką migracji
Product.in_batches(of: 5_000) do |batch|
batch.update_all(featured: false)
sleep(0.1) # zaczerpnij oddechu; nie saturuj bazy
end
Zasada się generalizuje: nigdy nie uruchamiaj pojedynczego polecenia dotykającego każdego wiersza dużej tabeli. Podziel je na partie i pozwól każdej małej partii commitować się szybko, by inne zapytania dostały swoją kolej.
Dodawanie indeksu: concurrently, zawsze
Budowanie indeksu normalnie blokuje tabelę przed zapisami na cały czas budowy — fatalne na
ruchliwej tabeli. Postgres ma dedykowaną odpowiedź: CREATE INDEX CONCURRENTLY, który buduje
indeks bez blokowania zapisów. Rails wspiera to z jednym zastrzeżeniem — nie da się tego
uruchomić wewnątrz transakcji, więc wyłączasz transakcję migracji:
class AddIndexToOrders < ActiveRecord::Migration[5.0]
disable_ddl_transaction! # wymagane dla CONCURRENTLY
def change
add_index :orders, :customer_id, algorithm: :concurrently
end
end
Ta jedna zmiana — algorithm: :concurrently plus disable_ddl_transaction! — to różnica
między budową indeksu blokującą checkout na dwie minuty a taką, której nikt nie zauważa.
Zmiana nazwy kolumny: nigdy w jednym kroku
Zmiana nazwy kolumny wygląda trywialnie i jest jedną z najgroźniejszych rzeczy, jakie możesz
zrobić żywej aplikacji — nie z powodu blokad, lecz timingu deployu. Twój kod i twój schemat
są wdrażane w nieco różnych momentach, więc zawsze jest okno, w którym stary kod działa wobec
nowego schematu (albo na odwrót). Zmień nazwę email na email_address w jednej migracji, a
w tym oknie działająca aplikacja odpytuje kolumnę, która już nie istnieje — błędy wszędzie.
Bezpiecznym podejściem jest wzorzec expand-contract (zwany też parallel-change), rozłożony na wiele deployów:
- Expand: dodaj nową kolumnę
email_address. Wdróż kod, który zapisuje do obu kolumn i czyta ze starej. Teraz obie istnieją i pozostają w synchronizacji. - Wypełnij nową kolumnę ze starej, w partiach.
- Przenieś odczyty: wdróż kod, który czyta z nowej kolumny. Obie wciąż są zapisywane.
- Contract: gdy nic nie odnosi się do starej kolumny, wdróż kod, który przestaje ją zapisywać, potem usuń ją w finałowej migracji.
To więcej kroków, ale każdy z nich jest niezależnie bezpieczny, i w żadnym momencie działający kod nie jest niezgodny ze schematem. Ten sam wzorzec obsługuje zmianę typu kolumny, podział kolumny albo wyciągnięcie danych do nowej tabeli. Model myślowy: schemat i kod muszą być zgodne w każdym stanie pośrednim, bo w rolling deploy zawsze jest stan pośredni.
Dodawanie NOT NULL i ograniczeń bezpiecznie
Dodanie ograniczenia NOT NULL waliduje każdy istniejący wiersz, trzymając blokadę — wolno
na dużej tabeli. Bezpieczną ścieżką w Postgresie jest dodanie ograniczenia jako NOT VALID
najpierw (co pomija natychmiastowy pełny skan), potem VALIDATE go w osobnym kroku (co
sprawdza istniejące wiersze bez ciężkiej blokady). Klucze obce podążają za tym samym
wzorcem dodaj-potem-waliduj. Powracający motyw: oddziel szybki krok „zadeklaruj to” od
wolnego kroku „zweryfikuj to”, by wolna część nie trzymała blokującej blokady.
Werdykt
Migracje bez przestoju to nie magia; to mały zbiór dyscyplin stosowanych konsekwentnie.
Ustaw timeout blokady, by migracja zawiodła szybko, zamiast kaskadować. Dodawaj kolumny bez
defaultów i wypełniaj w partiach. Buduj indeksy CONCURRENTLY. Nigdy nie dotykaj każdego
wiersza w jednym poleceniu. A przede wszystkim traktuj zmiany nazw i typów jako
wielodeployowe sekwencje expand-contract, bo w rolling deploy kod i schemat nigdy nie są
aktualizowane w dokładnie tym samym momencie, a twoim zadaniem jest trzymać je zgodnymi przez
każdy stan pośredni. Przyjmij narzędzie jak strong_migrations, by łapać groźne wzorce,
zanim je wyślesz, a zmiany schematu na ruchliwym sklepie przestają być wstrzymanym oddechem i
stają się rutyną — co, na czymś obsługującym pieniądze co sekundę, jest dokładnie tym, czym
muszą być.