

1/1/1970
Как мигрировать с Firestore на Postgres за 2 дня
Firebase. Одно это слово может легко обойтись вам в 10-20 раз дороже более дешёвой базы данных, а ещё ограничивать и усложнять архитектуру вашей БД.
Так что можете себе представить, как я обрадовался, когда закончил миграцию с Firestore всего за 2 дня. Ну, за 3 дня, если считать время, которое я потратил на сборку первоначального «PostgresSynchronizer». Или за 3,5 дня, если учитывать миграцию системы воркшопа. В любом случае, это было быстро. Реально быстро.
Изначально я оценивал этот проект в целый месяц работы! Но благодаря крутым промптам для LLM и другу, который рассказал мне о CDC, я справился значительно быстрее.
Если вам нужен pub/sub, Firebase или Supabase могут показаться очень заманчивыми. В конце концов, не так много баз данных поддерживают pub/sub «из коробки». Но простой архитектуры ниже хватит для большинства задач малого бизнеса (и она должна нормально масштабироваться примерно до 10–100 тысяч операций в секунду).
Но прежде чем погрузиться в детали работы pub/sub, я расскажу о шагах, которые предпринимал по порядку.
Сначала я подготовил промпт в Cursor для создания PostgresSynchronizer. У этого класса есть функция handleWrite, которая принимает путь Firestore, тип операции (create, update, delete) и данные. Затем она выполняет upsert данных в Postgres. После этого я обернул свой экземпляр хранилища Firestore так, чтобы любые изменения в Firestore вызывали и handleWrite.
Это, конечно, не на 100% надёжно: если сервер отключится или упадёт, возможно, какие-то мелкие изменения не сохранятся. Но для задач Foony этого достаточно, а схема Postgres даёт нам лучшие гарантии целостности данных (например, ограничения внешних ключей), чем Firestore.
Дальше я создал и запустил backfill, который проходит по всем коллекциям Firestore и вызывает handleWrite для каждого документа. Это гарантирует, что мы получим все исторические данные из Firestore. Для скорости я ограничил его только зарегистрированными пользователями (извините, гости, надо было регистрироваться). Работает отлично, и его можно безопасно запускать многократно.
Теперь, когда у меня был способ заполнить данные в Postgres и поддерживать их (в основном) в синхронизации с Firestore, я смог взяться за большую и страшную задачу: pub/sub.
Как, чёрт возьми, делать pub/sub с Postgres? Да и вообще с любой SQL-базой?
На помощь приходит Postgres Change Data Capture (CDC)!
CDC — это умное слово, которое просто означает «читать изменения в базе данных и публиковать их в систему pub/sub». В крупной компании вы бы взяли что-то вроде Debezium + Kafka. Но Kafka — это головная боль в настройке, а у нас уже есть Redis, и наша БД получает всего около 30 операций в секунду. Даже если бы мы выросли в 100 раз до ~100 000 одновременных пользователей, мы, скорее всего, всё ещё уместились бы в одном инстансе Redis. Так я и сделал.
(Можно ещё прикрутить pub/sub к Postgres через LISTEN/NOTIFY, но это не переживает переподключений и разваливается, как только реально понадобится fanout. CDC — скучный, но надёжный ответ.)
Мой друг Эрик рассказал мне о CDC и выложил в open source свой код CDC, который читает WAL (Write-Ahead Log, журнал, куда Postgres пишет каждое изменение в БД) через слот логической репликации и сохраняет его в sink (например, в Redis).
С помощью простого, детального промпта в Cursor мне удалось почти с одного выстрела получить рабочий код realtime CDC и шлюза (с парой мелких правок и тщательным код-ревью). Это два очень простых сервиса:
- singleton-сервис CDC, который читает WAL и публикует изменения в Redis Pub/Sub (можно получить большую надёжность через Streams, но ценой сложности);
- горизонтально автомасштабируемый сервис шлюза, который занимается JWT-авторизацией и WebSocket-соединениями от веб-клиентов.
Оба сервиса написаны на Go, и мне особенно нравится автомасштабируемый шлюз. Веб-клиент подписывается так же, как раньше с Firestore, и получает данные в том же формате. Клиент видит «usersPublic/» и «usersPrivate/» как две отдельные коллекции с разными правами. Шлюз отвечает за перевод этого запроса в соответствующие таблицы Postgres (в данном случае users) и за проверку прав клиента на эти данные.
Работает это невероятно хорошо. Вся система pub/sub обходится нам примерно в 0,50 $/мес на вычисления + трафик и не имеет кучи движущихся частей, которые могут сломаться (код CDC, шлюза и клиента простой, без сторонних зависимостей кроме Redis / Postgres).
С точки зрения DevEx новая система, можно сказать, проще: разработчикам не нужно знать, как моделировать данные в Firestore или писать правила безопасности Firestore. Если они понимают SQL, то быстро вносят правку в schema.sql, добавляют или меняют маршрут в all.go и готово. Документация тоже достаточно подробная, чтобы LLM легко следовали ей и вносили изменения. С точки зрения безопасности это тоже выигрыш: мы больше не подвержены опасной атаке denial-of-wallet (Wallet DoS), так как новая инфраструктура с фиксированной стоимостью и в разы дешевле.
В итоге, включая инстанс Postgres и ежедневные бэкапы в S3, нам удалось снизить расходы на инфраструктуру БД с 550 $/мес до всего 40 $/мес. Также мы уменьшили размер клиентского бандла примерно на 100 КБ, что приятно.
Когда новая система была готова, я протестировал её локально, чтобы убедиться, что всё работает как надо. Затем выкатил в продакшен. Если бы возникли проблемы, легко откатить клиент: на этот момент Firestore всё ещё был источником истины.
Идём в продакшен
Серверный переход был довольно страшным (миграции баз данных всегда такие). До этого момента Firestore оставался источником истины: система pub/sub на CDC просто читала из зеркала в Postgres, которое поддерживал синхронизатор. Именно деплой новых игровых серверов должен был сделать Postgres авторитетным для записей.
Вот как я выкатывал это:
- Сначала обновил партнёров. Foony интегрирован с такими партнёрами, как FRVR, поэтому за несколько дней до этого мы выкатили новый клиентский билд, который использовал бы наш новый шлюз CDC.
- Предупредил игроков. Примерно за десять минут до переключения мы опубликовали предупреждение о (надеюсь) коротком окне обслуживания.
- Сделал свежий бэкап БД. При больших миграциях это всегда хорошая идея. LLM называют это «belt and suspenders» (буквально «ремень и подтяжки»), что просто означает «перестраховаться и иметь план Б».
- Задеплоил оба кластера одновременно. Без blue/green в этот раз. Я хотел убедиться, что все кластеры пишут в одну и ту же базу, чтобы избежать возможных несогласованностей.
Также у меня был план отката, очень простой: если что-то пойдёт не так, я передеплою предыдущий клиент (который всё ещё читал из Firestore), передеплою серверы, перезапущу backfill и попробую позже. Это привело бы к примерно 5 минутам простоя на передеплое серверов и около полудня на догонку Postgres до состояния Firestore.
Сам переход занял всего около 1 минуты простоя. Единственный серьёзный баг, который всплыл, был при обновлении опыта: LOWER() в Postgres для колонки bigint неявно приводил её к text. Эх. Это легко исправилось простым приведением обратно к bigint, так что я продолжил миграцию очередным деплоем сервера.
Я был шокирован, что всё, казалось, идёт гладко. Очень мало людей сообщили о проблемах, и все они были очень мелкими (кроме истории с опытом). Что особенно меня поразило, так это насколько гладко прошла миграция, учитывая, что она в основном была vibe-coded. Совсем не похоже на страшные истории, которые можно прочитать в новостях.
Переписывание воркшопа
Теперь, когда Postgres стал авторитетом для записей, только один большой кусок кодовой базы всё ещё был завязан на Firestore: система воркшопа. Игроки используют её, чтобы делиться кастомными картами в Dino-Might Bomber Online, списками слов в Draw & Guess и так далее. Что-то вроде Steam Workshop, но для наших игр. Это была самая запутанная с Firestore фича, как на клиенте, так и на сервере, со странной структурой из-за ограничений модели данных Firestore, которую мне предстояло упростить.
Для начала я скормил Cursor такой промпт (через Opus 4.7 high в 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`, ...).
Учитывая то, что я видел от возможностей Opus 4.7 в связке с Cursor, я ожидал, что эта миграция закончится за ночь.
Главное, что я упустил в первоначальном промпте и что агент попросил уточнить: как эффективно обрабатывать сортировки «Последний день», «Последняя неделя», «Последний месяц» и «За всё время». Агент склонялся к подходу с бакетами по таймстампам (или вообще к отказу от сортировки по времени), но как сооснователь я настаивал на сохранении сортировки по времени, несмотря на дополнительную сложность.
После некоторых раздумий я придумал простое решение с фактором затухания. У каждого элемента воркшопа есть колонки played_count_day, played_count_week, played_count_month и played_count_all, и ежечасный cron умножает скользящие колонки на 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}')) и переписал все шесть действий воркшопа на бэкенде так, чтобы они работали напрямую с Postgres. Было несколько мелких хвостов, которые мне пришлось подчистить (один запрос к Firestore на сервере, ошибка в backfill из-за отсутствующих данных в Firestore и т. д.), но в целом код был почти идеален.
После запуска backfill и локального тестирования я выкатил изменения в продакшен. На этом кодовая база наконец стала свободной от Firestore. Красота.
Что дальше
В будущем я бы также хотел поддержать патчи в шлюзе. Сейчас шлюз отправляет весь документ в JSON на каждое обновление. Это немного расточительно, но наш egress фактически безлимитный благодаря Hetzner. Я бы реализовал это сейчас, но пока не могу оправдать дополнительную сложность.