background blurbackground mobile blur

1/1/1970

چطور در ۲ روز از Firestore به Postgres مهاجرت کنیم

Firebase. همین یک کلمه می‌تواند به‌راحتی ۱۰ تا ۲۰ برابر یک دیتابیس ارزان‌تر برایتان خرج بتراشد و طراحی دیتابیس‌تان را هم محدود و هم پیچیده کند.

پس می‌توانید تصور کنید چقدر خیالم راحت شد وقتی فقط در ۲ روز مهاجرت از Firestore را تمام کردم. خب، ۳ روز اگر زمانی را که برای ساختن «PostgresSynchronizer» اولیه‌ام صرف کردم حساب کنید. یا ۳.۵ روز اگر زمان مهاجرت سیستم workshop را هم بشمارید. به هر حال، سریع بود. واقعاً سریع.

برآورد اولیه‌ام برای این پروژه یک ماه کامل کار بود! اما به لطف چند پرامپت فوق‌العاده برای LLM و یک دوست که چیزهایی درباره‌ی CDC به من یاد داد، توانستم خیلی سریع‌تر از این کار عبور کنم.

اگر دنبال pub/sub هستید، Firebase یا Supabase ممکن است خیلی وسوسه‌انگیز به نظر برسند. آخر، دیتابیس‌های زیادی به‌صورت پیش‌فرض از pub/sub پشتیبانی نمی‌کنند. اما این معماری ساده‌ای که در پایین می‌بینید، تنها چیزی است که برای اکثر کسب‌وکارهای کوچک کافی است (و باید به‌خوبی تا حدود ۱۰هزار تا ۱۰۰هزار عملیات در ثانیه مقیاس‌پذیر باشد).

معماری pub/sub در Foony Postgres روی سرور اختصاصی Hetzner خودش اجرا می‌شود. سرویس‌های CDC و Gateway داخل یک کلاستر k3s در Hetzner اجرا می‌شوند. CDC لاگ Write-Ahead 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 (singleton)

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

{/* 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) و داده را می‌گیرد. سپس داده را به Postgres آپ‌سرت می‌کند. بعد، instance استور Firestore خودم را رپ کردم تا هر تغییری در Firestore، handleWrite را هم فراخوانی کند.

البته این روش ۱۰۰٪ بی‌نقص نیست. اگر سرور قطع شود یا کرش کند، ممکن است چند تغییر جزئی پایدار نشوند. اما برای کاربرد Foony به‌اندازه‌ی کافی خوب است، و اسکیمای Postgres تضمین‌های یکپارچگی داده‌ی بهتری (مثل foreign key constraint‌ها) نسبت به 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 در دسترس داریم، و دیتابیس‌مان فقط حدود ۳۰ عملیات در ثانیه می‌گیرد. حتی اگر مقیاس‌مان را ۱۰۰ برابر به حدود ۱۰۰٬۰۰۰ کاربر همزمان برسانیم، احتمالاً همچنان می‌توانیم این را در یک نمونه‌ی Redis جا بدهیم. پس همان کار را کردم.

(می‌شود pub/sub را با LISTEN/NOTIFY هم به Postgres چسباند، اما در برابر اتصال‌های مجدد دوام نمی‌آورد و وقتی واقعاً به fanout نیاز داشته باشید از کار می‌افتد. CDC پاسخ کسل‌کننده و پایدار ماجراست.)

دوستم، اریک، CDC را به من یاد داد و کد CDC خودش را متن‌باز کرد که WAL پستگرس (Write-Ahead Log، جایی که Postgres هر تغییری را که روی دیتابیس اعمال می‌کند می‌نویسد) را از طریق یک logical replication slot می‌خواند و آن را در یک sink (مثلاً Redis) ذخیره می‌کند.

با یک پرامپت ساده و دقیق در Cursor، توانستم کد realtime CDC و gateway را تقریباً در یک تلاش تمام کنم (با چند تغییر جزئی و یک code review کامل). این شامل دو سرویس بسیار ساده است:

  • یک سرویس CDC singleton که WAL را می‌خواند و آن را در Redis Pub/Sub منتشر می‌کند (می‌توان با Streams ماندگاری بیشتری گرفت، اما به قیمت پیچیدگی)
  • یک سرویس gateway با مقیاس‌پذیری افقی خودکار که احراز هویت JWT و اتصال‌های websocket از کلاینت‌های وب را مدیریت می‌کند.

هر دو سرویس با golang نوشته شده‌اند، و به‌خصوص بخش gateway با autoscaling را خیلی دوست دارم. کلاینت وب دقیقاً به همان روشی که قبلاً با Firestore اشتراک می‌گرفت، اشتراک می‌گیرد و داده را در همان فرمت پس می‌گیرد. کلاینت "usersPublic/" و "usersPrivate/" را به‌عنوان دو کالکشن مجزا با مجوزهای جداگانه می‌بیند. gateway مسئول ترجمه‌ی آن درخواست به جدول‌های زیربنایی Postgres (در این مورد users) و تأیید این است که کلاینت برای آن داده مجوز دارد.

این کار به‌طور حیرت‌انگیزی خوب جواب می‌دهد. کل سیستم pub/sub فقط حدود ۰.۵۰ دلار در ماه هزینه‌ی compute + egress برای ما دارد، و قطعات متحرک زیادی که بتوانند خراب شوند ندارد (کدهای CDC، gateway و کلاینت همگی ساده‌اند و هیچ وابستگی شخص ثالثی به‌جز Redis / Postgres ندارند).

از منظر DevEx، می‌شود گفت سیستم جدید ساده‌تر هم هست: توسعه‌دهنده‌ها لازم نیست بدانند چطور داده‌ها را در Firestore مدل‌سازی کنند یا با قوانین امنیتی Firestore کار کنند. تا وقتی SQL را بفهمند، می‌توانند سریع تغییری در schema.sql بدهند، یک route در all.go اضافه یا اصلاح کنند و کارشان تمام است. مستندات هم به‌اندازه‌ی کافی قوی است که LLMها به‌راحتی بتوانند آن را دنبال کنند و تغییرات اعمال کنند. از منظر امنیتی هم این یک پیروزی است، چون دیگر در معرض حمله‌ی خطرناک denial-of-wallet (Wallet DoS) نیستیم، چرا که زیرساخت جدیدمان هزینه‌ی ثابت دارد و به‌طرز دیوانه‌واری ارزان‌تر است.

در مجموع، شامل نمونه‌ی Postgres و بکاپ‌های روزانه‌ی S3، توانستیم هزینه‌ی زیرساخت دیتابیس را از ۵۵۰ دلار در ماه به فقط ۴۰ دلار در ماه برسانیم. همچنین حجم باندل کلاینت را حدود ۱۰۰ کیلوبایت کاهش دادیم، که عالی است.

حالا که سیستم جدید را داشتم، به‌صورت لوکال تست کردم تا مطمئن شوم همه‌چیز همان‌طور که انتظار می‌رود کار می‌کند. سپس آن را به‌صورت live دیپلوی کردم. اگر مشکلی پیش می‌آمد، rollback کلاینت آسان است، چون در این مرحله Firestore هنوز منبع حقیقت است.

رفتن به Live

سوییچ سمت سرور تا حدودی ترسناک بود (مهاجرت‌های دیتابیس همیشه هستند). تا این لحظه، Firestore هنوز منبع حقیقت بود. سیستم CDC pub/sub فقط از یک آینه‌ی Postgres می‌خواند که synchronizer آن را به‌روز نگه می‌داشت. دیپلوی سرورهای جدید بازی چیزی است که در واقع Postgres را به مرجع نوشتن‌ها تبدیل می‌کرد.

اینطور پیش بردم:

  1. اول partnerها را آپدیت کردم. Foony با شرکای مثل FRVR یکپارچه است، پس چند روز زودتر یک build کلاینت جدید دیپلوی کردیم که از gateway CDC جدیدمان استفاده می‌کرد.
  2. به بازیکنان هشدار دادم. حدود ده دقیقه قبل از سوییچ، اطلاعیه‌ای درباره‌ی یک بازه‌ی نگهداری (امیدوارم) کوتاه گذاشتیم.
  3. یک بکاپ تازه از دیتابیس گرفتم. این همیشه در مهاجرت‌های بزرگ دیتابیس ایده‌ی خوبی است. LLMها این را «belt and suspenders» می‌نامند، که اساساً یعنی محتاط‌تر بودن و داشتن یک نقشه‌ی پشتیبان.
  4. هر دو کلاستر را همزمان دیپلوی کردم. این بار blue/green نه. می‌خواستم مطمئن شوم همه‌ی کلاسترها روی یک دیتابیس می‌نویسند تا از هرگونه ناسازگاری احتمالی جلوگیری شود.

یک نقشه‌ی rollback هم آماده داشتم که خیلی ساده بود: اگر چیزی به هم می‌ریخت، کلاینت قبلی را (که هنوز از Firestore می‌خواند) دوباره دیپلوی می‌کردم، سرورها را دوباره دیپلوی می‌کردم، backfill را دوباره راه می‌انداختم و بعداً دوباره تلاش می‌کردم. این تقریباً ۵ دقیقه downtime برای ری‌دیپلوی سرورها ایجاد می‌کرد و حدود نیم روز طول می‌کشید تا دیتابیس Postgres دوباره به Firestore برسد.

سوییچ واقعی فقط حدود ۱ دقیقه downtime داشت. تنها باگ جدی که بروز کرد، در آپدیت تجربه (experience) بود: LOWER() در Postgres روی ستون bigint به‌صورت ضمنی به text تبدیل می‌کرد. آه. این با یک cast ساده به bigint به‌راحتی قابل رفع بود، پس مهاجرت را با یک deploy سرور دیگر ادامه دادم.

شوکه شدم که همه‌چیز انگار خوب پیش می‌رفت. خیلی کم بودند کسانی که مشکل گزارش کردند و همه‌ی آن‌ها هم خیلی جزئی بودند (جز همان موضوع experience). اما چیزی که به‌خصوص برایم شوکه‌کننده بود این بود که این مهاجرت با اینکه اکثرش vibe-coded بود، چقدر روان پیش رفت. خیلی متفاوت با داستان‌های ترسناکی که ممکن است در اخبار بخوانید.

بازنویسی Workshop

حالا که Postgres مرجع نوشتن‌ها شده بود، فقط یک بخش بزرگ از codebase هنوز در Firestore گیر کرده بود: سیستم workshop. بازیکنان از آن برای اشتراک‌گذاری نقشه‌های سفارشی در 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 در harness کِرسر دیده بودم، انتظار داشتم این مهاجرت یک‌شبه تمام شود.

نکته‌ی بزرگی که در پرامپت اولیه‌ام جا انداخته بودم، که agent از من خواست شفافش کنم، این بود که چطور باید به‌طور کارآمد مرتب‌سازی‌های "Last Day"، "Last Week"، "Last Month" و "All Time" را مدیریت کنیم. agent اصرار به یک رویکرد timestamp-bucketed داشت (یا کلاً قید مرتب‌سازی زمانی را بزنیم)، اما هم‌بنیان‌گذار اصرار داشت که علی‌رغم پیچیدگی اضافه، مرتب‌سازی مبتنی بر زمان حفظ شود.

بعد از کمی فکر، یک راه‌حل ساده با یک ضریب کاهشی (decay factor) به ذهنم رسید. هر آیتم workshop ستون‌های played_count_day، played_count_week، played_count_month و played_count_all دارد، و یک cron ساعتی ستون‌های rolling را به‌ترتیب در 23/24، 167/168 و (720-1)/720 ضرب می‌کند. در ترکیب با partial indexها (WHERE private = false AND played_count_day >= 0.368) روی هر محور مرتب‌سازی، این به ما کوئری‌های «محبوب‌ترین بر اساس بازه‌ی زمانی» را به قیمت ناچیز می‌دهد، بدون هیچ زیرساخت اضافی. این کاری بود که با Firestore به دلیل هزینه‌های گزاف خواندن و نوشتن انجامش نمی‌دادم، اما در Postgres اساساً رایگان است.

با این کار، رفتم بخوابم. بیدار شدم و با هیجان کارش را چک کردم! agent جدول‌های جدید workshop_items، workshop_item_votes و user_subscriptions را اضافه کرد، کلاینت را برای خواندن آیتم‌های جداگانه از طریق gateway CDC سیم‌کشی کرد (realtime.use('workshopItems/{id}')) و هر شش action مربوط به workshop را در بک‌اند بازنویسی کرد تا مستقیماً با Postgres صحبت کنند. چند جزئیات معلق بود که باید جمعشان می‌کردم (یک کوئری به Firestore در سرور، خطایی در backfill به دلیل گم‌بودن داده در Firestore، و غیره)، اما در مجموع کد تقریباً بی‌نقص بود.

پس از اجرای backfill و تست برای اطمینان از اینکه همه‌چیز به‌صورت لوکال کار می‌کند، تغییر را به‌صورت live دیپلوی کردم. با این کار، codebase بالاخره از Firestore خلاص شد. زیبا.

کارهای آینده

در آینده، دوست دارم پشتیبانی از patchها را در gateway اضافه کنم. در حال حاضر، gateway برای هر آپدیت کل داکیومنت را به‌صورت JSON برمی‌گرداند. کمی هدررفت است، اما به لطف بودن روی Hetzner، egress ما عملاً نامحدود است. اگر می‌توانستم همین حالا این را پیاده می‌کردم، اما هنوز نمی‌توانم پیچیدگی اضافه‌اش را توجیه کنم.

8 Ball Pool online multiplayer billiards icon