← Blog

„One to rule them all” — historia przebudowy serwisu

Przebudowa serwisu i migracja milionów dokumentów NoSQL na Google Cloud, bez zatrzymywania produkcji.

Kacper Walczak · 3 września 2024 · 3 min czytania

W tym wpisie
  1. Dlaczego nie „big bang”
  2. Architektura V2
  3. Migracja w tle na Google Cloud
  4. Podwójny zapis i porównywanie odpowiedzi
  5. Co bym zrobił inaczej

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:

  1. Stary serwis żyje do końca. V1 obsługuje produkcję aż do momentu, gdy V2 udowodni, że daje te same odpowiedzi.
  2. Migracja jest procesem, nie eventem. Dane płyną w tle, w batchach, z możliwością wznowienia w dowolnym momencie.
  3. Każdy dokument ma wersję. Bez pola schemaVersion nie 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 lastId w małej kolekcji migration_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 schemaVersion dodał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ć.