FATAL: sorry, too many clients already تحت الضغط، المشكلة مش إن PostgreSQL ضعيف. المشكلة إنه فعلاً مش مصمم يتعامل مع آلاف الـ connections مباشرةً. PgBouncer بيحل الموضوع بملف config واحد صغير و container واحد إضافي، وبيوفّرلك حرفياً 80% من ذاكرة قاعدة البيانات.
المشكلة باختصار
في PostgreSQL، كل connection = process منفصل. يعني لمّا 500 عميل يفتحوا connection، هتلاقي 500 process شغّال على السيرفر. كل واحد فيهم بيحجز في المتوسط من 5 إلى 15 ميجابايت RAM، وده من غير حساب الـ work_mem أو الـ temp buffers. على سيرفر بـ 8GB RAM، الحسابات بتوقف من أول 400 connection تقريباً.
الـ default limit في PostgreSQL هو max_connections = 100 لسبب وجيه. فرق كتير بترفعه لـ 500 أو 1000 ظنًا ان ده بيحل المشكلة، والنتيجة: سيرفر بيوقع بدل ما يستجيب.
ليه Connection Pool أصلاً ضروري
خلّيني أشرح بمثال بسيط قبل ما ندخل في التقنية. تخيّل مستشفى فيه 20 طبيب. لو كل مريض بيدخل ياخد طبيب لنفسه ويقعده معاه طول اليوم حتى لو هيعمل كشف 5 دقايق، المستشفى هيقدر يخدم 20 مريض بس. لكن لو في موظف استقبال بيوزّع المرضى على الأطباء الفاضيين بالدور، نفس الـ 20 طبيب يقدروا يخدموا 500 مريض في اليوم.
PgBouncer هو موظف الاستقبال ده. بيقعد قدام PostgreSQL ويستقبل آلاف الـ client connections، وبيوزّعهم على pool صغير من server connections فعلية. العميل مش بياخد server connection لنفسه طول العمر، بياخدها بس ثواني لمّا محتاجها فعلياً لتنفيذ query.
الـ 3 Pool Modes — الاختيار الغلط هنا بيكسرلك الـ app
PgBouncer فيه 3 أوضاع pooling، والفرق بينهم مش تفصيل — هو اللي بيحدد شكل الكود اللي هتكتبه.
- Session pooling: العميل بياخد server connection من أول ما يعمل connect لحد ما يعمل disconnect. أبسط وضع، ومش بيوفّر حاجة تقريبًا لأنك بتعمل نفس ما بيحصل بدون PgBouncer.
- Transaction pooling: العميل بياخد server connection بس طول مدة الـ transaction، وبمجرد ما يعمل COMMIT أو ROLLBACK، الـ connection بترجع للـ pool. ده الوضع الافتراضي اللي بيستخدمه معظم الناس، وبيحقق أكبر توفير.
- Statement pooling: كل query لوحده بياخد connection. أقصى توفير ممكن، بس بيكسر أي حاجة فيها transaction.
الوضع المناسب لـ 95% من الحالات هو transaction pooling. بس فيه قيود لازم تعرفها قبل ما تفعّله، هنتكلم عنها تحت.
تثبيت شغّال في 4 دقايق — docker-compose
الأسهل والأكثر احترافية: شغّله كـ sidecar container جنب قاعدة البيانات.
services:
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
command: -c max_connections=50
pgbouncer:
image: edoburu/pgbouncer:1.22.1
environment:
DATABASE_URL: "postgres://postgres:secret@postgres:5432/app"
POOL_MODE: transaction
MAX_CLIENT_CONN: 1000
DEFAULT_POOL_SIZE: 25
RESERVE_POOL_SIZE: 5
SERVER_IDLE_TIMEOUT: 60
ports:
- "6432:5432"
depends_on:
- postgres
في الـ app، بدّل الـ DATABASE_URL من postgres:5432 إلى pgbouncer:5432 وخلاص. 1000 عميل بيتصلوا بـ PgBouncer، و PgBouncer بيفتح بس 25 connection فعلي على PostgreSQL.
قياس فعلي: قبل وبعد على app عادية
على app Node.js بـ 150 req/s، قبل PgBouncer:
- active PostgreSQL connections: 320 (من 40 instance × pool size 8 في الـ app نفسها).
- ذاكرة PostgreSQL المستهلكة: 3.4 GB.
- P95 latency لـ query بسيط: 180ms (بسبب context switching في الـ OS بين الـ processes).
بعد ما PgBouncer اتحط في الوسط بـ DEFAULT_POOL_SIZE=25:
- active PostgreSQL connections: 25 بحد أقصى.
- ذاكرة PostgreSQL المستهلكة: 680 MB (انخفاض 80%).
- P95 latency لنفس الـ query: 42ms.
الأرقام دي متطابقة مع الأرقام المنشورة في دراسات Percona و Crunchy Data، واللي بتقول ان الـ overhead بتاع PostgreSQL process بيبدأ يبقى واضح بعد 100 connection تقريبًا.
الـ trade-offs اللي لازم تعرفها
1. Prepared statements بتتكسر في transaction mode. لأن كل transaction ممكن تروح لـ server connection مختلفة، و prepared statement متسجّلة على server connection واحدة بس. الحلول: تفعيل server_reset_query = DISCARD ALL، أو استخدام PgBouncer 1.21+ اللي فيها دعم protocol-level prepared statements، أو تعطيل prepared statements في الـ driver.
2. Session-level features مش شغّالة. حاجات زي LISTEN/NOTIFY, SET SESSION, advisory locks على مستوى session، WITH HOLD cursors — كل دي هتفشل في transaction mode. لو بتستخدمهم، اعمل connection منفصل لـ PostgreSQL مباشرة بدون PgBouncer لهم فقط.
3. Monitoring بيحتاج شغل إضافي. لما بتبص على pg_stat_activity، هتلاقي اسم الـ client دايمًا PgBouncer، مش الـ app الفعلية. استخدم application_name في الـ connection string، أو اربط PgBouncer مع Prometheus exporter عشان تشوف pool saturation.
حساب حجم الـ Pool الصحيح
في قاعدة عملية من خبرة Percona و Brandur Leach (منشورة في مقال Stripe engineering): اختار DEFAULT_POOL_SIZE مش بناءً على عدد العملاء، بناءً على عدد CPU cores في PostgreSQL وطبيعة الـ workload.
- Workload CPU-bound (queries سريعة):
pool_size = cores × 2. - Workload IO-bound (queries بتقرا disk كتير):
pool_size = cores × 4. - Mixed workload: ابدأ بـ
cores × 3وقيس.
يعني لـ PostgreSQL على سيرفر 8 cores، 25 connection في الـ pool كفاية لـ workload عادي. لو رفعت الرقم لـ 100، مش هتكسب performance — هتخسر لأن الـ CPU contention بيزيد.
متى لا تستخدم PgBouncer
PgBouncer مش حل لكل حاجة. فيه حالات ما يصلحش فيها:
- app واحدة بـ 20 connection بس. الـ overhead مش يستاهل، و pool داخلي في الـ ORM كفاية.
- تعتمد كتير على LISTEN/NOTIFY أو session variables. هتضطر تعمل bypass على جزء كبير من الـ app، والتعقيد هيبقى أكتر من الفايدة.
- Amazon RDS أو Azure Database for PostgreSQL. في Amazon RDS Proxy جاهز ومتوافق مع IAM، غالباً أفضل من إدارة PgBouncer بإيدك. نفس الشيء مع Supabase و Neon — الـ pooler الداخلي بتاعهم فيه نفس القدرات.
- ورش التطوير المحلية. طبقة إضافية هتعقّد debugging بدون فايدة حقيقية.
الخطوة التالية
افتح الـ docker-compose بتاعك دلوقتي، وضيف خدمة PgBouncer بالـ config اللي فوق. عدّل الـ DATABASE_URL في الـ app تاخد من port 6432 بدل 5432، ورجع max_connections في PostgreSQL لـ 50. شغّل الـ app تحت load test بـ k6 أو Apache Bench، وشوف الذاكرة في PostgreSQL. لو مش لقيت فرق واضح، ابعتلي الـ pool_size و max_client_conn وأنا أقولك المشكلة فين.
المصادر
- PgBouncer Official Configuration Reference — شرح كامل لكل الـ pool modes والـ parameters.
- PostgreSQL Docs: Connection Configuration — تفاصيل
max_connectionsوالـ memory overhead لكل backend process. - Percona Blog: Connection Pools for PostgreSQL — مقارنة بين PgBouncer و pgpool-II مع قياسات.
- Brandur Leach: A Postgres Connection Scaling Story — معادلة اختيار pool size من تجربة Stripe.
- AWS RDS Proxy Docs — البديل المُدار للتشغيل داخل AWS.