

1/1/1970
Sådan migrerer du fra Firestore til Postgres på 2 dage
Firebase. Det ene ord kan nemt koste dig 10-20 gange så meget som en billigere database, og det kan både begrænse og komplicere dit databasedesign.
Så du kan forestille dig, hvor lettet jeg var, da jeg blev færdig med at migrere væk fra Firestore på bare 2 dage. Nå ja, 3 dage hvis du tæller den tid, jeg brugte på at bygge min oprindelige "PostgresSynchronizer". Eller 3,5 dage hvis du tæller den tid, jeg brugte på at migrere workshop-systemet. Under alle omstændigheder gik det hurtigt. Rigtig hurtigt.
Mit oprindelige skøn for dette projekt var en hel måneds arbejde! Men takket være nogle fede LLM-prompts og en ven, der lærte mig om CDC, kunne jeg komme igennem det betydeligt hurtigere.
Hvis du leder efter pub/sub, kan Firebase eller Supabase lyde meget fristende. Trods alt er der ikke mange databaser, der understøtter pub/sub ud af boksen. Men den simple arkitektur nedenfor er alt, hvad du behøver til de fleste små virksomheders use cases (og skalerer fint op til omkring 10k-100k ops/sekund).
Før vi dykker ned i detaljerne om, hvordan pub/sub fungerer, vil jeg lige gennemgå de skridt, jeg tog, i rækkefølge.
Først lavede jeg en prompt i Cursor for at bygge en PostgresSynchronizer. Denne klasse har en funktion, handleWrite, der tager en Firestore-sti, typen af operation (create, update, delete) og dataene. Den udfører derefter en upsert af dataene til Postgres. Jeg pakkede så min Firestore-store-instans ind, så enhver ændring i Firestore også ville kalde handleWrite.
Det er selvfølgelig ikke 100% perfekt. Hvis serveren mister forbindelsen eller crasher, er det muligt, at nogle få mindre ændringer ikke bliver gemt. Men det er godt nok til Foonys use case, og Postgres-skemaet giver os bedre garantier for dataintegritet (f.eks. foreign key-constraints) end Firestore.
Dernæst oprettede og kørte jeg en backfill, der itererer over alle Firestore-collections og kalder handleWrite for hvert dokument. Det sikrer, at vi får alle historiske data fra Firestore. For hastighedens skyld begrænsede jeg dette til kun registrerede brugere (beklager til gæsterne, I burde have tilmeldt jer). Det fungerer godt, og det er sikkert at køre flere gange.
Nu hvor jeg havde en måde at fylde data ind i Postgres på og holde det (for det meste) synkroniseret med Firestore, kunne jeg tage fat på det store, skræmmende problem: pub/sub.
Hvordan i alverden laver man pub/sub med Postgres? Eller en hvilken som helst SQL-database for den sags skyld?
Postgres Change Data Capture (CDC) til undsætning!
CDC er et fancy ord, der bare betyder "læs de ændringer, der er foretaget i databasen, og udgiv dem til et pub/sub-system". I en stor virksomhed ville du måske bruge noget som Debezium + Kafka. Men Kafka er besværligt at sætte op, vi har allerede Redis på plads, og vores database får kun omkring 30 ops/sekund. Selv hvis vi skulle 100-doble vores skala til ~100.000 samtidige brugere, kan vi sandsynligvis stadig få det til at passe i en enkelt Redis-instans. Så det var det, jeg gjorde.
(Du kan også sætte pub/sub på Postgres med LISTEN/NOTIFY, men det overlever ikke genforbindelser og falder fra hinanden, så snart du faktisk har brug for fanout. CDC er det kedelige, holdbare svar.)
Min ven, Eric, lærte mig om CDC og open source'ede sin CDC-kode, der læser Postgres' WAL (Write-Ahead Log, hvor Postgres skriver hver eneste ændring, den foretager i databasen) via en logisk replication slot og gemmer det i en sink (f.eks. Redis).
Med en simpel, detaljeret prompt i Cursor kunne jeg næsten one-shot'e den realtime CDC- og gateway-kode (med nogle mindre justeringer og en grundig kodegennemgang). Det omfatter to meget enkle services:
- en singleton CDC-service, der læser WAL og udgiver den til Redis Pub/Sub (du kan få mere holdbarhed med Streams, men på bekostning af kompleksitet)
- en horisontalt autoskalerende gateway-service, der håndterer JWT-auth og websocket-forbindelser fra web-klienter.
Begge services er i golang, og jeg elsker især den autoskalerende gateway-del. Web-klienten abonnerer på samme måde, som den plejede med Firestore, og får data tilbage i samme format. Klienten ser "usersPublic/" og "usersPrivate/" som to separate collections med separate tilladelser. Gateway'en er ansvarlig for at oversætte den anmodning til de underliggende Postgres-tabeller (i dette tilfælde users) og validere, at klienten har tilladelse til de data.
Det fungerer chokerende godt. Hele pub/sub-systemet koster os kun omkring 0,50 $/måned i compute + egress-omkostninger, og det har ikke mange bevægelige dele, der kan gå i stykker (CDC, gateway og klientkode er alle simple uden tredjepartsafhængigheder ud over Redis/Postgres).
Fra et DevEx-perspektiv er det nye system uden tvivl også enklere: udviklere behøver ikke vide, hvordan man modellerer data i Firestore eller Firestore-sikkerhedsregler. Så længe de forstår SQL, kan de hurtigt foretage en redigering af vores schema.sql, tilføje eller ændre en route i all.go, og så er de klar. Dokumentationen er også stærk nok til, at LLM'er nemt kan følge den og lave ændringer. Fra et sikkerhedsperspektiv er det også en gevinst, da vi ikke længere er udsat for et farligt denial-of-wallet-angreb (Wallet DoS), eftersom vores nye infra har faste omkostninger og er vanvittigt meget billigere.
Alt i alt, inklusive Postgres-instansen og daglige S3-backups, lykkedes det os at gå fra 550 $/måned til kun 40 $/måned i database-infraomkostninger. Vi reducerede også klientens bundle-størrelse med ~100KB, hvilket er rart.
Nu hvor jeg havde det nye system, testede jeg lokalt for at sikre, at alt fungerede som forventet. Derefter deployede jeg det live. Hvis der opstod problemer, var det nemt at rulle klienten tilbage, da Firestore stadig var the source of truth på dette tidspunkt.
Live-deployment
Server-cutover'et var lidt skræmmende (databasemigreringer er det altid). Indtil dette tidspunkt var Firestore stadig the source of truth. CDC pub/sub-systemet læste blot fra et Postgres-mirror, som synchronizeren holdt opdateret. At deploye de nye game servers var det, der faktisk ville gøre Postgres autoritativ for skrivninger.
Sådan rullede jeg det ud:
- Opdaterede partnere først. Foony er integreret med partnere som FRVR, så vi deployede et nyt klient-build et par dage tidligere, som ville bruge vores nye CDC-gateway.
- Advarede spillerne. Cirka ti minutter før cutover'et postede vi en heads-up om et (forhåbentlig) kort vedligeholdelsesvindue.
- Tog en frisk database-backup. Det er altid en god idé ved store databasemigreringer. LLM'er kalder det "belt and suspenders", hvilket dybest set bare betyder at være ekstra forsigtig og have en plan B.
- Deployede begge clusters samtidigt. Ingen blue/green denne gang. Jeg ville sikre mig, at alle clusters skrev til den samme database for at undgå potentielle uoverensstemmelser.
Jeg havde også en rollback-plan klar, som var meget enkel: hvis noget gik galt, ville jeg redeploye den forrige klient (der stadig læste fra Firestore), redeploye serverne, genstarte backfill'en og prøve igen senere. Det ville have forårsaget omkring 5 minutters nedetid, mens serverne blev redeployet, og omkring en halv dag for at få Postgres-databasen til at indhente Firestore.
Selve cutover'et tog kun omkring 1 minuts nedetid. Den eneste alvorlige bug, der dukkede op, var i opdateringen af experience: Postgres' LOWER() på en bigint-kolonne castede implicit til text. Suk. Det var en nem nok løsning med en simpel cast tilbage til bigint, så jeg fortsatte migreringen ved at lave endnu en server-deploy.
Jeg var chokeret over, at alt så ud til at gå glat. Der var meget få, der rapporterede problemer, og de var alle meget små (bortset fra experience-tingen). Det, der især chokerede mig, var dog, hvor glat denne migrering forløb, i betragtning af at den i overvejende grad var vibe-codet. Ret anderledes end de skræmmende historier, du måske læser i nyhederne.
Workshop-rewrite'et
Med Postgres nu som autoritativ for skrivninger var der kun én stor del af kodebasen tilbage, der stadig var viklet ind i Firestore: workshop-systemet. Spillere bruger det til at dele brugerdefinerede kort i Dino-Might Bomber Online, ordlister i Draw & Guess osv. Ligesom Steam Workshop, men til vores spil. Det var den mest Firestore-sammenfiltrede feature tilbage, både på klienten og serveren, og den havde en akavet struktur på grund af begrænsninger i Firestores datamodel, som jeg var nødt til at forenkle.
Til at starte med gav jeg Cursor denne prompt (med Opus 4.7 high i plan mode):
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`, ...).
Ud fra det, jeg havde set af Opus 4.7's evner med Cursor-harnesset, forventede jeg, at denne migrering ville være færdig i løbet af natten.
Den store ting, jeg overså i min oprindelige prompt, og som agenten bad mig præcisere, var, hvordan vi effektivt skulle håndtere sortering efter "Sidste dag", "Sidste uge", "Sidste måned" og "All Time". Agenten skubbede på for en timestamp-bucketed tilgang (eller at droppe timestamp-sortering helt), men medstifteren insisterede på at beholde tidsbaseret sortering på trods af den ekstra kompleksitet.
Efter lidt overvejelse kom jeg op med en simpel løsning, der involverede en henfaldsfaktor. Hvert workshop-item har kolonnerne played_count_day, played_count_week, played_count_month og played_count_all, og et timeligt cron-job multiplicerer de rullende kolonner med henholdsvis 23/24, 167/168 og (720-1)/720. Kombineret med partial indexes (WHERE private = false AND played_count_day >= 0.368) på hver sorteringsakse giver dette os spotbillige "mest populære efter tidsperiode"-forespørgsler uden ekstra infrastruktur. Det er noget, jeg ikke ville have gjort med Firestore på grund af de ublu omkostninger ved læsninger og skrivninger, men det er stort set gratis i Postgres.
Med det gik jeg i seng. Jeg vågnede og tjekkede spændt arbejdet! Agenten tilføjede de nye tabeller workshop_items, workshop_item_votes og user_subscriptions, koblede klienten til at læse individuelle items via CDC-gateway'en (realtime.use('workshopItems/{id}')) og omskrev alle seks workshop-actions på backenden til at tale direkte med Postgres. Der var et par løse ender, jeg måtte rydde op i (én forespørgsel til Firestore på serveren, en fejl med backfill'en forårsaget af manglende data i Firestore osv.), men overordnet var koden næsten perfekt.
Efter at have kørt backfill'en og testet, at alt virkede lokalt, deployede jeg ændringen live. Med det var kodebasen endelig Firestore-fri. Smukt.
Fremtidigt arbejde
I fremtiden vil jeg også gerne understøtte patches i gateway'en. Lige nu sender gateway'en hele dokumentet tilbage som JSON for hver opdatering. Det er lidt spild, men vores egress er reelt set ubegrænset takket være Hetzner. Jeg ville implementere det nu, men jeg kan endnu ikke retfærdiggøre den ekstra kompleksitet.