توسيع طبقة التطبيق
توسيع طبقة التطبيق
طبقة التطبيق هي المكان الذي تعيش فيه منطق العمل — أسطول من الخوادم التي تستقبل طلبات HTTP، وتُشغِّل الكود، وتستعلم قواعد البيانات، وتُعيد الاستجابات. عندما لا يستطيع خادم واحد مواكبة الطلب، تُضيف المزيد. يبدو ذلك بسيطاً، لكن إضافة نسخ يُظهر مشكلة غير مرئية حتى تُجرِّبها: الجلسات (Sessions). يتناول هذا الدرس كيفية تشغيل نسخ متعددة من التطبيق بالتوازي، وكيفية التعامل مع حالة الجلسة بشكل صحيح عند القيام بذلك.
لماذا تتوسع طبقة التطبيق أفقياً بسهولة؟
مقارنةً بقواعد البيانات، تُعدّ خوادم التطبيق أسهل مكوّن لتوسيعه. تُنجز عادةً عملاً بالمعالج والذاكرة — تحليل الطلبات، وتشغيل المنطق، وتسلسل الاستجابات — وهي بشكل عام عديمة الحالة بالتصميم (سنتناول هذا التحفظ قريباً). الخوادم عديمة الحالة لا تتشارك شيئاً مع بعضها، لذا يمكن لموزع الحمل إرسال أي طلب إلى أي نسخة. هل تحتاج ضعف الطاقة؟ أضف ضعف عدد الخوادم. الرياضيات تكاد تكون خطية، على الأقل حتى تصطدم بعنق زجاجة مشترك في الطرف الآخر كقاعدة البيانات.
الأرقام الواقعية تعطي حدساً: خادم API محسَّن جيداً يعالج 300 طلب/ثانية لكل نواة، يعمل على جهاز رباعي النوى، يعالج نحو 1,200 طلب/ثانية قبل أن يرتفع الكمون. عند 5,000 طلب/ثانية تحتاج نحو خمسة أجهزة من هذا النوع. وعند 50,000 طلب/ثانية، خمسين. النمط يكاد يكون متوازياً بصورة مُحرجة — وهذا هو السبب في أن شركات مثل Netflix تُشغِّل عشرات الآلاف من نسخ التطبيق عبر مناطق التوفر.
مشكلة الجلسة
تحتفظ معظم تطبيقات الويب بـجلسات — قطع صغيرة من الحالة تستمر عبر طلبات متعددة للمستخدم نفسه. الأمثلة الكلاسيكية هي سلة التسوق، ومعرّف المستخدم المُصادَق، ورمز CSRF، أو تقدّم نموذج متعدد الخطوات. تقليدياً، كانت الجلسات تُخزَّن في ملف أو في الذاكرة على الخادم الذي تعامل مع الطلب الأول. يعمل ذلك بشكل مثالي مع خادم واحد. يتعطل فور إضافة خادم ثانٍ.
تخيّل مستخدماً يسجل الدخول على خادم التطبيق 1. تُنشأ جلسته على ذلك الجهاز. في الطلب التالي، يوجّه موزع الحمل طلبه إلى خادم التطبيق 2. الخادم 2 ليس لديه أي سجل لجلسته — يبدو المستخدم وكأنه قد سجّل الخروج. هذه هي مشكلة الجلسة الثابتة الكلاسيكية، ولها ثلاثة حلول رئيسية.
الحل الأول — الجلسات الثابتة (Session Affinity)
يستخدم موزع الحمل ملفاً (Cookie) أو تجزئة عنوان IP لضمان توجيه كل طلب من مستخدم معين دائماً إلى الخادم نفسه. التنفيذ بسيط — تدعمه معظم موزعات الحمل بعلم واحد — ولا يتطلب أي تغييرات على التطبيق.
المشاكل:
- إذا أُعيد تشغيل الخادم المُثبَّت أو أُزيل من دوران النشر، تُفقد كل الجلسات الموجودة عليه ويُسجَّل خروج هؤلاء المستخدمين فعلياً.
- يصبح الحمل غير متساوٍ. مجموعة صغيرة من "المستخدمين الكثيفي الاستخدام" يمكنها تشبع نسخة واحدة بينما تجلس أخرى دون عمل.
- التوسع التلقائي يصبح معقداً: لا يمكنك بسهولة استنزاف خادم دون نقل الجلسات أولاً.
- يصعب تصحيح الأخطاء لأنك لا تستطيع إعادة إنتاج تجربة مستخدم من جهاز مختلف.
الحل الثاني — مخزن الجلسة المركزي
تُخزَّن الجلسات في مخزن خارجي مشترك — عادةً Redis أو Memcached — يستطيع كل نسخة من خوادم التطبيق الوصول إليه. عند وصول طلب، يقرأ خادم التطبيق الجلسة من Redis باستخدام معرّف الجلسة من الكوكي، ويعالج الطلب، ويكتب أي تغييرات إلى Redis. لا يهم أي خادم فعلي تعامل مع الطلب.
Redis هو الاختيار شبه الشامل لتخزين الجلسات لأن:
- كمون القراءة/الكتابة أقل من ميلي ثانية (~0.1–0.5 مللي ثانية على المضيف المحلي، ~1 مللي ثانية عبر الشبكة المحلية).
- مدة انتهاء الصلاحية (TTL) مدمجة: تنتهي الجلسات تلقائياً، مما يُبقي المخزن نظيفاً.
- خيارات الثبات (RDB/AOF) تحمي من إعادة تشغيل Redis.
- وضع المجموعة (Cluster) يتوسع لمليارات المفاتيح عند الحاجة.
التكلفة هي جولة شبكة إضافية إلى Redis في كل طلب مُصادَق. عملياً هذه 0.5–2 مللي ثانية وهي ضئيلة مقارنةً باستعلام قاعدة البيانات الذي يليها. الفائدة — الحرية الأفقية الكاملة في طبقة التطبيق — تستحق ذلك دائماً تقريباً.
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 مُعمّى).
مقارنة الأساليب الثلاثة
يعتمد الاختيار بين هذه الاستراتيجيات على متطلبات تطبيقك:
- الجلسات الثابتة — مناسبة فقط للتطبيقات القديمة التي لا يمكن تعديلها وحيث يكون فقدان الجلسة العرضي عند إعادة التشغيل مقبولاً. تجنّبها للأنظمة الجديدة.
- مخزن جلسة مركزي (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): يُبقي بيئتين متطابقتين (أزرق وأخضر). موزع الحمل الحي يشير إلى الأزرق. تنشر الإصدار الجديد على الأخضر، وتُشغِّل اختبارات دخان، ثم تُحوِّل موزع الحمل إلى الأخضر في ثوانٍ. إذا كان هناك خطأ، تعود إلى الأزرق. صفر توقف وتراجع فوري.
لا يكون أيٌّ من النمطين ممكناً مع الجلسات الثابتة، لأن نقل الطلبات بعيداً عن نسخة سيُسجِّل خروج جميع المستخدمين المُثبَّتين عليها. الجلسات عديمة الحالة تجعل كلا النمطين تافلاً.