

1/1/1970
Cara Migrasi dari Firestore ke Postgres dalam 2 Hari
Firebase. Satu perkataan itu sahaja boleh menyebabkan kos anda 10-20x ganda lebih mahal berbanding pangkalan data lain yang lebih murah, dan boleh menyekat serta merumitkan reka bentuk pangkalan data anda.
Jadi anda boleh bayangkan betapa leganya saya apabila berjaya menyiapkan migrasi keluar dari Firestore hanya dalam 2 hari. Atau 3 hari kalau dikira masa membina "PostgresSynchronizer" awal saya. Atau 3.5 hari kalau dikira masa migrasi sistem workshop. Apa pun, ia cepat. Sangat cepat.
Anggaran awal saya untuk projek ini ialah sebulan penuh kerja! Tetapi, berkat beberapa prom LLM yang hebat, dan seorang kawan yang mengajar saya tentang CDC, saya berjaya menyelesaikannya jauh lebih pantas.
Kalau anda mencari pub/sub, Firebase atau Supabase mungkin kedengaran sangat menggoda. Lagipun, tidak banyak pangkalan data yang menyokong pub/sub secara semula jadi. Tetapi seni bina ringkas di bawah ini sudah memadai untuk kebanyakan kes penggunaan perniagaan kecil (dan sepatutnya boleh dikembangkan sehingga sekitar 10k-100k ops / saat).
Sebelum kita masuk ke butiran cara pub/sub berfungsi, saya akan ceritakan langkah-langkah yang saya ambil mengikut urutan.
Pertama sekali, saya membuat satu prom dalam Cursor untuk membina PostgresSynchronizer. Kelas ini mempunyai fungsi, handleWrite, yang menerima laluan Firestore, jenis operasi (create, update, delete), dan datanya. Ia kemudian akan upsert data tersebut ke Postgres. Saya kemudian membungkus instance store Firestore saya supaya sebarang perubahan pada Firestore turut memanggil handleWrite.
Ini bukanlah 100% sempurna, tentulah. Kalau pelayan terputus atau crash, ada kemungkinan beberapa perubahan kecil tidak dikekalkan. Tetapi ia cukup baik untuk kes penggunaan Foony, dan skema Postgres memberi kami jaminan integriti data yang lebih baik (contohnya kekangan kunci asing) berbanding Firestore.
Seterusnya, saya membuat dan menjalankan backfill yang melelar ke atas semua koleksi Firestore dan memanggil handleWrite untuk setiap dokumen. Ini memastikan kami mendapat semua data sejarah daripada Firestore. Untuk kelajuan, saya hadkan ini kepada pengguna berdaftar sahaja (maaf ya tetamu, anda patut daftar). Ia berfungsi dengan baik, dan selamat untuk dijalankan beberapa kali.
Sekarang setelah saya ada cara untuk mengisi data dalam Postgres, dan memastikan ia (sebahagian besarnya) selari dengan Firestore, saya boleh tackle masalah besar yang menakutkan itu: pub/sub.
Macam mana agaknya nak buat pub/sub dengan Postgres? Atau mana-mana pangkalan data SQL pun?
Postgres Change Data Capture (CDC) menyelamatkan keadaan!
CDC ialah istilah mewah yang sebenarnya bermaksud "baca perubahan yang dibuat pada pangkalan data dan terbitkannya ke sistem pub/sub". Dalam perniagaan besar, anda mungkin menggunakan sesuatu seperti Debezium + Kafka. Tetapi Kafka itu memang menyusahkan untuk disediakan, kami sudah ada Redis, dan pangkalan data kami hanya menerima sekitar 30 ops / saat. Walaupun kami gandakan 100x skala kepada ~100,000 pengguna serentak, kemungkinan besar ia masih boleh muat dalam satu instance Redis. Jadi itulah yang saya buat.
(Anda juga boleh tambah pub/sub pada Postgres menggunakan LISTEN/NOTIFY, tetapi ia tidak tahan bila sambungan semula berlaku dan akan tumbang sebaik sahaja anda benar-benar memerlukan fanout. CDC ialah jawapan yang membosankan tetapi tahan lasak.)
Kawan saya, Eric, telah mengajar saya tentang CDC dan membuka sumber kod CDC beliau yang membaca WAL Postgres (Write-Ahead Log, tempat Postgres menulis setiap perubahan yang dibuatnya pada pangkalan data) melalui slot replikasi logikal, dan mengekalkannya ke sink (contohnya Redis).
Dengan prom yang ringkas tetapi terperinci dalam Cursor, saya berjaya hampir one-shot kod CDC dan gateway masa nyata (dengan beberapa tweak kecil dan code review menyeluruh). Ini termasuk dua perkhidmatan yang sangat ringkas:
- perkhidmatan CDC singleton yang membaca WAL dan menerbitkannya ke Redis Pub/Sub (boleh dapat lebih ketahanan dengan Streams, tetapi dengan kos kerumitan yang lebih tinggi)
- perkhidmatan gateway autoscaling mendatar yang mengendalikan auth JWT dan sambungan websocket daripada klien web.
Kedua-dua perkhidmatan ini ditulis dalam golang, dan saya sangat suka bahagian gateway autoscaling itu. Klien web melanggan dengan cara yang sama seperti dahulu dengan Firestore, dan menerima data dalam format yang sama. Klien melihat "usersPublic/" dan "usersPrivate/" sebagai dua koleksi berasingan dengan kebenaran yang berasingan. Gateway bertanggungjawab untuk menterjemah permintaan itu kepada jadual Postgres asas (dalam kes ini users), dan mengesahkan bahawa klien mempunyai kebenaran untuk data tersebut.
Ini berfungsi dengan sangat baik. Seluruh sistem pub/sub hanya menelan kos sekitar $0.50 / bulan dalam kos compute + egress, dan tidak banyak bahagian bergerak yang boleh rosak (kod CDC, gateway, dan klien semuanya ringkas tanpa kebergantungan pihak ketiga selain Redis / Postgres).
Dari perspektif DevEx, sistem baharu ini boleh dikatakan lebih ringkas juga: pembangun tidak perlu tahu cara memodelkan data dalam Firestore atau peraturan keselamatan Firestore. Asalkan mereka faham SQL, mereka boleh dengan cepat membuat suntingan pada schema.sql kami, menambah atau mengubah suai route dalam all.go, dan semuanya selesai. Dokumentasinya juga cukup kukuh sehingga LLM mudah mengikutinya dan membuat perubahan. Dari perspektif keselamatan, ini juga merupakan satu kemenangan kerana kami tidak lagi terdedah kepada serangan denial-of-wallet (Wallet DoS) yang berbahaya memandangkan infra baharu kami berkos tetap dan jauh lebih murah.
Secara keseluruhan, termasuk instance Postgres dan sandaran S3 harian, kami berjaya turun daripada $550 / bulan kepada hanya $40 / bulan dalam kos infra pangkalan data. Kami juga mengurangkan saiz bundle klien sebanyak ~100KB, yang juga bagus.
Setelah saya mempunyai sistem baharu, saya menguji secara tempatan untuk memastikan semuanya berfungsi seperti yang diharapkan. Kemudian, saya menggunakan ia secara langsung. Kalau ada sebarang isu, mudah untuk rollback klien kerana Firestore masih menjadi sumber kebenaran pada ketika itu.
Beroperasi Secara Langsung
Cutover pelayan itu agak menakutkan (migrasi pangkalan data memang sentiasa menakutkan). Sehingga ketika ini, Firestore masih menjadi sumber kebenaran. Sistem pub/sub CDC hanya membaca dari cermin Postgres yang dikemas kini oleh synchronizer. Penggunaan pelayan permainan baharu inilah yang sebenarnya akan menukar Postgres menjadi pihak berkuasa untuk penulisan.
Inilah cara saya melancarkannya:
- Mengemas kini rakan kongsi terlebih dahulu. Foony bersepadu dengan rakan kongsi seperti FRVR, jadi kami menggunakan binaan klien baharu beberapa hari awal yang akan menggunakan gateway CDC baharu kami.
- Memberi amaran kepada pemain. Kira-kira sepuluh minit sebelum cutover, kami menyiarkan notis tentang tempoh penyelenggaraan (yang diharap) singkat.
- Mengambil sandaran pangkalan data segar. Ini sentiasa idea yang baik untuk migrasi pangkalan data besar. LLM memanggil ini "belt and suspenders", yang pada dasarnya bermaksud berhati-hati tambahan dan mempunyai pelan sandaran.
- Menggunakan kedua-dua kluster secara serentak. Kali ini tiada blue/green. Saya mahu memastikan semua kluster menulis ke pangkalan data yang sama untuk mengelakkan sebarang potensi ketidakkonsistenan.
Saya juga mempunyai pelan rollback yang sangat ringkas: kalau apa-apa berlaku, saya akan deploy semula klien sebelumnya (yang masih membaca ke Firestore), deploy semula pelayan, mulakan semula backfill, dan cuba lagi kemudian. Ini akan menyebabkan kira-kira 5 minit downtime sementara pelayan deploy semula, dan kira-kira setengah hari untuk menyelarikan pangkalan data Postgres semula dengan Firestore.
Cutover sebenar hanya mengambil masa 1 minit downtime. Satu-satunya pepijat serius yang muncul ialah dalam mengemas kini pengalaman: LOWER() Postgres pada lajur bigint secara tersirat menukarnya kepada text. Hmm. Ini cukup mudah dibetulkan dengan cast ringkas kembali kepada bigint, jadi saya meneruskan migrasi dengan mengeluarkan deploy pelayan yang lain.
Saya terkejut yang semuanya berjalan lancar. Sangat sedikit orang yang melaporkan masalah, dan kesemuanya adalah kecil sahaja (selain isu pengalaman tadi). Apa yang sangat mengejutkan saya, bagaimanapun, ialah betapa lancarnya migrasi ini berjalan memandangkan ia majoritinya adalah vibe-coded. Agak berbeza daripada kisah-kisah menakutkan yang anda mungkin baca dalam berita.
Penulisan Semula Workshop
Dengan Postgres kini menjadi pihak berkuasa untuk penulisan, hanya satu bahagian besar pangkalan kod yang masih terjerat dengan Firestore: sistem workshop. Pemain menggunakannya untuk berkongsi peta tersuai dalam Dino-Might Bomber Online, senarai perkataan dalam Draw & Guess, dan sebagainya. Macam Steam Workshop, tetapi untuk permainan kami. Ia merupakan ciri yang paling terjerat dengan Firestore yang masih tinggal, di kedua-dua klien dan pelayan, dan ia mempunyai struktur yang janggal disebabkan oleh batasan dalam model data Firestore yang terpaksa saya permudahkan.
Untuk bermula, saya berikan Cursor prom ini (menggunakan Opus 4.7 high dalam mod pelan):
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`, ...).
Dengan apa yang saya lihat tentang keupayaan Opus 4.7 dengan harness Cursor, saya menjangkakan migrasi ini akan selesai dalam satu malam.
Perkara besar yang saya terlepas dalam prom asal saya, yang ditanya oleh agent untuk dijelaskan, ialah cara kami sepatutnya mengendalikan susunan "Last Day", "Last Week", "Last Month", dan "All Time" dengan cekap. Agent itu mencadangkan pendekatan bucketed-timestamp (atau menggugurkan susunan timestamp terus), tetapi pengasas bersama berdegil untuk mengekalkan susunan berdasarkan masa walaupun dengan kerumitan tambahan.
Selepas berfikir sekejap, saya datang dengan penyelesaian ringkas yang melibatkan faktor pereputan. Setiap item workshop mempunyai lajur played_count_day, played_count_week, played_count_month, dan played_count_all, dan kerja cron setiap jam akan mendarabkan lajur bergulir tadi dengan 23/24, 167/168, dan (720-1)/720 masing-masing. Digabungkan dengan indeks separa (WHERE private = false AND played_count_day >= 0.368) pada setiap paksi susunan, ini memberikan kami pertanyaan "paling popular mengikut tempoh masa" yang sangat murah tanpa sebarang infrastruktur tambahan. Ini sesuatu yang saya tidak akan buat dengan Firestore disebabkan kosnya yang melampau untuk bacaan dan penulisan, tetapi secara asasnya percuma dalam Postgres.
Dengan itu, saya pun tidur. Saya bangun, dan teruja melihat hasil kerjanya! Agent itu telah menambah jadual baharu workshop_items, workshop_item_votes, dan user_subscriptions, menghubungkan klien untuk membaca item individu melalui gateway CDC (realtime.use('workshopItems/{id}')), dan menulis semula kesemua enam tindakan workshop pada backend untuk bercakap dengan Postgres secara langsung. Ada beberapa perkara terlepas yang perlu saya kemaskan (satu pertanyaan ke Firestore di pelayan, ralat dengan backfill disebabkan data tiada dalam Firestore, dan lain-lain), tetapi secara keseluruhan kod itu hampir sempurna.
Selepas menjalankan backfill, dan menguji untuk memastikan semuanya berfungsi secara tempatan, saya deploy perubahan itu secara langsung. Dengan itu, pangkalan kod akhirnya bebas-Firestore. Cantik.
Kerja Masa Depan
Pada masa hadapan, saya juga ingin menyokong patches dalam gateway. Pada masa ini, gateway menghantar semula keseluruhan dokumen sebagai JSON untuk setiap kemas kini. Ia agak membazir, tetapi egress kami pada dasarnya tidak terhad berkat berada di Hetzner. Saya akan melaksanakan ini sekarang, tetapi belum boleh wajarkan kerumitan tambahan itu lagi.