background blurbackground mobile blur

1/1/1970

Wie ich in 2 Tagen von Firestore zu Postgres migriert habe

Firebase. Dieses eine Wort kann dich locker das 10- bis 20-fache dessen kosten, was eine günstigere Datenbank kostet, und kann dein Datenbankdesign sowohl einschränken als auch verkomplizieren.

Du kannst dir also vorstellen, wie erleichtert ich war, als ich die Migration von Firestore in nur 2 Tagen abgeschlossen hatte. Naja, 3 Tagen, wenn man die Zeit mitzählt, die ich für den Bau meines anfänglichen "PostgresSynchronizer" gebraucht habe. Oder 3,5 Tagen, wenn man die Zeit für die Migration des Workshop-Systems mitrechnet. Wie auch immer, es ging schnell. Richtig schnell.

Meine ursprüngliche Schätzung für dieses Projekt war ein ganzer Monat Arbeit! Aber dank einiger großartiger LLM-Prompts und eines Freundes, der mir CDC beigebracht hat, konnte ich das deutlich schneller durchziehen.

Wenn du Pub/Sub suchst, klingen Firebase oder Supabase vielleicht sehr verlockend. Schließlich unterstützen nicht viele Datenbanken Pub/Sub von Haus aus. Aber diese einfache Architektur hier unten ist alles, was du für die meisten Anwendungsfälle in kleinen Unternehmen brauchst (und sollte sich bis etwa 10.000 bis 100.000 Operationen pro Sekunde gut skalieren lassen).

Foonys Pub/Sub-Architektur Postgres läuft auf einem eigenen Hetzner-Server. Die CDC- und Gateway-Services laufen in einem Hetzner-k3s-Cluster. CDC liest das Write-Ahead-Log von Postgres und veröffentlicht Änderungen an Redis Pub/Sub (ein separater Hetzner-Server). Das Gateway abonniert Redis und verteilt Updates an WebSocket-Clients.

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

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

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

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

{/* 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-Client

WebSocket-Client WebSocket-Client

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

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

Bevor wir aber in die Details eintauchen, wie das Pub/Sub funktioniert, gehe ich die Schritte durch, die ich der Reihe nach unternommen habe.

Zuerst habe ich in Cursor einen Prompt erstellt, um einen PostgresSynchronizer zu bauen. Diese Klasse hat eine Funktion, handleWrite, die einen Firestore-Pfad, den Typ der Operation (create, update, delete) und die Daten entgegennimmt. Sie führt dann ein Upsert der Daten in Postgres durch. Anschließend habe ich meine Firestore-Store-Instanz so umhüllt, dass jede Änderung an Firestore auch handleWrite aufruft.

Das ist natürlich nicht zu 100 % perfekt. Wenn der Server die Verbindung verliert oder abstürzt, ist es möglich, dass ein paar kleine Änderungen nicht persistiert werden. Aber für Foonys Anwendungsfall ist das gut genug, und das Postgres-Schema bietet uns bessere Garantien für Datenintegrität (z. B. Fremdschlüssel-Constraints) als Firestore.

Als Nächstes habe ich ein Backfill erstellt und ausgeführt, das über alle Firestore-Collections iteriert und für jedes Dokument handleWrite aufruft. Damit stellen wir sicher, dass wir alle historischen Daten aus Firestore bekommen. Aus Geschwindigkeitsgründen habe ich das nur auf registrierte Nutzer beschränkt (sorry, Gäste, ihr hättet euch registrieren sollen). Das funktioniert gut und kann gefahrlos mehrfach ausgeführt werden.

Jetzt, da ich eine Möglichkeit hatte, die Daten in Postgres zu befüllen und sie (größtenteils) synchron mit Firestore zu halten, konnte ich das große, beängstigende Problem angehen: Pub/Sub.

Wie zum Teufel macht man Pub/Sub mit Postgres? Oder überhaupt mit einer SQL-Datenbank?

Postgres Change Data Capture (CDC) zur Rettung!

CDC ist ein schickes Wort, das einfach bedeutet "die an der Datenbank vorgenommenen Änderungen lesen und an ein Pub/Sub-System veröffentlichen". In einem großen Unternehmen würde man vielleicht so etwas wie Debezium + Kafka verwenden. Aber Kafka ist mühsam einzurichten, wir haben Redis bereits im Einsatz, und unsere Datenbank verzeichnet nur etwa 30 Operationen pro Sekunde. Selbst wenn wir unsere Skala um das 100-Fache auf ~100.000 gleichzeitige Nutzer erhöhen würden, würde das wahrscheinlich immer noch in eine einzige Redis-Instanz passen. Also habe ich genau das gemacht.

(Man kann Pub/Sub auch mit LISTEN/NOTIFY an Postgres anflanschen, aber das übersteht keine Verbindungsabbrüche und scheitert, sobald man tatsächlich Fanout braucht. CDC ist die langweilige, robuste Antwort.)

Mein Freund Eric hat mir CDC beigebracht und seinen CDC-Code als Open Source veröffentlicht, der das WAL von Postgres (Write-Ahead-Log, dort schreibt Postgres jede Änderung an der Datenbank hinein) über einen logischen Replikations-Slot liest und es an eine Senke (z. B. Redis) persistiert.

Mit einem einfachen, detaillierten Prompt in Cursor konnte ich den Echtzeit-CDC- und Gateway-Code größtenteils in einem Rutsch erzeugen (mit ein paar kleineren Anpassungen und einem gründlichen Code-Review). Das umfasst zwei sehr einfache Services:

  • einen Singleton-CDC-Service, der das WAL liest und an Redis Pub/Sub veröffentlicht (mit Streams kann man mehr Haltbarkeit bekommen, aber auf Kosten der Komplexität)
  • einen horizontal autoskalierenden Gateway-Service, der JWT-Auth und WebSocket-Verbindungen von Web-Clients verarbeitet.

Beide Services sind in Go geschrieben, und ich liebe besonders den autoskalierenden Gateway-Teil. Der Web-Client abonniert auf die gleiche Weise wie früher mit Firestore und erhält die Daten im gleichen Format zurück. Der Client sieht "usersPublic/" und "usersPrivate/" als zwei separate Collections mit separaten Berechtigungen. Das Gateway ist dafür zuständig, diese Anfrage in die zugrunde liegenden Postgres-Tabellen zu übersetzen (in diesem Fall users) und zu prüfen, ob der Client die Berechtigung für diese Daten hat.

Das funktioniert schockierend gut. Das gesamte Pub/Sub-System kostet uns nur etwa 0,50 $ pro Monat an Rechen- und Egress-Kosten und hat nicht viele bewegliche Teile, die kaputtgehen können (der CDC-, Gateway- und Client-Code sind alle einfach und haben keine Drittanbieter-Abhängigkeiten außer Redis und Postgres).

Aus DevEx-Perspektive ist das neue System wohl auch einfacher: Entwickler müssen nicht wissen, wie man Daten in Firestore modelliert oder Firestore-Sicherheitsregeln schreibt. Solange sie SQL verstehen, können sie schnell eine Änderung an unserer schema.sql vornehmen, eine Route in all.go hinzufügen oder anpassen, und schon sind sie startklar. Die Dokumentation ist auch stark genug, dass LLMs ihr leicht folgen und Änderungen vornehmen können. Aus Sicherheitsperspektive ist das ebenfalls ein Gewinn, da wir keinem gefährlichen Denial-of-Wallet-Angriff (Wallet DoS) mehr ausgesetzt sind, weil unsere neue Infrastruktur Fixkosten hat und wahnsinnig viel günstiger ist.

Insgesamt konnten wir, inklusive der Postgres-Instanz und täglicher S3-Backups, die Infrastrukturkosten für die Datenbank von 550 $ pro Monat auf nur 40 $ pro Monat senken. Wir haben außerdem die Client-Bundle-Größe um ~100 KB reduziert, was schön ist.

Jetzt, da ich das neue System hatte, habe ich lokal getestet, um sicherzustellen, dass alles wie erwartet funktioniert. Dann habe ich es live deployt. Falls es Probleme geben würde, ist es einfach, den Client zurückzurollen, da Firestore zu diesem Zeitpunkt noch die Quelle der Wahrheit ist.

Live gehen

Die Server-Umstellung war irgendwie beängstigend (Datenbankmigrationen sind das immer). Bis zu diesem Zeitpunkt war Firestore noch die Quelle der Wahrheit, das CDC-Pub/Sub-System las nur von einem Postgres-Spiegel, den der Synchronizer aktuell hielt. Erst das Deployen der neuen Game-Server würde Postgres tatsächlich als autoritativ für Schreibvorgänge umschalten.

So habe ich es ausgerollt:

  1. Zuerst Partner aktualisiert. Foony ist mit Partnern wie FRVR integriert, also haben wir ein paar Tage früher einen neuen Client-Build deployt, der unser neues CDC-Gateway verwenden würde.
  2. Die Spieler vorgewarnt. Etwa zehn Minuten vor der Umstellung haben wir einen Hinweis auf ein (hoffentlich) kurzes Wartungsfenster gepostet.
  3. Frisches Datenbank-Backup gemacht. Das ist bei großen Datenbankmigrationen immer eine gute Idee. LLMs nennen das "belt and suspenders" (Gürtel und Hosenträger), was im Grunde nur bedeutet, besonders vorsichtig zu sein und einen Plan B zu haben.
  4. Beide Cluster gleichzeitig deployt. Diesmal kein Blue/Green. Ich wollte sicherstellen, dass alle Cluster in dieselbe Datenbank schreiben, um mögliche Inkonsistenzen zu vermeiden.

Ich hatte auch einen Rollback-Plan parat, der sehr einfach war: Wenn etwas schiefgehen sollte, würde ich den vorherigen Client (der noch zu Firestore las) erneut deployen, die Server neu deployen, das Backfill neu starten und es später nochmal versuchen. Das hätte etwa 5 Minuten Downtime verursacht, während die Server neu deployt werden, und etwa einen halben Tag, um die Postgres-Datenbank wieder mit Firestore in Einklang zu bringen.

Die eigentliche Umstellung dauerte nur etwa 1 Minute Downtime. Der einzige ernsthafte Bug, der auftauchte, betraf das Aktualisieren von Erfahrung: Das LOWER() von Postgres auf einer bigint-Spalte hat implizit in text gecastet. Seufz. Das ließ sich leicht beheben, mit einem einfachen Cast zurück zu bigint, also habe ich die Migration mit einem weiteren Server-Deploy fortgesetzt.

Ich war geschockt, dass alles reibungslos zu laufen schien. Nur sehr wenige Leute meldeten Probleme, und die waren alle sehr klein (abgesehen von der Sache mit der Erfahrung). Was mich besonders schockiert hat: wie reibungslos diese Migration verlief, obwohl sie überwiegend per Vibe-Coding entstanden ist. Ein deutlicher Unterschied zu den gruseligen Geschichten, die man in den Nachrichten lesen kann.

Das Workshop-Rewrite

Da Postgres nun autoritativ für Schreibvorgänge war, war nur noch ein großer Teil der Codebase mit Firestore verflochten: das Workshop-System. Spieler nutzen es, um eigene Karten in Dino-Might Bomber Online zu teilen, Wortlisten in Draw & Guess und so weiter. Sozusagen wie der Steam Workshop, aber für unsere Spiele. Es war das am stärksten mit Firestore verflochtene Feature, das noch übrig war, sowohl auf dem Client als auch auf dem Server, und es hatte aufgrund von Einschränkungen in Firestores Datenmodell eine etwas unhandliche Struktur, die ich vereinfachen musste.

Zum Start habe ich Cursor diesen Prompt gegeben (mit Opus 4.7 high im Plan-Modus):

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

Nach allem, was ich von den Fähigkeiten von Opus 4.7 mit dem Cursor-Harness gesehen hatte, erwartete ich, dass diese Migration über Nacht fertig wird.

Das Wichtigste, das ich in meinem ursprünglichen Prompt vergessen hatte und worauf der Agent eine Klärung wollte: wie wir die Sortierung nach "Letzter Tag", "Letzte Woche", "Letzter Monat" und "Gesamt" effizient handhaben sollten. Der Agent drängte auf einen Ansatz mit Zeitstempel-Buckets (oder die Zeitstempel-Sortierung ganz zu streichen), aber der Mitgründer bestand darauf, die zeitbasierte Sortierung trotz ihrer zusätzlichen Komplexität beizubehalten.

Nach etwas Nachdenken kam ich auf eine einfache Lösung mit einem Verfallsfaktor. Jedes Workshop-Item hat die Spalten played_count_day, played_count_week, played_count_month und played_count_all, und ein stündlicher Cronjob multipliziert die rollierenden Spalten jeweils mit 23/24, 167/168 und (720-1)/720. Kombiniert mit partiellen Indizes (WHERE private = false AND played_count_day >= 0.368) auf jeder Sortierachse ergibt das spottbillige "Beliebteste nach Zeitraum"-Queries ohne zusätzliche Infrastruktur. Das hätte ich mit Firestore aufgrund der überzogenen Kosten für Reads und Writes nie gemacht, aber in Postgres ist es praktisch kostenlos.

Damit ging ich ins Bett. Ich wachte auf und prüfte aufgeregt die Arbeit! Der Agent hatte die neuen Tabellen workshop_items, workshop_item_votes und user_subscriptions hinzugefügt, den Client so verdrahtet, dass er einzelne Items über das CDC-Gateway liest (realtime.use('workshopItems/{id}')), und alle sechs Workshop-Actions auf dem Backend neu geschrieben, sodass sie direkt mit Postgres sprechen. Es gab ein paar lose Enden, die ich aufräumen musste (ein Query an Firestore auf dem Server, ein Fehler beim Backfill durch fehlende Daten in Firestore usw.), aber insgesamt war der Code fast perfekt.

Nach dem Ausführen des Backfills und dem Testen, dass alles lokal funktioniert, habe ich die Änderung live deployt. Damit war die Codebase endlich Firestore-frei. Wunderschön.

Zukünftige Arbeit

In Zukunft würde ich gerne auch Patches im Gateway unterstützen. Aktuell schickt das Gateway bei jedem Update das gesamte Dokument als JSON zurück. Das ist etwas verschwenderisch, aber unser Egress ist dank Hetzner effektiv unbegrenzt. Ich würde das jetzt schon umsetzen, aber die zusätzliche Komplexität kann ich noch nicht rechtfertigen.

8 Ball Pool online multiplayer billiards icon