Skip to content
← Wszystkie wpisy
4 min czytania Dawid Skłodowski

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:

  1. Dodaj kolumnę bez defaultu (szybko).
  2. Ustaw default dla przyszłych wierszy w osobnym kroku.
  3. 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:

  1. 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.
  2. Wypełnij nową kolumnę ze starej, w partiach.
  3. Przenieś odczyty: wdróż kod, który czyta z nowej kolumny. Obie wciąż są zapisywane.
  4. 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ć.