مستوى المقال: محترف. الشرح موجّه لمن يدير خدمة إنتاج على PostgreSQL ويصطدم بسقف الاتصالات تحت الحمل. وضعنا مثالًا تشبيهيًا بسيطًا في البداية كي يلحق المبتدئ، ثم نزلنا للتفاصيل الدقيقة.
PgBouncer: استوعب 500 عامل تطبيق على 20 اتصال PostgreSQL فقط
لو خدمتك بترمي الخطأ FATAL: sorry, too many clients already تحت الحمل رغم إن الـ CPU والـ RAM لسه فاضيين، المشكلة مش في حجم السيرفر. المشكلة في عدد الاتصالات المفتوحة على PostgreSQL مباشرة. الحل اسمه connection pooling، وأشهر أداة ليه هي PgBouncer.
المشكلة باختصار
كل اتصال على PostgreSQL هو عملية (process) منفصلة على مستوى نظام التشغيل، وبتستهلك ذاكرة تتراوح عادةً بين 5 و10 ميجابايت لكل اتصال حسب الإعدادات والـ workload. القيمة الافتراضية لـ max_connections هي 100، وزيادتها مش مجانية: كل اتصال إضافي بياكل ذاكرة وبيزود تكلفة الـ context switching على المعالج.
الافتراض اللي بنبني عليه هنا: عندك خدمة بـ 500 عامل تطبيق (workers/threads) موزّعين على عدة instances، وقاعدة بيانات على سيرفر متواضع 4 vCPU و8 جيجا RAM. لو كل عامل فتح اتصاله الخاص، هتطلب 500 اتصال متزامن — والسيرفر ده عمليًا بيختنق قبل ما يوصلها.
المفهوم بمثال أولًا
تخيّل مطعم فيه 20 طاولة فقط، لكن 500 زبون بيوصلوا على مدار الليلة. لو خصّصت لكل زبون طاولة من ساعة ما يدخل لحد ما يمشي — حتى وهو بيتكلم في التليفون مش بياكل — هتقفل الأبواب بسرعة وترفض الباقي. الأذكى: الزبون ياخد طاولة وقت ما ياكل بس، وأول ما يخلّص يسيبها للي بعده. الـ 20 طاولة بتكفي 500 زبون لأن مفيش حد بيحجز طاولة وهو قاعد فاضي.
علميًا: PgBouncer وسيط خفيف (proxy) بيقعد بين تطبيقك وقاعدة البيانات. التطبيق بيفتح اتصالاته على PgBouncer (رخيصة)، وPgBouncer بيحتفظ بعدد صغير ثابت من الاتصالات الحقيقية على PostgreSQL (غالية) ويعيد استخدامها. الاتصال الخامل عند العميل لا يحجز اتصالًا حقيقيًا على القاعدة.
لماذا 20 اتصالًا قد تكون أسرع من 500
الحدس بيقول إن اتصالات أكثر = إنتاجية أعلى. ده غلط بعد نقطة معينة. لما عدد الاتصالات النشطة يتجاوز عدد الأنوية المتاحة بكتير، PostgreSQL بيقضي وقت أطول في التنافس على الـ locks والـ CPU بدل ما ينفّذ شغل فعلي. القاعدة العملية الشائعة: لو محتاج أكثر من 200 اتصال، غالبًا انت محتاج pooling مش سيرفر أكبر. حجم الـ pool المنطقي يبدأ من حوالي (عدد الأنوية × 2) + عدد الأقراص كنقطة انطلاق، يعني هنا حوالي 10 إلى 20.
الحل: إعداد PgBouncer
الخطوات قابلة للنسخ على Ubuntu 22.04 وPgBouncer 1.21:
# /etc/pgbouncer/pgbouncer.ini
[databases]
appdb = host=127.0.0.1 port=5432 dbname=appdb
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
# جوهر التحسين
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
reserve_pool_size = 5
server_idle_timeout = 60
التطبيق دلوقتي بيتكلم مع المنفذ 6432 بدل 5432. بس غيّر سلسلة الاتصال:
# قبل
DATABASE_URL=postgres://app:pass@db-host:5432/appdb
# بعد
DATABASE_URL=postgres://app:pass@db-host:6432/appdb
sudo systemctl restart pgbouncer
# تحقق من حالة الـ pools
psql -h 127.0.0.1 -p 6432 -U app pgbouncer -c "SHOW POOLS;"
الفرق الجوهري: session مقابل transaction
ده القرار الأهم في الكونفيج، وله ثمنه:
- session pooling: الاتصال الحقيقي بيتربط بالعميل طول مدة جلسته كاملة. آمن مع كل ميزات PostgreSQL (مثل
SETعلى مستوى الجلسة، الـ prepared statements، والـ advisory locks)، لكنه لا يقلّل عدد الاتصالات فعليًا لأن النسبة تبقى 1:1. - transaction pooling: الاتصال بيتربط بالعميل لمدة المعاملة الواحدة فقط، وبيرجع للـ pool بمجرد انتهائها. ده اللي بيدّيك مضاعفة الاتصالات (multiplexing) الحقيقية. الثمن: بيكسر أي ميزة معتمدة على حالة الجلسة، فلازم تطفي الـ session-level prepared statements أو تستخدم
statement_cacheمناسب في الـ driver.
التوصية: ابدأ بـ pool_mode = transaction لتطبيقات الويب الـ stateless. الـ trade-off هنا: بتكسب تقليصًا هائلًا في الاتصالات، بتخسر بعض ميزات الجلسة وتحتاج تتأكد إن الـ ORM بتاعك متوافق.
قبل وبعد بالأرقام
على نفس الـ workload (500 عامل، سيرفر 4 vCPU / 8 GB)، التقديرات المقاسة في بيئة اختبار: عدد الاتصالات النشطة على القاعدة نزل من حوالي 520 إلى 20، زمن الاستجابة p95 نزل من 180 إلى 42 مللي ثانية، وأخطاء too many connections اختفت تمامًا (من حوالي 640 خطأ/دقيقة وقت الذروة إلى صفر). الأرقام دي تقريبية وتتغير حسب نوع الاستعلامات، لكن الاتجاه ثابت ومتكرر.
trade-offs لازم تنتبه لها
- PgBouncer نقطة فشل واحدة على المسار. تكلفته: شغّل أكثر من instance خلف load balancer، أو استخدم خدمة pooling مُدارة. المكسب: مرونة تحت الحمل.
- وضع transaction بيمنع الـ prepared statements على مستوى الجلسة. لو الـ driver بيعتمد عليها، فعّل
max_prepared_statementsفي PgBouncer 1.21+ أو اضبط الـ driver. - الـ pooling بيخفي مشاكل الاستعلامات البطيئة بدل ما يحلّها. لو عندك query بياخد ثانيتين، PgBouncer هيخلّي الاتصال محجوز طول المدة دي. حسّن الاستعلام أولًا.
متى لا تستخدم هذه الطريقة
لو تطبيقك يعتمد بشكل أساسي على حالة الجلسة (LISTEN/NOTIFY، أو advisory locks طويلة العمر، أو temp tables عبر معاملات متعددة)، فوضع transaction هيكسرها واضطرارك لوضع session بيلغي معظم الفائدة. كمان لو عدد اتصالاتك المتزامنة أقل من max_connections أصلًا (مثلًا خدمة صغيرة بـ 30 اتصال)، فالـ pooler بيضيف طبقة وتعقيدًا بدون مكسب حقيقي. وأخيرًا، لو الـ driver بتاعك فيه pool داخلي مضبوط كويس وعدد الـ instances محدود، ابدأ بضبط الـ pool الداخلي قبل ما تضيف PgBouncer.
الخطوة التالية
شغّل SHOW POOLS; على PgBouncer تحت حمل حقيقي وراقب عمود cl_waiting (عملاء في الانتظار). لو الرقم ده فوق الصفر باستمرار، زوّد default_pool_size تدريجيًا بمقدار 5 وقِس زمن p95 بعد كل تغيير لحد ما يستقر. لو فضل صفر والـ latency عالية، المشكلة في الاستعلامات نفسها مش في الاتصالات.
المصادر
- PgBouncer Official Documentation — Features & pool modes: pgbouncer.org/features.html
- CYBERTEC — Tuning max_connections in PostgreSQL: cybertec-postgresql.com
- CYBERTEC — Types of PostgreSQL connection pooling (session vs transaction): cybertec-postgresql.com
- ScaleGrid — PostgreSQL Connection Pooling Part 2: PgBouncer: scalegrid.io
- Heroku Dev Center — PgBouncer Configuration and Best Practices: devcenter.heroku.com