اتصالات قواعد البيانات في البيئات بلا خوادم: حساب المجمّع ووضع المعاملات في PgBouncer
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗الخلاصة: مجمّع اتصالات قاعدة البيانات في النشر بلا خوادم يخص كل نسخة تشغيل على حدة وليس التطبيق كله. الحد الأقصى الحقيقي هو عدد النسخ المتزامنة مضروبًا في حجم المجمّع، ولهذا فإن الحل نادرًا ما يكون قاعدة بيانات أكبر، بل وسيط اتصالات يعمل بوضع المعاملات مع مشغّل مضبوط لينجو من هذا الوضع.
صادفنا خطأ FATAL: sorry, too many clients already في أكثر من نشر لدى عملائنا بينما كانت قاعدة البيانات نفسها خاملة وكود التطبيق سليمًا. الافتراض الخاطئ واحد دائمًا: يضبط الفريق مجمّعًا واحدًا بقيمة max معقولة ويظن أن هذا الرقم يصف النشر كله، بينما هو يصف نسخة تشغيل واحدة فقط. هذه ملاحظات فريقنا، محدَّثة بما غيّره Vercel Fluid Compute في هذه الحسبة.
أهم النقاط
مجمّع الاتصالات هو ذاكرة مؤقتة لاتصالات TCP مفتوحة داخل عملية Node.js واحدة. إجمالي الاتصالات التي يفتحها تطبيق بلا خوادم يساوي تقريبًا عدد النسخ المتزامنة مضروبًا في قيمة max لكل مجمّع، وليس قيمة max التي ضبطتها.
يأتي PostgreSQL افتراضيًا بقيمة max_connections = 100 ويحجز ثلاثة منها لمستخدمي superuser، وكل اتصال عملية خادم منفصلة لها ذاكرتها الخاصة، فالسقف حقيقي وليس مجرد تحذير إعدادات.
يعمل PgBouncer في وضع المعاملات على توزيع عملاء كثيرين على عدد قليل من اتصالات الخادم، لكنه يفقد حالة الجلسة: العبارات المحضّرة على مستوى البروتوكول، وLISTEN/NOTIFY، وأوامر SET على مستوى الجلسة، والأقفال الاستشارية الممتدة بين المعاملات تتعطل جميعها.
يحتاج Drizzle مع postgres.js إلى ضبط prepare على false خلف وسيط بوضع المعاملات، ويحتاج Prisma إلى pgbouncer=true في رابط التطبيق إضافة إلى directUrl منفصل لعمليات الترحيل.
يعيد Vercel Fluid Compute استخدام نسخة التشغيل الواحدة عبر طلبات متزامنة، لذا صار المجمّع المعرَّف على مستوى الوحدة يخدم عدة استعلامات في وقت واحد، والنصيحة القديمة بضبط max على 1 صارت تُسلسل المعالجات ولم تعد الخيار الافتراضي الصحيح.
لماذا تنفد اتصالات PostgreSQL في التطبيقات بلا خوادم؟
لأن كل نسخة تشغيل تدير عملية Node.js خاصة بها تحمل مجمّعها الخاص، فالاتصالات المفتوحة فعليًا هي عدد النسخ المتزامنة مضروبًا في قيمة max لكل مجمّع. لا شيء في المجمّع مشترك بين العمليات. عشر نسخ بقيمة max تساوي 10 تعني مئة اتصال لا عشرة.
هذا الحاصل يصطدم بحد صارم في الخادم. يبدأ PostgreSQL بقيمة max_connections = 100، ويحجز ثلاثة منها لمستخدمي superuser، وينشئ عملية خادم منفصلة لكل اتصال بذاكرتها المستقلة. لذلك يرتبط العطل بالحمل: ترفع المنصة عدد النسخ عند ارتفاع الزيارات، وتفتح كل نسخة جديدة مجمّعها الخاص، فيرد PostgreSQL بالخطأ FATAL: sorry, too many clients already بينما يظل استهلاك المعالج في قاعدة البيانات مستقرًا، وهذا سبب غموض الخطأ عند مصادفته أول مرة.
ما الذي غيّره Fluid Compute في تجميع الاتصالات؟
يعيد Vercel Fluid Compute استخدام نسخة تشغيل واحدة عبر طلبات متزامنة، فيصبح المجمّع المعرَّف على مستوى الوحدة مشتركًا بين عدة طلبات جارية داخل العملية نفسها. النصيحة الكلاسيكية بضبط max على 1، بحجة أن النسخة تخدم طلبًا واحدًا فقط، صارت ضارة في هذا النموذج: مجمّع بسعة اتصال واحد يُسلسل المعالجات المتزامنة خلف اتصال وحيد ويحوّل استعلامًا سريعًا إلى طابور انتظار.
إعداداتنا الافتراضية اليوم ثلاثة. إنشاء المجمّع على مستوى الوحدة ليبقى بين الاستدعاءات بدل إعادة بنائه مع كل طلب. إبقاء قيمة max صغيرة، بضع وحدات لا عشرات، لأنها تُضرب في عدد النسخ. ضبط idleTimeoutMillis على مدة قصيرة، نحو عشر ثوانٍ، حتى تعود الاتصالات الخاملة إلى الوسيط بدل احتلال مقعد في الخادم بين موجات الحمل.
أي وضع تجميع في PgBouncer يجب استخدامه؟
وضع المعاملات، لحركة التطبيق المعتادة. PgBouncer وسيط خفيف يوزّع عملاء كثيرين على مجموعة صغيرة من اتصالات الخادم الحقيقية، وإعداد pool_mode هو ما يحدد مدة احتفاظ العميل بأحد تلك الاتصالات.
وضع الجلسة session يحتفظ باتصال الخادم طوال جلسة العميل كاملة، ولا يكسر شيئًا، وهو الخيار الصحيح لعمليات الترحيل وLISTEN/NOTIFY والعمليات طويلة العمر.
وضع المعاملات transaction يحتفظ باتصال الخادم لمدة معاملة واحدة فقط. يكسر العبارات المحضّرة وأوامر SET على مستوى الجلسة والأقفال الاستشارية خارج المعاملات ومؤشرات WITH HOLD، وهو الوضع الصحيح لحركة التطبيقات بلا خوادم.
وضع العبارة statement يحتفظ باتصال الخادم لعبارة واحدة، ويكسر كل ما يكسره وضع المعاملات إضافة إلى المعاملات متعددة العبارات، ونادرًا ما يُستخدم خارج البنى المجزأة التي لا تستعمل المعاملات أصلًا.
وضع المعاملات هو ما يجعل العمل بلا خوادم ممكنًا مع PostgreSQL: مئات اتصالات العملاء تتشارك بضع عشرات من اتصالات الخادم، لأن المعالج النموذجي يحتفظ باتصاله لأجزاء من الثانية. تستخدم Supabase اليوم Supavisor بدل PgBouncer، ولدى Neon وAmazon RDS Proxy تطبيقاتهما الخاصة، لكن دلالات الأوضاع واحدة في كل مكان. ما تختاره فعليًا هو مقدار حالة الجلسة التي تقبل خسارتها.
لماذا تتعطل العبارات المحضّرة خلف الوسيط؟
لأن العبارة المحضّرة على مستوى البروتوكول حالة جلسة مرتبطة باتصال خادم واحد، ووضع المعاملات قد يمنح المعاملة التالية اتصال خادم مختلفًا. تظهر المشكلة كخطأين متناوبين تحت الحمل: prepared statement s1 already exists حين يحضّر المشغّل عبارة على اتصال يملكها فعلًا، وprepared statement s1 does not exist حين ينفّذها على اتصال لم يحضّرها قط.
هناك ثلاثة حلول صادقة. يستطيع PgBouncer 1.21 وما بعده تتبع العبارات المحضّرة في وضع المعاملات عند ضبط max_prepared_statements على قيمة أكبر من صفر، لكن تأكد أن مزوّدك يفعّل ذلك قبل الاعتماد عليه. وإلا فعطّلها في المشغّل: يستخدم node-postgres العبارات المحضّرة المسماة فقط عند تسمية الاستعلام صراحة فهو آمن غالبًا كما هو، بينما يحضّر postgres.js افتراضيًا ويحتاج prepare مضبوطًا على false، ويحتاج Prisma إضافة pgbouncer=true إلى رابط الاتصال المجمّع.
كيف نمنع إعادة التحميل الساخن من فتح مجمّع جديد مع كل حفظ؟
بتخزين المجمّع على globalThis أثناء التطوير. يعيد نظام إعادة التحميل الساخن في Next.js تقييم الوحدات المعدَّلة، فيُنفَّذ استدعاء new Pool() على مستوى الوحدة من جديد مع كل حفظ بينما يبقى المجمّع السابق محتفظًا بمقابسه. عشرون عملية حفظ في جلسة عمل واحدة تعني عشرين مجمّعًا حيًّا، وينتهي الأمر برفض قاعدة البيانات المحلية للاتصالات، وهو خطأ بشكل إنتاجي لا يمكن إعادة إنتاجه في الإنتاج.
النمط هو قراءة مجمّع موجود من globalThis، وإنشاء واحد فقط عند غيابه، وإعادة إسناده فقط حين لا تكون NODE_ENV تساوي production. يبقى كائن globalThis حيًّا بعد إعادة تقييم الوحدة، فتعيد كل عملية تقييم لاحقة استخدام المجمّع نفسه، بينما يحتفظ الإنتاج بمجمّع واحد نظيف لكل نسخة تشغيل.
أي رابط اتصال تستخدمه عمليات الترحيل؟
الرابط المباشر بوضع الجلسة، لا وسيط وضع المعاملات أبدًا. تأخذ عمليات ترحيل المخطط أقفالًا استشارية وتنفّذ أوامر DDL يجب أن تبقى محفوظة عبر عدة عبارات، ووسيط وضع المعاملات قد يوجّه العبارة التالية إلى خادم آخر لا يوجد فيه ذلك القفل. لهذا السبب بالذات يمنحك المزوّدون رابطين: رابطًا مجمّعًا على منفذ منفصل لحركة التطبيق، ورابطًا مباشرًا لعمليات الترحيل.
يعبّر Prisma عن ذلك بجعل url يشير إلى الرابط المجمّع وdirectUrl يشير إلى رابط وضع الجلسة. ويتبع Drizzle Kit التقسيم نفسه بتوجيه ملف إعداداته إلى الرابط المباشر بينما يستخدم عميل التشغيل الرابط المجمّع. والقاعدة تتجاوز الترحيل: LISTEN/NOTIFY، والأقفال الاستشارية المستخدمة كقفل موزّع، وكل ما يضبط متغير جلسة ويتوقع بقاءه، كلها تنتمي إلى الاتصال المباشر.
متى يكون مشغّل قاعدة البيانات عبر HTTP خيارًا أفضل؟
حين ينفّذ المعالج عبارة واحدة مكتفية بذاتها ولا يريد الفريق إدارة مجمّع TCP أصلًا. تتيح حزمة @neondatabase/serverless دالة neon() التي ترسل عبارة SQL واحدة عبر HTTP: لا مجمّع تضبط حجمه، ولا اتصال يتسرب، ولا وضع وسيط تفكر فيه. الثمن أن HTTP بلا حالة، فلا معاملات تفاعلية ممتدة عبر عبارات ولا إعدادات جلسة. وتشحن الحزمة نفسها كائن Pool يعمل عبر WebSocket للحالات التي تحتاج معاملات حقيقية.
قاعدتنا مملة عن قصد: مشغّل HTTP لمعالجات القراءة ذات الاستعلام الواحد، ومجمّع TCP عبر وسيط بوضع المعاملات لكل ما يفتح معاملة متعددة العبارات، واتصال مباشر لعمليات الترحيل والعمال الخلفيين. استخدام الثلاثة في تطبيق واحد بنية معمارية طبيعية وليس عيبًا.
الأسئلة الشائعة
س: ما الحجم المناسب لقيمة max في مجمّع اتصالات بلا خوادم؟
ج: صغير، ابدأ بين ثلاثة وخمسة لكل نسخة تشغيل ثم راقب عدد الاتصالات على جانب الخادم عند ذروة التزامن. الرقم المهم هو عدد النسخ المتزامنة مضروبًا في max وليس max وحدها.
س: هل ما زلنا نحتاج PgBouncer عند استخدام Prisma؟
ج: نعم. مجمّع Prisma على جانب العميل يخص كل نسخة تشغيل مثل غيره ولا ينسّق بين العمليات. أضف pgbouncer=true إلى الرابط المجمّع حتى يتوقف Prisma عن الاعتماد على العبارات المحضّرة المسماة.
س: هل يكسر وضع المعاملات المعاملاتِ نفسها؟
ج: لا. يثبّت وسيط وضع المعاملات اتصال خادم واحدًا طوال مدة المعاملة كاملة. ما يكسره هو الحالة الممتدة بين المعاملات، مثل العبارات المحضّرة وأوامر SET على مستوى الجلسة والأقفال الاستشارية المأخوذة خارج معاملة.
س: لماذا تنفد اتصالات قاعدة البيانات محليًا بينما لا تنفد في الإنتاج؟
ج: السبب في الغالب إعادة التحميل الساخن التي تنشئ مجمّعًا جديدًا مع كل حفظ ملف بينما تحتفظ المجمّعات القديمة بمقابسها. تخزين المجمّع على globalThis أثناء التطوير يزيل العرض تمامًا.
س: هل يمكن استخدام LISTEN/NOTIFY من دالة بلا خوادم؟
ج: ليس عبر وسيط بوضع المعاملات، لأن جلسة الاستماع لا تُحفظ بين المعاملات. استخدم اتصال جلسة مباشرًا على عملية طويلة العمر، أو طابور رسائل حقيقيًا.
قراءات إضافية
هندسة الويب المتكاملة
لماذا يستمر استهلاك الذاكرة في عمليات Node بالارتفاع: ملاحظات ميدانية عن لقطات الكومة والكاش في نطاق الوحدة والتسريبات التي تظهر عند إعادة استخدام النسخ
خدمة Node تبقى بصحة جيدة لساعة بعد كل نشر ثم يبدأ استهلاكها بالارتفاع تعني تسريباً فوق الطلب لا داخله. هذه ملاحظاتنا الميدانية حول التفريق بين تسريب حقيقي وتذبذب جمع المهملات، والتقاط لقطات الكومة عبر --heapsnapshot-signal، وقراءة لوحة Retainers وصولاً إلى Map في نطاق الوحدة، ولماذا تجعل إعادة استخدام النسخ في Fluid Compute الكود بلا خادم يتسرب كخادم طويل العمر.
هندسة الويب المتكاملة
لماذا لا تنجح ذاكرة CDN أبدًا: ملاحظاتنا الميدانية حول Cache-Control وs-maxage وstale-while-revalidate
كانت نقطة نهاية JSON عامة تصل إلى الخادم الأصلي في كل طلب بينما بدت إعدادات CDN صحيحة. هذه ملاحظاتنا الميدانية حول قراءة ترويسات x-vercel-cache وAge بدلًا من لوحات التحكم، والفرق بين max-age وs-maxage وstale-while-revalidate، ومتى تتفوق CDN-Cache-Control على ترويسة Cache-Control وحدها، والحالات الخمس التي تُعطّل التخزين على الحافة بصمت.