كلاود
development

PgBouncer أمام PostgreSQL: آلاف الاتصالات على سيرفر واحد

كلاود|٣١ أكتوبر ٢٠٢٦|6 دقائق قراءة|٤٢ مشاهدة
PgBouncer أمام PostgreSQL: آلاف الاتصالات على سيرفر واحد

كل اتصال PostgreSQL عملية مستقلة تستهلك نحو 10MB، لذلك يجمع PgBouncer في وضع transaction آلاف اتصالات التطبيق على 20 اتصالاً حقيقياً فقط، فتتضاعف قدرة سيرفر بذاكرة 4GB دون ترقية العتاد.

الجواب المختصر

PostgreSQL ينشئ عملية نظام تشغيل كاملة لكل اتصال، وكل عملية تستهلك عادة من 5 إلى 10MB من الذاكرة قبل أن تنفذ استعلاماً واحداً، وترتفع أكثر مع الاستعلامات الثقيلة بسبب work_mem. لذلك 500 اتصال مفتوح تعني نحو 5GB ذاكرة، وهذا أكثر من كل ذاكرة سيرفر بحجم 4GB. PgBouncer يحل المشكلة بوضع وسيط خفيف بين التطبيق والقاعدة: التطبيق يفتح آلاف الاتصالات إلى PgBouncer، وPgBouncer يمررها على عدد صغير ثابت من الاتصالات الحقيقية، 20 مثلاً، ويستهلك هو نفسه نحو 2KB لكل اتصال عميل.

الوضع المناسب لتطبيقات Laravel وDjango هو transaction، حيث يُعار الاتصال الحقيقي للتطبيق طوال المعاملة فقط ثم يعود للمجمّع. هذا الوضع يعطي أكبر توفير، لكنه يكسر كل ما يعتمد على حالة الجلسة: أوامر SET، والأقفال الاستشارية على مستوى الجلسة، وLISTEN/NOTIFY، والجداول المؤقتة، والـprepared statements في الإصدارات القديمة من PgBouncer. إن عرفت هذه الحدود وعالجتها، يتحمل سيرفر نمو بمعالجين و4GB آلاف الاتصالات المتزامنة بثبات.

لماذا يكلّف كل اتصال هذا القدر

معمارية PostgreSQL قائمة على العمليات لا على الخيوط. كل اتصال جديد يعني fork لعملية backend جديدة، تحمل نسختها الخاصة من ذاكرة الكتالوج المؤقتة وخطط الاستعلامات، وتحجز work_mem عند كل عملية ترتيب أو تجميع. فتح الاتصال نفسه يستغرق من 2 إلى 5 مللي ثانية مع المصادقة، وهذا وقت ضائع إن كان التطبيق يفتح اتصالاً لكل طلب كما يفعل PHP-FPM.

المشكلة الثانية أقل وضوحاً: حتى الاتصالات الخاملة لها ثمن. مع 400 اتصال تقضي النواة وقتاً أطول في تبديل السياق بين العمليات، ويصبح حساب لقطات المعاملات (snapshots) أبطأ لأن القاعدة تفحص كل backend نشط. لهذا تنصح الممارسة العامة بإبقاء الاتصالات النشطة الحقيقية قريبة من عدد الأنوية مضروباً في 2 إلى 4، أي من 8 إلى 16 على سيرفر بمعالجين، وهو رقم بعيد جداً عن 400.

تطبيق Laravel نموذجي على PHP-FPM بـ50 عملية لكل سيرفر ويب، مع ثلاثة سيرفرات ويب وعمّال طوابير، يصل بسهولة إلى 200 اتصال. أضف Django مع Gunicorn أو Celery وستتخطى max_connections الافتراضي البالغ 100 خلال أول ذروة.

أوضاع التجميع الثلاثة وما يكسره كل وضع

الوضعمتى يعود الاتصال للمجمّعالتوفيرما يتأثر
sessionعند قطع العميل للاتصالمحدودلا شيء تقريباً
transactionبعد COMMIT أو ROLLBACKكبيرSET، الأقفال الاستشارية، LISTEN، الجداول المؤقتة
statementبعد كل استعلامالأكبرالمعاملات متعددة الاستعلامات ممنوعة كلياً

يفيد وضع session فقط في تقليل كلفة فتح الاتصالات المتكرر، لكنه لا يقلل عدد الاتصالات الحقيقية في الذروة. أما statement فيصلح لأحمال قراءة بسيطة جداً، ولا يصلح لأي تطبيق يستخدم المعاملات، أي لا يصلح لمعظم أنظمة ERP. يبقى transaction الخيار العملي في 90% من الحالات.

الـprepared statements

هذه كانت أشهر مشكلة: التطبيق يحضّر استعلاماً على اتصال حقيقي، ثم ينفذه في معاملة لاحقة قد تقع على اتصال آخر لا يعرفه، فيظهر خطأ prepared statement does not exist. منذ الإصدار 1.21 يدعم PgBouncer الـprepared statements على مستوى البروتوكول عبر الإعداد max_prepared_statements، فاضبطه على 100 أو 200. إن كنت على إصدار أقدم، فعّل محاكاة التحضير في التطبيق.

الأقفال الاستشارية والإعدادات

دالة pg_advisory_lock تربط القفل بالجلسة لا بالمعاملة، فإن عاد الاتصال للمجمّع بقي القفل معلقاً على اتصال قد يستخدمه طلب آخر. البديل هو pg_advisory_xact_lock الذي يتحرر مع نهاية المعاملة. الشيء نفسه مع SET: استبدله بـSET LOCAL داخل المعاملة، فهو ينتهي معها ولا يتسرب لطلب آخر.

إعداد pgbouncer.ini عملياً

ثبّت PgBouncer على نفس سيرفر القاعدة في البداية، فهذا يلغي قفزة شبكة إضافية. في قسم [databases] عرّف القاعدة: app = host=127.0.0.1 port=5432 dbname=app. في قسم [pgbouncer] القيم الأساسية:

  • listen_port = 6432 ويتصل التطبيق بهذا المنفذ بدل 5432.
  • auth_type = scram-sha-256 مع auth_query لقراءة كلمات المرور من القاعدة بدل نسخها في ملف.
  • pool_mode = transaction.
  • max_client_conn = 2000 لاتصالات التطبيق، وهذا رقم رخيص لأن كل اتصال عميل يكلف نحو 2KB.
  • default_pool_size = 20 اتصالاً حقيقياً لكل قاعدة ومستخدم، وreserve_pool_size = 5 للذروات القصيرة.
  • max_prepared_statements = 100 على الإصدار 1.21 وما بعده.
  • server_idle_timeout = 600 لإغلاق الاتصالات الخاملة بعد 10 دقائق.

بعدها خفّض max_connections في PostgreSQL إلى 50 أو 60، فلم يعد هناك داعٍ لـ200، وارفع shared_buffers قليلاً بالذاكرة التي تحررت.

Laravel

غيّر DB_PORT إلى 6432 في ملف البيئة. إن كان PgBouncer أقدم من 1.21، أضف خيار PDO::ATTR_EMULATE_PREPARES بقيمة true في إعداد اتصال pgsql. تجنب إبقاء المعاملات مفتوحة أثناء استدعاء API خارجي، فالاتصال الحقيقي محجوز طوال المعاملة، ومعاملة تنتظر 3 ثوانٍ ردَّ بوابة دفع تحجز اتصالاً من أصل 20 طوال تلك المدة.

Django

اضبط PORT على 6432، واجعل DISABLE_SERVER_SIDE_CURSORS = True لأن مؤشرات الخادم تعيش خارج المعاملة الواحدة. قيمة CONN_MAX_AGE يمكن رفعها إلى 60 ثانية بلا قلق، فالاتصال المستمر هنا إلى PgBouncer لا إلى القاعدة.

أين تضع PgBouncer مع نمو النظام

البداية على سيرفر القاعدة نفسه هي الأبسط، لكن حين يصبح لديك أكثر من سيرفر تطبيق يظهر خياران. الأول أن يبقى PgBouncer مركزياً بجوار القاعدة، فتتحكم في سقف الاتصالات من مكان واحد، وهذا ما نوصي به حتى 5 سيرفرات تطبيق تقريباً. الثاني أن تضع نسخة على كل سيرفر تطبيق، فتختفي قفزة الشبكة الأولى، لكن يصبح مجموع المجمّعات هو الحد الفعلي: خمسة سيرفرات بمجمّع 20 لكل منها تعني 100 اتصال حقيقي، فاحسب الرقم قبل أن تتفاجأ به. PgBouncer نفسه يعمل على نواة واحدة، ونسخة واحدة تتحمل عادة من 10,000 إلى 20,000 استعلام بسيط في الثانية قبل أن تصبح هي عنق الزجاجة، وهو رقم بعيد عن أحمال معظم أنظمة ERP.

قبل وبعد: أرقام على سيرفر نمو

هذه نتائج تقريبية لاختبار pgbench مرجعي بقراءة وكتابة مختلطة على سيرفر بمعالجين و4GB RAM، مع 400 عميل متزامن، وأرقامك ستختلف بحسب استعلاماتك لكن الاتجاه ثابت:

المقياساتصال مباشرعبر PgBouncer
اتصالات PostgreSQL الحقيقية40020
ذاكرة عمليات القاعدةنحو 3.4GB مع بدء swapنحو 0.6GB
معاملات في الثانيةنحو 900نحو 2,600
زمن p95نحو 450msنحو 60ms

المفارقة أن تقليل الاتصالات الحقيقية من 400 إلى 20 رفع الإنتاجية نحو ثلاثة أضعاف. السبب أن 20 عملية تعمل بلا تزاحم على معالجين أسرع من 400 عملية تتقاتل على نفس الأنوية والذاكرة. جرّب بنفسك: pgbench -c 400 -j 4 -T 120 مرة على المنفذ 5432 ومرة على 6432.

مراقبة المجمّع

اتصل بقاعدة الإدارة الافتراضية pgbouncer ونفّذ SHOW POOLS. العمود الأهم cl_waiting: إن ظل أكبر من صفر لأكثر من ثوانٍ، فالمجمّع صغير أو هناك معاملات طويلة تحجز الاتصالات. SHOW STATS يعطيك avg_wait_time، وإن تجاوز 10ms باستمرار فارفع default_pool_size بخطوات من 5، ولا تقفز إلى 100 لأنك ستعيد المشكلة الأصلية.

راقب أيضاً المعاملات الطويلة في PostgreSQL عبر pg_stat_activity وحالة idle in transaction، فهي العدو الأول لوضع transaction. اضبط idle_in_transaction_session_timeout على 30 ثانية حتى لا تحجز معاملة منسية اتصالاً للأبد.

الحجم المناسب من باقات كلاود

لتطبيق Laravel أو Django بعشرات المستخدمين المتزامنين وقاعدة تحت 20GB، باقة نمو (2 vCPU و4GB RAM و40GB SSD و500GB نقل) بـ6,900 ريال سنوياً تكفي للقاعدة وPgBouncer معاً. إن نمت القاعدة وصار الـcache يحتاج ذاكرة أكبر، أضف +2GB RAM بـ600 ريال سنوياً قبل التفكير في الانتقال لباقة أعلى. للأنظمة الأثقل التي تخدم مئات المستخدمين المتزامنين، باقة أعمال (4 vCPU و8GB) بـ11,500 ريال سنوياً هي الخطوة التالية الطبيعية.

السيرفرات في الرياض بزمن استجابة نحو 8ms، والتفعيل تلقائي بعد الدفع. التفاصيل في صفحة الخادم السحابي، وكل الباقات والإضافات في الأسعار والباقات.

خطوات التنفيذ

  • عدّ اتصالاتك الحالية في الذروة عبر pg_stat_activity، فإن تجاوزت ضعفي عدد الأنوية بكثير فأنت تحتاج PgBouncer.
  • ابحث في الكود عن pg_advisory_lock وSET وLISTEN والجداول المؤقتة، وعالجها قبل التحويل.
  • ثبّت PgBouncer بوضع transaction ومجمّع 20، ووجّه بيئة الاختبار إليه أولاً.
  • قس بـpgbench قبل وبعد، ثم حوّل الإنتاج وراقب cl_waiting أول أسبوع.
مشاركة