> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# Connection Pooling: لماذا الاتصال بقاعدة البيانات مكلف
- URL: https://arabroot.io/articles/تجميع-الاتصالات-connection-pooling-للمبتدئين/
- Published: 2026-10-05T17:15:00.000Z
- Updated: 2026-10-09T09:42:40.000Z
- Description: لماذا تظهر رسالة too many clients عندما تزيد نسخ تطبيقك، وكيف يحل مجمع الاتصالات وPgBouncer هذه المشكلة.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, هندسة البرمجيات وتصميم الأنظمة, قواعد البيانات, DevOps وCI/CD, الاستضافة الذاتية

كل تطبيق ويب تقريباً يبدأ بنفس السطر: يصل الطلب، فيفتح الكود اتصالاً بقاعدة البيانات، وينفذ استعلاماً يأخذ مللي ثانية أو اثنتين، ثم يغلق الاتصال ويعود الرد. وهذا يعمل بلا مشاكل في بيئة التطوير، ثم يزيد عدد الزوار وتضيف عمالاً ونسخاً من التطبيق، فتظهر فجأة في السجلات رسالة `FATAL: sorry, too many clients already`، أو تجد أن نصف زمن الطلب يذهب في فتح الاتصال وليس في الاستعلام نفسه. وقد ذكرنا تجميع الاتصالات Connection Pooling باختصار في عدة مقالات: في [مقال تصميم قواعد البيانات](https://arabroot.io/articles/%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D8%B9-%D9%88%D8%A7%D9%84%D8%AA%D9%88%D8%B3%D8%B9/#pooling)، وفي [مقال المعالج والخيوط والتزامن](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D8%A7%D9%84%D8%AC-%D9%88%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-%D9%88%D8%A7%D9%84%D8%AA%D8%B2%D8%A7%D9%85%D9%86-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#db-connections) عند حساب عدد العمال، وفي [مقال PostgreSQL للجداول الضخمة](https://arabroot.io/articles/postgresql-%D9%84%D9%84%D8%AC%D8%AF%D8%A7%D9%88%D9%84-%D8%A7%D9%84%D8%B6%D8%AE%D9%85%D8%A9/#pgbouncer)، وفي [المدخل إلى تصميم الأنظمة](https://arabroot.io/articles/%D9%85%D8%AF%D8%AE%D9%84-%D8%A7%D9%84%D9%89-%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D8%A7%D9%84%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D9%85%D9%86-%D8%B3%D9%8A%D8%B1%D9%81%D8%B1-%D9%88%D8%A7%D8%AD%D8%AF-%D8%A7%D9%84%D9%89-%D9%85%D9%84%D8%A7%D9%8A%D9%8A%D9%86/#database-scaling)، وهذا المقال هو المرجع الذي يشرح الفكرة كاملة مرة واحدة: لماذا الاتصال مكلف، وكيف يعمل المجمع، وأين تضعه، وكيف تضبط PgBouncer، وما الذي ينكسر إذا أخطأت في الإعداد.

وسوف نناقش في هذا المقال ما يلي:

- ماذا يكلف فتح اتصال واحد بقاعدة البيانات، ولماذا يرفض PostgreSQL الاتصالات بعد حد معين.
- ما هو مجمع الاتصالات Connection Pool، وإعداداته الأساسية، وكيف تختار حجمه بدل أن تخمنه.
- أين يكون المجمع: داخل التطبيق مثل HikariCP وSQLAlchemy، أو أداة مستقلة مثل PgBouncer، أو الاثنين معاً.
- أوضاع PgBouncer الثلاثة، وما الذي ينكسر في وضع transaction، والاستعلامات المجهزة Prepared Statements، والمصادقة، والحجم.
- مختبر حقيقي على PostgreSQL 18 وPgBouncer 1.26: الرفض المباشر، ثم نفس الحمل عبر PgBouncer، وثمن فتح الاتصال، وخطأ `SET` في وضع transaction.
- تضاعف الاتصالات Connection Multiplication مع النسخ الكثيرة وال Serverless.
- المجمع بين Nginx والتطبيق Upstream Keepalive: نفاد المنافذ المؤقتة و`TIME_WAIT`، وإعدادات `keepalive`، ومهلة التطبيق، مع مختبر يعد الاتصالات.
- مجمع الاتصالات في Redis وValkey: ماذا يكلف الاتصال، وسلوك مكتبات Python وNode.js وGo، والأوامر الحاجبة وPub/Sub، مع مختبر.
- المراقبة، والأخطاء الشائعة، وقائمة تحقق.

## ماذا يكلف فتح اتصال واحد؟

الاتصال بقاعدة البيانات ليس سطراً في الكود فقط، وإنما سلسلة خطوات على الشبكة وعلى السيرفر، فالكلاينت يفتح اتصال TCP عبر المصافحة الثلاثية Three-way Handshake، ثم إذا كان الاتصال مشفراً يتفاوض على TLS، ثم يرسل اسم المستخدم وقاعدة البيانات ويمر بالمصادقة Authentication، وفي PostgreSQL الحديث تكون المصادقة الافتراضية `scram-sha-256` التي تحسب الهاش بعدد جولات قيمته الافتراضية 4096 كما يظهر من `SHOW scram_iterations`، وهذا الحساب مقصود ليكون بطيئاً على من يحاول تخمين كلمة المرور، وبالتالي فهو بطيء عليك أيضاً عندما تكرره مع كل طلب.

ثم يأتي الجزء الأهم في PostgreSQL، حيث تقول [الوثائق الرسمية](https://www.postgresql.org/docs/18/connect-estab.html?ref=arabroot.io) إنه يعمل بنموذج «عملية لكل مستخدم» Process per User، وإن العملية المشرفة `postmaster` تنشئ عملية خلفية Backend Process جديدة في كل مرة يطلب فيها كلاينت اتصالاً، وهذه العملية هي التي تكمل التشفير والمصادقة ثم تخدم هذا الكلاينت وحده حتى يغلق الاتصال. وهذا يعني أن كل اتصال مفتوح هو عملية كاملة على سيرفر قاعدة البيانات لها ذاكرتها، حتى لو كانت لا تفعل شيئاً سوى الانتظار.

الصورة التالية تبين ما يدفعه كل طلب عندما لا يوجد مجمع اتصالات:

![ثلاثة طلبات، كل طلب يمر بخمس مراحل مرقمة: 1 اتصال TCP، 2 إنشاء عملية backend جديدة على PostgreSQL، 3 التفاوض على TLS، 4 المصادقة SCRAM، 5 الاستعلام نفسه، ثم إغلاق الاتصال، مع خط أحمر تحت المراحل من 1 إلى 4 يوضح أنها تتكرر مع كل طلب، وبجانبها PostgreSQL والعملية المشرفة postmaster تنشئ عمليات backend بعدد الاتصالات](https://arabroot.io/content/images/2026/10/connection-pooling-01-no-pool-1.webp)

دون مجمع: المراحل من 1 إلى 4 تتكرر مع كل طلب، والاستعلام نفسه هو الجزء الصغير

وهنا مشكلتان منفصلتان يحلهما تجميع الاتصالات:

- **زمن فتح الاتصال Connection Latency:** إذا كان الاستعلام يأخذ مللي ثانية واحدة وفتح الاتصال يأخذ عشرين، فأنت تدفع عشرين ضعفاً مقابل عمل لا يحتاجه المستخدم، وسوف نقيس ذلك في [المختبر](#lab-connect-cost).
- **عدد الاتصالات المفتوحة:** القيمة الافتراضية للإعداد [max\_connections](https://www.postgresql.org/docs/18/runtime-config-connection.html?ref=arabroot.io) هي 100، ومنها ثلاثة محجوزة للمستخدمين الخارقين Superusers عبر `superuser_reserved_connections`، وتذكر الوثائق أن PostgreSQL يحدد حجم بعض موارده، ومنها الذاكرة المشتركة Shared Memory، مباشرة من قيمة `max_connections`، لذلك فرفعها ليس مجانياً. وعندما تمتلئ الخانات العادية يرى المستخدم العادي الرسالة `remaining connection slots are reserved for roles with the SUPERUSER attribute`، وعندما تمتلئ كلها يرى الجميع `sorry, too many clients already`.

وقد يتساءل البعض: لماذا لا نرفع `max_connections` إلى ألفين وننتهي؟ والإجابة أن المشكلة لم تكن في الرقم، فقد شرحنا في [مقال المعالج والخيوط والتزامن](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D8%A7%D9%84%D8%AC-%D9%88%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-%D9%88%D8%A7%D9%84%D8%AA%D8%B2%D8%A7%D9%85%D9%86-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#more-threads) أن العمليات النشطة أكثر من الأنوية لا تنهي العمل أسرع، وإنما تتنافس على المعالج والأقفال والذاكرة، وكل اتصال يستطيع أن يستخدم `work_mem` أكثر من مرة في استعلام واحد كما ذكرنا في [مقال الجداول الضخمة](https://arabroot.io/articles/postgresql-%D9%84%D9%84%D8%AC%D8%AF%D8%A7%D9%88%D9%84-%D8%A7%D9%84%D8%B6%D8%AE%D9%85%D8%A9/#pgbouncer)، لذلك الحل أن تقلل عدد الاتصالات الحقيقية وتعيد استخدامها، وهذا بالضبط ما يفعله المجمع.

## ما هو مجمع الاتصالات Connection Pool؟

مجمع الاتصالات Connection Pool هو مجموعة من الاتصالات المفتوحة مسبقاً بقاعدة البيانات، يستعير Acquire منها الكود اتصالاً عندما يحتاجه، ثم يعيده Release إلى المجمع Pool بدل أن يغلقه، فيبقى الاتصال الحقيقي مفتوحاً ويخدم الطلب التالي دون أن يدفع ثمن المصافحة والمصادقة وإنشاء العملية مرة أخرى. والتشبيه الأقرب هو شبابيك البنك، فالبنك لا يوظف موظفاً جديداً لكل عميل يدخل ثم يفصله عندما يخرج، وإنما لديه عدد ثابت من الشبابيك، وإذا كانت كلها مشغولة يأخذ العميل رقماً وينتظر في الصالة.

الصورة التالية تبين دورة الاتصال داخل المجمع:

![التطبيق يستعير اتصالاً من مجمع حجمه الأقصى 4 اتصالات (الخطوة 1)، ثلاثة منها مشغولة وواحد خامل، وينفذ الاستعلام على PostgreSQL (الخطوة 2) ثم يعيد الاتصال للمجمع (الخطوة 3)، وعندما تكون كل الاتصالات مشغولة ينتظر الطلب في طابور حتى مهلة الاستعارة (الخطوة 4)، مع فحص الصحة ومهلة الخمول والعمر الأقصى للاتصال](https://arabroot.io/content/images/2026/10/connection-pooling-02-pool-1.webp)

الاتصال يفتح مرة واحدة ويستخدم آلاف المرات، والطلب الزائد ينتظر في طابور بدل أن يفتح اتصالاً جديداً

لاحظ أن الخطوة 4 هي ما يجعل المجمع مفيداً تحت الضغط، فبدل أن يفتح الطلب رقم خمسين اتصالاً جديداً ويوصل قاعدة البيانات إلى حدها، ينتظر بضعة مللي ثوان حتى يعود اتصال، والانتظار القصير في طابور Queue أفضل بكثير من رفض الاتصال أو من خمسين عملية تتنافس على أربع أنوية.

### إعدادات المجمع Pool التي تجدها في كل مكتبة

الأسماء تختلف من مكتبة لأخرى، ولكن المفاهيم واحدة تقريباً في كل المجمعات:

- **الحد الأدنى Min Size أو Min Idle:** عدد الاتصالات التي يبقيها المجمع مفتوحة حتى لو لم يكن هناك عمل، فيجد أول طلب بعد فترة هدوء اتصالاً جاهزاً.
- **الحد الأقصى Max Size:** أهم رقم في المجمع، لأنه أكبر عدد من الاتصالات الحقيقية يفتحه هذا المجمع بقاعدة البيانات، وهو الرقم الذي سوف يتضاعف لاحقاً بعدد النسخ.
- **مهلة الخمول Idle Timeout:** الاتصال الخامل أكثر من هذه المدة يغلق، فيصغر المجمع بعد انتهاء الضغط.
- **العمر الأقصى Max Lifetime:** كل اتصال يغلق ويستبدل بعد مدة معينة حتى لو كان سليماً، والسبب أن جدران الحماية Firewalls وموزعات الحمل قد تقطع الاتصالات الطويلة بصمت، وأن تغيير إعدادات قاعدة البيانات أو الانتقال إلى سيرفر جديد يحتاج اتصالات جديدة.
- **مهلة الاستعارة Acquire Timeout أو Connection Timeout:** أقصى مدة ينتظرها الطلب في الطابور قبل أن يفشل بخطأ واضح، وهذه المهلة أفضل من انتظار بلا نهاية يحجز عامل الويب ويظهر للمستخدم صفحة معلقة.
- **فحص الصحة Health Check أو Validation:** التأكد من أن الاتصال ما زال حياً قبل إعطائه للكود، إما بإرسال استعلام صغير قبل كل استعارة (مثل `pool_pre_ping` في SQLAlchemy) أو بفحص دوري للاتصالات الخاملة (مثل `keepaliveTime` في HikariCP)، والسبب أن إعادة تشغيل قاعدة البيانات تترك في المجمع اتصالات ميتة سوف يفشل أول استعلام عليها.

### كم يكون حجم المجمع Pool Size؟

الخطأ الأشهر أن يفترض المطور أن المجمع الأكبر أسرع، فيضع 100 أو 200 اتصال لكل نسخة من التطبيق. وصفحة [About Pool Sizing](https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing?ref=arabroot.io) في مشروع HikariCP من أفضل ما كتب في هذا الموضوع، فهي تنقل تجربة من فريق الأداء في Oracle خفض فيها حجم المجمع من 2048 اتصالاً إلى 96 فقط، فنزل زمن الاستجابة من حوالي 100 مللي ثانية إلى حوالي 2 مللي ثانية. والسبب نفس ما شرحناه في [مقال المعالج والخيوط](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D8%A7%D9%84%D8%AC-%D9%88%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-%D9%88%D8%A7%D9%84%D8%AA%D8%B2%D8%A7%D9%85%D9%86-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/): قاعدة البيانات لا تنفذ في نفس اللحظة أكثر من عدد أنويتها، والباقي ينتظر ويضيف تبديلاً للسياق Context Switching وتنافساً على الأقفال والقرص.

وتقترح الصفحة نقطة بداية بالمعادلة التالية:

```text
connections = ((core_count * 2) + effective_spindle_count)
```

حيث `core_count` عدد الأنوية في سيرفر قاعدة البيانات وليس في سيرفر التطبيق، و`effective_spindle_count` يساوي صفراً إذا كانت البيانات النشطة كلها في الذاكرة ويقترب من عدد الأقراص كلما قلت نسبة القراءة من الذاكرة. فسيرفر بأربع أنوية وقرص واحد يعطي 9، وتقربها الصفحة إلى 10\. لاحظ أن هذا الرقم هو مجموع الاتصالات النشطة على قاعدة البيانات من كل التطبيقات، وليس حجم المجمع في كل نسخة، وأنه نقطة بداية تعدلها بالقياس وليس قانوناً.

وهناك طريقة ثانية للتفكير تأتي من قانون ليتل [Little's Law](https://en.wikipedia.org/wiki/Little%27s%5Flaw?ref=arabroot.io)، وهو أن متوسط العناصر داخل النظام يساوي معدل وصولها مضروباً في متوسط بقائها، أي `L = λ × W`. فإذا كان تطبيقك يحتاج 500 معاملة في الثانية، وكل معاملة تمسك الاتصال 10 مللي ثوان في المتوسط، فأنت تحتاج في المتوسط 500 × 0.01 = 5 اتصالات مشغولة، ولو جعلتها 10 لتحمل التذبذب فهذا يكفي، ولن يفيدك أن تجعلها 100\. وهذا الحساب يبين أيضاً أن الطريق الأسرع لتقليل الاتصالات هو تقصير مدة إمساك الاتصال: استعلامات أسرع بالفهارس الصحيحة، ومعاملات قصيرة، وعدم استدعاء API خارجي والاتصال محجوز.

## أين يكون المجمع Pool؟

هناك ثلاثة أماكن للمجمع، والصورة التالية تبينها:

![ثلاثة أعمدة مرقمة: 1 مجمع داخل عملية التطبيق مثل HikariCP وSQLAlchemy وpg Pool وdatabase/sql يفتح 10 اتصالات بقاعدة البيانات، 2 أداة مستقلة مثل PgBouncer أو Odyssey أو Pgpool-II تستقبل اتصالات كثيرة قصيرة من عدة تطبيقات وتمررها على اتصالات قليلة، 3 الطبقتان معاً: مجمع صغير بحد 5 في كل تطبيق ثم PgBouncer أمام قاعدة البيانات](https://arabroot.io/content/images/2026/10/connection-pooling-03-where-1.webp)

المجمع داخل التطبيق، أو أداة مستقلة أمام قاعدة البيانات، أو الطبقتان معاً

### المجمع Pool داخل التطبيق

أغلب لغات البرمجة فيها مجمع جاهز في مكتبة الاتصال أو في ال ORM، وهو أول ما يجب أن تستخدمه، لأنه لا يحتاج سيرفراً إضافياً ولا يضيف خطوة على الشبكة. والجدول التالي يبين القيم الافتراضية لأشهرها كما في وثائقها الرسمية، ولاحظ كم تختلف:

| المكتبة                                                                                       | الحد الأقصى الافتراضي                                   | مهلة الاستعارة Acquire Timeout                | ملاحظات                                                                                                                                                                                                                                                                    |
| --------------------------------------------------------------------------------------------- | ------------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [HikariCP](https://github.com/brettwooldridge/HikariCP?ref=arabroot.io) (Java)                | maximumPoolSize \= 10                                   | connectionTimeout \= 30 ثانية                 | minimumIdle يساوي الحد الأقصى، وidleTimeout 10 دقائق، وmaxLifetime 30 دقيقة، وkeepaliveTime دقيقتان، وهو المجمع الذي [يختاره Spring Boot](https://docs.spring.io/spring-boot/reference/data/sql.html?ref=arabroot.io#data.sql.datasource.connection-pool) كلما كان متاحاً. |
| [SQLAlchemy](https://docs.sqlalchemy.org/en/20/core/pooling.html?ref=arabroot.io) (Python)    | pool\_size \= 5 مع max\_overflow \= 10، أي 15           | pool\_timeout \= 30 ثانية                     | pool\_recycle معطل افتراضياً، وpool\_pre\_ping معطل، ولا ينشئ أي اتصال قبل أول استخدام.                                                                                                                                                                                    |
| [psycopg\_pool](https://www.psycopg.org/psycopg3/docs/api/pool.html?ref=arabroot.io) (Python) | min\_size \= 4، وmax\_size يساوي min\_size إذا لم تحدده | timeout \= 30 ثانية                           | max\_idle 10 دقائق، وmax\_lifetime ساعة، ولا يوجد فحص صحة إلا إذا مررت check.                                                                                                                                                                                              |
| [node-postgres](https://node-postgres.com/apis/pool?ref=arabroot.io) (pg.Pool)                | max \= 10                                               | connectionTimeoutMillis \= 0، أي لا توجد مهلة | idleTimeoutMillis 10 ثوان، وmaxLifetimeSeconds معطل.                                                                                                                                                                                                                       |
| [Go database/sql](https://pkg.go.dev/database/sql?ref=arabroot.io#DB.SetMaxIdleConns)         | SetMaxOpenConns \= 0، أي بلا حد                         | لا توجد مهلة خاصة، والمهلة من ال Context      | الاتصالات الخاملة المحفوظة 2 فقط افتراضياً، وSetConnMaxLifetime وSetConnMaxIdleTime معطلان.                                                                                                                                                                                |

في الجدول أعلاه لاحظ التالي:

- **Go بلا حد أعلى افتراضياً:** النوع `sql.DB` هو مجمع اتصالات بنفسه كما تقول وثائقه، ولكن دون `SetMaxOpenConns` سوف يفتح اتصالاً لكل Goroutine تحتاجه تحت الضغط، لذلك حدد الرقم دائماً، وارفع `SetMaxIdleConns` إلى قريب منه، وإلا فالمجمع سوف يغلق ويفتح الاتصالات باستمرار لأنه لا يحفظ إلا اثنين.
- **node-postgres ينتظر للأبد افتراضياً:** إذا امتلأ المجمع ولم تحدد `connectionTimeoutMillis` فالطلب ينتظر بلا نهاية، لذلك ضع مهلة بالثواني.
- **SQLAlchemy يتجاوز حجمه:** الحد الحقيقي هو `pool_size + max_overflow`، وهذا الرقم هو ما تحسب به الاتصالات وليس `pool_size` وحده.
- **المجمع لكل عملية Process وليس لكل سيرفر:** تطبيق Python مع Gunicorn بتسعة عمال Workers يعني تسعة مجمعات Pools منفصلة، وتطبيق Node.js في وضع cluster بثماني عمليات يعني ثمانية، وهنا يبدأ تضاعف الاتصالات Connection Multiplication الذي نشرحه في [قسم النسخ الكثيرة](#many-instances). وتنبه [وثائق SQLAlchemy](https://docs.sqlalchemy.org/en/20/core/pooling.html?ref=arabroot.io) إلى أن الاتصالات لا يجب أن تنتقل من العملية الأم إلى العمليات الفرعية بعد `fork`، وأن تستدعي `engine.dispose(close=False)` في بداية كل عملية فرعية.

وأخطر خطأ مع أي مجمع هو أن تستعير اتصالاً ثم لا تعيده، فتقول [وثائق node-postgres](https://node-postgres.com/apis/pool?ref=arabroot.io) إنك يجب أن تعيد الاتصال بعد الانتهاء منه، وإلا ينفد المجمع وتنتظر كل الطلبات التالية أو تفشل بانتهاء المهلة. والطريقة الصحيحة أن تعيده في `finally` مهما حدث، كما في المثال التالي:

```javascript
import pg from 'pg';

const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  max: 5,                          // counted in the connection budget
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,  // fail fast instead of waiting forever
  maxLifetimeSeconds: 1800,
});

// Simple queries: pool.query borrows and returns the connection for you.
export const getOrder = (id) => pool.query('SELECT * FROM orders WHERE id = $1', [id]);

// Transactions need ONE connection for all statements, returned in finally.
export async function payOrder(id) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    await client.query("UPDATE orders SET status = 'paid' WHERE id = $1", [id]);
    await client.query('INSERT INTO payments (order_id) VALUES ($1)', [id]);
    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}
```

في الكود أعلاه لاحظ التالي:

- المجمع ينشأ مرة واحدة عند تشغيل التطبيق ويصدر من الملف، ولا تنشئه داخل دالة الطلب، فالمجمع لكل طلب هو نفس فتح اتصال لكل طلب مع خطوات إضافية.
- المعاملة لا تستخدم `pool.query`، والسبب كما تشرح الوثائق أن المعاملة في PostgreSQL مرتبطة باتصال واحد، و`pool.query` قد يرسل كل أمر على اتصال مختلف.
- `client.release()` في `finally`، فحتى لو فشل الاستعلام يعود الاتصال للمجمع.

### أداة مستقلة أمام قاعدة البيانات

المجمع داخل التطبيق لا يرى إلا تطبيقه، فإذا كان لديك عشر خدمات وكل خدمة بعدة نسخ، أو عمليات قصيرة العمر مثل سكربتات Cron وال Serverless التي لا تستطيع الاحتفاظ بمجمع أصلاً، فأنت تحتاج مجمعاً مستقلاً يقف بين كل التطبيقات وقاعدة البيانات، ويتكلم بروتوكول PostgreSQL نفسه، فيظن التطبيق أنه يتصل بقاعدة البيانات مباشرة. والجدول التالي يجمع أشهر هذه الأدوات كما في مستودعاتها عند كتابة المقال:

| الأداة                                                                                    | الترخيص                                                                                    | ما يميزها                                                                                                                                                                                                                                            | الحالة                                                                                                                  |
| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| [PgBouncer](https://www.pgbouncer.org/?ref=arabroot.io)                                   | [ISC](https://github.com/pgbouncer/pgbouncer/blob/master/COPYRIGHT?ref=arabroot.io)        | خفيف جداً ومكتوب بلغة C، يفعل شيئاً واحداً هو التجميع، وموجود في حزم كل التوزيعات وفي مستودع PGDG.                                                                                                                                                   | نشط، وآخر إصدار 1.26.0 في سبتمبر 2026 كما في [سجل التغييرات](https://www.pgbouncer.org/changelog.html?ref=arabroot.io). |
| [Pgpool-II](https://www.pgpool.net/docs/latest/en/html/intro-whatis.html?ref=arabroot.io) | [ترخيص على نمط BSD](https://github.com/pgpool/pgpool2/blob/master/COPYING?ref=arabroot.io) | أكثر من مجمع: توزيع القراءة على النسخ Load Balancing، والانتقال التلقائي Failover، وال Watchdog، وتخزين نتائج الاستعلامات.                                                                                                                           | نشط، والإصدار في الوثائق 4.7.3، وهو أثقل وأعقد في الإعداد.                                                              |
| [Odyssey](https://github.com/yandex/odyssey?ref=arabroot.io)                              | BSD-3-Clause                                                                               | من Yandex، متعدد الخيوط Multi-threaded فيستخدم عدة أنوية، بينما PgBouncer كما تذكر [وثائقه](https://www.pgbouncer.org/config.html?ref=arabroot.io) يعمل بخيط واحد على نواة واحدة لكل نسخة، وتحتاج عدة نسخ منه مع so\_reuseport لتستخدم أكثر من نواة. | نشط، وآخر إصدار v1.5.2 في سبتمبر 2026.                                                                                  |
| [Supavisor](https://github.com/supabase/supavisor?ref=arabroot.io)                        | Apache-2.0                                                                                 | من Supabase، مكتوب بلغة Elixir، مصمم لعدد ضخم من الكلاينتات ولقواعد بيانات كثيرة Multi-tenant في Cluster.                                                                                                                                            | نشط، وآخر إصدار v2.9.13 في سبتمبر 2026.                                                                                 |
| [PgCat](https://github.com/postgresml/pgcat?ref=arabroot.io)                              | MIT                                                                                        | مكتوب بلغة Rust، مع توزيع القراءة والتوزيع على أكثر من قاعدة Sharding.                                                                                                                                                                               | آخر إصدار 0.2.5 في نوفمبر 2024 وآخر تحديث للمستودع في فبراير 2025، لذلك لا ننصح به لمشروع جديد.                         |
| [ProxySQL](https://github.com/sysown/proxysql?ref=arabroot.io)                            | GPL-3.0                                                                                    | المقابل المعروف في عالم MySQL وMariaDB: تجميع، وتوجيه القراءة والكتابة، وقواعد على الاستعلامات.                                                                                                                                                      | نشط، وآخر إصدار v3.0.11 في أغسطس 2026.                                                                                  |

والاختيار الافتراضي لأغلب من يستضيف PostgreSQL بنفسه هو PgBouncer، والسبب أنه صغير ومستقر ويفعل شيئاً واحداً، وأن أغلب المقالات والأدوات تفترضه، أما Pgpool-II فيناسب من يريد أداة واحدة للتجميع والتوزيع والانتقال التلقائي ويقبل تعقيدها، وOdyssey عندما يصبح PgBouncer محدوداً بنواة واحدة مع آلاف الاتصالات المشفرة. وإذا كنت على خدمة سحابية مدارة فغالباً تجد مجمعاً جاهزاً، مثل [RDS Proxy](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-proxy.html?ref=arabroot.io) في AWS الذي يذكر توثيقه أنه يعيد استخدام الاتصالات ويضع الطلبات الزائدة في طابور بدل أن يرهق قاعدة البيانات.

### الطبقتان معاً

وقد يتساءل البعض: إذا وضعت PgBouncer، فهل أحذف المجمع من التطبيق؟ والإجابة لا، فالأفضل غالباً أن تبقي مجمعاً صغيراً في كل تطبيق، لأنه يوفر على التطبيق فتح اتصال بPgBouncer مع كل طلب، وهذا الاتصال نفسه فيه TCP وربما TLS ومصادقة كما سوف ترى في المختبر، ثم يقوم PgBouncer بالتجميع الثاني بين كل التطبيقات، فيستقبل مئات الاتصالات من المجمعات الصغيرة ويمررها على عدد قليل من الاتصالات الحقيقية. ولكن انتبه إلى أمرين: المجمع في التطبيق يجب أن يكون صغيراً، لأن PgBouncer هو الذي يحدد العدد الحقيقي الآن، وإذا كانت الاتصالات الخاملة في التطبيق تبقى مفتوحة طويلاً فهي لا تكلف قاعدة البيانات شيئاً في وضع transaction لأن PgBouncer لا يربطها باتصال حقيقي إلا أثناء المعاملة.

### إعادة استخدام اتصالات HTTP

نفس الفكرة موجودة في HTTP، فالاتصال بين المتصفح والسيرفر أو بين ال Reverse Proxy والتطبيق فيه مصافحة TCP وربما TLS، وإعادة استخدامه توفر هذا الثمن. وتنص [مواصفة HTTP/1.1](https://www.rfc-editor.org/rfc/rfc9112.html?ref=arabroot.io#name-persistence) على أن الاتصالات الدائمة Persistent Connections هي السلوك الافتراضي، أي أن الاتصال يحمل عدة طلبات واحداً بعد الآخر حتى يرسل أحد الطرفين `Connection: close`، وهذا ما يسمى Keep-Alive، ثم جاءت [HTTP/2](https://www.rfc-editor.org/rfc/rfc9113.html?ref=arabroot.io) بتعدد التدفقات Multiplexing، فتقول المواصفة إن ذلك يسمح باستخدام اتصالات TCP أقل من HTTP/1.x. وقد شرحنا هذه الأساسيات ومعها HTTP/3 في [مقال HTTP وHTTPS للمبتدئين](https://arabroot.io/articles/http-%D9%88-https-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)، والصورة التالية تبين الفرق:

![ثلاثة صفوف: الأول بأسلوب HTTP/1.0 يفتح TLS جديداً قبل كل طلب، والثاني HTTP/1.1 مع keep-alive يفتح TLS مرة ثم ثلاثة طلبات متتالية على نفس الاتصال، والثالث HTTP/2 يفتح TLS مرة ثم ثلاثة طلبات في نفس الوقت على تدفقات متداخلة، وتحتها المتصفح يتصل بNginx الذي يحتفظ باتصالات مفتوحة مع التطبيق عبر upstream keepalive](https://arabroot.io/content/images/2026/10/connection-pooling-06-http-reuse.webp)

Keep-Alive يعيد استخدام الاتصال لطلبات متتالية، وHTTP/2 يحمل عدة طلبات في نفس الوقت، وNginx يحتفظ باتصالاته مع التطبيق

والجزء الذي يهمك في هذا المقال هو الاتصال بين Nginx والتطبيق، لأن Nginx هنا هو الكلاينت الذي يفتح الاتصالات ويجب أن يعيد استخدامها مثل أي مجمع، وقد خصصنا له [قسماً كاملاً مع مختبر](https://arabroot.io/articles/%D8%AA%D8%AC%D9%85%D9%8A%D8%B9-%D8%A7%D9%84%D8%A7%D8%AA%D8%B5%D8%A7%D9%84%D8%A7%D8%AA-connection-pooling-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#nginx-upstream) بعد أقسام PgBouncer.

### مجمعات Pools أخرى تستخدمها دون أن تنتبه

- **Redis وValkey:** مكتبات الكلاينت تحتفظ باتصالاتها في مجمع، مثل [ConnectionPool في redis-py](https://redis.readthedocs.io/en/stable/connections.html?ref=arabroot.io)، والقاعدة نفسها: أنشئ الكلاينت مرة واحدة عند تشغيل التطبيق ولا تنشئه مع كل طلب، وقد خصصنا لها [قسماً مع مختبر](https://arabroot.io/articles/%D8%AA%D8%AC%D9%85%D9%8A%D8%B9-%D8%A7%D9%84%D8%A7%D8%AA%D8%B5%D8%A7%D9%84%D8%A7%D8%AA-connection-pooling-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#redis-pool) في هذا المقال.
- **SMTP:** إرسال آلاف الرسائل بفتح اتصال SMTP وTLS ومصادقة لكل رسالة بطيء، لذلك مكتبات مثل [Nodemailer](https://nodemailer.com/smtp/pooled?ref=arabroot.io) فيها الخيار `pool: true` الذي يبقي حتى 5 اتصالات مفتوحة افتراضياً، ويرسل حتى 100 رسالة على الاتصال الواحد قبل أن يفتح غيره، وقد شرحنا كيف يمر البريد عبر SMTP وكيف يعاد استخدام الاتصال في [مقال كيف يعمل البريد الإلكتروني](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D9%8A%D8%B9%D9%85%D9%84-%D8%A7%D9%84%D8%A8%D8%B1%D9%8A%D8%AF-%D8%A7%D9%84%D8%A7%D9%84%D9%83%D8%AA%D8%B1%D9%88%D9%86%D9%8A-smtp-%D9%88-imap/)، والإرسال من التطبيق عبر SMTP أو API في دليل [البريد التفاعلي](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%B1%D9%8A%D8%AF-%D8%A7%D9%84%D8%AA%D9%81%D8%A7%D8%B9%D9%84%D9%8A-transactional-email/#smtp-vs-api).
- **مجمعات الخيوط Thread Pools:** نفس الفكرة مع الخيوط بدل الاتصالات، فإنشاء الخيط مكلف، لذلك تحتفظ بعدد ثابت منها وتعطيها المهام، وقد شرحناها في [مقال المعالج والخيوط والتزامن](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D8%A7%D9%84%D8%AC-%D9%88%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-%D9%88%D8%A7%D9%84%D8%AA%D8%B2%D8%A7%D9%85%D9%86-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/).

## أوضاع PgBouncer: متى يعود الاتصال إلى المجمع Pool؟

أهم إعداد في PgBouncer هو [pool\_mode](https://www.pgbouncer.org/config.html?ref=arabroot.io)، لأنه يحدد متى يعيد PgBouncer الاتصال الحقيقي بقاعدة البيانات إلى المجمع ليخدم كلاينت آخر، والصورة التالية تبين الأوضاع الثلاثة على اتصال حقيقي واحد:

![ثلاثة صفوف لاتصال حقيقي واحد مع مرور الوقت: 1 وضع session يحجز الاتصال للكلاينت A طوال جلسته حتى ينقطع، 2 وضع transaction يعطي الاتصال للكلاينت A ثم B ثم A ثم C بحسب المعاملات، 3 وضع statement يبدل الكلاينت بعد كل استعلام ولا يسمح بمعاملات من عدة أوامر، وتحتها ملاحظة أن وضع transaction يخدم كلاينتات أكثر باتصالات أقل ولكن SET وLISTEN والأقفال الاستشارية والجداول المؤقتة تبقى على الاتصال الحقيقي](https://arabroot.io/content/images/2026/10/connection-pooling-04-modes-1.webp)

كلما أعاد PgBouncer الاتصال أسرع خدم كلاينتات أكثر، وكسر ميزات أكثر مرتبطة بالجلسة

- **الجلسة `session`:** وهو الوضع الافتراضي، فيبقى الاتصال الحقيقي للكلاينت طوال اتصاله ولا يعود إلا عندما ينقطع، وهذا الوضع يدعم كل ميزات PostgreSQL، ولكنه لا يقلل عدد الاتصالات الحقيقية إلا إذا كانت التطبيقات تفتح وتغلق اتصالاتها كثيراً، لأنه يوفر عليها المصافحة والمصادقة وإنشاء العملية فقط.
- **المعاملة `transaction`:** يمسك الكلاينت الاتصال الحقيقي طوال المعاملة فقط، ثم يعود للمجمع بعد `COMMIT` أو `ROLLBACK`، والأمر خارج أي معاملة صريحة يعد معاملة وحده. وهذا هو الوضع الذي يعطيك الفائدة الكبيرة، فمئات الكلاينتات التي تقضي أغلب وقتها خاملة تتشارك عشرة اتصالات حقيقية.
- **الأمر `statement`:** يعود الاتصال بعد كل استعلام، ولا يسمح بمعاملة من عدة أوامر، وتذكر [صفحة الميزات](https://www.pgbouncer.org/features.html?ref=arabroot.io) أنه موجه أساساً ل PL/Proxy، لذلك لن تحتاجه في تطبيق عادي.

### ما الذي ينكسر في وضع transaction؟

في وضع transaction لا يملك الكلاينت اتصالاً حقيقياً ثابتاً، لذلك كل شيء يحفظه PostgreSQL على مستوى الجلسة Session لا يتبع الكلاينت، وإنما يبقى على الاتصال الحقيقي ويراه الكلاينت التالي. والجدول التالي من [صفحة الميزات في وثائق PgBouncer](https://www.pgbouncer.org/features.html?ref=arabroot.io):

| الميزة                                                                      | وضع session | وضع transaction                                         |
| --------------------------------------------------------------------------- | ----------- | ------------------------------------------------------- |
| SET وRESET                                                                  | تعمل        | لا تعمل                                                 |
| LISTEN                                                                      | تعمل        | لا تعمل (أما NOTIFY فتعمل)                              |
| المؤشرات WITH HOLD                                                          | تعمل        | لا تعمل                                                 |
| الاستعلامات المجهزة على مستوى البروتوكول Protocol-level Prepared Statements | تعمل        | تعمل بشرط أن تكون max\_prepared\_statements أكبر من صفر |
| PREPARE وDEALLOCATE بالصيغة النصية                                          | تعمل        | لا تعمل                                                 |
| الجداول المؤقتة ON COMMIT DROP                                              | تعمل        | تعمل                                                    |
| الجداول المؤقتة PRESERVE ROWS أو DELETE ROWS                                | تعمل        | لا تعمل                                                 |
| الأقفال الاستشارية Advisory Locks على مستوى الجلسة                          | تعمل        | لا تعمل                                                 |
| LOAD                                                                        | تعمل        | لا تعمل                                                 |

وكلمة «لا تعمل» هنا أخطر مما تبدو، فالأمر لا يفشل بخطأ واضح، وإنما ينفذ على اتصال حقيقي ثم يذهب هذا الاتصال لكلاينت آخر، فيفقد الكلاينت الأول إعداده ويرثه كلاينت لا يعرف عنه شيئاً، وسوف ترى ذلك بالمخرج الحقيقي في [المختبر](#lab-set-pitfall). والحل لكل حالة:

- **الإعدادات:** استخدم `SET LOCAL` داخل المعاملة، فتقول [وثائق SET](https://www.postgresql.org/docs/18/sql-set.html?ref=arabroot.io) إن أثره ينتهي مع نهاية المعاملة سواء نجحت أم لا، أو ضع الإعداد الدائم على المستخدم نفسه بالأمر `ALTER ROLE app SET statement_timeout = '5s'`.
- **الأقفال الاستشارية:** استخدم النسخة المرتبطة بالمعاملة `pg_advisory_xact_lock` بدل `pg_advisory_lock`.
- **LISTEN وأدوات الترحيل Migrations والمهام الطويلة:** اجعلها تتصل بقاعدة البيانات مباشرة، أو عبر قاعدة ثانية في PgBouncer بوضع session، فيمكن تحديد `pool_mode` لكل قاعدة في قسم `[databases]`.

### الاستعلامات المجهزة Prepared Statements

أغلب مكتبات الاتصال والأطر تستخدم الاستعلامات المجهزة على مستوى البروتوكول، فترسل نص الاستعلام مرة باسم معين، ثم تنفذه بعد ذلك بالاسم والقيم، وهذا الاسم محفوظ في الاتصال الحقيقي الذي جهز عليه. وفي وضع transaction قد يجهز الكلاينت الاستعلام على اتصال وينفذه على اتصال آخر لا يعرف هذا الاسم، وكانت هذه أكبر مشكلة في PgBouncer لسنوات، حتى أضاف الإصدار 1.21.0 في أكتوبر 2023 دعم الاستعلامات المجهزة على مستوى البروتوكول عبر الإعداد `max_prepared_statements`، ثم أصبح مفعلاً افتراضياً بقيمة 200 منذ الإصدار 1.24.0 في يناير 2025 كما في [سجل التغييرات](https://www.pgbouncer.org/changelog.html?ref=arabroot.io). وفي هذا الوضع يتتبع PgBouncer الاستعلامات المجهزة لكل كلاينت، ويجهزها على الاتصال الحقيقي الذي يعطيه له عند الحاجة، لذلك تأكد أن إصدارك 1.21 أو أحدث، وأن القيمة لم توضع على صفر في ملف إعداد قديم، وسوف ترى في المختبر ماذا يحدث عندما تكون صفراً. أما `PREPARE` بالصيغة النصية فما زال لا يعمل في وضع transaction كما في الجدول أعلاه.

### المصادقة: ملف المستخدمين أو auth\_query

الكلاينت يتصل ب PgBouncer كأنه قاعدة البيانات، لذلك PgBouncer يحتاج أن يتحقق من كلمة المرور بنفسه، ثم يتصل بقاعدة البيانات بنفس المستخدم، وهناك طريقتان كما في [وثائق الإعداد](https://www.pgbouncer.org/config.html?ref=arabroot.io):

- **ملف المستخدمين `auth_file`:** ملف نصي يسمى عادة `userlist.txt` فيه سطر لكل مستخدم بالاسم وكلمة المرور أو الهاش، والأفضل أن تضع هاش SCRAM كما هو محفوظ في `pg_authid`، فلا تكتب كلمة المرور الأصلية في أي ملف، وهذه الطريقة بسيطة ولكنك تحتاج تحديث الملف عند كل تغيير لكلمة مرور.
- **الاستعلام `auth_query`:** يتصل PgBouncer بقاعدة البيانات بمستخدم واحد محدد في `auth_user`، ويقرأ هاش كلمة مرور الكلاينت من قاعدة البيانات نفسها، والاستعلام الافتراضي يقرأ من `pg_authid`، وهذا الجدول لا يقرؤه إلا مستخدم خارق، لذلك الطريقة الشائعة أن تنشئ دالة بصلاحية `SECURITY DEFINER` تعيد الاسم والهاش فقط، وتعطي مستخدم PgBouncer صلاحية تنفيذها وحدها. وهذه الطريقة أنسب عندما يكثر المستخدمون أو تتغير كلمات المرور.

### أحجام المجمع Pool Size في PgBouncer

هذه الإعدادات هي التي تحدد كم اتصالاً حقيقياً يصل إلى PostgreSQL، وقيمها الافتراضية من [وثائق الإعداد](https://www.pgbouncer.org/config.html?ref=arabroot.io):

- **`max_client_conn` (الافتراضي 100):** أقصى عدد من الكلاينتات يقبله PgBouncer، وهذا رقم رخيص لأن كل كلاينت خامل لا يكلف إلا القليل من الذاكرة، لذلك ترفعه إلى مئات أو آلاف.
- **`default_pool_size` (الافتراضي 20):** عدد الاتصالات الحقيقية لكل زوج من المستخدم وقاعدة البيانات، ولاحظ «لكل زوج»، فثلاثة مستخدمين على قاعدتين يعني حتى ستة مجمعات.
- **`max_db_connections` (الافتراضي 0، أي بلا حد):** سقف للاتصالات الحقيقية لكل قاعدة بيانات من كل المستخدمين، وهو الحماية من مشكلة «لكل زوج» السابقة.
- **`min_pool_size` و`reserve_pool_size` (الافتراضي 0):** الأول يبقي عدداً من الاتصالات مفتوحاً دائماً، والثاني يسمح باتصالات إضافية مؤقتة إذا طال انتظار الكلاينتات.
- **`server_idle_timeout` (الافتراضي 600 ثانية) و`server_lifetime` (الافتراضي 3600 ثانية):** مهلة الخمول والعمر الأقصى للاتصال الحقيقي.
- **`query_wait_timeout` (الافتراضي 120 ثانية):** أقصى مدة ينتظرها الكلاينت حتى يجد اتصالاً حقيقياً، ثم يقطع PgBouncer اتصاله.

والقاعدة أن يكون مجموع الاتصالات الحقيقية من PgBouncer، مضافاً إليه كل ما يتصل مباشرة مثل أدوات الترحيل والنسخ الاحتياطي والمراقبة، أقل من `max_connections` مع هامش، ولاحظ أن الاتصالات الخاملة التي يحتفظ بها PgBouncer تحسب من الحد مثل غيرها، وهذا ما سوف تراه في المختبر.

## المختبر: نفس الحمل مع PgBouncer ودونه

سوف نشغل PostgreSQL 18 بحد اتصالات صغير عمداً هو 20، ثم PgBouncer أمامه، ونرسل الحمل بأداة [pgbench](https://www.postgresql.org/docs/18/pgbench.html?ref=arabroot.io) الموجودة مع PostgreSQL، وكل شيء في شبكة Docker واحدة والمنافذ مربوطة على العنوان المحلي فقط. وإذا لم يكن Docker مثبتاً لديك فابدأ بدليل [تثبيت Docker على Ubuntu](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-docker-%D8%B9%D9%84%D9%89-ubuntu/).

💡

كل الأزمنة في هذا المختبر من جهاز واحد عليه Docker، والاتصال داخل شبكة Docker دون TLS، وهي تبين الفرق بين الحالات على نفس الجهاز وليست سرعة يمكن أن تتوقعها على سيرفرك، فعلى شبكة حقيقية مع TLS يكون ثمن فتح الاتصال أكبر.

### تجهيز قاعدة البيانات وPgBouncer

نبدأ بالشبكة وقاعدة البيانات، ثم ننشئ مستخدماً عادياً للتطبيق ونجهز بيانات `pgbench`:

```bash
docker network create lab-pool-net
docker run -d --name lab-pool-pg --network lab-pool-net -p 127.0.0.1:55490:5432 \
  -e POSTGRES_PASSWORD=labpass -e POSTGRES_DB=shop \
  postgres:18 -c max_connections=20

docker exec lab-pool-pg psql -U postgres -d shop \
  -c "CREATE ROLE app LOGIN PASSWORD 'apppass';" \
  -c "GRANT ALL ON SCHEMA public TO app;" \
  -c "ALTER DATABASE shop OWNER TO app;"
docker exec -e PGPASSWORD=apppass lab-pool-pg pgbench -h 127.0.0.1 -U app -i -s 10 -q shop
```

ولا توجد Docker Image رسمية من مشروع PgBouncer، وصفحة [التثبيت](https://www.pgbouncer.org/install.html?ref=arabroot.io) في وثائقه تشرح البناء من الكود فقط، أما Docker Image الخاصة ب Bitnami التي كان كثيرون يستخدمونها فلم تعد متاحة مجاناً على Docker Hub كما يذكر وصفها هناك. لذلك سوف نبني Docker Image صغيرة فوق `postgres:18` ونثبت فيها PgBouncer من مستودع PGDG، وهو مستودع حزم مجتمع PostgreSQL الموجود أصلاً في تلك ال Image:

```dockerfile
FROM postgres:18
RUN apt-get update && apt-get install -y --no-install-recommends pgbouncer && rm -rf /var/lib/apt/lists/*
USER postgres
CMD ["pgbouncer", "/etc/pgbouncer/pgbouncer.ini"]
```

```bash
docker build -t lab-pool-pgbouncer:local .
docker run --rm lab-pool-pgbouncer:local pgbouncer --version
```

والمخرج سوف يكون كما يلي:

```text
PgBouncer 1.26.0
libevent 2.1.13-stable
adns: c-ares 1.34.5
tls: OpenSSL 3.5.7 9 Jun 2026
systemd: yes
```

الآن نجهز ملف المستخدمين من هاش SCRAM المحفوظ في قاعدة البيانات، ثم ملف الإعداد:

```bash
docker exec lab-pool-pg psql -U postgres -d shop -Atc \
  "SELECT format('\"%s\" \"%s\"', rolname, rolpassword) FROM pg_authid WHERE rolname='app';" > userlist.txt
```

```ini
[databases]
shop = host=lab-pool-pg port=5432 dbname=shop

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
admin_users = app
pool_mode = transaction
default_pool_size = 10
max_client_conn = 500
max_db_connections = 15
max_prepared_statements = 200
```

في الإعداد أعلاه لاحظ التالي:

- `default_pool_size = 10` يعني أن PgBouncer لن يفتح أكثر من 10 اتصالات حقيقية للمستخدم `app` على قاعدة `shop`، و`max_db_connections = 15` سقف لكل المستخدمين على هذه القاعدة، وكلاهما أقل من 17 خانة متاحة للمستخدمين العاديين.
- `max_client_conn = 500` يسمح بخمسمئة كلاينت، أي خمسين ضعف الاتصالات الحقيقية.
- `admin_users = app` يسمح لهذا المستخدم بالدخول إلى قاعدة الإدارة الوهمية `pgbouncer` لتنفيذ `SHOW POOLS`، وفي الإنتاج Production اجعله مستخدماً منفصلاً.
- الملف `userlist.txt` فيه هاش SCRAM وليس كلمة المرور، ومع ذلك احمه بصلاحيات الملف، فمن يملك الهاش يستطيع محاولة تخمين كلمة المرور منه دون اتصال.

```bash
docker run -d --name lab-pool-bouncer --network lab-pool-net -p 127.0.0.1:55491:6432 \
  -v "$PWD/pgbouncer.ini:/etc/pgbouncer/pgbouncer.ini:ro" \
  -v "$PWD/userlist.txt:/etc/pgbouncer/userlist.txt:ro" \
  lab-pool-pgbouncer:local
docker logs lab-pool-bouncer
```

```text
LOG kernel file descriptor limit: 1048576 (hard: 1048576); max_client_conn: 500, max expected fd use: 532
LOG listening on 0.0.0.0:6432
LOG listening on unix:/tmp/.s.PGSQL.6432
LOG process up: PgBouncer 1.26.0, libevent 2.1.13-stable (epoll), adns: c-ares 1.34.5, tls: OpenSSL 3.5.7 9 Jun 2026
```

### خمسون كلاينت مباشرة على قاعدة البيانات

نرسل 50 كلاينت من `pgbench` مباشرة إلى PostgreSQL، وهو ما يحدث عندما تشغل عدة نسخ من تطبيق كل واحدة بمجمع كبير:

```bash
docker run --rm --network lab-pool-net -e PGPASSWORD=apppass postgres:18 \
  pgbench -h lab-pool-pg -U app -c 50 -j 1 -T 10 -S shop
```

والمخرج سوف يكون كما يلي:

```text
pgbench (18.6 (Debian 18.6-1.pgdg13+2))
starting vacuum...end.
pgbench: error: connection to server at "lab-pool-pg" (192.168.48.2), port 5432 failed: FATAL:  remaining connection slots are reserved for roles with the SUPERUSER attribute
pgbench: error: could not create connection for client 17
```

لاحظ أن الكلاينتات من 0 إلى 16 اتصلت، أي 17 اتصالاً، وهي 20 ناقص الخانات Slots الثلاث المحجوزة للمستخدم الخارق، ثم رفض الكلاينت رقم 17 وتوقفت التجربة كلها. وإذا كررت نفس التجربة بالمستخدم الخارق `postgres` بخمسة وعشرين كلاينت فسوف تصل إلى الحد الكامل وترى الرسالة الأشهر:

```text
pgbench: error: connection to server at "lab-pool-pg" (192.168.48.2), port 5432 failed: FATAL:  sorry, too many clients already
pgbench: error: could not create connection for client 20
```

### مئتا كلاينت عبر PgBouncer

الآن نرسل أربعة أضعاف العدد، 200 كلاينت، ولكن عبر PgBouncer على المنفذ 6432، وأثناء التشغيل نسأل PgBouncer عن حالة المجمع ونسأل PostgreSQL عن عدد الاتصالات الحقيقية:

```bash
docker run --rm --network lab-pool-net -e PGPASSWORD=apppass postgres:18 \
  pgbench -h lab-pool-bouncer -p 6432 -U app -c 200 -j 1 -T 15 -S shop

# in a second terminal, while the test runs:
docker run --rm --network lab-pool-net -e PGPASSWORD=apppass postgres:18 \
  psql -h lab-pool-bouncer -p 6432 -U app pgbouncer -c "SHOW POOLS;"
docker exec lab-pool-pg psql -U postgres -d shop -c \
  "SELECT usename, state, count(*) FROM pg_stat_activity WHERE backend_type='client backend' GROUP BY 1,2 ORDER BY 1,2;"
```

ومخرج `SHOW POOLS` أثناء الحمل سوف يكون كما يلي:

```text
 database  |   user    | cl_active | cl_waiting | cl_active_cancel_req | cl_waiting_cancel_req | sv_active | sv_active_cancel | sv_being_canceled | sv_idle | sv_used | sv_tested | sv_login | maxwait | maxwait_us |  pool_mode  | load_balance_hosts
-----------+-----------+-----------+------------+----------------------+-----------------------+-----------+------------------+-------------------+---------+---------+-----------+----------+---------+------------+-------------+--------------------
 pgbouncer | pgbouncer |         1 |          0 |                    0 |                     0 |         0 |                0 |                 0 |       0 |       0 |         0 |        0 |       0 |          0 | statement   |
 shop      | app       |        17 |        183 |                    0 |                     0 |        10 |                0 |                 0 |       0 |       0 |         0 |        0 |       0 |      23655 | transaction |
(2 rows)
```

ومن جهة PostgreSQL:

```text
 usename  | state  | count
----------+--------+-------
 app      | idle   |    10
 postgres | active |     1
(2 rows)
```

ونتيجة `pgbench` في النهاية:

```text
number of clients: 200
number of threads: 1
maximum number of tries: 1
duration: 15 s
number of transactions actually processed: 97971
number of failed transactions: 0 (0.000%)
latency average = 27.035 ms
initial connection time = 1855.751 ms
tps = 7397.719846 (without initial connection time)
```

في المخرج أعلاه لاحظ التالي:

- 200 كلاينت ولا يوجد أي خطأ، و97971 معاملة نفذت على 10 اتصالات حقيقية فقط، وهو ما يظهر في `sv_active = 10` وفي `pg_stat_activity` الذي لا يرى إلا 10 اتصالات من المستخدم `app`.
- `cl_waiting = 183` يعني أن 183 كلاينت أرسلوا استعلاماً وينتظرون اتصالاً حقيقياً في تلك اللحظة، و`maxwait_us` يبين أن أقدمهم ينتظر 23 مللي ثانية تقريباً، وهذا هو الطابور الذي رأيته في الخطوة 4 من صورة المجمع، وبسببه متوسط زمن المعاملة 27 مللي ثانية رغم أن الاستعلام نفسه أسرع بكثير. فالمجمع لم يجعل قاعدة البيانات أسرع، وإنما جعلها تخدم 200 كلاينت دون رفض ودون أن تتجاوز 10 عمليات.
- حالة الاتصالات في PostgreSQL `idle` في لحظة القياس، والسبب أن الاستعلام سريع جداً، فأغلب الوقت يذهب في نقل النتائج بين PgBouncer و`pgbench`.

وبعد انتهاء الحمل نعرض الإحصاءات المتراكمة بالأمر `SHOW STATS` (اختصرنا الأعمدة إلى أهمها):

```text
database                      | shop
total_server_assignment_count | 97976
total_xact_count              | 97976
total_query_count             | 97976
total_wait_time               | 2216970445
total_client_login_count      | 201
avg_wait_time                 | 22627
```

لاحظ أن `total_client_login_count = 201` هي 200 كلاينت من `pgbench` وكلاينت الإدارة، بينما الاتصالات الحقيقية بقيت 10، وأن `total_wait_time` بالميكروثانية، وهو مجموع وقت انتظار الكلاينتات في الطابور، وتعرف [وثائق الأوامر](https://www.pgbouncer.org/usage.html?ref=arabroot.io) كل عمود من هذه الأعمدة.

### ثمن فتح الاتصال: الخيار -C

الخيار `-C` في `pgbench` يفتح اتصالاً جديداً لكل معاملة بدل اتصال واحد لكل كلاينت، كما تقول [وثائقه](https://www.postgresql.org/docs/18/pgbench.html?ref=arabroot.io)، وهو بالضبط سلوك التطبيق الذي لا يستخدم مجمعاً. سوف نشغل ستة كلاينتات أربع مرات: مباشرة مع اتصال دائم، ومباشرة مع اتصال لكل معاملة، ثم نفس الحالتين عبر PgBouncer:

```bash
for args in "-h lab-pool-pg" "-h lab-pool-pg -C" "-h lab-pool-bouncer -p 6432" "-h lab-pool-bouncer -p 6432 -C"; do
  echo "== pgbench $args"
  docker run --rm --network lab-pool-net -e PGPASSWORD=apppass postgres:18 \
    pgbench $args -U app -c 6 -j 1 -T 10 -S shop 2>&1 | grep -E "error|processed|latency average|connection time|tps"
done
```

والمخرج سوف يكون كما يلي:

```text
== pgbench -h lab-pool-pg
number of transactions actually processed: 92522
latency average = 0.641 ms
initial connection time = 123.778 ms
tps = 9366.346731 (without initial connection time)
== pgbench -h lab-pool-pg -C
number of transactions actually processed: 522
latency average = 114.978 ms
average connection time = 18.981 ms
tps = 52.183969 (including reconnection times)
== pgbench -h lab-pool-bouncer -p 6432
number of transactions actually processed: 159002
latency average = 0.376 ms
initial connection time = 34.433 ms
tps = 15954.328249 (without initial connection time)
== pgbench -h lab-pool-bouncer -p 6432 -C
number of transactions actually processed: 1323
latency average = 45.417 ms
average connection time = 7.514 ms
tps = 132.108245 (including reconnection times)
```

في المخرج أعلاه لاحظ التالي:

- الاتصال لكل معاملة مباشرة أنزل المعاملات من 92522 إلى 522 في عشر ثوان، أي أقل بأكثر من مئة مرة، وكل اتصال جديد أخذ حوالي 19 مللي ثانية بينما الاستعلام نفسه أقل من مللي ثانية، وهذا كله داخل جهاز واحد دون TLS ودون شبكة حقيقية.
- الاتصال لكل معاملة عبر PgBouncer أسرع من المباشر (7.5 مللي ثانية للاتصال بدل 19)، والسبب أن PgBouncer لا ينشئ عملية جديدة على PostgreSQL، وإنما يعطي الكلاينت اتصالاً حقيقياً مفتوحاً، ولكنه ما زال بطيئاً لأن الكلاينت يدفع ثمن TCP ومصادقة SCRAM مع PgBouncer نفسه. لذلك PgBouncer لا يغني عن المجمع داخل التطبيق إذا كان التطبيق قادراً على الاحتفاظ باتصالاته، وهذا سبب الطبقتين.
- مع الاتصال الدائم في الحالتين كان الفرق بين المباشر وPgBouncer يتغير من تشغيل لآخر، فعند تكرار نفس الأمر حصلنا على 13895 و21037 معاملة في الثانية مباشرة، و11791 و12107 عبر PgBouncer، لذلك لا تستنتج من هذه الأرقام أن PgBouncer يسرع الاستعلام، فهو خطوة إضافية على الشبكة، وفائدته في عدد الاتصالات وفي ثمن فتحها.

وحدث شيء مفيد قبل هذا القياس، فقد شغلنا أولاً ثمانية كلاينتات مباشرة بعد تجربة ال 200 كلاينت، فرفض الكلاينت الثامن:

```text
pgbench: error: connection to server at "lab-pool-pg" (192.168.48.2), port 5432 failed: FATAL:  remaining connection slots are reserved for roles with the SUPERUSER attribute
pgbench: error: could not create connection for client 7
```

والسبب أن PgBouncer ما زال يحتفظ بعشرة اتصالات حقيقية خاملة من التجربة السابقة، ولن يغلقها قبل `server_idle_timeout` وهو 600 ثانية افتراضياً، فبقي للاتصال المباشر 7 خانات فقط من 17\. وهذا ما قصدناه بأن اتصالات المجمع الخاملة تحسب من `max_connections` مثل غيرها، لذلك عندما تحسب ميزانية الاتصالات اجمع الحد الأقصى لكل مجمع، وليس ما تراه مستخدماً في لحظة معينة.

### خطأ SET في وضع transaction

الآن نرى بالمخرج ما يعنيه «SET لا يعمل» في جدول الميزات. السكربت التالي يشغل ثلاثة كلاينتات عبر PgBouncer: الكلاينت A يضع `statement_timeout` على ثانيتين خارج أي معاملة ثم ينتظر ثانية في جهة الكلاينت، وأثناء انتظاره يبدأ الكلاينت B معاملة فيها `pg_sleep(3)`، ثم يسأل A عن إعداده مرة أخرى، وبعد انتهاء الاثنين يتصل كلاينت ثالث C:

```bash
#!/bin/sh
# set-pitfall.sh: A sets statement_timeout outside a transaction, then waits on the client side;
# B borrows a server connection for a 3-second transaction; A asks again; C connects after B.
P="psql -h lab-pool-bouncer -p 6432 -U app -d shop -Atq"
$P <<'SQL' &
SET statement_timeout = '2s';
SELECT 'A1 pid=' || pg_backend_pid() || ' statement_timeout=' || current_setting('statement_timeout');
\! sleep 1
SELECT 'A2 pid=' || pg_backend_pid() || ' statement_timeout=' || current_setting('statement_timeout');
SQL
sleep 0.5
$P <<'SQL' &
BEGIN;
SELECT 'B  pid=' || pg_backend_pid() || ' statement_timeout=' || current_setting('statement_timeout');
SELECT pg_sleep(3);
COMMIT;
SQL
wait
$P -c "SELECT 'C  pid=' || pg_backend_pid() || ' statement_timeout=' || current_setting('statement_timeout');"
```

```bash
docker restart lab-pool-bouncer   # start with fresh server connections
docker run --rm --network lab-pool-net -e PGPASSWORD=apppass \
  -v "$PWD/set-pitfall.sh:/set-pitfall.sh:ro" postgres:18 sh /set-pitfall.sh
```

والمخرج سوف يكون كما يلي:

```text
A1 pid=1520 statement_timeout=2s
B  pid=1520 statement_timeout=2s
A2 pid=1521 statement_timeout=0
ERROR:  canceling statement due to statement timeout
C  pid=1520 statement_timeout=2s
```

في المخرج أعلاه لاحظ التالي:

- الأمر `SET` من A نفذ على العملية 1520، وبعد انتهائه عاد الاتصال الحقيقي للمجمع ومعه الإعداد.
- B أخذ نفس الاتصال 1520 فورث إعداد A دون أن يطلبه، فألغى PostgreSQL الأمر `pg_sleep(3)` بعد ثانيتين بالخطأ `canceling statement due to statement timeout`، وتخيل هذا في الإنتاج: تقرير طويل يفشل مرة وينجح مرة بحسب الاتصال الذي حصل عليه.
- A نفسه عندما سأل مرة ثانية حصل على الاتصال 1521 فوجد القيمة الافتراضية 0، أي أنه فقد إعداده.
- C بعد انتهاء الجميع حصل أيضاً على 1520 وورث الإعداد، فالإعداد يبقى على الاتصال الحقيقي حتى يغلق، لأن PgBouncer لا ينفذ أمر التنظيف `server_reset_query` في وضع transaction.

والحل كما ذكرنا هو `SET LOCAL` داخل المعاملة، والمخرج التالي من نفس المختبر يبين أن الإعداد ينتهي مع المعاملة ولا يرثه أحد حتى على نفس الاتصال الحقيقي:

```sql
BEGIN;
SET LOCAL statement_timeout = '2s';
SELECT 'inside  pid=' || pg_backend_pid() || ' statement_timeout=' || current_setting('statement_timeout');
COMMIT;
-- then, from a new client:
SELECT 'after   pid=' || pg_backend_pid() || ' statement_timeout=' || current_setting('statement_timeout');
```

```text
inside  pid=1571 statement_timeout=2s
after   pid=1571 statement_timeout=0
```

### الاستعلامات المجهزة مع max\_prepared\_statements

الخيار `-M prepared` في `pgbench` يستخدم الاستعلامات المجهزة على مستوى البروتوكول مثل أغلب المكتبات، ونشغله عبر PgBouncer مرتين: بالقيمة الافتراضية 200، ثم بعد تغيير `max_prepared_statements` إلى 0 في الملف وإعادة تشغيل PgBouncer:

```bash
docker run --rm --network lab-pool-net -e PGPASSWORD=apppass postgres:18 \
  pgbench -h lab-pool-bouncer -p 6432 -U app -M prepared -c 20 -j 1 -T 5 -S shop
```

مع القيمة 200 كانت النتيجة:

```text
number of transactions actually processed: 39850
number of failed transactions: 0 (0.000%)
tps = 8592.247098 (without initial connection time)
```

ومع القيمة 0 (اختصرنا الأسطر المتكررة):

```text
pgbench: error: client 1 script 0 aborted in command 1 query 0: ERROR:  prepared statement "P_0" does not exist
pgbench: error: client 5 script 0 aborted in command 1 query 0: ERROR:  prepared statement "P_0" does not exist
pgbench: error: client 8 script 0 aborted in command 1 query 0: ERROR:  prepared statement "P_0" does not exist
...
pgbench: error: client 13 script 0 aborted in command 1 query 0: ERROR:  prepared statement "P_0" does not exist
pgbench: error: Run was aborted; the above results are incomplete.
```

لاحظ أن كل الكلاينتات العشرين توقفت، والرسالة `prepared statement "P_0" does not exist` تعني أن الكلاينت جهز الاستعلام على اتصال حقيقي ثم حاول تنفيذه على اتصال آخر، وفي تشغيل آخر بنفس الإعداد ظهرت الرسالة العكسية `prepared statement "P_0" already exists` عندما حاول كلاينت أن يجهز اسماً جهزه كلاينت آخر على نفس الاتصال. فإذا رأيت إحدى الرسالتين خلف PgBouncer فأول ما تفحصه هو هذا الإعداد وإصدار PgBouncer.

### حذف المختبر

بعد أن تنتهي احذف ال Containers والشبكة وال Image التي بنيتها، وال Volume المجهول الذي أنشأه PostgreSQL يحذف مع الخيار `-v`:

```bash
docker rm -f -v lab-pool-bouncer lab-pool-pg
docker network rm lab-pool-net
docker rmi lab-pool-pgbouncer:local
```

## تضاعف الاتصالات Connection Multiplication: النسخ الكثيرة وال Serverless

المجمع في التطبيق يحل المشكلة لنسخة واحدة، ثم تأتي ال Containers وتوزيع الحمل Load Balancing فيتضاعف عدد الاتصالات مع كل نسخة جديدة. الصورة التالية تبين المثال الذي نراه كثيراً:

![خمسون Container للتطبيق في كل واحد مجمع بحد أقصى 20 اتصالاً، والمجموع 1000 اتصال، وبعضها يضاف أثناء النشر عندما تعمل النسخة القديمة والجديدة معاً، يتجه إلى PostgreSQL بحد max_connections يساوي 100 فيظهر الخطأ FATAL: sorry, too many clients already، وتحتها القاعدة: عدد النسخ × عدد العمليات × حجم المجمع يجب أن يبقى أقل من max_connections](https://arabroot.io/content/images/2026/10/connection-pooling-05-multiply-1.webp)

كل مجمع صغير وحده معقول، ولكن المجموع هو ما تراه قاعدة البيانات

والحساب الصحيح هو حاصل ضرب هذه الأرقام ببعضها: عدد النسخ × عدد العمليات داخل كل نسخة (عمال Gunicorn أو عمليات PM2 مثلاً) × الحد الأقصى للمجمع في كل عملية، ثم تضيف العمال الخلفيين Background Workers، ثم تضاعف ما يعمل أثناء النشر إذا كانت النسخة الجديدة تبدأ قبل أن تتوقف القديمة، ثم تضيف أدوات الإدارة والنسخ الاحتياطي. فخمسون Container بمجمع 20 هي 1000 اتصال، وهذا الرقم لا يصلح لقاعدة بحد 100 ولا لقاعدة بحد 1000، والسبب أن قاعدة بثماني أنوية لا تستفيد من أكثر من بضع عشرات من الاستعلامات النشطة في نفس الوقت كما رأيت في [قسم الحجم](#pool-sizing). والحل أحد أمرين أو كلاهما:

- **مجمع أصغر في كل نسخة:** إذا كانت ميزانيتك 80 اتصالاً و10 نسخ بعاملين، فحجم المجمع في كل عامل 4، وهذا يكفي غالباً إذا كانت الاستعلامات سريعة.
- **PgBouncer في وضع transaction:** فتبقى المجمعات في التطبيقات كما هي، وتتصل كلها ب PgBouncer، وهو يحدد العدد الحقيقي بـ `default_pool_size` و`max_db_connections`.

أما ال Serverless، مثل الدوال التي تعمل عند الطلب، فالمشكلة فيها أسوأ، لأن كل نسخة من الدالة قد تفتح اتصالها الخاص، وعدد النسخ يرتفع مع الطلبات دون سقف تتحكم فيه، والنسخة قد تتجمد ثم تختفي دون أن تغلق اتصالها. لذلك المجمع الخارجي هنا ليس تحسيناً وإنما شرط، سواء كان PgBouncer أمام قاعدتك، أو المجمع الذي يقدمه مزود الخدمة مثل RDS Proxy، مع مجمع بحد 1 أو 2 داخل الدالة نفسها.

وتوزيع القراءة والكتابة Read/Write Splitting يدخل في نفس الصورة، فإذا أضفت نسخاً للقراءة Read Replicas كما شرحنا في [مقال تصميم قواعد البيانات](https://arabroot.io/articles/%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D8%B9-%D9%88%D8%A7%D9%84%D8%AA%D9%88%D8%B3%D8%B9/#replicas)، فكل نسخة لها `max_connections` خاص بها وتحتاج مجمعها الخاص، والطريقة الأبسط أن يكون لديك عنوانان في التطبيق، واحد للكتابة وواحد للقراءة، كل واحد بمجمعه، أما أدوات مثل Pgpool-II وPgCat التي توجه الاستعلامات تلقائياً بحسب نوعها فتحتاج اختباراً دقيقاً، لأن القراءة بعد الكتابة مباشرة قد تذهب إلى نسخة متأخرة.

## المجمع Pool بين Nginx والتطبيق Upstream Keepalive

كل ما قلناه عن قاعدة البيانات ينطبق على الخطوة التي قبلها، فعندما يقف Nginx أمام تطبيقك كما شرحنا في دليل [تطبيقك خلف Reverse Proxy](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%83-%D8%AE%D9%84%D9%81-reverse-proxy/)، يصبح Nginx هو الكلاينت والتطبيق هو السيرفر، وكل طلب يصل من المتصفح يحتاج اتصالاً من Nginx إلى التطبيق. فإذا كان Nginx يفتح اتصالاً جديداً لكل طلب ثم يغلقه، فأنت تدفع مصافحة TCP مع كل طلب، وتستهلك شيئاً آخر لا يظهر في أي رسم بياني حتى ينفد، وهو المنافذ المؤقتة Ephemeral Ports.

### لماذا يهم الاتصال بين Nginx والتطبيق؟

كل اتصال TCP يخرج من Nginx إلى التطبيق يأخذ منفذاً محلياً من مجال المنافذ المؤقتة، وتقول [وثائق نواة Linux](https://docs.kernel.org/networking/ip-sysctl.html?ref=arabroot.io) إن المجال الافتراضي في الإعداد `ip_local_port_range` هو من 32768 إلى 60999، أي 28232 منفذاً. ثم إن الطرف الذي يغلق الاتصال أولاً يبقيه في حالة الانتظار `TIME_WAIT` مدة ثابتة هي 60 ثانية كما في الثابت [TCP\_TIMEWAIT\_LEN](https://github.com/torvalds/linux/blob/master/include/net/tcp.h?ref=arabroot.io) في كود النواة، والسبب أن TCP يريد أن يتأكد من أن الحزم المتأخرة من الاتصال القديم لن تختلط باتصال جديد على نفس المنافذ. فعندما يغلق Nginx اتصاله بالتطبيق بعد كل طلب يبقى كل منفذ محجوزاً دقيقة كاملة، وبحساب بسيط فإن 28232 منفذاً على 60 ثانية تعطي حوالي 470 اتصالاً جديداً في الثانية إلى نفس عنوان التطبيق ومنفذه، وبعدها يفشل فتح الاتصال ويرى المستخدم الخطأ 502، وسوف ترى ذلك بالمخرج الحقيقي في [المختبر](https://arabroot.io/articles/%D8%AA%D8%AC%D9%85%D9%8A%D8%B9-%D8%A7%D9%84%D8%A7%D8%AA%D8%B5%D8%A7%D9%84%D8%A7%D8%AA-connection-pooling-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#lab-nginx).

وقد يتساءل البعض: لماذا لم أر هذه المشكلة من قبل وتطبيقي على نفس السيرفر؟ والإجابة أن القيمة الافتراضية للإعداد `tcp_tw_reuse` في نفس الوثائق هي 2، أي أن النواة تعيد استخدام منافذ `TIME_WAIT` عندما يكون ذلك آمناً لحركة loopback فقط، فإذا كان التطبيق على `127.0.0.1` فالنواة تخفي عنك المشكلة غالباً، ثم تظهر فجأة عندما تنقل التطبيق إلى Container على شبكة Docker أو إلى سيرفر آخر.

### إعدادات keepalive في كتلة upstream

الحل هو مجمع اتصالات على جهة Nginx، تسميه وثائقه ذاكرة الاتصالات Cache for Connections، فيبقي Nginx الاتصال بالتطبيق مفتوحاً بعد انتهاء الطلب ويعيد استخدامه للطلب التالي. وقد كان Nginx حتى وقت قريب يفتح اتصالاً جديداً بالتطبيق مع كل طلب إلا إذا أضفت [keepalive](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fupstream%5Fmodule.html?ref=arabroot.io#keepalive) في كتلة `upstream` مع `proxy_http_version 1.1` وحذف ترويسة `Connection`. ومنذ الإصدار 1.29.7 في 24 مارس 2026 تغير ذلك كما في [سجل تغييرات Nginx](https://nginx.org/en/CHANGES?ref=arabroot.io): أصبح `keepalive` مفعلاً افتراضياً بقيمة 32 اتصالاً خاملاً لكل عامل، وأصبحت القيمة الافتراضية ل `proxy_http_version` هي 1.1، ولم يعد Nginx يرسل ترويسة `Connection` إلى التطبيق افتراضياً. لذلك إذا كان إصدارك أقدم فأضف الإعداد بنفسك:

```nginx
upstream app_backend {
    server 127.0.0.1:8080;
    keepalive 16;                      # idle connections kept per worker
}

server {
    listen 443 ssl;
    server_name app.example.com;

    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;          # keep-alive needs HTTP/1.1 to the upstream
        proxy_set_header Connection "";  # do not forward "Connection: close"
    }
}
```

في الإعداد أعلاه لاحظ التالي:

- `proxy_http_version 1.1`: الإصدار 1.0 كان الافتراضي قبل 1.29.7 كما في [وثائق الوحدة](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fproxy%5Fmodule.html?ref=arabroot.io#proxy%5Fhttp%5Fversion)، وفي HTTP/1.0 يغلق الاتصال بعد كل رد، لذلك لا يعمل `keepalive` إلا مع 1.1، أو مع 2 منذ الإصدار 1.29.4 إذا كانت وحدة HTTP/2 موجودة في نسختك.
- `proxy_set_header Connection ""`: الإصدارات القديمة كانت ترسل `Connection: close` إلى التطبيق، فيغلق التطبيق الاتصال بعد الرد مهما كانت قيمة `keepalive`.
- إذا كان التطبيق PHP-FPM عبر FastCGI فالإعداد المقابل هو `fastcgi_keep_conn on` مع `keepalive` في كتلة `upstream` كما في [وثائق upstream](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fupstream%5Fmodule.html?ref=arabroot.io#keepalive%5Ftimeout).

وبقية إعدادات المجمع في كتلة `upstream` من نفس الوثائق هي:

- **`keepalive` (الافتراضي 32 منذ 1.29.7):** أقصى عدد من الاتصالات الخاملة Idle التي يحتفظ بها كل عامل Worker لهذا ال upstream، وإذا زادت أغلق الأقل استخداماً مؤخراً. ولاحظ أمرين: الرقم لكل عامل وليس ل Nginx كله، فإذا كان `worker_processes auto` على سيرفر بثماني أنوية فالسقف 8 × 32 = 256 اتصالاً خاملاً، والرقم لا يحدد عدد الاتصالات الكلي، فتنبه الوثائق إلى أن `keepalive` لا يحد عدد الاتصالات التي يفتحها العامل، وأن تجعله صغيراً بما يكفي ليبقى التطبيق قادراً على قبول اتصالات جديدة. ومنذ 1.29.7 لا تتشارك ال locations المختلفة نفس الاتصالات افتراضياً، وهذا معنى المعامل `local` في القيمة الافتراضية `keepalive 32 local`.
- **`keepalive_requests` (الافتراضي 1000):** بعد هذا العدد من الطلبات يغلق الاتصال ويفتح غيره، والسبب كما في الوثائق تحرير الذاكرة المرتبطة بالاتصال، وكانت القيمة 100 قبل الإصدار 1.19.10.
- **`keepalive_time` (الافتراضي ساعة):** أقصى مدة يخدم فيها الاتصال الواحد الطلبات، وهو المقابل للعمر الأقصى Max Lifetime في مجمعات قواعد البيانات.
- **`keepalive_timeout` (الافتراضي 60 ثانية):** الاتصال الخامل أكثر من هذه المدة يغلق، وهو المقابل لمهلة الخمول Idle Timeout.

وإذا كنت تستخدم طريقة توزيع غير الافتراضية مثل `least_conn` أو `ip_hash` على إصدار أقدم من 1.29.7، فالوثائق تطلب أن تكتبها قبل سطر `keepalive` داخل كتلة `upstream`.

### مهلة التطبيق يجب أن تكون أطول من مهلة Nginx

المجمع هنا له طرفان، وكل طرف يغلق الاتصال الخامل بعد مهلته، فNginx يحتفظ بالاتصال الخامل 60 ثانية، بينما سيرفر HTTP في Node.js يغلقه بعد 5 ثوان كما في وثائق [server.keepAliveTimeout](https://nodejs.org/api/http.html?ref=arabroot.io#serverkeepalivetimeout) مع ثانية إضافية في الإصدارات الحديثة عبر `keepAliveTimeoutBuffer`، وGunicorn في Python ينتظر ثانيتين فقط كما في [وثائق الإعداد keepalive](https://gunicorn.org/reference/settings/?ref=arabroot.io)، أي أن التطبيق هو الذي يغلق أولاً. وفي أغلب الأحيان يرى Nginx إغلاق الاتصال الخامل ويحذفه من المجمع، ولكن إذا أغلقه التطبيق في نفس اللحظة التي أرسل فيها Nginx طلباً جديداً عليه، يصل الطلب إلى اتصال مغلق، وعندها قد يعيد Nginx المحاولة على اتصال آخر لطلب مثل GET، أما POST وPATCH فتقول [وثائق proxy\_next\_upstream](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fproxy%5Fmodule.html?ref=arabroot.io#proxy%5Fnext%5Fupstream) إنها لا ترسل إلى سيرفر آخر افتراضياً بعد إرسالها، فيرى المستخدم الخطأ 502 مرة كل فترة دون أي أثر في سجلات التطبيق، وهذا من أزعج الأخطاء لأنه يظهر تحت الحمل ولا يتكرر عندما تحاول إعادته.

والقاعدة، وهي نفس ما توصي به [وثائق موزع الحمل في AWS](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/edit-load-balancer-attributes.html?ref=arabroot.io) لتطبيقاتها، أن تكون مهلة الخمول في التطبيق أطول من مهلة الخمول في الطرف الذي أمامه، فيكون Nginx هو من يغلق الاتصال الخامل دائماً. فإما أن ترفع مهلة التطبيق فوق 60 ثانية، كما في السطر `server.keepAliveTimeout = Number(process.env.KEEPALIVE_MS || 65000)` في تطبيق المختبر، وفي Gunicorn الخيار `--keep-alive 65` مع عمال `gthread` أو `gevent`، لأن العامل `sync` لا يدعم الاتصالات الدائمة أصلاً كما تذكر الوثائق، وإما أن تخفض `keepalive_timeout` في كتلة `upstream` إلى أقل من مهلة التطبيق، مثل `keepalive_timeout 4s` أمام Node.js بقيمته الافتراضية.

### المختبر: نفس الحمل مع keepalive ودونه

سوف نشغل تطبيق Node.js صغيراً يعد اتصالات TCP التي تصله من Nginx، ثم Nginx 1.31.6 أمامه بعاملين، ونرسل 20000 طلب من 50 كلاينت في نفس الوقت بأداة [ab](https://httpd.apache.org/docs/2.4/programs/ab.html?ref=arabroot.io) الموجودة في Docker Image الرسمية ل httpd، وذلك ثلاث مرات: `keepalive 0` الذي يعطل المجمع مثل الإصدارات الأقدم من 1.29.7، ثم `keepalive 4`، ثم القيمة الافتراضية دون أي سطر. والتطبيق كما يلي:

```javascript
// app.js: a tiny HTTP app that counts the TCP connections it accepts from Nginx.
const http = require('node:http');
let fromProxy = 0, requests = 0;

const server = http.createServer((req, res) => {
  if (req.url === '/stats') return res.end(JSON.stringify({ connections_from_nginx: fromProxy, requests }) + '\n');
  if (req.url === '/reset') { fromProxy = 0; requests = 0; return res.end('reset\n'); }
  requests++;
  res.end('ok\n');
});

// Count every new TCP connection, except our own checks from inside the container.
server.on('connection', (socket) => { if (socket.remoteAddress !== '127.0.0.1') fromProxy++; });

server.keepAliveTimeout = Number(process.env.KEEPALIVE_MS || 65000);  // longer than Nginx's 60s
server.listen(3000, () => console.log(`listening on :3000, keepAliveTimeout=${server.keepAliveTimeout}ms`));
```

وملف Nginx للحالة الافتراضية كما يلي، وفي الملفين الآخرين `nginx-off.conf` و`nginx-small.conf` نضع مكان التعليق داخل `upstream` السطر `keepalive 0;` أو `keepalive 4;`:

```nginx
worker_processes 2;
events { worker_connections 1024; }

http {
    access_log off;

    upstream app_backend {
        server lab-pool2-app:3000;
        # no keepalive line: since 1.29.7 the default is keepalive 32
    }

    server {
        listen 80;
        location / {
            proxy_pass http://app_backend;
        }
    }
}
```

في الإعداد أعلاه لاحظ التالي:

- `worker_processes 2` حتى يكون معنى «لكل عامل» واضحاً في الأرقام، فالسقف في الحالة الافتراضية 2 × 32 = 64 اتصالاً خاملاً.
- لا يوجد `proxy_http_version` ولا `proxy_set_header Connection`، لأن الإصدار 1.31.6 يستخدم 1.1 ولا يرسل الترويسة افتراضياً.
- مهلة التطبيق 65 ثانية، أي أطول من 60 ثانية في Nginx كما شرحنا في القسم السابق.

ثم ننشئ الشبكة ونشغل التطبيق، والسكربت التالي يشغل Nginx بالملف المطلوب ويرسل الحمل، ثم يسأل التطبيق عن عدد الاتصالات، ويعد الاتصالات في حالة `TIME_WAIT` من الملف `/proc/net/tcp`، حيث الحالة 06 هي `TIME_WAIT` و`0BB8` هو المنفذ 3000 بالنظام السداسي عشر:

```bash
docker network create lab-pool2-net
docker run -d --name lab-pool2-app --network lab-pool2-net -p 127.0.0.1:18780:3000 \
  -v "$PWD/app.js:/app.js:ro" node:26-alpine node /app.js
```

```bash
#!/bin/sh
# run-nginx.sh <off|small|default>: same load through Nginx, then count the connections the app accepted.
v=$1
docker rm -f lab-pool2-nginx >/dev/null 2>&1
docker run -d --name lab-pool2-nginx --network lab-pool2-net -p 127.0.0.1:18781:80 \
  -v "$PWD/nginx-$v.conf:/etc/nginx/nginx.conf:ro" nginx:1.31-alpine >/dev/null
sleep 1
docker exec lab-pool2-app wget -qO- http://127.0.0.1:3000/reset >/dev/null
docker run --rm --network lab-pool2-net httpd:2.4-alpine \
  ab -k -n ${N:-20000} -c 50 http://lab-pool2-nginx/ 2>&1 | grep -E "Complete requests|Failed requests|Requests per second|Time per request.*mean\)"
echo "app:   $(docker exec lab-pool2-app wget -qO- http://127.0.0.1:3000/stats)"
echo "nginx TIME_WAIT to app:  $(docker exec lab-pool2-nginx sh -c "cat /proc/net/tcp | awk '\$3 ~ /:0BB8\$/ && \$4 == \"06\"' | wc -l")"
echo "app   TIME_WAIT:         $(docker exec lab-pool2-app sh -c "cat /proc/net/tcp | awk '\$2 ~ /:0BB8\$/ && \$4 == \"06\"' | wc -l")"
```

```bash
for v in off small default; do echo "== $v"; sh run-nginx.sh $v; done
```

والمخرج سوف يكون كما يلي:

```text
== off
Complete requests:      20000
Failed requests:        0
Requests per second:    867.64 [#/sec] (mean)
Time per request:       57.628 [ms] (mean)
app:   {"connections_from_nginx":20001,"requests":20000}
nginx TIME_WAIT to app:  20000
app   TIME_WAIT:         0
== small
Complete requests:      20000
Failed requests:        0
Requests per second:    12414.16 [#/sec] (mean)
Time per request:       4.028 [ms] (mean)
app:   {"connections_from_nginx":1757,"requests":20000}
nginx TIME_WAIT to app:  1748
app   TIME_WAIT:         0
== default
Complete requests:      20000
Failed requests:        0
Requests per second:    7925.34 [#/sec] (mean)
Time per request:       6.309 [ms] (mean)
app:   {"connections_from_nginx":102,"requests":20000}
nginx TIME_WAIT to app:  52
app   TIME_WAIT:         0
```

في المخرج أعلاه لاحظ التالي:

- دون المجمع فتح Nginx اتصالاً جديداً لكل طلب تقريباً، 20001 اتصال مقابل 20000 طلب، وبقي 20000 منفذ في حالة `TIME_WAIT` على جهة Nginx وصفر على جهة التطبيق، لأن Nginx هو الذي يغلق الاتصال أولاً، فالمنافذ التي تنفد هي منافذ Nginx.
- مع `keepalive 4` نزل العدد إلى 1757، ولكنه ما زال كبيراً، والسبب أن الحمل 50 طلباً في نفس الوقت والمجمع لا يحتفظ إلا بأربعة اتصالات خاملة لكل عامل، فكل ما زاد عنها يغلق بعد الطلب ثم يفتح غيره في الدفعة التالية، فالرقم الصغير جداً يضيع جزءاً كبيراً من الفائدة.
- مع القيمة الافتراضية خدم 102 اتصال كل الطلبات، أي أن كل اتصال حمل حوالي 200 طلب، وال 52 اتصالاً في `TIME_WAIT` هي ما زاد عن 32 لكل عامل في لحظات الذروة.
- عدد الطلبات في الثانية تغير كثيراً بين الحالات وبين التشغيلات، فلا تستنتج منه أن `keepalive 4` أسرع من 32، فالحمل والتطبيق وNginx كلها على جهاز واحد عليه Docker، والرقم الذي يهمك هنا هو عدد الاتصالات.

الآن نكسر الإعداد عمداً، فنرسل 45000 طلب بدل 20000 مع `keepalive 0`، أي أكثر من المنافذ المتاحة في دقيقة واحدة، ثم نعد أخطاء Nginx:

```bash
N=45000 sh run-nginx.sh off
docker logs lab-pool2-nginx 2>&1 | grep -c "Address not available"
docker logs lab-pool2-nginx 2>&1 | grep -m1 "Address not available"
```

والمخرج سوف يكون كما يلي:

```text
Complete requests:      45000
Failed requests:        2592
Requests per second:    675.16 [#/sec] (mean)
Time per request:       74.056 [ms] (mean)
app:   {"connections_from_nginx":42409,"requests":42408}
nginx TIME_WAIT to app:  25560
app   TIME_WAIT:         0
2592
2026/10/06 04:34:33 [crit] 31#31: *41 connect() to 192.168.96.2:3000 failed (99: Address not available) while connecting to upstream, client: 192.168.96.4, server: , request: "GET / HTTP/1.0", upstream: "http://192.168.96.2:3000/", host: "lab-pool2-nginx"
```

لاحظ أن 2592 طلباً فشلت، وهو نفس عدد أسطر الخطأ في سجل Nginx، والطلبات التي وصلت للتطبيق 42408 أي 45000 ناقص 2592، وأن عدد المنافذ في `TIME_WAIT` وصل إلى 25560 قريباً من سقف 28232، فالرسالة `connect() failed (99: Address not available)` تعني أن النواة لم تجد منفذاً محلياً حراً لاتصال جديد، وكل طلب من هذه رأى المستخدم مقابله الخطأ 502\. وبعد دقيقة من الانتظار حتى تفرغ المنافذ، أعدنا نفس ال 45000 طلب بالإعداد الافتراضي:

```bash
N=45000 sh run-nginx.sh default
```

```text
Complete requests:      45000
Failed requests:        0
Requests per second:    3150.08 [#/sec] (mean)
Time per request:       15.873 [ms] (mean)
app:   {"connections_from_nginx":322,"requests":45000}
nginx TIME_WAIT to app:  275
app   TIME_WAIT:         0
```

لا يوجد أي خطأ، و322 اتصالاً فقط خدمت 45000 طلب. والصورة التالية تبين نتيجة المختبر:

![صفان: الأول keepalive 0 حيث يرسل ab عشرين ألف طلب إلى Nginx بعاملين فيفتح خطوطاً حمراء كثيرة إلى التطبيق، والنتيجة 20001 اتصال جديد و20000 منفذ في TIME_WAIT على جهة Nginx، ومع 45000 طلب 2592 خطأ 502 بسبب 99: Address not available، وتحته شريط يذكر أن المنافذ المؤقتة من 32768 إلى 60999 أي 28232 منفذاً وكل منفذ يبقى 60 ثانية في TIME_WAIT. والثاني keepalive 32 الافتراضي منذ 1.29.7 حيث يحتفظ كل عامل بحتى 32 اتصالاً خاملاً، فتخدم أربعة خطوط خضراء كل الطلبات، والنتيجة 102 اتصال و52 في TIME_WAIT ودون أخطاء مع 45000 طلب، والرقم لكل عامل أي عاملان × 32 = حتى 64 اتصالاً خاملاً](https://arabroot.io/content/images/2026/10/connection-pooling-07-nginx-upstream.webp)

دون المجمع يأخذ كل طلب منفذاً جديداً من Nginx لمدة دقيقة، ومع المجمع تخدم بضع عشرات من الاتصالات عشرات الآلاف من الطلبات

احذف Container ال Nginx بعد الانتهاء، أما التطبيق والشبكة فسوف نحذفهما مع مختبر Redis في القسم التالي:

```bash
docker rm -f lab-pool2-nginx
```

## مجمع الاتصالات Pool في Redis وValkey

Redis وبديله المفتوح Valkey يقفان غالباً بجانب قاعدة البيانات للتخزين المؤقت Cache والجلسات Sessions والطوابير Queues، وسوف نشرحهما بالتفصيل في [مرحلة قواعد البيانات في خريطة التعلم](https://arabroot.io/start/#stage-databases)، أما هنا فيهمنا جانب واحد هو الاتصال، لأن نفس الخطأ الذي رأيته مع PostgreSQL يتكرر معهما كثيراً، وهو أن ينشئ الكود كلاينت جديداً مع كل طلب.

### ماذا يكلف الاتصال في Redis وValkey؟

الاتصال ب Valkey أرخص بكثير من الاتصال ب PostgreSQL، فلا توجد عملية جديدة لكل اتصال، ولكنه ليس مجانياً. فتقول [وثائق Valkey](https://valkey.io/topics/latency/?ref=arabroot.io) إنه يستخدم في الغالب تصميماً أحادي الخيط Single-threaded لتنفيذ الأوامر، أي أن عملية واحدة تنفذ أوامر كل الكلاينتات واحداً بعد الآخر، وقد تعالج عمليات الإدخال والإخراج I/O في الخلفية ولكن لا ينفذ إلا أمر واحد في اللحظة، لذلك مئة اتصال لا تجعل Valkey ينفذ مئة أمر في نفس الوقت، كما رأيت مع أنوية PostgreSQL. وكل اتصال مفتوح يحجز واصف ملف File Descriptor وذاكرة لمخازن الإدخال والإخراج Buffers، وتذكر [وثائق الكلاينتات](https://valkey.io/topics/clients/?ref=arabroot.io) أن القيمة الافتراضية للإعداد `maxclients` هي 10000، وأن Valkey يحجز 32 واصفاً لنفسه ويخفض الحد تلقائياً إذا كان حد نظام التشغيل `ulimit -n` أقل، وأن الكلاينت الزائد يستقبل خطأ ثم يغلق اتصاله، وأن اتصالات الكلاينتات عندما تكثر قد تستهلك ذاكرة كبيرة تصل إلى حذف البيانات Eviction أو نفاد الذاكرة، لذلك فيه آلية لقطعها اسمها Client Eviction.

وتعرف حال الاتصالات من ثلاثة أوامر:

- [**INFO clients**](https://valkey.io/commands/info/?ref=arabroot.io)**:** الحقل `connected_clients` هو عدد الاتصالات المفتوحة الآن، و`blocked_clients` عدد الكلاينتات المنتظرة في أمر حاجب مثل `BLPOP`، و`maxclients` هو الحد.
- **`INFO stats`:** الحقل `total_connections_received` هو عدد الاتصالات التي قبلها السيرفر منذ تشغيله، وهو أهم رقم لكشف الكلاينت الذي يفتح اتصالاً مع كل طلب، لأن `connected_clients` قد يبقى صغيراً بينما هذا الرقم يرتفع بالآلاف كما سوف ترى في المختبر، و`rejected_connections` عدد الاتصالات المرفوضة بسبب `maxclients`.
- [**CLIENT LIST**](https://valkey.io/commands/client-list/?ref=arabroot.io)**:** سطر لكل اتصال فيه العنوان وعمر الاتصال `age` ومدة خموله `idle` وآخر أمر `cmd` واسم المكتبة `lib-name`، فتعرف أي تطبيق يفتح هذه الاتصالات.

### المجمع Pool في مكتبات Redis المشهورة

المكتبات هنا تختلف أكثر من مكتبات PostgreSQL، والسبب أن بروتوكول Redis يسمح بإرسال عدة أوامر على نفس الاتصال دون انتظار رد كل واحد، وهذا ما يسمى Pipelining، فبعض المكتبات تكتفي باتصال واحد يخدم كل الطلبات، وبعضها يستخدم مجمعاً مثل مكتبات قواعد البيانات. والجدول التالي من وثائق كل مكتبة وكودها عند كتابة المقال:

| المكتبة                                                                                              | السلوك الافتراضي                                                                                                           | ملاحظات                                                                                                                                                                                                                                                                                                                            |
| ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [redis-py](https://redis.readthedocs.io/en/stable/connections.html?ref=arabroot.io) (Python)         | كل Redis() معه ConnectionPool، وحده الأقصى max\_connections \= 100 منذ الإصدار 8.0، وإذا امتلأ يرفع ConnectionError فوراً. | BlockingConnectionPool ينتظر بدل أن يفشل، وقيمه الافتراضية في [الكود](https://github.com/redis/redis-py/blob/master/redis/connection.py?ref=arabroot.io) 50 اتصالاً ومهلة 20 ثانية، ومنذ [الإصدار 8.0](https://github.com/redis/redis-py/releases/tag/v8.0.0?ref=arabroot.io) أصبحت مهلة القراءة socket\_timeout 5 ثوان افتراضياً. |
| [node-redis](https://github.com/redis/node-redis/blob/master/docs/pool.md?ref=arabroot.io) (Node.js) | createClient() اتصال واحد لكل الأوامر.                                                                                     | للأوامر الحاجبة وWATCH يوجد createClientPool() بحد أدنى 1 وأقصى 100 ومهلة انتظار 3000 مللي ثانية، وكان اسمه في الإصدار 4 مجمع العزل Isolation Pool فوق الاتصال الرئيسي ثم أصبح كلاساً مستقلاً في الإصدار 5.                                                                                                                        |
| [ioredis](https://github.com/redis/ioredis?ref=arabroot.io) (Node.js)                                | كل كائن new Redis() هو اتصال واحد كما يذكر ال README.                                                                      | الخيار enableAutoPipelining يجمع الأوامر التي تصدر في نفس دورة ال Event Loop في Pipeline واحد، وللاشتراكات Pub/Sub والأوامر الحاجبة تنشئ كائناً ثانياً.                                                                                                                                                                            |
| [go-redis](https://pkg.go.dev/github.com/redis/go-redis/v9?ref=arabroot.io#Options) (Go)             | PoolSize \= 10 × runtime.GOMAXPROCS(0)، أي 10 اتصالات لكل نواة.                                                            | تذكر [التعليقات في الكود](https://github.com/redis/go-redis/blob/master/options.go?ref=arabroot.io) أن المجمع قد يفتح اتصالات فوق PoolSize عند الحاجة، وأن الحد الحقيقي هو MaxActiveConns.                                                                                                                                         |

في الجدول أعلاه لاحظ التالي:

- **اتصال واحد في Node.js يكفي غالباً:** لأن Node.js يعمل بخيط واحد وحلقة أحداث Event Loop، فالأوامر من كل الطلبات تصطف على نفس الاتصال وتعود ردودها بالترتيب، ولا تحتاج مجمعاً إلا للحالات التي تحجز الاتصال كما في [دليل المجمع في node-redis](https://github.com/redis/node-redis/blob/master/docs/pool.md?ref=arabroot.io).
- **go-redis يتضاعف مع الأنوية:** نسخة واحدة على سيرفر ب 32 نواة تعني مجمعاً من 320 اتصالاً، وعشرون نسخة تعني 6400 من أصل 10000 في `maxclients`، وهذا نفس تضاعف الاتصالات الذي شرحناه في [قسم النسخ الكثيرة](#many-instances).
- **الأوامر الحاجبة تحجز الاتصال:** تقول [وثائق BLPOP](https://valkey.io/commands/blpop/?ref=arabroot.io) إنه يحجب الاتصال Blocks the Connection حتى يصل عنصر أو تنتهي المهلة، فالعامل الذي ينتظر المهام ب `BLPOP` يمسك اتصالاً من المجمع طوال انتظاره، وإذا كان المجمع صغيراً ينتظر باقي الكود، وسوف ترى ذلك في المختبر، لذلك أعط العمال الذين ينتظرون المهام كلاينت أو مجمعاً منفصلاً.
- **Pub/Sub يحتاج اتصالاً خاصاً به:** تقول [وثائق Pub/Sub](https://valkey.io/topics/pubsub/?ref=arabroot.io) إن الكلاينت المشترك في قناة لا يرسل في بروتوكول RESP2 إلا أوامر الاشتراك وإلغائه و`PING` و`QUIT` و`RESET`، بينما يسمح RESP3 بكل الأوامر، لذلك تطلب [وثائق node-redis](https://github.com/redis/node-redis/blob/master/docs/pub-sub.md?ref=arabroot.io) نسخة مستقلة بالدالة `duplicate()` للمشترك.

### المختبر: كلاينت لكل طلب أو مجمع واحد

نشغل Valkey 9.1.2 على نفس الشبكة، ثم Container ل Python نثبت فيه redis-py، والسكربت التالي ينفذ 2000 طلب، وكل طلب هنا أمر `SET` ثم `GET`، مرة بكلاينت جديد لكل طلب ومرة بكلاينت واحد مشترك، ثم يرسل 50 خيطاً Thread في نفس الوقت على مجمعين، ويطبع بعد كل مرحلة `connected_clients` و`total_connections_received`:

```python
# pool_lab.py: client per request vs one shared pool against Valkey (timings are illustrative).
import sys, time, threading
import redis

HOST, N = "lab-pool2-valkey", 2000
admin = redis.Redis(host=HOST)          # a separate client only for reading INFO

def stats(label):
    c, s = admin.info("clients"), admin.info("stats")
    print(f"{label:<28} connected_clients={c['connected_clients']:<4} total_connections_received={s['total_connections_received']}")

def handle_request_new_client(i):
    r = redis.Redis(host=HOST)          # WRONG: a new client (and a new pool) for every request
    r.set(f"user:{i}", "x")
    return r.get(f"user:{i}")

shared = redis.Redis(host=HOST)         # RIGHT: created once when the app starts
def handle_request_shared(i):
    shared.set(f"user:{i}", "x")
    return shared.get(f"user:{i}")

def bench(label, fn):
    t = time.perf_counter()
    for i in range(N):
        fn(i)
    ms = (time.perf_counter() - t) * 1000
    print(f"{label:<28} {N} requests in {ms:.0f} ms ({ms / N:.3f} ms each)")
    stats(label)

print("redis-py", redis.__version__)
stats("start")
bench("client per request", handle_request_new_client)
bench("shared client", handle_request_shared)

# 50 threads on one client: the default pool opens a connection for each busy thread.
def burst(client, label):
    ths = [threading.Thread(target=lambda: [client.get("k") for _ in range(200)]) for _ in range(50)]
    [t.start() for t in ths]; [t.join() for t in ths]
    stats(label)
burst(redis.Redis(host=HOST), "50 threads, default pool")
burst(redis.Redis(connection_pool=redis.BlockingConnectionPool(host=HOST, max_connections=10)),
      "50 threads, blocking pool 10")
```

```bash
docker run -d --name lab-pool2-valkey --network lab-pool2-net -p 127.0.0.1:18782:6379 valkey/valkey:9-alpine
docker run -d --name lab-pool2-py --network lab-pool2-net -v "$PWD:/lab:ro" python:3.13-slim sleep infinity
docker exec lab-pool2-py pip install -q redis
docker exec lab-pool2-py python /lab/pool_lab.py
```

والمخرج سوف يكون كما يلي:

```text
redis-py 8.1.0
start                        connected_clients=1    total_connections_received=1
client per request           2000 requests in 19928 ms (9.964 ms each)
client per request           connected_clients=1    total_connections_received=2001
shared client                2000 requests in 1979 ms (0.990 ms each)
shared client                connected_clients=2    total_connections_received=2002
50 threads, default pool     connected_clients=52   total_connections_received=2052
50 threads, blocking pool 10 connected_clients=12   total_connections_received=2062
```

في المخرج أعلاه لاحظ التالي:

- الكلاينت لكل طلب فتح 2000 اتصال لألفي طلب، وأخذ الطلب الواحد حوالي 10 مللي ثوان مقابل مللي ثانية واحدة مع الكلاينت المشترك، والسبب أن كل كلاينت جديد يبحث عن عنوان السيرفر ويفتح اتصال TCP ويرسل أوامر التعريف بالمكتبة قبل أمرنا، وهذه الأزمنة من جهاز واحد عليه Docker وهي للمقارنة فقط.
- `connected_clients` بقي 1 بعد الكلاينت لكل طلب، فلو نظرت إليه وحده لظننت أن كل شيء سليم، والسبب أن Python يغلق اتصال الكلاينت القديم عندما يتخلص من الكائن، أما `total_connections_received` فقفز 2000، لذلك راقب معدل هذا الرقم وليس `connected_clients` وحده. وفي بيئة لا يغلق فيها الكلاينت القديم فوراً، أو تحت حمل حقيقي متزامن، ترتفع الاتصالات المفتوحة نفسها حتى تصل إلى `maxclients`.
- الكلاينت المشترك فتح اتصالاً واحداً فقط لكل الطلبات، فارتفع الرقم من 2001 إلى 2002.
- 50 خيطاً على المجمع الافتراضي فتحت 50 اتصالاً، أي اتصالاً لكل خيط مشغول، فوصل `connected_clients` إلى 52 مع كلاينت المراقبة والكلاينت المشترك، بينما `BlockingConnectionPool` بحد 10 خدم نفس الخيوط الخمسين بعشرة اتصالات، والباقي انتظر دوره في المجمع كما يفعل أي مجمع جيد.

الآن نرى ماذا يفعل الأمر الحاجب بالمجمع، فالسكربت التالي يضع خيطين ينتظران المهام ب `BLPOP` مدة 3 ثوان على مجمع بحد 2، ثم يحاول نفس التطبيق أن ينفذ `GET` عادياً:

```python
# blpop_lab.py: a blocking command holds its pooled connection until it returns.
import threading, time
import redis

pool = redis.BlockingConnectionPool(host="lab-pool2-valkey", max_connections=2, timeout=1)
r = redis.Redis(connection_pool=pool)

for n in range(2):  # two workers wait up to 3 seconds for a job
    threading.Thread(target=lambda: r.blpop("jobs", timeout=3), daemon=True).start()
time.sleep(0.5)

admin = redis.Redis(host="lab-pool2-valkey")   # outside the pool, to look at the server
for line in admin.client_list():
    print(f"id={line['id']} cmd={line['cmd']} flags={line['flags']} idle={line['idle']}")
print("blocked_clients =", admin.info("clients")["blocked_clients"])

t = time.perf_counter()
try:
    r.get("user:1")                   # a normal request from the same app
except redis.exceptions.ConnectionError as e:
    print(f"GET failed after {time.perf_counter() - t:.1f} s: {e}")
```

```bash
docker exec lab-pool2-py python /lab/blpop_lab.py
```

والمخرج سوف يكون كما يلي:

```text
id=2079 cmd=blpop flags=b idle=0
id=2080 cmd=blpop flags=b idle=0
id=2081 cmd=client|list flags=N idle=0
blocked_clients = 2
GET failed after 1.0 s: No connection available.
```

لاحظ أن `CLIENT LIST` يبين الاتصالين وآخر أمر عليهما `blpop` والعلامة `flags=b` التي تعني أن الكلاينت محجوب Blocked، وأن الأمر `GET` البسيط انتظر ثانية كاملة ثم فشل بالخطأ `No connection available.` رغم أن Valkey نفسه لا يفعل شيئاً، فالمشكلة في المجمع وليست في السيرفر. والصورة التالية تبين نتائج هذا المختبر:

![ثلاثة أجزاء مرقمة: 1 كلاينت جديد لكل طلب، تطبيق يرسل 2000 طلب بخطوط حمراء كثيرة إلى Valkey، والنتيجة total_connections_received زاد 2000 و9.96 مللي ثانية للطلب، 2 كلاينت واحد ينشأ عند التشغيل مع ConnectionPool وخط أخضر واحد إلى Valkey، والنتيجة زيادة اتصال واحد و0.99 مللي ثانية للطلب، 3 مجمع BlockingConnectionPool بحد 2 اتصالاه محجوزان بأمر BLPOP لمدة 3 ثوان مع flags=b، فينتظر الأمر GET user:1 ثانية ثم يفشل بالخطأ No connection available](https://arabroot.io/content/images/2026/10/connection-pooling-08-redis-pool.webp)

الكلاينت لكل طلب يفتح اتصالاً مع كل طلب، والكلاينت المشترك يفتح اتصالاً واحداً، والأمر الحاجب يمسك اتصاله من المجمع حتى يعود

وهناك فخ آخر ظهر أثناء المختبر يتعلق بمهلة القراءة الافتراضية في redis-py 8، فعندما أرسلنا `BLPOP` بمهلة 10 ثوان على كلاينت بالإعدادات الافتراضية، بعد تصفير الإحصاءات بالأمر `CONFIG RESETSTAT`:

```bash
docker exec lab-pool2-valkey valkey-cli CONFIG RESETSTAT
docker exec lab-pool2-py python -c "
import redis, time
r = redis.Redis(host='lab-pool2-valkey')
t = time.perf_counter()
try:
    r.blpop('jobs', timeout=10)
except redis.exceptions.TimeoutError as e:
    print(f'BLPOP failed after {time.perf_counter() - t:.1f} s: {e}')
"
docker exec lab-pool2-valkey valkey-cli INFO stats | grep total_connections_received
```

```text
BLPOP failed after 58.9 s: Timeout reading from socket
total_connections_received:13
```

لاحظ أن الأمر لم ينتظر 10 ثوان كما طلبنا، وإنما فشل بعد 58.9 ثانية، والسبب كما تذكر [ملاحظات الإصدار 8.0](https://github.com/redis/redis-py/releases/tag/v8.0.0?ref=arabroot.io) أن `socket_timeout` أصبح 5 ثوان افتراضياً، فتنتهي مهلة القراءة قبل أن ينتهي `BLPOP`، وأن المكتبة أصبحت تعيد المحاولة 10 مرات افتراضياً، فأرسلت الأمر على اتصال جديد بعد كل فشل، وهذا ما يظهر في `total_connections_received`: 13 اتصالاً منها 11 من محاولات `BLPOP` واثنان من أوامر `valkey-cli`. لذلك إذا استخدمت الأوامر الحاجبة مع redis-py 8 فاجعل لها كلاينت منفصلاً بقيمة `socket_timeout` أكبر من مهلة الأمر.

وبعد الانتهاء احذف ال Containers والشبكة التي أنشأناها في المختبرين:

```bash
docker rm -f lab-pool2-app lab-pool2-valkey lab-pool2-py
docker network rm lab-pool2-net
```

## كيف تراقب المجمع Pool؟

المجمع الذي لا تراقبه سوف يخبرك بمشكلته عن طريق المستخدمين، لذلك راقب هذه الأرقام:

- **وقت الانتظار Wait Time:** أهم رقم على الإطلاق، فهو الوقت الذي ينتظره الطلب حتى يحصل على اتصال، وفي PgBouncer تجده في `maxwait` و`cl_waiting` من `SHOW POOLS`، وتقول [الوثائق](https://www.pgbouncer.org/usage.html?ref=arabroot.io) إن ارتفاع `maxwait` يعني أن الاتصالات الحقيقية لا تخدم الطلبات بالسرعة الكافية. وفي HikariCP تجده في مقياس انتظار الاستعارة إذا ربطته بنظام المراقبة.
- **الاتصالات النشطة والخاملة Active/Idle:** إذا كانت كل اتصالات المجمع نشطة دائماً فإما أن المجمع صغير، وإما أن الاستعلامات بطيئة، وغالباً الثاني، فابدأ بالاستعلامات كما شرحنا في [مقال الجداول الضخمة](https://arabroot.io/articles/postgresql-%D9%84%D9%84%D8%AC%D8%AF%D8%A7%D9%88%D9%84-%D8%A7%D9%84%D8%B6%D8%AE%D9%85%D8%A9/) قبل أن تكبر المجمع.
- **الحالة في PostgreSQL:** العمود `state` في [pg\_stat\_activity](https://www.postgresql.org/docs/18/monitoring-stats.html?ref=arabroot.io#MONITORING-PG-STAT-ACTIVITY-VIEW) يبين لك كم اتصالاً `active` وكم `idle`، والأخطر هو `idle in transaction`، أي كود بدأ معاملة واستعار الاتصال ثم نسيه، وهذا يحجز الاتصال ويمنع VACUUM، فاضبط [idle\_in\_transaction\_session\_timeout](https://www.postgresql.org/docs/18/runtime-config-client.html?ref=arabroot.io) لينهي هذه الجلسات.
- **اتصالات Nginx بالتطبيق:** الأمر `ss -s` على سيرفر Nginx يبين عدد الاتصالات في حالة `timewait`، فإذا اقترب من عشرات الآلاف فالمجمع معطل أو صغير، والسطر `connect() failed (99: Address not available)` في سجل الأخطاء يعني أن المنافذ نفدت فعلاً.
- **اتصالات Redis وValkey:** معدل ارتفاع `total_connections_received` في الدقيقة، فإذا كان قريباً من عدد الطلبات فهناك كلاينت ينشأ مع كل طلب، ثم `connected_clients` مقارنة ب `maxclients`، و`rejected_connections` الذي يجب أن يبقى صفراً.
- **أخطاء انتهاء مهلة الاستعارة:** كل خطأ من نوع «لم أحصل على اتصال خلال 30 ثانية» يعني طلباً فشل، فاجعله تنبيهاً وليس سطراً في السجل فقط.

## الأخطاء الشائعة

- **المجمع الكبير جداً:** 100 اتصال لكل نسخة لأن «الأكثر أسرع»، وقد رأيت في [قسم الحجم](#pool-sizing) أن الأقل أسرع غالباً، فابدأ صغيراً وكبر بالقياس.
- **مجمع لكل طلب:** إنشاء المجمع داخل دالة الطلب أو داخل ال Controller يعني أن كل طلب يفتح اتصالاته ثم يتركها، وهذا أسوأ من عدم وجود مجمع، فأنشئه مرة واحدة عند تشغيل التطبيق.
- **نسيان إعادة الاتصال:** استعارة دون `release` في `finally` أو دون `with` أو `try-with-resources`، فيتسرب Leak اتصال مع كل خطأ حتى يفرغ المجمع ويتوقف التطبيق كله، وHikariCP فيه الإعداد `leakDetectionThreshold` الذي يسجل تحذيراً عندما يبقى الاتصال مستعاراً أكثر من المدة التي تحددها.
- **نسيان تضاعف الاتصالات:** حساب المجمع لنسخة واحدة ثم تشغيل 50 نسخة، أو نسيان أن Gunicorn بتسعة عمال هو تسعة مجمعات، أو نسيان النشر الذي يضاعف كل شيء دقيقة.
- **رفع max\_connections بدل التجميع:** يؤجل الرسالة ويزيد استهلاك الذاكرة والتنافس، والحل المجمع وPgBouncer.
- **وضع transaction مع SET وLISTEN والأقفال الاستشارية:** رأيت في المختبر أن الإعداد يضيع من صاحبه ويذهب لغيره، فراجع الكود والمكتبات قبل التحويل، وانقل ما يحتاج الجلسة إلى اتصال مباشر أو مجمع بوضع session.
- **إمساك الاتصال أثناء عمل بطيء:** استعارة الاتصال ثم استدعاء API خارجي أو إرسال بريد قبل `COMMIT`، فيبقى الاتصال محجوزاً ثواني، والحل أن تنهي عمل قاعدة البيانات وتعيد الاتصال، ثم تنفذ العمل البطيء أو ترسله إلى طابور كما شرحنا في [قسم المهام الخلفية](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D8%A7%D9%84%D8%AC-%D9%88%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-%D9%88%D8%A7%D9%84%D8%AA%D8%B2%D8%A7%D9%85%D9%86-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#background-jobs).
- **Nginx دون مجمع أمام التطبيق:** إصدار أقدم من 1.29.7 دون `keepalive`، أو `proxy_http_version` باقية على 1.0، فيفتح Nginx اتصالاً لكل طلب حتى تنفد المنافذ ويظهر الخطأ 502 كما رأيت في المختبر.
- **مهلة التطبيق أقصر من مهلة Nginx:** Node.js بخمس ثوان وGunicorn بثانيتين خلف Nginx بستين ثانية، فتظهر أخطاء 502 متفرقة تحت الحمل، والحل أن يغلق Nginx الاتصال الخامل أولاً.
- **كلاينت Redis لكل طلب:** إنشاء `Redis()` أو `createClient()` داخل دالة الطلب، فيرتفع `total_connections_received` مع كل طلب ويتضاعف زمنه، ومثله استخدام نفس المجمع الصغير لأوامر `BLPOP` ولطلبات المستخدمين.
- **مجمع دون مهلة ودون فحص صحة:** طلبات تنتظر للأبد عندما يمتلئ المجمع، أو تفشل بعد كل إعادة تشغيل لقاعدة البيانات لأن المجمع يعطيها اتصالات ميتة Dead Connections انقطعت دون أن يعلم.

## قائمة تحقق

1. هل يوجد مجمع واحد لكل عملية في التطبيق، ينشأ عند التشغيل وليس مع كل طلب؟
2. هل حسبت ميزانية الاتصالات: النسخ × العمليات × الحد الأقصى للمجمع + العمال الخلفيون + النشر + أدوات الإدارة، وهل المجموع أقل من `max_connections` مع هامش؟
3. هل لكل مجمع مهلة استعارة بالثواني، وفحص صحة، وعمر أقصى للاتصال؟
4. هل كل استعارة تعاد في `finally` أو ما يقابلها في لغتك، وهل المعاملات قصيرة ولا تنتظر خدمات خارجية؟
5. إذا كثرت النسخ أو الخدمات أو كان لديك Serverless، هل يوجد PgBouncer أو مجمع خارجي أمام قاعدة البيانات؟
6. إذا كان PgBouncer في وضع transaction، هل راجعت `SET` و`LISTEN` والأقفال الاستشارية والجداول المؤقتة، وهل الإصدار 1.21 أو أحدث مع `max_prepared_statements` أكبر من صفر؟
7. هل `max_db_connections` مضبوط حتى لا تفتح أزواج المستخدمين وقواعد البيانات أكثر من الحد؟
8. هل تراقب وقت الانتظار في المجمع، و`idle in transaction` في PostgreSQL، وأخطاء انتهاء مهلة الاستعارة؟
9. هل اتصالات Nginx مع التطبيق تعاد استخدامها، إما بإصدار 1.29.7 أو أحدث أو بإعداد `keepalive` يدوياً، وهل مهلة الخمول في التطبيق أطول من `keepalive_timeout` في Nginx؟
10. هل كلاينت Redis أو Valkey ينشأ مرة واحدة عند التشغيل، وهل للأوامر الحاجبة وPub/Sub اتصالات منفصلة عن طلبات المستخدمين؟

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- فتح الاتصال بقاعدة البيانات مكلف: TCP وTLS ومصادقة، وفي PostgreSQL عملية كاملة لكل اتصال، وفي المختبر أخذ كل اتصال حوالي 19 مللي ثانية مقابل استعلام أقل من مللي ثانية.
- المجمع يفتح الاتصالات مرة ويعيرها للطلبات، والطلب الزائد ينتظر في طابور بدل أن يرهق القاعدة، وحجمه الصحيح صغير ويبدأ من عدد أنوية قاعدة البيانات وليس من عدد المستخدمين.
- ابدأ بالمجمع داخل التطبيق وحدد حده الأقصى ومهلته دائماً، ثم أضف PgBouncer عندما تكثر النسخ أو الخدمات أو تستخدم Serverless، وأبق المجمعات الصغيرة في التطبيقات.
- وضع transaction في PgBouncer هو الذي يعطي الفائدة الكبيرة، فقد خدم 200 كلاينت بعشرة اتصالات حقيقية، ولكنه يكسر ما يرتبط بالجلسة، فاستخدم `SET LOCAL` والأقفال المرتبطة بالمعاملة، وتأكد من دعم الاستعلامات المجهزة.
- كل مجمع صغير معقول وحده، والمشكلة في تضاعفها مع عدد النسخ والعمليات، فاحسب المجموع، وتذكر أن الاتصالات الخاملة في المجمعات تحسب من الحد.
- Nginx نفسه مجمع اتصالات أمام التطبيق، ودون `keepalive` فتح في المختبر اتصالاً لكل طلب حتى نفدت المنافذ وظهر الخطأ 502، ومع القيمة الافتراضية منذ 1.29.7 خدم 322 اتصالاً 45000 طلب، واجعل مهلة التطبيق أطول من مهلة Nginx.
- في Redis وValkey أنشئ الكلاينت مرة واحدة، فالكلاينت لكل طلب فتح 2000 اتصال لألفي طلب وكان أبطأ بعشر مرات، وراقب `total_connections_received`، وافصل الأوامر الحاجبة وPub/Sub عن مجمع الطلبات.
- نفس الفكرة في HTTP مع Keep-Alive وHTTP/2، وفي SMTP ومجمعات الخيوط.

[![](https://arabroot.io/content/images/2026/10/postgres-large-tables-cover-cover.webp)دليلPostgreSQL للجداول الضخمة: عشرات الملايين من الصفوفماذا تفعل عندما يصل جدول الطلبات إلى عشرات الملايين من الصفوف وتبطئ لوحة التحكم ويمتلئ القرص، وكيف تقيس قبل أن تصلح.](https://arabroot.io/articles/postgresql-%D9%84%D9%84%D8%AC%D8%AF%D8%A7%D9%88%D9%84-%D8%A7%D9%84%D8%B6%D8%AE%D9%85%D8%A9/)[![](https://arabroot.io/content/images/2026/10/db-design-cover-cover.webp)دليلتصميم قواعد البيانات: التطبيع Normalization ومتى تكسرهلماذا يفشل الجدول الواحد الكبير، وكيف تقسمه إلى جداول لا تناقض نفسها، وما الذي يتغير عندما تكبر البيانات.](https://arabroot.io/articles/%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D8%B9-%D9%88%D8%A7%D9%84%D8%AA%D9%88%D8%B3%D8%B9/)[![](https://arabroot.io/content/images/2026/10/cpu-concurrency-cover-cover.webp)دليلالتزامن Concurrency والخيوط: ماذا يعني لسيرفرك وتطبيقاتكلماذا يكون المعالج عند 100% والتطبيق بطيئاً، ولا تسرع الخيوط كود Python، وكيف تضبط عدد العمال وحدود ال Containers.](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D8%A7%D9%84%D8%AC-%D9%88%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-%D9%88%D8%A7%D9%84%D8%AA%D8%B2%D8%A7%D9%85%D9%86-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/system-design-intro-cover-cover.webp)دليلمدخل إلى تصميم الأنظمة: من سيرفر واحد إلى ملايين المستخدمينكيف يكبر متجر إلكتروني من سيرفر واحد إلى نظام يخدم ملايين المستخدمين، وما الثمن الذي تدفعه في كل خطوة.](https://arabroot.io/articles/%D9%85%D8%AF%D8%AE%D9%84-%D8%A7%D9%84%D9%89-%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D8%A7%D9%84%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D9%85%D9%86-%D8%B3%D9%8A%D8%B1%D9%81%D8%B1-%D9%88%D8%A7%D8%AD%D8%AF-%D8%A7%D9%84%D9%89-%D9%85%D9%84%D8%A7%D9%8A%D9%8A%D9%86/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال واختباره على PostgreSQL 18.6 وPgBouncer 1.26.0 من مستودع PGDG مع Docker Engine 29.8.0.
- أكتوبر 2026: إضافة مجمع Redis وValkey ومجمع اتصالات Nginx بالتطبيق، مع مختبر على Nginx 1.31.6 وNode.js 26.10.0 وValkey 9.1.2 وredis-py 8.1.0.