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 الحقيقية | 400 | 20 |
| ذاكرة عمليات القاعدة | نحو 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 أول أسبوع.


