تجمّع الاتصالات في قاعدة البيانات: اضبط الحجم والمهلات

بقلم فريق تقني ·· برمجة
تجمّع الاتصالات في قاعدة البيانات: اضبط الحجم والمهلات

رسالة FATAL: sorry, too many clients already تدفع معظم الفرق إلى رفع max_connections وإعادة الإقلاع. هذا يهدّئ الوضع أسبوعاً ويورّثك مشكلة أكبر: اتصالات خاملة تلتهم الذاكرة والمعالج، وكمون يتضاعف بلا معاملة إضافية واحدة في الثانية. الضبط الصحيح يسير في الاتجاه المعاكس — تجمّع أصغر، ومهلات مفعّلة، وميزانية اتصالات محسوبة على أقصى عدد نسخ لا على العدد الحالي.

ما يكلّفه الاتصال الواحد في PostgreSQL بالميغابايت

PostgreSQL يفرد عملية نظام مستقلة لكل اتصال، لا خيطاً كما في MySQL، فيستهلك كل اتصال ذاكرة ومعالجاً حتى وهو خامل.

الرقم يعتمد على التهيئة. قياس أندريس فرويند عام 2020 وضع العبء الصافي تحت 2 ميغابايت مع تفعيل huge_pages، وقفز به إلى نحو 7.6 ميغابايت بدونها بسبب جداول الصفحات. وقياس AWS على RDS PostgreSQL أعطى نحو 1.5 ميغابايت لاتصال خامل تماماً، ونحو 10.8 بعد تنفيذ SELECT واحد، ونحو 14.5 بعد استعلامات وجداول مؤقتة. الفارق بين القياسين ليس تناقضاً: قراءة الذاكرة المقيمة RSS تحسب الذاكرة المشتركة مرة لكل عملية، فتبدو حصة الاتصال أكبر مما هي عليه فعلاً.

الاتصال الخامل يستهلك معالجاً أيضاً. على نسخة بنواتين افتراضيتين، ارتفع الاستهلاك من نحو 1% بلا اتصالات إلى نحو 5% عند 1000 اتصال خامل، ونحو 8% عند 2000.

وإن قرأت مقالات قديمة عن انهيار الإنتاجية مع كثرة الاتصالات، انتبه إلى التاريخ: تحسينات اللقطات التي دخلت PostgreSQL 14 عالجت جزءاً كبيراً من المشكلة. في قياس فرويند نفسه قبل التحسينات وبعدها، ومع 10000 اتصال خامل واتصال نشط واحد، ارتفعت النتيجة من 16034 إلى 33140 معاملة في الثانية. الخمول لم يعد كارثة كما كان، لكنه ما زال ضريبة.

أما كلفة إنشاء الاتصال نفسه فلا رقم عام لها، لأنها تتبع شبكتك: جولة TCP، ثم مصافحة TLS، ثم المصادقة، ثم إفراد عملية جديدة. قِسها في بيئتك: شغّل pgbench مرة بخيار -C الذي يفتح اتصالاً جديداً لكل معاملة، ومرة بدونه. الفارق في الإنتاجية بين التشغيلين هو ما تدفعه.

احسب الحجم من الأنوية لا من عدد المستخدمين

الصيغة المرجعية في ويكي PostgreSQL، وتتبنّاها HikariCP: عدد الاتصالات النشطة ≈ (عدد الأنوية × 2) + عدد الأقراص. مثال HikariCP نفسه: 4 أنوية وقرص واحد يعطيان 9، فليكن التجمّع 10.

لكن انتبه إلى عمر هذه الصيغة. آخر تعديل لصفحة الويكي كان عام 2014، وهي تقرّ نصّاً بغياب أي تحليل لأقراص SSD، وتنبّه إلى أن effective_spindle_count يساوي صفراً حين تكون البيانات كلها في الذاكرة. في 2026 ومع NVMe، الحدّ مدفوع بالأنوية أساساً، والصيغة نقطة انطلاق تُعايَر بالقياس، لا رقم نهائي.

المبدأ الذي يستحق الحفظ من توثيق HikariCP: تجمّع صغير مشبَع بخيوط تنتظر أفضل من تجمّع كبير. والسبب يخرج من قانون ليتل مباشرة.

خذ خادماً بـ8 أنوية وNVMe: (8×2)+1 = 17، قرّبها إلى 20. إذا أشبعت القاعدة عند 4000 معاملة في الثانية، فمتوسط زمن المكوث = 20 ÷ 4000 = 5 مللي ثانية. ارفع التجمّع إلى 200 والعتاد مشبَع أصلاً، فيصبح 200 ÷ 4000 = 50 مللي ثانية. الإنتاجية نفسها، والكمون عشرة أضعاف. التجمّع الكبير لا يزيد الطاقة، بل يطيل الطابور داخل القاعدة بدل أن يبقيه خارجها حيث تراه وتقيسه.

حالة واحدة تكسر القاعدة: إذا احتاج الخيط الواحد إلى أكثر من اتصال في آن واحد، فأنت مهدَّد بجمود تام. الصيغة الآمنة هنا: الحجم = عدد الخيوط × (أقصى اتصالات لكل خيط − 1) + 1.

ميزانية الاتصالات: من max_connections إلى نصيب كل نسخة

max_connections الافتراضي عادةً 100 في PostgreSQL 17 و18، وقد يقل إن لم تدعمه إعدادات النواة عند initdb. لا يتغيّر إلا بإعادة إقلاع الخادم، ورفعه يزيد تخصيص الذاكرة المشتركة. وsuperuser_reserved_connections الافتراضي 3، بينما reserved_connections الافتراضي 0، وهي فتحات محجوزة لأدوار تملك صلاحية pg_use_reserved_connections — وهذه هي الطريقة الصحيحة لحجز فتحة لأداة المراقبة بدل منحها صلاحيات المستخدم الخارق.

السعة الفعلية المتاحة للتطبيق = max_connections ناقص المحجوزين. اجمع ميزانيتك هكذا: (نسخ التطبيق × حجم تجمّعها) + (عمليات العمّال × حجم تجمّعها) + وظائف cron + أدوات الهجرة + المراقبة + جلسة طوارئ واحدة. مثال: 4 نسخ × 4 = 16، وعاملان × 1 = 2، وcron والهجرات = 2، فالمجموع 20، وعندها تكفي قيمة 50 لـmax_connections، لا 500.

الخطأ الأشهر في Kubernetes أن التوسّع الأفقي يضرب حجم التجمّع في عدد الحاويات بصمت. اقسم الميزانية على أقصى عدد نسخ يسمح به إعداد التوسّع، لا على العدد الذي تراه الآن في اللوحة.

المهلات: كل ما يحميك معطّل افتراضياً

الإعدادالافتراضيما يقطعه
statement_timeout0العبارة التي تجاوزت المدة
idle_in_transaction_session_timeout0الجلسة الخاملة داخل معاملة مفتوحة
lock_timeout0انتظار القفل
transaction_timeout0 (أُضيف في PostgreSQL 17)المعاملة كاملة مهما تعدّدت عباراتها
idle_session_timeout0الجلسة الخاملة خارج أي معاملة

نقطتان من التوثيق مباشرة: لا يُنصح بضبط statement_timeout في postgresql.conf لأنه يطال كل الجلسات بما فيها النسخ الاحتياطي والهجرات — اضبطه على مستوى الدور. ويُحذَّر من idle_session_timeout على اتصالات تمرّ عبر وسيط تجمّع، لأن تلك الطبقة قد لا تتعامل جيداً مع إغلاق مفاجئ للاتصال.

على جانب HikariCP الافتراضيات مضبوطة أصلاً: connectionTimeout عند 30 ثانية، وidleTimeout عند 10 دقائق، وmaxLifetime عند 30 دقيقة، وkeepaliveTime عند دقيقتين، وmaximumPoolSize عند 10، وleakDetectionThreshold عند 0 أي معطّل. والتوصية الصريحة في توثيقه: اجعل maxLifetime أقصر ببضع ثوانٍ من أي حد تفرضه القاعدة أو البنية التحتية، مثل موازن الحمل أو server_lifetime في PgBouncer وهو 3600 ثانية.

المصائد تختلف بحسب مكتبتك. في node-postgres، القيمة الافتراضية لـconnectionTimeoutMillis هي 0، أي بلا مهلة إطلاقاً، فيعلّق طلب الويب إلى الأبد بدل أن يفشل سريعاً — غيّره في أول يوم. وفي SQLAlchemy: pool_size = 5 مع max_overflow = 10 وpool_timeout = 30 ثانية وpool_recycle = -1. وفي Django: CONN_MAX_AGE الافتراضي 0 أي إغلاق الاتصال بعد كل طلب، والتجمّع المدمج عبر "pool": True جديد في Django 5.1 ويتطلب psycopg[pool].

لا يوجد مصدر رسمي يوصي بأرقام محددة هنا، فخذ ما يلي نقاط معايرة تبدأ منها، لا توصية نهائية: بضع ثوانٍ لـstatement_timeout على دور الويب، ومهلة قصيرة للحصول على اتصال من التجمّع، وعشرات الثواني لـidle_in_transaction_session_timeout.

ما يكسره وضع transaction في PgBouncer فعلاً

أوضاع PgBouncer ثلاثة: session وهو الافتراضي، وtransaction، وstatement الذي يمنع أي معاملة ممتدة عبر عبارات متعددة.

وضع transaction هو مصدر المكسب الحقيقي، ومصدر الأعطال أيضاً. مصفوفة التوافق الرسمية تعدّد ما لا يعمل: SET وRESET، وLISTEN، والمؤشرات WITH HOLD، وPREPARE وDEALLOCATE النصّية، والجداول المؤقتة بصيغتي PRESERVE ROWS وDELETE ROWS، وLOAD، والأقفال الاستشارية على مستوى الجلسة. في المقابل NOTIFY يعمل، والجداول المؤقتة بصيغة ON COMMIT DROP تعمل.

العبارات المُحضَّرة كانت العقبة الكبرى وقد زالت: PgBouncer 1.21 أضاف دعمها على مستوى البروتوكول، و1.24 فعّله افتراضياً برفع max_prepared_statements من 0 إلى 200. لكن SQL النصّي PREPARE ... AS يبقى غير مدعوم. وانتبه إن كنت على Azure، فهي تضبط max_prepared_statements على 0 خلافاً لافتراضي المشروع.

وقبل التبديل، افحص مكتبتك: asyncpg يحتاج statement_cache_size=0، وDjango يحتاج DISABLE_SERVER_SIDE_CURSORS = True، وNpgsql يحتاج No Reset On Close=true.

افتراضيات PgBouncer نفسها تستحق المراجعة: max_client_conn = 100، وdefault_pool_size = 20، وquery_wait_timeout = 120 ثانية، وserver_idle_timeout = 600، وserver_lifetime = 3600. وتذكّر أنه أحادي الخيط، وهو نقطة فشل مفردة ما لم تُوزَّع على نسخ خلف موازن حمل.

ونظيرها في MySQL: خيوط لا عمليات

MySQL يعطي كل اتصال خيطاً داخل عملية واحدة افتراضياً، وبنية الجلسة الأساسية بحدود 10 كيلوبايت. لكن لا تخطّط بهذا الرقم، فالتوصية العملية عند حساب الذاكرة نحو 10 ميغابايت لكل اتصال وسطياً، لأن مخازن الفرز والربط والحزم تُحتسب لكل جلسة.

قاعدة الحجم مختلفة كذلك: مدونة MySQL الرسمية تعطي قاعدة إبهام مقدارها 4 أضعاف عدد الأنوية، وقياسها على 48 نواة بلغ الذروة عند 128 خيط مستخدم. الهامش أوسع من هامش PostgreSQL، لكن المبدأ واحد — بعد الذروة تنقلب الزيادة إلى خسارة.

وthread_cache_size يُحسب افتراضياً 8 + (max_connections ÷ 100) ونادراً ما يُغيَّر، وترى المدونة نفسها أن مخزن الخيوط ربما صار إرثاً قديماً لأن إنشاء خيط النظام صار رخيصاً نسبياً. وعند الامتلاء يسمح MySQL باتصال إضافي واحد فوق max_connections محجوز لحساب يملك صلاحية CONNECTION_ADMIN، وهذا بابك للتشخيص.

ونظير PgBouncer هنا ProxySQL بتعدّد الإرسال، وقائمة ما يعطّله عملية وطويلة: أي معاملة مفتوحة، وLOCK TABLES، وGET_LOCK()، وأي استعلام تظهر فيه علامة @ ضمن بصمته، وCREATE TEMPORARY TABLE، وعبارات PREPARE النصّية. والفرق مهم: قفل الجداول يعطّل تعدّد الإرسال حتى UNLOCK TABLES، أما البقية فتعطّله في تلك الجلسة بلا عودة.

استعلامات تشخيص جاهزة للنسخ

نقطة دقة قبل العدّ: pg_stat_activity يتضمّن عمليات خلفية مثل autovacuum وcheckpointer وwalwriter والعمّال المتوازيين، ففلترة backend_type ضرورية وإلا ضخّمت الرقم بلا سبب.

SELECT datname, usename, application_name, state, count(*)
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY 1,2,3,4
ORDER BY 5 DESC;

والاستعلام الثاني يكشف الجلسات العالقة داخل معاملة مفتوحة أكثر من دقيقة، وهي أخطر ما يستنزف التجمّع لأنها تحتجز اتصالاً وتمنع تنظيف الصفوف الميتة في آن واحد. وإن وجدت كثيراً منها فراجع مستويات العزل في قواعد البيانات قبل أن تلوم التجمّع.

SELECT pid, application_name, state,
       now() - xact_start AS xact_age,
       now() - state_change AS idle_age,
       left(query, 120) AS q
FROM pg_stat_activity
WHERE state IN ('idle in transaction', 'idle in transaction (aborted)')
  AND now() - state_change > interval '1 minute'
ORDER BY idle_age DESC;

على جانب PgBouncer، أمر SHOW POOLS يختصر التشخيص: cl_waiting هو مؤشر ضيق التجمّع، وmaxwait يخبرك كم انتظر أقدم عميل بالثواني، وارتفاعه يعني أن تجمّع الخوادم لا يكفي. لا تعامل sv_idle المرتفع كهدر، فهو المخزون الجاهز الذي يوفّر عليك مصافحة جديدة.

والأخطاء نفسها تحمل تشخيصها: رمز SQLSTATE 53300 مع sorry, too many clients already يعني نفاد الفتحات، و57014 هو رمز إلغاء الاستعلام وهو ما يُرجعه انتهاء statement_timeout، و25P03 يعني انتهاء idle_in_transaction_session_timeout. ورسالة خطأ HikariCP تحوي total وactive وidle وwaiting؛ قراءة waiting مقابل active تفرّق بين تجمّع صغير جداً واستعلامات بطيئة جداً، وعلاج كلٍّ منهما مختلف.

تجمّع داخل التطبيق أم وسيط أمام القاعدة؟

المعيارتجمّع داخل التطبيقوسيط خارجي
عدد النسخثابت ومحدودمتغيّر ديناميكياً أو بلا سقف
نمط الاتصالاتطويلة العمرمتذبذبة أو قصيرة العمر
تعدّد اللغاتتجمّع منفصل لكل خدمةميزانية واحدة للجميع
ميزات الجلسةتعمل كلهامكسورة في وضع transaction
العبارات المُحضَّرةبلا قيودمدعومة منذ 1.21 عدا PREPARE النصّي
نقطة الفشلموزّعة مع النسخمفردة ما لم تُنسخ خلف موازن
التشخيصمقاييس المكتبةSHOW POOLS وSHOW STATS

الجمع بينهما يعالج صفّي الجدول الأولين معاً: تجمّع صغير داخل التطبيق يمتصّ التذبذب اللحظي، وPgBouncer في وضع transaction أمام القاعدة يحرس السقف الكلي حين يتغيّر عدد النسخ.

حين يكون التطبيق في منطقة والقاعدة في أخرى

إن كان التطبيق في منطقة سحابية والقاعدة في أخرى، فكل اتصال جديد يقطع جولات TCP وTLS والمصادقة عبر المسافة كاملة. وهذا يفرض أمرين: تجمّع طويل العمر لا يُغلق اتصالاته بعد كل طلب، ووضع الوسيط بجوار القاعدة لا بجوار التطبيق. وإن ألزمتك متطلبات إقامة البيانات باستضافة داخل البلد، فالخيار عملياً PgBouncer يُدار ذاتياً لا وسيطاً مُداراً جاهزاً.

وعلى الخطط الصغيرة يكون عدد الاتصالات هو القيد الملموس قبل المعالج والذاكرة: وحدة حوسبة واحدة في Neon تعطي max_connections = 419، وطبقة Burstable في Azure لا تدعم PgBouncer المدمج أصلاً.

أين يضيع التجمّع في الإنتاج

رفع max_connections بدل ضبط التجمّع. الحساب أعلاه يبيّن الثمن: كمون أطول بلا معاملة إضافية واحدة، وذاكرة مشتركة أكبر عند كل إقلاع.

ضرب حجم التجمّع في عدد النسخ دون قصد. تجمّع بـ20 اتصالاً يبدو معقولاً حتى تتوسّع إلى 12 حاوية فتطلب 240 فتحة من قاعدة سقفها 100.

نداء خارجي داخل حدود المعاملة. طلب HTTP أو إرسال بريد بين BEGIN وCOMMIT يحتجز اتصالاً بطول استجابة طرف ثالث، ويمنع تنظيف الصفوف الميتة طوال المدة. أخرج النداء خارج المعاملة دائماً.

استعلامات تبدو بريئة وتستنزف التجمّع من الداخل. زمن احتجاز الاتصال يُضرب في عدد الاستعلامات داخل الطلب. حلقة تدور على العلاقات تمسك اتصالها عشرات أضعاف ما يظهر في زمن الاستعلام المفرد، ولهذا يسبق تشخيص مشكلة N+1 في الاستعلامات أي تعديل على حجم التجمّع.

عدم إرجاع الاتصال إلى التجمّع في مسار الخطأ. استخدم finally أو defer أو with بحسب لغتك، وفعّل leakDetectionThreshold لتعرف أين يتسرّب الاتصال قبل أن يفرغ التجمّع.

القياس أولاً، ثم الميزانية، ثم المهلات

لا تغيّر رقماً واحداً قبل أن تعرف رقمك الحالي: شغّل استعلام العدّ بحسب الحالة والتطبيق، وسجّل كم اتصالاً حياً لديك فعلاً وكم منها idle in transaction. ثم اجمع ميزانية الاتصالات على الورق باستخدام أقصى عدد نسخ يسمح به إعداد التوسّع، وقارنها بـmax_connections الحالي.

بعدها اضبط الحجم بالصيغة كنقطة انطلاق — (الأنوية × 2) + الأقراص — وقس الكمون والإنتاجية قبل التغيير وبعده بدل الاعتماد على الإحساس. فعّل المهلات على مستوى الدور لا في postgresql.conf، وابدأ بـstatement_timeout وidle_in_transaction_session_timeout لأنهما يمنعان أخطر حالتين. راجع افتراضيات مكتبتك تحديداً، وخصوصاً مهلة الحصول على الاتصال إن كنت على node-postgres.

ولا تنتقل إلى PgBouncer إلا بعد أن تثبت أن الضغط من كثرة النسخ لا من بطء الاستعلامات. فإن انتقلت، افحص قائمة ما يكسره وضع transaction أولاً، واضبط إعداد مكتبتك المطلوب، واجعل عمر الاتصال في تجمّع التطبيق أقصر من server_lifetime. وضَعْ نسختين من الوسيط خلف موازن حمل حتى لا تستبدل مشكلة سعة بنقطة فشل مفردة.