

1/1/1970
如何在 2 天内从 Firestore 迁移到 Postgres
Firebase。就这一个词,轻轻松松就能让你的成本比便宜的数据库高出 10-20 倍,还会限制并复杂化你的数据库设计。
所以你可以想象,当我仅仅用 2 天就完成了从 Firestore 的迁移时,我有多么如释重负。好吧,如果算上我搭建最初的 "PostgresSynchronizer" 的时间,那就是 3 天。如果再算上迁移工坊系统的时间,那就是 3.5 天。不管怎么说,反正很快。真的很快。
我最初对这个项目的估计是整整一个月的工作量!但是,多亏了一些超棒的 LLM 提示词,以及一位向我介绍 CDC 的朋友,我才能大大加快进度。
如果你在找发布/订阅功能,Firebase 或 Supabase 听起来可能很诱人。毕竟,没多少数据库开箱即用地支持发布/订阅。但下面这个简单的架构对于大多数小型业务场景来说已经足够了(并且应该可以很好地扩展到约 10k-100k 操作/秒)。
不过在我们深入了解发布/订阅是如何工作之前,我先按顺序讲一下我所采取的步骤。
首先,我在 Cursor 里写了个提示词,用来构建 PostgresSynchronizer。这个类有一个函数 handleWrite,接收一个 Firestore 路径、操作类型(创建、更新、删除)和数据。然后它将数据 upsert 到 Postgres。接着我封装了我的 Firestore store 实例,这样任何对 Firestore 的变更都会同样调用 handleWrite。
当然,这并不是 100% 完美的。如果服务器断连或崩溃,有可能少数微小的变更没有被持久化。但对 Foony 的使用场景来说已经足够了,而且 Postgres 的 schema 给我们提供了比 Firestore 更好的数据完整性保证(例如外键约束)。
接下来,我创建并运行了一个回填脚本,它遍历所有 Firestore 集合并为每个文档调用 handleWrite。这能确保我们获取到 Firestore 中所有的历史数据。为了速度,我把范围限制为只处理已注册的用户(对不起,游客们,你们应该早点注册的)。这运行得很好,而且可以安全地多次运行。
现在我已经有了一种方式把数据填充到 Postgres,并(基本上)和 Firestore 保持同步,我就可以攻克那个又大又吓人的难题了:发布/订阅。
你究竟怎么用 Postgres 实现发布/订阅?或者说任何 SQL 数据库?
Postgres 变更数据捕获 (CDC) 来救场!
CDC 是个花哨的词,意思就是"读取数据库的变更,并将它们发布到一个发布/订阅系统"。在大公司里,你可能会用类似 Debezium + Kafka 的方案。但 Kafka 搭起来很麻烦,我们手头已经有 Redis 了,而且我们的数据库只有大约 30 操作/秒。即便我们的规模扩大 100 倍,达到约 10 万并发用户,我们大概率仍然能塞进一个 Redis 实例中。所以我就这么做了。
(你也可以用 LISTEN/NOTIFY 给 Postgres 加上发布/订阅,但它在重连后无法恢复,而且一旦真的需要扇出就撑不住了。CDC 才是无聊但可靠的答案。)
我朋友 Eric 给我科普了 CDC,并开源了他的 CDC 代码。这份代码通过逻辑复制槽读取 Postgres 的 WAL(预写日志,Postgres 把对数据库的每一次变更都写在这里),并将其持久化到某个 sink(例如 Redis)。
在 Cursor 中用一个简单、详细的提示词,我基本就能一次性跑通实时 CDC 和网关代码(只做了一些小调整和彻底的代码审查)。其中包括两个非常简单的服务:
- 一个单例的 CDC 服务,负责读取 WAL 并把它发布到 Redis Pub/Sub(用 Streams 可以获得更强的持久性,但代价是更高的复杂度)
- 一个水平自动扩缩的网关服务,处理 JWT 鉴权和来自 Web 客户端的 websocket 连接。
两个服务都是用 golang 写的,我尤其喜欢自动扩缩的网关部分。Web 客户端订阅的方式和之前用 Firestore 时完全一样,拿到的数据格式也一样。客户端看到 "usersPublic/" 和 "usersPrivate/" 是两个有着不同权限的独立集合。网关负责将这个请求翻译为对底层 Postgres 表(在这里是 users)的查询,并验证客户端是否有权访问该数据。
效果好得惊人。整套发布/订阅系统每月只让我们花费约 0.5 美元的计算和出口流量费用,而且没有太多容易出问题的组件(CDC、网关和客户端代码都很简单,除了 Redis 和 Postgres 之外没有任何第三方依赖)。
从 DevEx 的角度来看,新系统也可以说更简单了:开发者不需要知道怎么在 Firestore 中建模数据,也不需要懂 Firestore 安全规则。只要他们懂 SQL,就可以快速修改我们的 schema.sql,在 all.go 中添加或修改路由,然后就齐活了。文档也足够完善,LLM 可以轻松地遵循它并进行修改。从安全角度看,这也是一个胜利:由于新基础设施是固定成本的,而且便宜得离谱,我们不再面临危险的"钱包拒绝服务"(Wallet DoS)攻击。
总体来说,包括 Postgres 实例和每日 S3 备份在内,我们把数据库基础设施成本从每月 550 美元降到了仅仅每月 40 美元。我们还把客户端 bundle 体积减少了约 100KB,这也不错。
现在新系统已就位,我在本地测试以确保一切如预期工作。然后,我把它部署到线上。如果有任何问题,回滚客户端也很容易,因为在这个时间点 Firestore 仍然是数据源。
上线切换
服务器切换有点吓人(数据库迁移向来如此)。直到此时,Firestore 仍然是真实数据源,CDC 发布/订阅系统只是从同步器持续更新的 Postgres 镜像中读取数据。部署新的游戏服务器才是真正让 Postgres 成为写入权威源的关键。
下面是我的发布过程:
- 先更新合作方。 Foony 与 FRVR 等合作方有集成,所以我们提前几天部署了一个新的客户端版本,它会使用我们新的 CDC 网关。
- 提醒玩家。 在切换前大约十分钟,我们发布了一条公告,告知玩家(希望是)短暂的维护时间。
- 做了一次新的数据库备份。 在进行大型数据库迁移时,这总是个好主意。LLM 把这叫做"系皮带还系吊带"(belt and suspenders),基本上就是格外谨慎并备有 Plan B 的意思。
- 同时部署两个集群。 这次没有蓝绿部署。我想确保所有集群都写入同一个数据库,以避免任何潜在的不一致。
我也准备了一个回滚计划,非常简单:如果出什么岔子,我就重新部署之前的客户端(它仍然读取 Firestore)、重新部署服务器、重启回填,稍后再试。这种情况下大约会有 5 分钟的停机时间用于重新部署服务器,以及大约半天时间用于让 Postgres 数据库重新追上 Firestore。
实际的切换只造成了大约 1 分钟的停机时间。唯一冒出来的严重 bug 是在更新经验值时:Postgres 的 LOWER() 在一个 bigint 列上隐式转换成了 text。唉。修起来很简单,只要简单地转回 bigint 就行了,所以我又发了一次服务器部署,继续推进迁移。
让一切如此顺利地推进让我很震惊。报告问题的用户非常少,而且都是非常小的问题(除了那个经验值的事)。但更让我震惊的是,鉴于这次迁移大部分是 vibe-coding 写出来的,过程居然这么顺畅。跟你可能在新闻里看到的吓人故事完全不同。
工坊系统的重写
现在 Postgres 已经成为写入的权威源,代码库里还有最后一大块跟 Firestore 纠缠不清的部分:工坊系统。玩家用它来分享Dino-Might Bomber Online的自定义地图、Draw & Guess的词库等等。有点像 Steam 创意工坊,但是为我们的游戏服务的。这是剩下的最依赖 Firestore 的功能了,在客户端和服务器都是如此,而且由于 Firestore 数据模型的限制,它有一个尴尬的结构,我必须简化它。
首先,我给 Cursor 输入了这个提示词(在 plan 模式下使用 Opus 4.7 high):
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`, ...).
根据我看到的 Opus 4.7 在 Cursor 框架下的能力,我预计这次迁移会在一夜之间完成。
我最初提示词里漏掉的一个大问题(智能体让我澄清的),是我们应该如何高效地处理 "Last Day"、"Last Week"、"Last Month" 和 "All Time" 排序。智能体倾向于使用按时间戳分桶的方案(或者干脆放弃按时间戳排序),但联合创始人坚持保留基于时间的排序,尽管这增加了复杂性。
经过一番思考,我想出了一个涉及衰减因子的简单方案。每个工坊条目有 played_count_day、played_count_week、played_count_month 和 played_count_all 几个列,每小时一个定时任务分别把滚动列乘以 23/24、167/168 和 (720-1)/720。再结合每个排序维度上的部分索引 (WHERE private = false AND played_count_day >= 0.368),我们就能用极低的成本实现"按时间段最热门"的查询,而且不需要任何额外的基础设施。这是我在 Firestore 上不会做的事情,因为它读写费用太离谱了,但在 Postgres 上基本是免费的。
搞定后,我就去睡觉了。醒来之后,我兴奋地检查它的成果!智能体添加了新的 workshop_items、workshop_item_votes 和 user_subscriptions 表,把客户端接到了通过 CDC 网关读取单个条目 (realtime.use('workshopItems/{id}')),并在后端重写了所有六个工坊 action,让它们直接和 Postgres 通讯。还剩一些零碎的尾巴需要我清理(服务器上一个对 Firestore 的查询、由于 Firestore 中缺数据导致的回填错误等等),但总体而言代码几乎是完美的。
跑完回填,并在本地测试确保一切正常之后,我把这次变更部署到线上。至此,代码库终于摆脱了 Firestore。漂亮。
未来工作
未来,我还想在网关上支持补丁(patches)。目前网关每次更新都会把整个文档作为 JSON 发回。这有点浪费,但因为我们在 Hetzner 上,出口流量基本是无限的。我现在就可以实现这个功能,但增加的复杂度还不足以让我下决心去做。