التوسّع وموازنة الأحمال

توسيع طبقة التطبيق

18 دقيقة الدرس 6 من 10

توسيع طبقة التطبيق

طبقة التطبيق هي المكان الذي تعيش فيه منطق العمل — أسطول من الخوادم التي تستقبل طلبات HTTP، وتُشغِّل الكود، وتستعلم قواعد البيانات، وتُعيد الاستجابات. عندما لا يستطيع خادم واحد مواكبة الطلب، تُضيف المزيد. يبدو ذلك بسيطاً، لكن إضافة نسخ يُظهر مشكلة غير مرئية حتى تُجرِّبها: الجلسات (Sessions). يتناول هذا الدرس كيفية تشغيل نسخ متعددة من التطبيق بالتوازي، وكيفية التعامل مع حالة الجلسة بشكل صحيح عند القيام بذلك.

لماذا تتوسع طبقة التطبيق أفقياً بسهولة؟

مقارنةً بقواعد البيانات، تُعدّ خوادم التطبيق أسهل مكوّن لتوسيعه. تُنجز عادةً عملاً بالمعالج والذاكرة — تحليل الطلبات، وتشغيل المنطق، وتسلسل الاستجابات — وهي بشكل عام عديمة الحالة بالتصميم (سنتناول هذا التحفظ قريباً). الخوادم عديمة الحالة لا تتشارك شيئاً مع بعضها، لذا يمكن لموزع الحمل إرسال أي طلب إلى أي نسخة. هل تحتاج ضعف الطاقة؟ أضف ضعف عدد الخوادم. الرياضيات تكاد تكون خطية، على الأقل حتى تصطدم بعنق زجاجة مشترك في الطرف الآخر كقاعدة البيانات.

الأرقام الواقعية تعطي حدساً: خادم API محسَّن جيداً يعالج 300 طلب/ثانية لكل نواة، يعمل على جهاز رباعي النوى، يعالج نحو 1,200 طلب/ثانية قبل أن يرتفع الكمون. عند 5,000 طلب/ثانية تحتاج نحو خمسة أجهزة من هذا النوع. وعند 50,000 طلب/ثانية، خمسين. النمط يكاد يكون متوازياً بصورة مُحرجة — وهذا هو السبب في أن شركات مثل Netflix تُشغِّل عشرات الآلاف من نسخ التطبيق عبر مناطق التوفر.

مشكلة الجلسة

تحتفظ معظم تطبيقات الويب بـجلسات — قطع صغيرة من الحالة تستمر عبر طلبات متعددة للمستخدم نفسه. الأمثلة الكلاسيكية هي سلة التسوق، ومعرّف المستخدم المُصادَق، ورمز CSRF، أو تقدّم نموذج متعدد الخطوات. تقليدياً، كانت الجلسات تُخزَّن في ملف أو في الذاكرة على الخادم الذي تعامل مع الطلب الأول. يعمل ذلك بشكل مثالي مع خادم واحد. يتعطل فور إضافة خادم ثانٍ.

تخيّل مستخدماً يسجل الدخول على خادم التطبيق 1. تُنشأ جلسته على ذلك الجهاز. في الطلب التالي، يوجّه موزع الحمل طلبه إلى خادم التطبيق 2. الخادم 2 ليس لديه أي سجل لجلسته — يبدو المستخدم وكأنه قد سجّل الخروج. هذه هي مشكلة الجلسة الثابتة الكلاسيكية، ولها ثلاثة حلول رئيسية.

Session problem: local storage vs shared storage vs stateless token Sticky Sessions (affinity routing) Load Balancer App Server 1 session: userA App Server 2 no sessions breaks on server failure uneven load distribution Centralised Session Store (recommended for most apps) Load Balancer App Server 1 App Server 2 App Server 3 Session Store Redis / Memcached any server reads any session add/remove instances freely
يسار: الجلسات الثابتة توجّه المستخدم إلى الخادم نفسه دائماً، لكنها تفشل عند إعادة التشغيل. يمين: مخزن جلسة مركزي يفصل الجلسات عن النسخ فيستطيع أي خادم خدمة أي طلب.

الحل الأول — الجلسات الثابتة (Session Affinity)

يستخدم موزع الحمل ملفاً (Cookie) أو تجزئة عنوان IP لضمان توجيه كل طلب من مستخدم معين دائماً إلى الخادم نفسه. التنفيذ بسيط — تدعمه معظم موزعات الحمل بعلم واحد — ولا يتطلب أي تغييرات على التطبيق.

المشاكل:

  • إذا أُعيد تشغيل الخادم المُثبَّت أو أُزيل من دوران النشر، تُفقد كل الجلسات الموجودة عليه ويُسجَّل خروج هؤلاء المستخدمين فعلياً.
  • يصبح الحمل غير متساوٍ. مجموعة صغيرة من "المستخدمين الكثيفي الاستخدام" يمكنها تشبع نسخة واحدة بينما تجلس أخرى دون عمل.
  • التوسع التلقائي يصبح معقداً: لا يمكنك بسهولة استنزاف خادم دون نقل الجلسات أولاً.
  • يصعب تصحيح الأخطاء لأنك لا تستطيع إعادة إنتاج تجربة مستخدم من جهاز مختلف.
متى تتجنب الجلسات الثابتة: أي نظام يحتاج عمليات نشر متتالية، أو توسعاً تلقائياً، أو إعادة تشغيل بدون توقف، لا ينبغي أن يعتمد على الجلسات الثابتة كاستراتيجية جلسة أساسية. إنها عكازة تؤجّل المشكلة بدلاً من حلها.

الحل الثاني — مخزن الجلسة المركزي

تُخزَّن الجلسات في مخزن خارجي مشترك — عادةً Redis أو Memcached — يستطيع كل نسخة من خوادم التطبيق الوصول إليه. عند وصول طلب، يقرأ خادم التطبيق الجلسة من Redis باستخدام معرّف الجلسة من الكوكي، ويعالج الطلب، ويكتب أي تغييرات إلى Redis. لا يهم أي خادم فعلي تعامل مع الطلب.

Redis هو الاختيار شبه الشامل لتخزين الجلسات لأن:

  • كمون القراءة/الكتابة أقل من ميلي ثانية (~0.1–0.5 مللي ثانية على المضيف المحلي، ~1 مللي ثانية عبر الشبكة المحلية).
  • مدة انتهاء الصلاحية (TTL) مدمجة: تنتهي الجلسات تلقائياً، مما يُبقي المخزن نظيفاً.
  • خيارات الثبات (RDB/AOF) تحمي من إعادة تشغيل Redis.
  • وضع المجموعة (Cluster) يتوسع لمليارات المفاتيح عند الحاجة.

التكلفة هي جولة شبكة إضافية إلى Redis في كل طلب مُصادَق. عملياً هذه 0.5–2 مللي ثانية وهي ضئيلة مقارنةً باستعلام قاعدة البيانات الذي يليها. الفائدة — الحرية الأفقية الكاملة في طبقة التطبيق — تستحق ذلك دائماً تقريباً.

دعم الأطر البرمجية: كل إطار ويب رئيسي لديه مُشغِّل جلسة Redis. في Laravel هو SESSION_DRIVER=redis؛ في Express، connect-redis؛ في Spring Boot، Spring Session مع Redis. تغيير سطر واحد في الإعداد يمنحك طبقة جلسة قابلة للتوسع أفقياً فوراً.

الحل الثالث — الرموز عديمة الحالة (JWT / Signed Cookies)

أنظف حل معماري هو دفع الحالة خارج الخادم كلياً. بدلاً من معرّف جلسة يُشير إلى تخزين على جانب الخادم، يكون الرمز هو الحالة ذاتها. يُضمِّن JWT (JSON Web Token) أو كوكي موقّع بـHMAC معرّف المستخدم والأدوار وتاريخ الانتهاء داخل الكوكي نفسه، موقّعاً تشفيرياً بحيث لا يمكن العبث به. يحتاج الخادم فقط مفتاح التوقيع — لا تخزين مطلوب.

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

مقايضات يجب فهمها:

  • إلغاء صلاحية الرمز صعب. JWT صالح حتى انتهاء صلاحيته. إذا احتجت إلغاء جلسة فوراً (مثل اختراق حساب)، يجب إما الاحتفاظ بقائمة حظر صغيرة (مما يُنتقص من عدم الحالة) أو استخدام أوقات انتهاء صلاحية قصيرة جداً (~15 دقيقة) مع رموز تحديث.
  • حجم الرمز. JWT بادعاءات قياسية يبلغ ~300–500 بايت، يُرسَل مع كل طلب. كوكي الجلسة 32 بايت. عند حجم مرور عالٍ جداً، يكون الفرق في تحليل الترويسات قابلاً للقياس (وإن كان نادراً ما يكون عنق الزجاجة).
  • البيانات الحساسة في الرموز. JWT مشفّر بـbase-64، غير مُعمّى. لا تضع أسراراً أو بيانات شخصية في الحمولة إلا إذا استخدمت JWE (JWT مُعمّى).
JWT stateless request flow vs session-store flow Session-Store Flow Client App Server Redis Session Store GET /feed cookie: sid=abc GET sid=abc {userId: 42} query DB 200 response 2 hops: Redis + DB JWT Stateless Flow Client App Server Database GET /feed JWT in header verify sig (no I/O) query DB 200 response 1 hop: DB only (no session I/O)
نهج مخزن الجلسة يتطلب جولة Redis في كل طلب؛ نهج JWT يتحقق من الرمز داخل العملية (معالج فقط) ويذهب مباشرةً إلى قاعدة البيانات.

مقارنة الأساليب الثلاثة

يعتمد الاختيار بين هذه الاستراتيجيات على متطلبات تطبيقك:

  • الجلسات الثابتة — مناسبة فقط للتطبيقات القديمة التي لا يمكن تعديلها وحيث يكون فقدان الجلسة العرضي عند إعادة التشغيل مقبولاً. تجنّبها للأنظمة الجديدة.
  • مخزن جلسة مركزي (Redis) — الافتراضي الأفضل لمعظم تطبيقات الويب ذات العرض على جانب الخادم، أو أي تطبيق بحالة على الخادم تحتاج إلغاءً فورياً (مثل البنوك وأدوات الإدارة). تكلفة الكمون صغيرة؛ بساطة التشغيل عالية.
  • JWT عديم الحالة / Signed Cookies — الأفضل لبنيات الخدمات المصغّرة وتطبيقات الجوال وواجهات API العامة حيث تحتاج خدمات مستقلة كثيرة للتحقق من الهوية بدون مخزن مشترك. يتطلب إدارة دورة حياة الرمز بعناية.

ما بعد الجلسات: مصائد الحالة المشتركة الأخرى

الجلسات هي مشكلة الحالة المشتركة الأبرز، لكن ليست الوحيدة. انتبه لـ:

  • الكاشات داخل العملية — إحماء كاش على خادم 1 يعني أن خادم 2 بارد. استخدم كاشاً مشتركاً (Redis) للبيانات التي تحتاجها جميع النسخ.
  • الكتابة على الملفات المحلية — رفع ملف على نظام الملفات المحلي لخادم التطبيق 1 يجعله غير مرئي لخادم التطبيق 2. استخدم تخزين الكائنات (S3، GCS، Azure Blob) بدلاً من ذلك.
  • المهام المجدولة — إذا شغّلت كل نسخة مهمة Cron، تُشغَّل المهمة N مرات. استخدم قفلاً موزعاً أو مُجدول مهام مخصص لضمان التنفيذ مرة واحدة بالضبط.
  • اتصالات WebSocket — اتصال WebSocket مُثبَّت على خادم واحد. بثّ رسالة لجميع عملاء مستخدم ما يتطلب طبقة Pub/Sub (Redis Pub/Sub، Ably، Pusher) حتى يستطيع أي خادم النشر لأي اتصال بغض النظر عن الخادم الذي يحتفظ به.
القاعدة الذهبية للتوسع الأفقي: أي حالة يجب أن تبقى بعد الطلب يجب أن تعيش خارج عملية التطبيق. يجب أن تكون نسخ التطبيق قابلة للتخلص منها — تعمل في ثوانٍ، وتُغلق دون احتفال، وتكون قابلة للاستبدال الكامل بأي نسخة مطابقة.

آليات النشر: النشر المتتالي والأزرق-الأخضر

بمجرد أن تصبح طبقة تطبيقك عديمة الحالة، يصبح نشر إصدارات جديدة بدون توقف أمراً مباشراً. نمطان شائعان:

  • النشر المتتالي (Rolling Deploy): استبدال النسخ واحدةً في كل مرة (أو عدداً منها). يُنضِّب موزع الحمل الطلبات من كل نسخة قبل إيقافها (connection draining)، ينتظر اكتمال الطلبات الجارية، ثم يُشغِّل الإصدار الجديد. في أي لحظة لا تكون كل النسخ متوقفة في آنٍ واحد. GitHub ينشر بهذه الطريقة مئات المرات يومياً.
  • النشر الأزرق-الأخضر (Blue-Green Deploy): يُبقي بيئتين متطابقتين (أزرق وأخضر). موزع الحمل الحي يشير إلى الأزرق. تنشر الإصدار الجديد على الأخضر، وتُشغِّل اختبارات دخان، ثم تُحوِّل موزع الحمل إلى الأخضر في ثوانٍ. إذا كان هناك خطأ، تعود إلى الأزرق. صفر توقف وتراجع فوري.

لا يكون أيٌّ من النمطين ممكناً مع الجلسات الثابتة، لأن نقل الطلبات بعيداً عن نسخة سيُسجِّل خروج جميع المستخدمين المُثبَّتين عليها. الجلسات عديمة الحالة تجعل كلا النمطين تافلاً.

استنزاف الاتصالات (Connection Draining): اضبط دائماً موزع الحمل بمهلة استنزاف (عادةً 30–120 ثانية). يسمح ذلك للنسخة المُلغاة تسجيلها بإنهاء جميع الطلبات الجارية قبل إغلاقها، مما يمنع إجهاض المعاملات نصف المُعالَجة.