„One to rule them all” — historia przebudowy serwisu
Przebudowa serwisu i migracja milionów dokumentów NoSQL na Google Cloud, bez zatrzymywania produkcji.
W tym wpisie
Był sobie serwis. Działał, przynosił pieniądze i miał jedną wadę: nikt nie chciał go już dotykać. Model danych rozrósł się przez lata „małych poprawek”, każde zapytanie miało wyjątek, a deployment odbywał się z zapartym tchem. Do tego kilka milionów dokumentów w bazie NoSQL, których nie dało się po prostu „przepisać w weekend”.
To jest historia o tym, jak z V1 zrobiliśmy V2 — i jak przenieść miliony dokumentów tak, żeby użytkownicy niczego nie zauważyli.
Dlaczego nie „big bang”
Najprostszy plan to: zatrzymać ruch, przekopiować dane, przełączyć DNS. Najprostszy i najgorszy. Przy milionach dokumentów kopiowanie trwa godziny, a każda niespodzianka w danych (a zawsze jest niespodzianka) oznacza rollback i drugą nieprzespaną noc.
Zamiast tego przyjęliśmy trzy zasady:
- Stary serwis żyje do końca. V1 obsługuje produkcję aż do momentu, gdy V2 udowodni, że daje te same odpowiedzi.
- Migracja jest procesem, nie eventem. Dane płyną w tle, w batchach, z możliwością wznowienia w dowolnym momencie.
- Każdy dokument ma wersję. Bez pola
schemaVersionnie wiesz, co już przeniosłeś, a co czeka.
Architektura V2
V2 dostał to, czego V1 nigdy nie miał: jeden model domenowy, przez który przechodzi każdy zapis i odczyt. Zamiast dziesięciu miejsc, które „trochę wiedzą” o kształcie dokumentu, jest jeden moduł, który tłumaczy stare dokumenty na nowe encje.
// upcaster: stary dokument -> aktualna wersja
export function upcast(doc: RawDoc): DocV3 {
switch (doc.schemaVersion ?? 1) {
case 1: return upcast({ ...fromV1(doc), schemaVersion: 2 });
case 2: return upcast({ ...fromV2(doc), schemaVersion: 3 });
case 3: return doc as DocV3;
default: throw new Error(`Unknown schemaVersion ${doc.schemaVersion}`);
}
}
Ten wzorzec (znany z event sourcingu jako upcasting) ma jedną ogromną zaletę: nie musisz migrować wszystkiego, żeby wystartować. V2 potrafi czytać każdą wersję dokumentu. Migracja w tle tylko „materializuje” nową wersję, żeby nie płacić za konwersję przy każdym odczycie.
Migracja w tle na Google Cloud
Sama migracja to worker uruchamiany na Cloud Run, który:
- pobiera batch dokumentów po kursorze (
_id > lastId, limit 500), - przepuszcza je przez
upcast(), - zapisuje w nowej kolekcji z
schemaVersion: 3, - zapisuje
lastIdw małej kolekcjimigration_state.
Stan w bazie zamiast w pamięci oznacza, że worker może paść w połowie i wznowić się od ostatniego kursora. Pub/Sub wyzwalał kolejne partie, a Cloud Logging pokazywał tempo: ile dokumentów na minutę, ile błędów, jakie wersje.
Najważniejsza rzecz, której nauczyła nas ta migracja: loguj dokumenty, które nie przechodzą walidacji, ale ich nie zatrzymuj. Zawsze znajdzie się kilkaset rekordów z 2016 roku o kształcie, o którym nikt nie pamięta. Odkładaliśmy je do kolekcji migration_quarantine i naprawialiśmy osobno.
Podwójny zapis i porównywanie odpowiedzi
W okresie przejściowym V1 i V2 działały równolegle:
- zapisy trafiały do obu baz (dual write, z V1 jako źródłem prawdy),
- odczyty szły do V1, ale w tle wołaliśmy V2 i porównywaliśmy odpowiedzi (shadow traffic).
Różnice trafiały do logów. Przez pierwsze dni było ich sporo — głównie kolejność elementów i formatowanie dat. Gdy przez tydzień licznik różnic wskazywał zero, przełączyliśmy odczyty na V2. Potem zapisy. Potem wyłączyliśmy V1.
Co bym zrobił inaczej
- Pole
schemaVersiondodałbym na samym początku życia projektu, nie przed migracją. - Kolekcję kwarantanny założyłbym pierwszego dnia, a nie po pierwszym crashu workera.
- Shadow traffic uruchomiłbym wcześniej — to najtańszy test integracyjny, jaki istnieje.
Serwis V2 działa do dziś. Jeden model, jeden pierścień, żeby wszystkimi rządzić.