ساخت سامانه‌های لحظه‌ای بر PostgreSQL: فراتر از ORM

تیم Atlas Verified
·
۲۴ بهمن ۱۴۰۴
·
8 دقیقه مطالعه
ساخت سامانه‌های لحظه‌ای بر PostgreSQL: فراتر از ORM

وقتی برنامه شما به به‌روزرسانی لحظه‌ای نیاز دارد، پاسخ پیش‌فرض در 2025 استفاده از خدمت مدیریت‌شده است: Firebase Realtime Database، Supabase Realtime، Pusher، Ably یا لایه WebSocket میزبانی‌شده. این خدمات پیچیدگی رساندن به‌روزرسانی‌ها به کلاینت‌های متصل را پنهان می‌کنند. خوب کار می‌کنند، تا وقتی که دیگر نکنند.

در Atlas Verified، کار را با پلتفرم پایگاه داده مدیریت‌شده‌ای آغاز کردیم که اشتراک‌های لحظه‌ای را شامل می‌شد. با رشد سامانه، به محدودیت‌های آن انتزاع رسیدیم. محدودیت‌های تجمیع اتصال، مشکلات قابلیت اتکای لحظه‌ای، نیاز به پرس‌وجوهای مکانی PostGIS و تمایل به منطق سفارشی تریگر، همگی ما را به یک سمت هدایت کردند: کنترل لایه پایگاه داده را خودمان به دست بگیریم.

به PostgreSQL خودمیزبان مهاجرت کردیم و سامانه لحظه‌ای خود را با سازوکار بومی LISTEN/NOTIFY در PostgreSQL و Server-Sent Events ساختیم. این مطلب توضیح می‌دهد چرا این تصمیم را گرفتیم، معماری چگونه کار می‌کند و چه آموختیم.

چرا پلتفرم مدیریت‌شده را ترک کردیم

پلتفرم‌های پایگاه داده مدیریت‌شده پیشنهاد ارزش جذابی دارند: احراز هویت، امنیت سطح ردیف، اشتراک‌های لحظه‌ای، APIهای خودکار تولیدشده و داشبورد، همگی آماده استفاده. برای محصولات در مراحل اولیه، این واقعاً ارزشمند است. می‌توانید بدون نوشتن حتی یک خط کد سمت سرور، برنامه‌ای کارآمد بسازید.

مشکلات وقتی پدید می‌آیند که برنامه بالغ می‌شود و نیازهای شما از فرض‌های پلتفرم فاصله می‌گیرند.

محدودیت اتصال‌ها. پلتفرم‌های مدیریت‌شده اغلب اتصال‌های پایگاه داده را از یک تجمیع‌کننده اتصال عبور می‌دهند که محدودیت‌های خود را اعمال می‌کند. وقتی چند خدمت سمت سرور دارید که هرکدام به مخزن اتصال خود نیاز دارند، مانند سرور API، سامانه پردازشگر پس‌زمینه و شنونده لحظه‌ای، اتصال‌های موجود به‌سرعت تمام می‌شوند. خودمان را در حال مهندسی برای دور زدن محدودیت‌های تجمیع‌کننده یافتیم، به‌جای حل مشکلات واقعی محصول.

قابلیت اتکای لحظه‌ای. سامانه لحظه‌ای پلتفرم مدیریت‌شده از طریق ثبت تغییرات داده بر روی گزارش پیش‌نویس تراکنش‌های PostgreSQL کار می‌کرد. این از نظر معماری زیباست، اما حالت‌های شکستی ایجاد می‌کرد که نمی‌توانستیم کنترلشان کنیم. تکثیر مبتنی بر WAL ذاتاً تأخیر دارد. خدمت لحظه‌ای گاهی عقب می‌افتاد و به‌روزرسانی‌های قدیمی تحویل می‌داد. اتصال مجدد پس از قطع شبکه قابل اتکا نبود. و اشکال‌زدایی این مسائل به دسترسی به زیرساختی نیاز داشت که کنترلش را نداشتیم.

الزامات افزونه‌ها. برنامه ما اسناد تجاری حاوی داده‌های جغرافیایی را پردازش می‌کند: موقعیت بنادر، مسیرهای حمل‌ونقل و موقعیت کشتی‌ها. PostGIS، افزونه مکانی PostgreSQL، برای این پرس‌وجوها ضروری است. پلتفرم مدیریت‌شده از PostGIS پشتیبانی می‌کرد، اما ما به نسخه‌ها و تنظیمات مشخصی نیاز داشتیم که همیشه در دسترس نبودند.

منطق سفارشی تریگر. به تریگرهای پایگاه داده نیاز داشتیم که رویدادهای ویژه برنامه را فعال کنند، نه فقط اعلان‌های عمومی «ردیف تغییر کرد». وقتی وضعیت پردازش سند تغییر می‌کند، باید به همان کلاینت متصل مشخصی که سند را بارگذاری کرده اطلاع دهیم. قابلیت لحظه‌ای پلتفرم مدیریت‌شده در سطح جدول بود؛ ما به سطح موجودیت نیاز داشتیم.

تصمیم مهاجرت درباره هزینه یا اصول نبود. تصمیمی عمل‌گرایانه بود: زمان بیشتری برای دور زدن محدودیت‌های پلتفرم صرف می‌کردیم تا زمانی که ساختن قابلیت‌ها توسط خودمان نیاز داشت.

‏LISTEN/NOTIFY: سازوکار پنهان انتشار/اشتراک PostgreSQL

‏PostgreSQL از نسخه 9.0، منتشرشده در 2010، سازوکار داخلی انتشار/اشتراک دارد. با وجود گذشت بیش از 15 سال، LISTEN/NOTIFY به‌طرز چشمگیری کم استفاده می‌شود. بیشتر توسعه‌دهندگان نمی‌دانند وجود دارد.

سازوکار ساده است. هر اتصال پایگاه داده می‌تواند برای اشتراک، LISTEN را روی کانالی نام‌گذاری‌شده اجرا کند. هر اتصال می‌تواند NOTIFY را اجرا کند تا پیامی به همه شنوندگان آن کانال بفرستد. محتوای پیام یک رشته است که به 8,000 بایت محدود می‌شود.

ویژگی‌هایی که LISTEN/NOTIFY را برای برنامه‌های لحظه‌ای قدرتمند می‌کنند:

بدون سربار زیرساخت جداگانه. هیچ واسطه پیام جداگانه‌ای برای استقرار، پایش یا مقیاس‌دهی وجود ندارد. اگر PostgreSQL دارید، انتشار/اشتراک هم دارید. برای این کاربرد مشخص، نه Redis لازم است، نه RabbitMQ و نه Kafka.

تضمین‌های تراکنشی. پیام‌های NOTIFY فقط وقتی تحویل داده می‌شوند که تراکنش دربرگیرنده آن‌ها نهایی شود. اگر تراکنشی ردیفی درج کند و اعلانی بفرستد و سپس بازگردانی شود، اعلان هرگز تحویل نمی‌شود. این ویژگی را بدون هماهنگ‌سازی دقیق از واسطه‌های پیام خارجی به دست نمی‌آورید.

یکپارچگی با تریگر. تریگرهای PostgreSQL می‌توانند در بخشی از اجرای خود pg_notify را فراخوانی کنند. این یعنی می‌توانید کاری کنید که هرگاه داده مشخصی تغییر کند، پایگاه داده به‌طور خودکار به شنوندگان اطلاع دهد، بدون هیچ کد برنامه‌ای در مسیر نوشتن.

محدودیت‌ها واقعی‌اند و باید آن‌ها را درک کرد:

بدون ماندگاری. اگر هنگام فعال شدن NOTIFY کسی در حال شنیدن نباشد، پیام از دست می‌رود. بازپخش، صف پیام یا تضمین تحویل برای کلاینت‌های قطع‌شده وجود ندارد. شنوندگان شما باید اتصال مجدد و بازیابی وضعیت را مدیریت کنند.

اندازه محتوای پیام. محدودیت 8,000 بایتی محتوای اعلان یعنی نمی‌توانید اشیای بزرگ را از طریق NOTIFY بفرستید. ما محتوای حداقلی، یعنی نام جدول، نوع عملیات و کلید اصلی را می‌فرستیم و می‌گذاریم کلاینت در صورت نیاز داده کامل را دریافت کند.

محدوده یک پایگاه داده. ‏LISTEN/NOTIFY در یک نمونه PostgreSQL کار می‌کند. در میان نسخه‌های تکثیرشده یا خوشه‌ها امتداد نمی‌یابد. برای معماری‌هایی که همه عملیات نوشتن به یک نمونه اصلی می‌روند، این محدودیت محسوب نمی‌شود.

معماری ما: تریگرها، SSE و تسهیم کانال‌ها

اجزا به این شکل کنار هم قرار می‌گیرند.

تریگرهای پایگاه داده روی جدول‌هایی نصب می‌شوند که به‌روزرسانی لحظه‌ای در آن‌ها مهم است: اسناد، پیام‌ها، گفت‌وگوها، اعلان‌ها، راستی‌آزمایی محموله‌ها و موارد دیگر. وقتی ردیفی درج، به‌روزرسانی یا حذف می‌شود، تریگر فعال می‌شود و pg_notify را با محتوای JSON شامل نام جدول، نوع عملیات، کلید اصلی و هر ستون فیلتر فراخوانی می‌کند.

یک اتصال شنونده اختصاصی در سرور API ما، LISTEN را روی کانال اعلان اجرا می‌کند و همه رویدادهای تریگر را دریافت می‌کند. این اتصال PostgreSQL اختصاصی و طولانی‌مدت، جدا از مخزن اتصال پرس‌وجو است و کاری جز شنیدن اعلان‌ها انجام نمی‌دهد.

یک گذرگاه رویداد اعلان‌ها را از شنونده دریافت می‌کند و بر اساس اشتراک‌ها به کلاینت‌های متصل هدایت می‌کند. کلاینتی که مشترک اسناد دارای شناسه محموله مشخص است، فقط اعلان‌های مربوط به اسناد همان محموله را دریافت می‌کند.

‏Server-Sent Events اعلان‌ها را به مرورگر تحویل می‌دهند. هر کلاینت متصل یک اتصال SSE به سرور API باز می‌کند و کانال‌ها و فیلترهای مورد نظرش را مشخص می‌کند. گذرگاه رویداد، اعلان‌های ورودی را با اشتراک‌های کلاینت تطبیق می‌دهد و رویدادهای منطبق را از اتصال‌های SSE مناسب ارسال می‌کند.

چرا SSE را به WebSockets ترجیح دادیم

این تصمیمی است که همواره از آن راضی بوده‌ایم. Server-Sent Events در همه ابعادی که برای کاربرد ما مهم‌اند از WebSockets ساده‌تر هستند.

یک‌طرفه در طراحی. نیازهای لحظه‌ای ما از سرور به کلاینت است: سرور به کلاینت می‌گوید چه زمانی داده تغییر می‌کند. کلاینت لازم نیست پیام لحظه‌ای به سرور بفرستد؛ برای آن از درخواست‌های استاندارد HTTP استفاده می‌کند. مدل یک‌طرفه SSE با نیاز واقعی ما تطابق دارد.

بومی HTTP. اتصال‌های SSE درخواست‌های استاندارد HTTP با نوع محتوای مشخص‌اند. بدون تنظیمات خاص از همه پراکسی‌های HTTP، متعادل‌کننده‌های بار و CDNها عبور می‌کنند. WebSockets به HTTP Upgrade نیاز دارد که بعضی پراکسی‌ها درست مدیریت نمی‌کنند و برخی دیواره‌های آتش شرکتی کاملاً مسدود می‌کنند.

اتصال مجدد خودکار. ‏API مربوط به EventSource در مرورگر، اتصال مجدد را به‌صورت بومی مدیریت می‌کند. اگر اتصال قطع شود، مرورگر با فاصله‌گذاری نمایی به‌طور خودکار دوباره متصل می‌شود. با WebSockets، منطق اتصال مجدد را خودتان پیاده‌سازی می‌کنید و دست‌کم یک بار اشتباه انجامش می‌دهید.

تنها حوزه‌ای که WebSockets در آن برتری دارد، یعنی ارتباط دوسویه، چیزی است که ما اصلاً نیاز نداریم. برای برنامه‌هایی که پیام‌رسانی لحظه‌ای از کلاینت به سرور می‌خواهند، WebSockets انتخاب درست است. برای برنامه‌هایی که اعلان‌ها را از سرور به کلاینت می‌فرستند، SSE انتخاب درست است.

ساخت لایه پرس‌وجو بدون ORM

وقتی پلتفرم مدیریت‌شده را ترک کردیم، API پرس‌وجوی خودکار تولیدشده آن را هم کنار گذاشتیم. برای سرور API خود به یک لایه دسترسی به پایگاه داده نیاز داشتیم.

گزینه‌های استاندارد، ORMهایی مانند Prisma، TypeORM یا Drizzle هستند، یا SQL خام همراه با درایور. ما مسیر سوم را انتخاب کردیم: سازنده پرس‌وجوی سبک که سطح API زنجیره‌پذیر ارائه می‌دهد و در زیربنا SQL خام تولید و اجرا می‌کند.

‏API آشنا. کد موجود ما بر اساس کتابخانه کلاینت پلتفرم مدیریت‌شده نوشته شده بود. سطح API سازگار به معنای تغییر حداقلی کد هنگام مهاجرت بود. پرس‌وجوهایی که قبلاً از کلاینت پلتفرم اجرا می‌شدند، می‌توانستند با نحو یکسان از سازنده پرس‌وجوی ما اجرا شوند.

بدون رفتار جادویی. ‏ORMها SQL تولید می‌کنند که نمی‌توانید ببینید و گاهی نمی‌توانید کنترل کنید. وقتی پرس‌وجو کند است، باید SQL تولیدشده را بخوانید که اغلب شباهت کمی به فراخوانی ORM تولیدکننده آن دارد. سازنده پرس‌وجوی ما SQL قابل پیش‌بینی تولید می‌کند: هر متد زنجیره‌شده به بند SQL مشخصی نگاشت می‌شود و همیشه می‌توانید پرس‌وجوی تولیدشده را بررسی کنید.

پیوندهای توکار. داده‌های تجاری رابطه‌ای هستند. یک محموله اسنادی دارد که گره‌هایی دارند و آن‌ها بررسی‌های راستی‌آزمایی دارند. امکان بیان انتخاب‌های تودرتو و دریافت نتیجه تودرتو، بدون نوشتن دستی پرس‌وجوی پیچیده JOIN در هر بار، بهبود چشمگیری در تجربه توسعه‌دهنده است.

نتیجه، لایه پرس‌وجویی با چند صد خط کد است که بخش عمده تعامل‌های پایگاه داده ما را مدیریت می‌کند و SQL تولید می‌کند که همیشه می‌توانیم بخوانیم و بهینه کنیم.

بودجه‌بندی مخزن اتصال

وقتی PostgreSQL را خودتان میزبانی می‌کنید، بودجه اتصال بر عهده شماست. تنظیم max_connections در PostgreSQL سقف مطلق را تعیین می‌کند و هر اتصال حافظه مصرف می‌کند.

دریافتیم نگاه به اتصال‌ها به‌عنوان بودجه، یعنی منبعی محدود و مشترک میان خدمات، از دسته‌ای از مشکلات جلوگیری می‌کند که تیم‌های بدون برنامه‌ریزی را گرفتار می‌کنند.

هر خدمت حداکثر تعداد اتصال خود را اعلام می‌کند. مجموع همه حداکثرها باید به‌وضوح پایین‌تر از محدودیت PostgreSQL بماند و فضایی برای اتصال‌های موردی، مانند نشست‌های psql، اسکریپت‌های مهاجرت و ابزارهای پایش باقی بگذارد. این بدیهی به نظر می‌رسد، اما بیشتر تیم‌ها تا وقتی در محیط عملیاتی با اتمام اتصال‌ها روبه‌رو نشوند، انجامش نمی‌دهند. با بودجه‌بندی اتصال‌ها از ابتدا، از آن هشدار خاص ساعت 3 بامداد جلوگیری کرده‌ایم.

مهاجرت‌های ایمن در مقیاس بزرگ

مهاجرت از پلتفرم مدیریت‌شده به PostgreSQL خودمیزبان، به معنای جابه‌جایی صدها جدول، تابع، تریگر و نمایه بود. اما چالش بزرگ‌تر، ساخت سامانه مهاجرتی بود که با تکامل برنامه همچنان کار کند.

سامانه مهاجرت خودکار ما این‌گونه کار می‌کند: فایل‌های مهاجرت با زمان‌مهر نام‌گذاری می‌شوند و در پوشه‌ای اختصاصی قرار دارند. اجراکننده مهاجرت، پوشه را بررسی می‌کند، آن را با جدول پیگیریِ ثبت‌کننده مهاجرت‌های اعمال‌شده مقایسه می‌کند و مهاجرت‌های در انتظار را به‌ترتیب اجرا می‌کند. هر مهاجرت درون یک تراکنش اجرا می‌شود؛ اگر شکست بخورد، به‌طور کامل بازگردانی می‌شود و تا رفع مشکل، استقرار را متوقف می‌کند.

اجراکننده مهاجرت در هر استقرار، پیش از شروع برنامه به‌طور خودکار اجرا می‌شود. یعنی تغییرات طرحواره بدون مراحل دستی، به‌عنوان بخشی از فرایند استقرار اعمال می‌شوند. توسعه‌دهنده یک فایل SQL دارای زمان‌مهر می‌سازد، آن را به مخزن ارسال می‌کند و استقرار بعدی آن را اعمال می‌کند.

تصمیم‌های کلیدی طراحی که این کار را ایمن نگه می‌دارند: مهاجرت‌ها از محافظ‌های هم‌توان استفاده می‌کنند تا اگر بخشی از آن‌ها اعمال شده بود، شکست نخورند. هر مهاجرت باید اسکریپت SQL مستقلی باشد که بدون ورودی انسانی اجرا شود. و اگر مهاجرتی شکست بخورد، تراکنش کاملاً بازگردانی می‌شود؛ برنامه با تغییر نیمه‌اعمال‌شده طرحواره شروع نخواهد شد.

آنچه به دست آوردیم

انتقال به PostgreSQL خودمیزبان با قابلیت لحظه‌ای سفارشی، سرمایه‌گذاری مهندسی چشمگیری بود. اما بازده آن قابل توجه بوده است:

قابلیت اتکا. اعلان‌های لحظه‌ای ظرف چند میلی‌ثانیه پس از تغییر داده زیربنایی می‌رسند. نه تأخیر تکثیر WAL وجود دارد و نه قطعی خدمات شخص ثالث در مسیر حیاتی.

امکان اشکال‌زدایی. وقتی مشکلی پیش می‌آید، می‌توانیم کل مسیر را ردگیری کنیم: تریگر فعال می‌شود، اعلان ارسال می‌شود، گذرگاه رویداد هدایت می‌کند و SSE تحویل می‌دهد. هر گام در کد ما، در گزارش‌های ما و روی زیرساخت ماست.

انعطاف‌پذیری. افزودن قابلیت لحظه‌ای به جدول جدید، تنها یک مهاجرت SQL است که تریگری نصب می‌کند. نه تنظیمات پلتفرم لازم است، نه داشبورد شخص ثالث و نه درخواست پشتیبانی.

عملکرد. اتصال‌های مستقیم PostgreSQL با مخزن تنظیم‌شده، از اتصال‌های هدایت‌شده از زیرساخت پلتفرم مدیریت‌شده بهتر عمل می‌کنند. تأخیر پرس‌وجو پس از مهاجرت به‌طور معناداری کاهش یافت.

بهای این انتخاب، مسئولیت عملیاتی است. پایگاه داده را مدیریت می‌کنیم، پشتیبان‌گیری را انجام می‌دهیم، تکثیر را پایش می‌کنیم و ظرفیت را برنامه‌ریزی می‌کنیم. برای تیمی که با عملیات PostgreSQL آشنا و راحت است، این قابل مدیریت است. برای تیمی که چنین نیست، انتزاع‌های پلتفرم مدیریت‌شده شاید ارزش پذیرفتن محدودیت‌هایشان را داشته باشند.


‏Atlas Verified روی PostgreSQL خودمیزبان با زیرساخت لحظه‌ای سفارشی اجرا می‌شود و روزانه هزاران راستی‌آزمایی انطباق تجاری را در سراسر زنجیره تأمین جهانی پردازش می‌کند.

از این مقاله لذت بردید؟

آن را با شبکه خود به اشتراک بگذارید