background blurbackground mobile blur

1/1/1970

Як мігрувати з Firestore на Postgres за 2 дні

Firebase. Це одне слово легко може коштувати вам у 10-20 разів більше, ніж дешевша база даних, і може водночас обмежувати й ускладнювати дизайн вашої БД.

Тож можете уявити, як я зрадів, коли завершив міграцію з Firestore лише за 2 дні. Ну, 3 дні, якщо рахувати час на побудову мого початкового "PostgresSynchronizer". Або 3,5 дні, якщо рахувати час на міграцію системи майстерні. У будь-якому разі, це було швидко. Реально швидко.

Моя початкова оцінка для цього проєкту складала цілий місяць роботи! Але завдяки кільком крутим промптам для LLM і другу, який розповів мені про CDC, я зміг впоратися значно швидше.

Якщо вам потрібен pub/sub, Firebase або Supabase можуть здатися дуже спокусливими. Зрештою, не так багато баз даних підтримують pub/sub з коробки. Але ось ця проста архітектура нижче — це все, що потрібно для більшості випадків малого бізнесу (і вона має непогано масштабуватися приблизно до 10k-100k операцій/секунду).

Архітектура pub/sub у Foony Postgres працює на власному сервері Hetzner. Сервіси CDC і Gateway працюють всередині кластера k3s на Hetzner. CDC читає Write-Ahead Log Postgres і публікує зміни в Redis Pub/Sub (окремий сервер Hetzner). Gateway підписується на Redis і розсилає оновлення WebSocket-клієнтам.

{/* Postgres host (left). Standalone Hetzner server. */} Сервер Postgres (Hetzner) Postgres WAL

{/* k3s cluster (middle). Contains the CDC and Gateway services as pods. */} Кластер k3s (Hetzner)

{/* CDC service (singleton). */} CDC (одиничний)

{/* Gateway service. Two layered rects convey "horizontally autoscaling" multiple replicas. */} Gateway (автомасштабування)

{/* Redis host (below k3s). Standalone Hetzner server. */} Сервер 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. */} WebSocket-клієнт

WebSocket-клієнт WebSocket-клієнт

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

Postgres WAL (Hetzner) → CDC (k3s) → Redis Pub/Sub (Hetzner) → Gateway (k3s) → WebSocket-клієнти

Перш ніж зануритися в деталі роботи 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 — це нудна, надійна відповідь.)

Мій друг Eric розповів мені про CDC і опублікував свій CDC-код з відкритим кодом, який читає WAL Postgres (Write-Ahead Log, куди Postgres записує кожну зміну, яку робить у базі даних) через слот логічної реплікації і зберігає його в sink (наприклад, Redis).

З простим, детальним промптом у Cursor я зміг переважно з одного разу отримати реалтайм-код CDC і gateway (з кількома незначними правками та ретельним переглядом коду). Це включає два дуже прості сервіси:

  • одиничний CDC-сервіс, який читає WAL і публікує його в Redis Pub/Sub (можна отримати більшу надійність зі Streams, але ціною складності)
  • gateway-сервіс із горизонтальним автомасштабуванням, який обробляє JWT-автентифікацію і WebSocket-з'єднання від веб-клієнтів.

Обидва сервіси на golang, і мені особливо подобається частина з автомасштабуванням gateway. Веб-клієнт підписується тим самим способом, що й раніше з Firestore, і отримує дані в тому самому форматі. Клієнт бачить "usersPublic/" і "usersPrivate/" як дві окремі колекції з окремими дозволами. Gateway відповідає за переклад цього запиту в підлеглі таблиці Postgres (у цьому випадку users) і перевірку того, що клієнт має дозвіл на ці дані.

Це працює приголомшливо добре. Уся система pub/sub коштує нам лише близько $0.50 на місяць на обчислення + витрати на egress, і не має багатьох рухомих частин, які можуть зламатися (код CDC, gateway і клієнта простий, без сторонніх залежностей окрім Redis / Postgres).

З точки зору DevEx нова система, мабуть, теж простіша: розробникам не потрібно знати, як моделювати дані у Firestore чи правила безпеки Firestore. Якщо вони розуміють SQL, вони можуть швидко внести правки в наш schema.sql, додати або змінити маршрут у all.go, і все готово. Документація також достатньо сильна, щоб LLM могли легко її дотримуватися і вносити зміни. З точки зору безпеки це теж виграш, оскільки ми більше не вразливі до небезпечної атаки denial-of-wallet (Wallet DoS), бо наша нова інфраструктура має фіксовану вартість і шалено дешевша.

У підсумку, включаючи екземпляр Postgres і щоденні бекапи на S3, нам вдалося перейти з $550 на місяць до лише $40 на місяць на витрати на інфраструктуру БД. Ми також зменшили розмір клієнтського бандла на ~100KB, що приємно.

Тепер, коли я мав нову систему, я протестував локально, щоб переконатися, що все працює як очікувалося. Потім задеплоїв у продакшн. Якби виникли якісь проблеми, легко відкотити клієнт, оскільки Firestore все ще є джерелом істини на цьому етапі.

Виходимо у продакшн

Перемикання сервера було дещо страшним (міграції БД завжди такі). До цього моменту Firestore все ще був джерелом істини, система pub/sub CDC просто читала з дзеркала Postgres, яке підтримувалося синхронізатором. Деплой нових ігрових серверів — це те, що фактично зробить Postgres авторитетним для запису.

Ось як я це викочував:

  1. Спершу оновив партнерів. Foony інтегрована з партнерами на кшталт FRVR, тому ми задеплоїли новий клієнтський білд на кілька днів раніше, який використовував наш новий CDC gateway.
  2. Попередив гравців. Приблизно за десять хвилин до перемикання ми опублікували попередження про (сподіваємось) коротке вікно технічного обслуговування.
  3. Зробив свіжий бекап БД. Це завжди гарна ідея при великих міграціях БД. LLM називають це "ремінь і підтяжки", що, по суті, означає бути особливо обережним і мати запасний план.
  4. Задеплоїв обидва кластери одночасно. Цього разу без 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):

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 gateway (realtime.use('workshopItems/{id}')) і переписав усі шість дій майстерні на бекенді, щоб вони спілкувалися з Postgres напряму. Було кілька дрібниць, які я мав прибрати (один запит до Firestore на сервері, помилка з backfill через відсутні дані у Firestore тощо), але в цілому код був майже ідеальним.

Після запуску backfill і тестування, щоб переконатися, що все працює локально, я задеплоїв зміни у продакшн. З цим кодова база нарешті стала вільною від Firestore. Краса.

Майбутня робота

У майбутньому я також хотів би підтримати патчі в gateway. Зараз gateway надсилає весь документ як JSON для кожного оновлення. Це трохи марнотратно, але наш egress фактично безлімітний завдяки Hetzner. Я б реалізував це зараз, але поки що не можу виправдати додаткову складність.

8 Ball Pool online multiplayer billiards icon