background blurbackground mobile blur

1/1/1970

Hur man migrerar bort från Firestore till Postgres på 2 dagar

Firebase. Det ena ordet kan lätt kosta dig 10-20 gånger mer än en billigare databas, och kan både begränsa och komplicera din databasdesign.

Så du kan tänka dig hur lättad jag var när jag blev klar med migreringen bort från Firestore på bara 2 dagar. Nåja, 3 dagar om man räknar tiden jag la på att bygga min första "PostgresSynchronizer". Eller 3,5 dagar om man räknar tiden jag la på att migrera workshop-systemet. Hur som helst, det gick snabbt. Riktigt snabbt.

Min ursprungliga uppskattning för det här projektet var en hel månads arbete! Men tack vare några grymma LLM-prompts, och en vän som lärde mig om CDC, lyckades jag ta mig igenom det här betydligt snabbare.

Om du letar efter pub/sub kan Firebase eller Supabase låta väldigt frestande. Trots allt är det inte många databaser som stöder pub/sub direkt ur lådan. Men den enkla arkitekturen nedan är allt du behöver för de flesta småföretagscase (och bör skala fint upp till ungefär 10k-100k operationer per sekund).

Foonys pub/sub-arkitektur Postgres körs på sin egen Hetzner-server. CDC- och Gateway-tjänsterna körs inuti ett Hetzner k3s-kluster. CDC läser Postgres Write-Ahead Log och publicerar ändringar till Redis Pub/Sub (en separat Hetzner-server). Gateway prenumererar på Redis och fördelar uppdateringar till WebSocket-klienter.

{/* Postgres host (left). Standalone Hetzner server. */} Postgres-server (Hetzner) Postgres WAL

{/* k3s cluster (middle). Contains the CDC and Gateway services as pods. */} k3s-kluster (Hetzner)

{/* CDC service (singleton). */} CDC (singleton)

{/* Gateway service. Two layered rects convey "horizontally autoscaling" multiple replicas. */} Gateway (autoskalande)

{/* Redis host (below k3s). Standalone Hetzner server. */} Redis-server (Hetzner) Redis Pub/Sub

{/* Data flow: Postgres WAL -> CDC. /} {/ CDC -> Redis (down through the bottom of the k3s frame). /} {/ Redis -> Gateway (back up into the k3s frame). */}

{/* WebSocket clients (outside everything). Three stacked cards convey fan-out. */} WebSocket-klient

WebSocket-klient WebSocket-klient

{/* Gateway -> WebSocket clients (fan-out across the public internet). */}

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

Innan vi går in på detaljerna om hur pub/sub fungerar tänkte jag dock gå igenom stegen jag tog i tur och ordning.

Först skrev jag en prompt i Cursor för att bygga en PostgresSynchronizer. Den här klassen har en funktion, handleWrite, som tar emot en Firestore-sökväg, typen av operation (create, update, delete) och datan. Den gör sedan en upsert av datan till Postgres. Jag wrappade sedan min Firestore-store-instans så att alla ändringar i Firestore också anropar handleWrite.

Det här är såklart inte 100% perfekt. Om servern tappar anslutningen eller kraschar är det möjligt att några mindre ändringar inte sparas. Men det är tillräckligt bra för Foonys användningsfall, och Postgres-schemat ger oss bättre garantier för dataintegritet (t.ex. foreign key-constraints) än Firestore.

Sedan skapade och körde jag en backfill som itererar över alla Firestore-collections och anropar handleWrite för varje dokument. Det säkerställer att vi får all historisk data från Firestore. För hastighetens skull begränsade jag detta till endast registrerade användare (förlåt gäster, ni borde ha registrerat er). Det fungerar bra och är säkert att köra flera gånger.

Nu när jag hade ett sätt att fylla på data i Postgres och hålla den (mestadels) i synk med Firestore kunde jag ta itu med det stora, läskiga problemet: pub/sub.

Hur fan gör man pub/sub med Postgres? Eller någon SQL-databas över huvud taget?

Postgres Change Data Capture (CDC) till undsättning!

CDC är ett fint ord som bara betyder "läs ändringarna som gjorts i databasen och publicera dem till ett pub/sub-system". I ett stort företag kanske du använder något som Debezium + Kafka. Men Kafka är jobbigt att sätta upp, vi har redan Redis på plats och vår databas får bara cirka 30 operationer per sekund. Även om vi skulle skala upp 100 gånger till ~100 000 samtidiga användare kan vi förmodligen fortfarande få plats med detta i en enda Redis-instans. Så det var det jag gjorde.

(Du kan också skruva på pub/sub på Postgres med LISTEN/NOTIFY, men det överlever inte återanslutningar och rasar samman så fort du faktiskt behöver fanout. CDC är det tråkiga, hållbara svaret.)

Min vän Eric lärde mig om CDC och öppnade upp sin CDC-kod som läser Postgres WAL (Write-Ahead Log, där Postgres skriver varje ändring den gör i databasen) via en logisk replikeringsslot, och sparar den till en sink (t.ex. Redis).

Med en enkel, detaljerad prompt i Cursor lyckades jag mest one-shot:a realtids-CDC och gateway-koden (med några mindre justeringar och en grundlig kodgranskning). Det här innehåller två väldigt enkla tjänster:

  • en singleton CDC-tjänst som läser WAL och publicerar den till Redis Pub/Sub (man kan få mer hållbarhet med Streams, men på bekostnad av komplexitet)
  • en horisontellt autoskalande gateway-tjänst som hanterar JWT-autentisering och websocket-anslutningar från webbklienter.

Båda tjänsterna är i golang, och jag älskar särskilt den autoskalande gateway-delen. Webbklienten prenumererar på samma sätt som den brukade göra med Firestore, och får tillbaka data i samma format. Klienten ser "usersPublic/" och "usersPrivate/" som två separata collections med separata behörigheter. Gatewayen är ansvarig för att översätta den begäran till de underliggande Postgres-tabellerna (i det här fallet users), och validera att klienten har behörighet till den datan.

Det här fungerar chockerande bra. Hela pub/sub-systemet kostar oss bara cirka 0,50 dollar per månad i compute- och egress-kostnader, och har inte många rörliga delar som kan gå sönder (CDC-, gateway- och klientkoden är alla enkla utan några tredjepartsberoenden utöver Redis och Postgres).

Ur ett DevEx-perspektiv är det nya systemet faktiskt också enklare: utvecklare behöver inte veta hur man modellerar data i Firestore eller Firestore-säkerhetsregler. Så länge de förstår SQL kan de snabbt göra en ändring i vår schema.sql, lägga till eller modifiera en route i all.go, och så är de igång. Dokumentationen är också stark nog att LLM:er har lätt att följa den och göra ändringar. Ur ett säkerhetsperspektiv är detta också en vinst eftersom vi inte längre är exponerade för en farlig denial-of-wallet-attack (Wallet DoS), då vår nya infrastruktur har fasta kostnader och är vansinnigt mycket billigare.

Sammantaget, inklusive Postgres-instansen och dagliga S3-backuper, lyckades vi gå från 550 dollar per månad till bara 40 dollar per månad i kostnader för databasinfrastruktur. Vi minskade också klientbundlestorleken med ~100KB, vilket är trevligt.

Nu när jag hade det nya systemet testade jag lokalt för att se till att allt fungerade som förväntat. Sedan deployade jag det live. Om det fanns några problem var det enkelt att rulla tillbaka klienten eftersom Firestore fortfarande var sanningskällan vid det här laget.

Live-lansering

Server-cutovern var lite läskig (databasmigreringar är det alltid). Fram till denna punkt var Firestore fortfarande sanningskällan. CDC-pub/sub-systemet läste bara från en Postgres-mirror som synchronizern höll uppdaterad. Att deploya de nya spelservrarna är vad som faktiskt skulle göra Postgres auktoritativ för skrivningar.

Så här rullade jag ut det:

  1. Uppdaterade partners först. Foony är integrerat med partners som FRVR, så vi deployade en ny klientbuild några dagar tidigare som skulle använda vår nya CDC-gateway.
  2. Varnade spelarna. Ungefär tio minuter före cutovern postade vi en heads-up om ett (förhoppningsvis) kort underhållsfönster.
  3. Tog en färsk databasbackup. Det är alltid en bra idé vid stora databasmigreringar. LLM:er kallar det "belt and suspenders", vilket i princip bara betyder att vara extra försiktig och ha en backupplan.
  4. Deployade båda klustren samtidigt. Ingen blue/green den här gången. Jag ville se till att alla kluster skrev till samma databas för att undvika potentiella inkonsekvenser.

Jag hade också en rollback-plan på plats, som var väldigt enkel: om något gick snett skulle jag redeploya föregående klient (som fortfarande läste från Firestore), redeploya servrarna, starta om backfillen och försöka igen senare. Det hade orsakat ungefär 5 minuters nedtid medan servrarna redeployades, och ungefär en halv dag för att hinna ikapp Postgres-databasen med Firestore.

Själva cutovern tog bara cirka 1 minuts nedtid. Den enda allvarliga buggen som dök upp var i uppdateringen av erfarenhet: Postgres LOWER() på en bigint-kolumn castade implicit till text. Suck. Det var en lätt nog fix med en enkel cast tillbaka till bigint, så jag fortsatte migreringen genom att göra ytterligare en serverdeploy.

Jag blev chockad över att allt verkade gå smidigt. Det var väldigt få som rapporterade problem, och de var alla väldigt små (förutom erfarenhetsgrejen). Vad som var särskilt chockerande för mig är dock hur smidigt den här migreringen gick med tanke på att den till största delen var vibe-kodad. Ganska annorlunda än de skrämmande historierna man kan läsa i nyheterna.

Workshop-omskrivningen

Med Postgres nu auktoritativ för skrivningar var bara en stor bit av kodbasen fortfarande sammanflätad med Firestore: workshop-systemet. Spelare använder det för att dela egna kartor i Dino-Might Bomber Online, ordlistor i Draw & Guess och så vidare. Lite som Steam Workshop, men för våra spel. Det var den mest Firestore-sammanflätade funktionen som återstod, både på klient- och serversidan, och det hade en lite klumpig struktur på grund av begränsningar i Firestores datamodell som jag behövde förenkla.

Till att börja med gav jag Cursor den här prompten (med Opus 4.7 high i planläge):

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`, ...).

Med vad jag hade sett av Opus 4.7:s förmågor med Cursor-harnessen förväntade jag mig att den här migreringen skulle bli klar över natten.

Det stora jag missade i min ursprungliga prompt, vilket agenten bad mig förtydliga, var hur vi effektivt borde hantera sortering efter "Senaste dygnet", "Senaste veckan", "Senaste månaden" och "Genom tiderna". Agenten förespråkade en tidsstämpel-bucketad approach (eller att helt ta bort tidsstämpelsortering), men medgrundaren insisterade på att behålla tidsbaserad sortering trots den ökade komplexiteten.

Efter lite eftertanke kom jag på en enkel lösning med en avklingningsfaktor. Varje workshop-item har kolumnerna played_count_day, played_count_week, played_count_month och played_count_all, och ett cron-jobb varje timme multiplicerar de rullande kolumnerna med 23/24, 167/168 respektive (720-1)/720. Kombinerat med partiella index (WHERE private = false AND played_count_day >= 0.368) på varje sorteringsaxel ger detta oss spottbilliga "mest populära per tidsperiod"-queries utan någon extra infrastruktur. Det här är något jag inte skulle ha gjort med Firestore på grund av dess överdrivna kostnader för läsningar och skrivningar, men det är i princip gratis i Postgres.

Med det gick jag och la mig. Jag vaknade och kollade upphetsat dess arbete! Agenten lade till de nya tabellerna workshop_items, workshop_item_votes och user_subscriptions, kopplade klienten till att läsa enskilda items via CDC-gatewayen (realtime.use('workshopItems/{id}')), och skrev om alla sex workshop-actions på backend till att prata direkt med Postgres. Det fanns några lösa trådar jag behövde städa upp (en query till Firestore på servern, ett fel med backfillen orsakat av saknad data i Firestore, etc.), men överlag var koden nästan perfekt.

Efter att ha kört backfillen och testat att allt fungerade lokalt deployade jag ändringen live. Med det var kodbasen äntligen Firestore-fri. Vackert.

Framtida arbete

I framtiden skulle jag också vilja stödja patchar i gatewayen. Just nu skickar gatewayen tillbaka hela dokumentet som JSON för varje uppdatering. Det är lite slösaktigt, men vår egress är i praktiken obegränsad tack vare att vi är på Hetzner. Jag skulle implementera detta nu, men jag kan inte riktigt motivera den extra komplexiteten ännu.

8 Ball Pool online multiplayer billiards icon