background blurbackground mobile blur

1/1/1970

Jak przenieść się z Firestore na Postgres w 2 dni

Firebase. To jedno słowo może z łatwością kosztować Cię 10-20x więcej niż tańsza baza danych, a do tego ograniczać i komplikować projekt Twojej bazy.

Możesz więc sobie wyobrazić, jaką ulgę poczułem, kiedy ukończyłem migrację z Firestore w zaledwie 2 dni. No dobrze, 3 dni, jeśli policzyć czas spędzony na budowaniu pierwszej wersji "PostgresSynchronizer". Albo 3,5 dnia, jeśli wliczyć migrację systemu warsztatu. Tak czy inaczej, było szybko. Naprawdę szybko.

Mój pierwotny szacunek dla tego projektu to cały miesiąc pracy! Ale dzięki kilku świetnym promptom dla LLM-ów i koledze, który nauczył mnie o CDC, udało mi się przez to przebrnąć znacznie szybciej.

Jeśli szukasz pub/sub, Firebase albo Supabase mogą brzmieć bardzo kusząco. W końcu niewiele baz danych obsługuje pub/sub od ręki. Ale ta prosta architektura poniżej to wszystko, czego potrzebujesz w większości zastosowań w małym biznesie (i powinna ładnie skalować się aż do około 10k-100k operacji na sekundę).

Architektura pub/sub Foony Postgres działa na własnym serwerze Hetzner. Usługi CDC i Gateway działają wewnątrz klastra k3s na Hetznerze. CDC odczytuje Write-Ahead Log Postgresa i publikuje zmiany do Redis Pub/Sub (osobny serwer Hetzner). Gateway subskrybuje Redisa i rozsyła aktualizacje do klientów WebSocket.

{/* Host Postgresa (po lewej). Samodzielny serwer Hetzner. */} Serwer Postgres (Hetzner) Postgres WAL

{/* Klaster k3s (środek). Zawiera usługi CDC i Gateway jako pody. */} Klaster k3s (Hetzner)

{/* Usługa CDC (singleton). */} CDC (singleton)

{/* Usługa Gateway. Dwa nałożone prostokąty pokazują "skalowanie horyzontalne" wielu replik. */} Gateway (autoskalowanie)

{/* Host Redisa (pod k3s). Samodzielny serwer Hetzner. */} Serwer Redis (Hetzner) Redis Pub/Sub

{/* Przepływ danych: Postgres WAL -> CDC. /} {/ CDC -> Redis (w dół przez dolną krawędź ramki k3s). /} {/ Redis -> Gateway (z powrotem w górę do ramki k3s). */}

{/* Klienci WebSocket (poza wszystkim). Trzy ułożone karty pokazują rozsyłanie. */} Klient WebSocket

Klient WebSocket Klient WebSocket

{/* Gateway -> klienci WebSocket (rozsyłanie przez publiczny internet). */}

Postgres WAL (Hetzner) → CDC (k3s) → Redis Pub/Sub (Hetzner) → Gateway (k3s) → klienci WebSocket

Zanim jednak wejdziemy w szczegóły działania pub/sub, omówię po kolei kroki, które wykonałem.

Najpierw stworzyłem prompt w Cursorze, żeby zbudować PostgresSynchronizer. Ta klasa ma funkcję handleWrite, która przyjmuje ścieżkę Firestore, typ operacji (create, update, delete) oraz dane. Następnie wykonuje upsert danych do Postgresa. Potem opakowałem instancję store'a Firestore tak, aby każda zmiana w Firestore wywoływała również handleWrite.

To oczywiście nie jest w 100% idealne. Jeśli serwer się rozłączy lub padnie, możliwe, że kilka drobnych zmian nie zostanie utrwalonych. Ale dla zastosowania Foony to wystarczająco dobre, a schemat Postgres daje nam lepsze gwarancje integralności danych (np. ograniczenia kluczy obcych) niż Firestore.

Następnie stworzyłem i uruchomiłem backfill, który iteruje po wszystkich kolekcjach Firestore i wywołuje handleWrite dla każdego dokumentu. Dzięki temu mamy wszystkie historyczne dane z Firestore. Dla szybkości ograniczyłem to tylko do zarejestrowanych użytkowników (przepraszam gości, trzeba było się zarejestrować). To działa świetnie i można to bezpiecznie uruchamiać wielokrotnie.

Mając już sposób na zapełnienie danych w Postgresie i utrzymywanie ich (w większości) zsynchronizowanych z Firestore, mogłem zająć się dużym, strasznym problemem: pub/sub.

Jak, do licha, robić pub/sub z Postgresem? Albo z jakąkolwiek bazą SQL?

Postgres Change Data Capture (CDC) na ratunek!

CDC to wymyślne słowo, które oznacza po prostu "czytaj zmiany wprowadzone do bazy danych i publikuj je do systemu pub/sub". W dużej firmie można użyć czegoś w stylu Debezium + Kafka. Ale Kafka jest uciążliwa w konfiguracji, my mamy już Redisa, a nasza baza danych otrzymuje tylko około 30 operacji na sekundę. Nawet gdybyśmy 100x zwiększyli skalę do ~100 000 jednoczesnych użytkowników, prawdopodobnie wciąż zmieścimy się w jednej instancji Redisa. I tak właśnie zrobiłem.

(Można też doczepić pub/sub do Postgresa za pomocą LISTEN/NOTIFY, ale to nie przetrwa ponownych połączeń i wywraca się, gdy faktycznie potrzebujesz rozsyłania. CDC to nudna, trwała odpowiedź.)

Mój kolega, Eric, nauczył mnie o CDC i udostępnił jako open source swój kod CDC, który odczytuje WAL Postgresa (Write-Ahead Log, tam gdzie Postgres zapisuje każdą zmianę wprowadzaną do bazy) poprzez slot replikacji logicznej i utrwala go do sinka (np. Redisa).

Dzięki prostemu, szczegółowemu promptowi w Cursorze udało mi się w większości one-shotem napisać kod CDC w czasie rzeczywistym i bramki (z drobnymi poprawkami i dokładnym przeglądem kodu). Obejmuje to dwie bardzo proste usługi:

  • singletonową usługę CDC, która czyta WAL i publikuje go do Redis Pub/Sub (można uzyskać większą trwałość ze Streamami, ale kosztem złożoności)
  • skalującą się horyzontalnie usługę gateway, która obsługuje uwierzytelnianie JWT i połączenia websocket od klientów webowych.

Obie usługi są w golangu i szczególnie podoba mi się ta autoskalująca się brama. Klient webowy subskrybuje w ten sam sposób co wcześniej z Firestore i otrzymuje dane w tym samym formacie. Klient widzi "usersPublic/" i "usersPrivate/" jako dwie osobne kolekcje z osobnymi uprawnieniami. Gateway jest odpowiedzialny za przetłumaczenie tego żądania na odpowiednie tabele Postgresa (w tym przypadku users) i walidację, czy klient ma uprawnienia do tych danych.

To działa szokująco dobrze. Cały system pub/sub kosztuje nas zaledwie około 0,50 $ miesięcznie w kosztach obliczeń i ruchu wychodzącego, a nie ma wielu ruchomych części, które mogą się zepsuć (kod CDC, bramy i klienta jest prosty i nie ma zewnętrznych zależności poza Redisem / Postgresem).

Z perspektywy DevEx, nowy system jest też prawdopodobnie prostszy: programiści nie muszą wiedzieć, jak modelować dane w Firestore ani znać reguł bezpieczeństwa Firestore. O ile rozumieją SQL, mogą szybko wprowadzić zmianę w naszym schema.sql, dodać lub zmodyfikować trasę w all.go i są gotowi. Dokumentacja jest też na tyle dobra, że LLM-y bez problemu za nią podążają i wprowadzają zmiany. Z perspektywy bezpieczeństwa to także wygrana, ponieważ nie jesteśmy już narażeni na niebezpieczny atak typu denial-of-wallet (Wallet DoS), gdyż nasza nowa infrastruktura ma stały koszt i jest absurdalnie tańsza.

W sumie, wliczając instancję Postgresa i codzienne kopie zapasowe S3, udało nam się przejść z 550 $ miesięcznie do zaledwie 40 $ miesięcznie w kosztach infrastruktury bazodanowej. Zmniejszyliśmy też rozmiar bundle'a klienta o ~100KB, co jest miłe.

Mając już nowy system, przetestowałem go lokalnie, aby upewnić się, że wszystko działa zgodnie z oczekiwaniami. Następnie wdrożyłem go na produkcję. Gdyby pojawiły się problemy, łatwo wycofać klienta, ponieważ Firestore wciąż jest na tym etapie źródłem prawdy.

Przejście na żywo

Przełączenie serwera było trochę straszne (migracje baz danych zawsze są). Do tego momentu Firestore wciąż był źródłem prawdy. System pub/sub CDC po prostu czytał z lustra Postgresa, które synchronizator utrzymywał na bieżąco. Wdrożenie nowych serwerów gier było tym, co faktycznie przerzuci Postgres na autoryzatywny dla zapisów.

Oto jak to wdrożyłem:

  1. Najpierw zaktualizowałem partnerów. Foony jest zintegrowane z partnerami takimi jak FRVR, więc kilka dni wcześniej wdrożyliśmy nową wersję klienta, która korzystałaby z naszej nowej bramy CDC.
  2. Ostrzegłem graczy. Około dziesięć minut przed przełączeniem opublikowaliśmy informację o (miejmy nadzieję) krótkim okresie konserwacji.
  3. Zrobiłem świeżą kopię zapasową bazy. To zawsze dobry pomysł przy dużych migracjach baz danych. LLM-y nazywają to "belt and suspenders" (pasek i szelki), co w zasadzie oznacza po prostu nadmierną ostrożność i posiadanie planu awaryjnego.
  4. Wdrożyłem oba klastry jednocześnie. Tym razem bez blue/green. Chciałem mieć pewność, że wszystkie klastry zapisują do tej samej bazy danych, aby uniknąć potencjalnych niespójności.

Miałem też plan wycofania, który był bardzo prosty: gdyby coś poszło nie tak, wdrożyłbym ponownie poprzedniego klienta (który wciąż czytał z Firestore), wdrożyłbym ponownie serwery, ponownie uruchomił backfill i spróbował później jeszcze raz. Spowodowałoby to około 5 minut przestoju podczas ponownego wdrażania serwerów i około pół dnia, aby doprowadzić bazę Postgres do stanu Firestore.

Faktyczne przełączenie zajęło tylko około 1 minuty przestoju. Jedyny poważny błąd, który się pojawił, dotyczył aktualizacji doświadczenia: LOWER() w Postgresie na kolumnie bigint niejawnie rzutował na text. Westchnienie. Była to dość łatwa poprawka z prostym rzutowaniem z powrotem na bigint, więc kontynuowałem migrację, wydając kolejne wdrożenie serwera.

Byłem w szoku, że wszystko wydawało się iść gładko. Bardzo niewiele osób zgłosiło problemy, a te wszystkie były bardzo drobne (poza tą sprawą z doświadczeniem). Szczególnie szokujące było dla mnie to, jak gładko poszła ta migracja, biorąc pod uwagę, że została w większości napisana w stylu "vibe-coding". Całkiem inaczej niż przerażające historie, jakie można przeczytać w wiadomościach.

Przepisanie warsztatu

Skoro Postgres był już autoryzatywny dla zapisów, tylko jeden duży fragment bazy kodu wciąż był splątany z Firestore: system warsztatu. Gracze używają go do udostępniania własnych map w Dino-Might Bomber Online, list słów w Draw & Guess i tak dalej. Coś jak Steam Workshop, ale dla naszych gier. Była to ostatnia funkcja najbardziej splątana z Firestore, zarówno po stronie klienta, jak i serwera, i miała niezgrabną strukturę z powodu ograniczeń modelu danych Firestore, którą musiałem uprościć.

Na początek dałem Cursorowi taki prompt (używając Opus 4.7 high w trybie planu):

Now that we've finished most of the migration to Postgres, we have the last big part of the migration: rewriting the workshop system.

We'll need to update schema.sql to support the new workshop tables.

Think of the workshop sort of like Steam's workshop.

In other words, the workshop must:
 - Support multiple games.
 - Support different data formats (e.g. maps in dinomight, word lists in paintjob)
 - Support favoriting
 - Support like / dislike
 - Support a description

Use best practices to ensure that the SQL is efficient.

After updating schema.sql, you will need to update both the backend and the frontend to use the new workshop system. You will also need to either use the `realtime.use` hook or the API for workshop info (up to you which you choose--realtime requires modifying all.go, API calls require updates to Action.ts, etc.). I'd probably go with the cdc gateway (realtime.use / all.go).

Keep your implementation simple where possible. At the end of this migration, we should no longer have `firestore.use` or any mention of firestore on the client. We should also no longer need firestore on the backend.

As part of this change, you will need to also create a backfill migration (there's already a migration backfill--just modify that to work with these firestore workshop collections) that migrates the firestore data to the postgres schema you decide on.

You shouldn't run the backfill--I will do that myself manually once you're done with your code. You can leave a call to the backfill (commented out) at the bottom of server/src/index.ts for me. The backfill should only handle migration of these remaining tables--it shouldn't do any backfilling of old tables that we've already migrated (e.g. no `userItems`, `usersPublic`, ...).

Po tym, co widziałem w możliwościach Opus 4.7 z harnessem Cursora, spodziewałem się, że ta migracja skończy się w nocy.

Wielką rzeczą, którą pominąłem w moim oryginalnym prompcie, a o której agent poprosił mnie o doprecyzowanie, było to, jak powinniśmy efektywnie obsłużyć sortowanie "Ostatni dzień", "Ostatni tydzień", "Ostatni miesiąc" i "Wszechczasów". Agent forsował podejście z kubełkowaniem znaczników czasu (albo całkowite rezygnowanie z sortowania po czasie), ale współzałożyciel nalegał, aby zachować sortowanie czasowe pomimo dodatkowej złożoności.

Po namyśle wymyśliłem proste rozwiązanie z czynnikiem zaniku. Każdy element warsztatu ma kolumny played_count_day, played_count_week, played_count_month i played_count_all, a cogodzinny cron job mnoży kolumny kroczące odpowiednio przez 23/24, 167/168 i (720-1)/720. W połączeniu z indeksami częściowymi (WHERE private = false AND played_count_day >= 0.368) na każdej osi sortowania daje nam to wręcz darmowe zapytania "najpopularniejsze w okresie" bez dodatkowej infrastruktury. To coś, czego nie zrobiłbym z Firestore z powodu jego wygórowanych kosztów odczytów i zapisów, ale w Postgresie jest to praktycznie za darmo.

Z tym poszedłem spać. Obudziłem się i z ekscytacją sprawdziłem efekty pracy! Agent dodał nowe tabele workshop_items, workshop_item_votes i user_subscriptions, podłączył klienta do odczytywania poszczególnych elementów przez bramę CDC (realtime.use('workshopItems/{id}')) i przepisał wszystkie sześć akcji warsztatu na backendzie, aby rozmawiały bezpośrednio z Postgresem. Było kilka luźnych końców, które musiałem dopiąć (jedno zapytanie do Firestore na serwerze, błąd z backfillem spowodowany brakującymi danymi w Firestore itp.), ale ogólnie kod był prawie idealny.

Po uruchomieniu backfilla i przetestowaniu, czy wszystko działa lokalnie, wdrożyłem zmianę na produkcję. Wraz z tym kodebejs był w końcu wolny od Firestore. Pięknie.

Przyszłe prace

W przyszłości chciałbym też wspierać patche w bramie. Obecnie brama odsyła cały dokument jako JSON przy każdej aktualizacji. Trochę to marnotrawne, ale nasz ruch wychodzący jest praktycznie nieograniczony dzięki temu, że jesteśmy na Hetznerze. Wdrożyłbym to teraz, ale nie potrafię jeszcze uzasadnić tej dodatkowej złożoności.

8 Ball Pool online multiplayer billiards icon