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

Dwie bazy, jedna aplikacja Rails: start na Mongo, migracja do Postgresa tabela po tabeli

Historia z okopów: zbudowaliśmy szybko na MongoDB, uderzyliśmy w granice modelu dokumentowego, uruchomiliśmy Mongo i Postgresa obok siebie w jednej aplikacji Rails i migrowaliśmy kolekcja po kolekcji do Postgresa bez przestoju. Co to kosztowało, jak to zrobiliśmy i co zrobilibyśmy inaczej.

To historia z okopów stojąca za kilkoma wcześniejszymi wpisami. Lata temu zaczęliśmy projekt na MongoDB, z dobrych wówczas powodów. Później uderzyliśmy w granice modelu dokumentowego, dokładnie jak ostrzegał wpis „wybór bazy danych” — i zamiast ryzykownego wielkowybuchowego przepisania, zrobiliśmy coś ciekawszego: uruchomiliśmy MongoDB i PostgreSQL obok siebie w tej samej aplikacji Rails i migrowaliśmy dane po jednej kolekcji naraz do Postgresa, aż nic nie zostało na Mongo. Zadziałało, wymagało realnego wysiłku i nauczyło nas rzeczy, których uczysz się tylko, przeżywając migrację. Oto jak poszło.

Dlaczego zaczęliśmy na Mongo

Decyzja o starcie na MongoDB nie była naiwna — to był świadomy handel za szybkość wczesnej iteracji. W fazie eksploracyjnej, gdy kształt domeny zmienia się tygodniowo, schemaless wolność MongoDB to prawdziwy przyspieszacz: dodaj pole, pisząc je, przekształć dokument bez migracji, działaj szybko, póki wciąż odkrywasz, czym dane chcą być. Przez pierwszy odcinek projektu to opłaciło się dokładnie tak, jak liczyliśmy — iterowaliśmy szybko, nieobciążeni migracjami na schemacie, który wciąż wymyślaliśmy.

Zakładem, jak każdy taki zakład, było to, że domena pozostanie w kształcie dokumentu. Nie pozostała.

Gdzie model dokumentowy zaczął boleć

Gdy produkt dojrzewał, domena okazała się fundamentalnie relacyjna — a napięcia były dokładnie tymi, z którymi model dokumentowy się zmaga:

  • Zapytania obejmujące encje. Realne pytania biznesowe joinowały przez to, co było osobnymi kolekcjami — zamówienia klientów z regionu z określonym produktem — a bez joinów robiliśmy to ręcznie w Ruby: wczytaj te, potem tamte, potem filtruj, N+1 między kolekcjami, którego żaden includes nie mógł uratować.
  • Integralność między dokumentami. Potrzebowaliśmy gwarancji, że referencja wskazuje na coś prawdziwego, że dwa powiązane rekordy pozostają spójne. MongoDB nie egzekwuje nic z tego między dokumentami; reimplementowaliśmy klucze obce i sprawdzenia spójności w kodzie aplikacji, źle.
  • Transakcje między rekordami. Operacje, które musiały być wszystko-albo-nic między wieloma dokumentami, nie miały transakcji do owinięcia — w 2017 MongoDB wciąż nie miało transakcji wielodokumentowych — więc pisaliśmy kod uzgadniający, by posprzątać częściowe porażki.
  • Doraźne raportowanie. Każde nowe pytanie biznesowe było projektem pipeline’u agregacji albo eksportem danych, gdzie w SQL byłoby GROUP BY.

Żadne z tych nie jest wadą MongoDB; to koszt używania magazynu dokumentów do danych relacyjnych. Nauczyliśmy się po prostu, drogą kosztowną, że nasza domena chciała Postgresa.

Plan: obok siebie, nie wielki wybuch

Pełne przepisanie — zatrzymaj się, przenieś wszystko do Postgresa, przełącz — było wykluczone. Aplikacja była żywa, obsługiwała pieniądze, a wielkowybuchowa migracja danych działającego systemu to sposób na katastrofę. Wybraliśmy więc podejście strangler: wprowadź Postgresa obok Mongo, uruchom oba w tej samej aplikacji i migruj model danych kawałek po kawałku, kurcząc ślad Mongo, aż będzie pusty.

Pierwszą niespodzianką jest to, że Rails radzi sobie z tym z większą gracją, niż byś oczekiwał, bo dwie bazy danych używają różnych ORM-ów: Mongoid dla MongoDB i ActiveRecord dla Postgresa. Współistnieją w jednej aplikacji bez walki — User może być modelem ActiveRecord, podczas gdy LegacyDocument jest wciąż modelem Mongoid, w tej samej bazie kodu, tym samym żądaniu. To uczyniło „oba naraz” mechanicznie możliwym.

# już zmigrowane — wsparte Postgresem
class Order < ApplicationRecord
  belongs_to :customer
end

# jeszcze nie zmigrowane — wciąż wsparte MongoDB
class Activity
  include Mongoid::Document
  field :action, type: String
end

Migracja jednej kolekcji naraz

Dla każdej kolekcji podążaliśmy za tą samą sekwencją w stylu expand-contract — tą samą dyscypliną co zero-downtime zmiana nazwy kolumny, zastosowaną na skalę całego modelu:

  1. Utwórz tabelę Postgres z porządnym schematem relacyjnym — kolumnami, typami, ograniczeniami i kluczami obcymi, których dokument nigdy nie miał. Zaprojektowanie prawdziwego schematu było często najcenniejszym krokiem, bo wymuszało relacyjne myślenie, które odkładaliśmy.
  2. Dual-write. Wdróż kod, który zapisuje nowe i zaktualizowane rekordy do obu — Mongo i Postgresa — trzymając je w synchronizacji naprzód, podczas gdy odczyty wciąż szły z Mongo. Teraz obie bazy danych miały żywe dane.
  3. Backfill. Skopiuj istniejące dokumenty do tabeli Postgres w partiach (nigdy jednym gigantycznym zapytaniem), transformując kształt dokumentu w wiersze relacyjne — spłaszczając osadzone pod-dokumenty w powiązane tabele, rozwiązując zdenormalizowane kopie w klucze obce.
  4. Przełącz odczyty na Postgresa. Gdy backfill był kompletny i zweryfikowany (liczby i wyrywkowe sprawdzenia się zgadzały), przełącz odczyty na ActiveRecord. Oba wciąż były zapisywane, więc to było odwracalne.
  5. Contract. Gdy model wsparty Postgresem działał czysto na produkcji, przestań zapisywać do Mongo i usuń kolekcję.

Każda kolekcja przechodziła przez to niezależnie, więc w dowolnym momencie część modeli była ActiveRecord, a część wciąż Mongoid, a aplikacja działała przez cały czas. Nigdy nie mieliśmy „weekendu migracji”; mieliśmy długą serię małych, odwracalnych, indywidualnie bezpiecznych kroków.

Naprawdę trudne części

Nie było gładko, a trudne części warto nazwać, bo są nieodłączne od migracji dwubazodanowej:

  • Referencje między dwoma bazami danych. W trakcie migracji rekord Postgres często musiał referencować rekord wciąż żyjący w Mongo albo na odwrót. Nie ma klucza obcego między bazami, więc trzymaliśmy obce id jako zwykłą wartość i rozwiązywaliśmy je ręcznie — starannie wybierając kolejność migracji, by najbardziej referencowane kolekcje przenieść pierwsze, minimalizując czas, gdy cokolwiek wskazywało przez podział.
  • Brak transakcji przez podział. Podczas dual-write zapis musiał wylądować w obu bazach danych, a nie ma transakcji obejmującej Mongo i Postgresa. Uczyniliśmy zapisy idempotentnymi i uruchamialiśmy zadania uzgadniające, by wykryć i naprawić wszelki dryf między oboma — akceptując krótką niespójność i zbiegając ją, zamiast udawać atomowość, której nie mogliśmy mieć.
  • Niedopasowanie kształtu. Osadzone dokumenty nie mapują się na wiersze jeden-do-jednego. Zamówienie z osadzonymi pozycjami stało się tabelą orders plus tabelą line_items; zdenormalizowane kopie stały się kluczami obcymi. Każdy backfill niósł realną logikę transformacji, a zrobienie tego dokładnie dobrze — bez utraconych danych, bez subtelnie błędnych konwersji — to było miejsce, gdzie szła troska.
  • Dwa systemy do uruchamiania i ogarniania. Przez czas trwania operowaliśmy, monitorowaliśmy i backupowaliśmy obie bazy, a każdy programista musiał wiedzieć, które modele żyją gdzie. Ten poznawczy i operacyjny narzut to cena niezrobienia wielkiego wybuchu, i jest realny — co jest argumentem za trzymaniem migracji w ruchu, zamiast żyć w stanie dwubazodanowym na zawsze.

Stan końcowy i lekcja

Ostatecznie ostatnia kolekcja się przeniosła, usunęliśmy Mongoid z Gemfile, a aplikacja działała w całości na PostgreSQL — z joinami, kluczami obcymi, transakcjami i raportowaniem GROUP BY, których domena chciała od początku. Schemat relacyjny, który byliśmy zmuszeni zaprojektować, kolekcja po kolekcji, był jaśniejszy niż model dokumentowy, który zastąpił, właśnie dlatego, że każda migracja kazała nam wyrazić strukturę jawnie.

Lekcją jest ta, którą wpis „wybór bazy danych” narysował abstrakcyjnie, teraz opłacona w praktyce: waż decyzję ku temu, dokąd aplikacja zmierza, a nie tylko gdzie zaczyna. Szybkość wczesnej iteracji MongoDB była realna i cenna, a dla naprawdę dokumentowej domeny byłaby właściwym wyborem na zawsze. Dla domeny relacyjnej schemaless przewaga startowa była pożyczką, a spłaciliśmy ją odsetkami długiej migracji. Gdybyśmy zaczynali ten projekt na nowo, znając domenę, zaczęlibyśmy na Postgresie — i sięgnęli po jsonb dla naprawdę elastycznych części, dostając schemaless wolność wewnątrz bazy danych relacyjnej.

Ale samą migrację zrobilibyśmy dokładnie tak samo. Obok siebie, dual-write, kolekcja po kolekcji, odwracalna na każdym kroku — zamieniła przerażające przepisanie w długą sekwencję nudnych, bezpiecznych zmian, na żywym systemie obsługującym pieniądze, bez przestoju. To, znacznie bardziej niż wybór bazy, jest częścią wartą skopiowania.