background blurbackground mobile blur

1/1/1970

Cómo migrar de Firestore a Postgres en 2 días

Firebase. Esa simple palabra puede costarte fácilmente entre 10 y 20 veces lo que cuesta una base de datos más barata, y puede tanto limitar como complicar el diseño de tu base de datos.

Así que ya os podéis imaginar el alivio que sentí cuando terminé de migrar de Firestore en tan solo 2 días. Bueno, 3 días si cuentas el tiempo que dediqué a construir mi "PostgresSynchronizer" inicial. O 3,5 días si cuentas el tiempo que dediqué a migrar el sistema del workshop. En cualquier caso, fue rápido. Muy rápido.

¡Mi estimación inicial para este proyecto era de un mes entero de trabajo! Pero, gracias a unos prompts geniales para el LLM y a un amigo que me enseñó sobre CDC, pude completarlo mucho más rápido.

Si buscas pub/sub, Firebase o Supabase pueden sonar muy tentadores. Al fin y al cabo, no muchas bases de datos soportan pub/sub de serie. Pero esta sencilla arquitectura que verás a continuación es todo lo que necesitas para la mayoría de casos de uso de pequeñas empresas (y debería escalar muy bien hasta unas 10.000-100.000 operaciones por segundo).

Arquitectura pub/sub de Foony Postgres se ejecuta en su propio servidor de Hetzner. Los servicios CDC y Gateway se ejecutan dentro de un clúster k3s de Hetzner. CDC lee el Write-Ahead Log de Postgres y publica los cambios en Redis Pub/Sub (un servidor de Hetzner aparte). El Gateway se suscribe a Redis y distribuye las actualizaciones a los clientes WebSocket.

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

{/* k3s cluster (middle). Contains the CDC and Gateway services as pods. */} Clúster k3s (Hetzner)

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

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

{/* Redis host (below k3s). Standalone Hetzner server. */} Servidor Redis (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. */} Cliente WebSocket

Cliente WebSocket Cliente WebSocket

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

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

Antes de entrar en los detalles de cómo funciona el pub/sub, voy a repasar los pasos que seguí en orden.

Primero, creé un prompt en Cursor para construir un PostgresSynchronizer. Esta clase tiene una función, handleWrite, que recibe una ruta de Firestore, el tipo de operación (crear, actualizar, eliminar) y los datos. Luego inserta o actualiza los datos en Postgres. Después envolví mi instancia del store de Firestore para que cualquier cambio en Firestore también llamara a handleWrite.

Esto no es 100% perfecto, claro. Si el servidor se desconecta o se cae, es posible que algunos cambios menores no se persistan. Pero es suficiente para el caso de uso de Foony, y el esquema de Postgres nos da mejores garantías de integridad de datos (por ejemplo, restricciones de clave foránea) que Firestore.

Después, creé y ejecuté un backfill que itera sobre todas las colecciones de Firestore y llama a handleWrite para cada documento. Esto asegura que obtengamos todos los datos históricos de Firestore. Por velocidad, limité esto solo a usuarios registrados (lo siento, invitados, deberíais haberos registrado). Funciona bien y es seguro ejecutarlo varias veces.

Ahora que tenía una forma de poblar los datos en Postgres y mantenerlos (en su mayoría) sincronizados con Firestore, pude abordar el gran y temido problema: pub/sub.

¿Cómo demonios haces pub/sub con Postgres? ¿O con cualquier base de datos SQL?

¡Postgres Change Data Capture (CDC) al rescate!

CDC es una palabreja que simplemente significa "leer los cambios hechos a la base de datos y publicarlos en un sistema pub/sub". En una gran empresa, podrías usar algo como Debezium + Kafka. Pero Kafka es un dolor de cabeza para configurar, ya tenemos Redis montado, y nuestra base de datos solo recibe unas 30 operaciones por segundo. Incluso si multiplicáramos por 100 nuestra escala hasta ~100.000 usuarios concurrentes, probablemente seguiríamos pudiendo meter esto en una sola instancia de Redis. Así que eso fue lo que hice.

(También puedes añadir pub/sub a Postgres con LISTEN/NOTIFY, pero no sobrevive a reconexiones y se desmorona cuando realmente necesitas fanout. CDC es la respuesta aburrida y duradera.)

Mi amigo Eric me enseñó sobre CDC y publicó como código abierto su código de CDC que lee el WAL de Postgres (Write-Ahead Log, donde Postgres escribe cada cambio que hace a la base de datos) a través de un slot de replicación lógica y lo persiste en un sink (por ejemplo, Redis).

Con un prompt sencillo y detallado en Cursor, conseguí prácticamente al primer intento el código del CDC en tiempo real y del gateway (con algunos retoques menores y una revisión exhaustiva del código). Esto incluye dos servicios muy sencillos:

  • un servicio CDC singleton que lee el WAL y lo publica en Redis Pub/Sub (puedes conseguir más durabilidad con Streams, pero a costa de complejidad)
  • un servicio gateway con autoescalado horizontal que gestiona la autenticación JWT y las conexiones websocket de los clientes web.

Ambos servicios están en golang, y me encanta especialmente la parte del gateway con autoescalado. El cliente web se suscribe de la misma manera que solía hacer con Firestore, y recibe datos en el mismo formato. El cliente ve "usersPublic/" y "usersPrivate/" como dos colecciones separadas con permisos separados. El gateway es responsable de traducir esa petición a las tablas subyacentes de Postgres (en este caso users) y validar que el cliente tiene permiso para esos datos.

Esto funciona increíblemente bien. Todo el sistema pub/sub nos cuesta solo unos 0,50 $ al mes en costes de cómputo y salida de datos, y no tiene muchas piezas móviles que puedan romperse (el código del CDC, del gateway y del cliente son simples y sin dependencias de terceros fuera de Redis y Postgres).

Desde una perspectiva de experiencia de desarrollo, el nuevo sistema es posiblemente también más sencillo: los desarrolladores no necesitan saber cómo modelar datos en Firestore ni las reglas de seguridad de Firestore. Mientras entiendan SQL, pueden hacer rápidamente una edición a nuestro schema.sql, añadir o modificar una ruta en all.go, y listo. La documentación también es lo suficientemente sólida como para que los LLMs la sigan con facilidad y hagan cambios. Desde una perspectiva de seguridad, esto también es una victoria, ya que ya no estamos expuestos a un peligroso ataque de denegación de cartera (Wallet DoS), porque nuestra nueva infraestructura es de coste fijo y muchísimo más barata.

En total, incluyendo la instancia de Postgres y los backups diarios a S3, conseguimos pasar de 550 $ al mes a tan solo 40 $ al mes en costes de infraestructura de base de datos. También redujimos el tamaño del bundle del cliente en ~100KB, lo cual está bien.

Ahora que tenía el nuevo sistema, lo probé en local para asegurarme de que todo funcionaba como se esperaba. Después, lo desplegué en producción. Si surgía algún problema, era fácil hacer rollback del cliente, ya que en ese momento Firestore seguía siendo la fuente de verdad.

Puesta en marcha

El cambio del servidor fue un poco aterrador (las migraciones de bases de datos siempre lo son). Hasta este punto, Firestore seguía siendo la fuente de verdad. El sistema pub/sub de CDC solo leía de un espejo en Postgres que el synchronizer mantenía actualizado. Desplegar los nuevos servidores de juego es lo que realmente haría que Postgres pasara a ser la autoridad para las escrituras.

Así fue como lo desplegué:

  1. Actualicé primero a los partners. Foony está integrado con partners como FRVR, así que desplegamos un nuevo build del cliente unos días antes que usaría nuestro nuevo gateway CDC.
  2. Avisé a los jugadores. Unos diez minutos antes del cambio, publicamos un aviso sobre una ventana de mantenimiento (esperemos que corta).
  3. Hice un backup fresco de la base de datos. Esto siempre es una buena idea con grandes migraciones de bases de datos. Los LLMs lo llaman "cinturón y tirantes", que básicamente significa ser extra precavido y tener un plan de respaldo.
  4. Desplegué ambos clústeres simultáneamente. Nada de blue/green esta vez. Quería asegurarme de que todos los clústeres escribieran en la misma base de datos para evitar cualquier posible inconsistencia.

También tenía un plan de rollback listo, que era muy simple: si algo iba mal, redesplegaría el cliente anterior (que aún leía de Firestore), redesplegaría los servidores, reiniciaría el backfill e intentaría de nuevo más tarde. Esto habría causado unos 5 minutos de inactividad mientras los servidores se redesplegaban, y aproximadamente medio día para volver a poner la base de datos de Postgres al día con Firestore.

El cambio real solo supuso aproximadamente 1 minuto de inactividad. El único bug serio que apareció fue al actualizar la experiencia: el LOWER() de Postgres en una columna bigint estaba haciendo un cast implícito a text. Suspiro. Fue una solución bastante fácil con un simple cast de vuelta a bigint, así que continué la migración haciendo otro despliegue del servidor.

Me quedé alucinado de que todo pareciera ir sobre ruedas. Hubo muy poca gente que reportara problemas, y todos eran muy menores (aparte del tema de la experiencia). Lo que me resultó especialmente sorprendente, sin embargo, es lo suave que fue esta migración dado que fue mayoritariamente vibe-coded. Bastante diferente de las historias de terror que puedes leer en las noticias.

La reescritura del Workshop

Con Postgres ya como autoridad para las escrituras, solo quedaba una gran parte del código todavía enredada con Firestore: el sistema del workshop. Los jugadores lo usan para compartir mapas personalizados en Dino-Might Bomber Online, listas de palabras en Draw & Guess, etc. Algo así como el Workshop de Steam, pero para nuestros juegos. Era la funcionalidad más enredada con Firestore que quedaba, tanto en el cliente como en el servidor, y tenía una estructura un poco rara debido a las limitaciones del modelo de datos de Firestore que tuve que simplificar.

Para empezar, le di a Cursor este prompt (usando Opus 4.7 high en modo plan):

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

Con lo que había visto de las capacidades de Opus 4.7 con el harness de Cursor, esperaba que esta migración se completara durante la noche.

Lo más importante que me faltó en mi prompt original, y que el agente me pidió que aclarara, fue cómo deberíamos gestionar eficientemente la ordenación por "Último día", "Última semana", "Último mes" y "Todo el tiempo". El agente abogaba por un enfoque con buckets de timestamp (o por eliminar la ordenación por timestamp por completo), pero el cofundador insistió en mantener la ordenación basada en tiempo a pesar de su complejidad añadida.

Tras pensarlo un poco, se me ocurrió una solución sencilla que implica un factor de decaimiento. Cada item del workshop tiene las columnas played_count_day, played_count_week, played_count_month y played_count_all, y un cron job que se ejecuta cada hora multiplica las columnas rodantes por 23/24, 167/168 y (720-1)/720 respectivamente. Combinado con índices parciales (WHERE private = false AND played_count_day >= 0.368) en cada eje de ordenación, esto nos da consultas de "más popular por periodo de tiempo" tiradas de precio sin infraestructura extra. Esto es algo que no habría hecho con Firestore debido a sus costes exorbitantes de lecturas y escrituras, pero es prácticamente gratis en Postgres.

Con eso, me fui a la cama. Me desperté y revisé emocionado su trabajo. El agente añadió las nuevas tablas workshop_items, workshop_item_votes y user_subscriptions, conectó el cliente para leer items individuales a través del gateway de CDC (realtime.use('workshopItems/{id}')), y reescribió las seis acciones del workshop en el backend para hablar directamente con Postgres. Hubo algunos cabos sueltos que tuve que rematar (una consulta a Firestore en el servidor, un error con el backfill causado por datos faltantes en Firestore, etc.), pero en general el código era casi perfecto.

Después de ejecutar el backfill y probar que todo funcionaba en local, desplegué el cambio en producción. Con eso, el código por fin quedó libre de Firestore. Precioso.

Trabajo futuro

En el futuro, también me gustaría dar soporte a parches en el gateway. Ahora mismo, el gateway envía de vuelta el documento entero como JSON en cada actualización. Es un poco desperdicio, pero nuestra salida de datos es efectivamente ilimitada gracias a estar en Hetzner. Lo implementaría ya, pero todavía no puedo justificar la complejidad añadida.

8 Ball Pool online multiplayer billiards icon