الموثوقية والإتاحة والمرونة

المهل الزمنية والمحاولات المتكررة والتراجع التدريجي

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

المهل الزمنية والمحاولات المتكررة والتراجع التدريجي

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

لماذا المهل الزمنية غير قابلة للتفاوض

المهلة الزمنية هي موعد نهائي: إذا لم يرد الطرف البعيد خلال N ميلي ثانية، أوقف الاستدعاء وأطلق الخيط والاتصال والذاكرة التي كانت تنتظر. بدون مهل زمنية:

  • تتراكم الخيوط في انتظار استجابة قد لا تأتي أبدًا.
  • تنضب مجمّعات الاتصال، وتبدأ الطلبات الجديدة في الانتظار أو الفشل فورًا.
  • تجرّ خدمة واحدة بطيئة كل خدمة تستدعيها إلى نفس البطء — وهو ما يُعرف بـالفشل المتتالي (cascading failure).

هناك قيمتان مختلفتان للمهلة الزمنية يجب ضبطهما لكل استدعاء بعيد:

  • مهلة الاتصال (Connect timeout) — المدة التي تنتظر فيها اكتمال مصافحة TCP (عادةً 1–5 ثوان). مهلة اتصال طويلة جداً تعني في الغالب أن المضيف غير متاح أو مثقل قبل أن يبدأ المعالجة.
  • مهلة القراءة (Read timeout) — المدة التي تنتظر فيها الاستجابة الكاملة بعد فتح الاتصال. يجب أن تعكس هذا أسوأ وقت معالجة متوقع للعملية، وليس فقط زمن الشبكة.
قاعدة عملية: اضبط مهلة القراءة لتكون ضعفَين إلى ثلاثة أضعاف زمن الاستجابة عند النسبة المئوية 99 (p99) للخدمة الأدنى في حالة التحميل الطبيعي — صارمة بما يكفي للفشل السريع، لكن متسامحة بما يكفي لتجنب المهل الزمنية الخاطئة خلال الارتفاعات المشروعة.

مرجعيات من الأنظمة الإنتاجية الكبرى: إطار gRPC الداخلي لـGoogle يستخدم مهلة افتراضية مدتها 5 ثوان؛ حزمة AWS SDK لـDynamoDB تستخدم ثانيتين للاتصال و10 ثوان للقراءة؛ توصي Stripe بـ80 ثانية لاستدعاءات واجهة الدفع. لا توجد قيمة عالمية — كل خدمة تحتاج إلى قيمة مضبوطة خاصة بها.

المحاولات المتكررة: متى وكيف

المهلة الزمنية تُخبرك أن الاستدعاء فشل. المحاولة المتكررة تحاول مرة أخرى. لكن المحاولات الساذجة تُسبب مشاكل جدية:

  • عاصفة المحاولات (Retry storms): عندما تكون خدمة أدنى بطيئة أو تعيد أخطاء، تُعيد مئات العملاء المحاولة في وقت واحد، مما يُضاعف الحمل تمامًا حين لا تستطيع الخدمة تحمّله.
  • العمليات غير المتماثلة (Non-idempotent): إعادة محاولة عملية دفع أو إنشاء طلب بدون مفاتيح التكافؤ تُسبب رسوماً أو طلبات مكررة.

المحاولات المتكررة منطقية فقط للـفشل العابر — انقطاعات الشبكة، الحمل اللحظي الزائد (HTTP 429 أو 503)، التعذر المؤقت. وهي تأتي بنتيجة عكسية في حالة:

  • أخطاء العميل مثل 400 Bad Request أو 404 Not Found — لن يُغيّر أي عدد من المحاولات طلباً مشوهاً.
  • أخطاء الخادم المستمرة التي تدل على عطل جذري في الخدمة الأدنى.
أفضل ممارسة: حدّد المحاولات بـ2–3 محاولات كحد أقصى في معظم الحالات. أعد المحاولة فقط لفئات أخطاء معروفة وآمنة (أخطاء الشبكة، HTTP 429، HTTP 503). للكتابة غير المتماثلة، استخدم مفتاح التكافؤ (idempotency key) — UUID يُرسَل مع كل محاولة — حتى يتمكن الخادم من اكتشاف المحاولات المتكررة وإزالة التكرار.

التراجع الأسي مع الاضطراب العشوائي

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

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

صيغة شائعة (توصي بها AWS):

wait = min(cap, base * 2^attempt) * random(0.5, 1.0) // قيم مثال: // base = 100ms, cap = 30s, attempt = 0, 1, 2, 3 ... // attempt 0: min(30000, 100) * rand = ~50–100 ms // attempt 1: min(30000, 200) * rand = ~100–200 ms // attempt 2: min(30000, 400) * rand = ~200–400 ms // attempt 3: min(30000, 800) * rand = ~400–800 ms // attempt 6: min(30000, 6400) * rand = ~3.2–6.4 s // attempt 9: min(30000, 51200→cap) * rand = ~15–30 s (محدود بالسقف)

يمنع cap الانتظار من النمو إلى ما لا نهاية. random(0.5, 1.0) يوزع المحاولات عبر نافذة زمنية بدلاً من توافقها. يُسمى هذا النمط الاضطراب الكامل (full jitter)، وهو الأكثر استخداماً.

Retry with Exponential Backoff and Jitter time Client Server Attempt 1 Error / Timeout backoff ~100ms+jitter Attempt 2 Error / Timeout backoff ~200–400ms+jitter Attempt 3 200 OK t=0 t+~100ms t+~400ms Failure Success Backoff window
محاولتان فاشلتان مع نوافذ تراجع متزايدة مع اضطراب عشوائي، تليهما محاولة ثالثة ناجحة.

ميزانيات المهل الإجمالية وانتشار الموعد النهائي

عندما تستدعي الخدمة A الخدمةَ B التي تستدعي بدورها الخدمة C، لكل قفزة مهلتها الخاصة. إذا منحت A الخدمةَ B مهلة 500 ميلي ثانية وأعطت B الخدمةَ C كذلك 500 ميلي ثانية، لكن B تحتاج 200 ميلي ثانية من المعالجة الداخلية، فإن C لديها في الواقع 300 ميلي ثانية فقط — لكن مهلتها مضبوطة على 500 ميلي ثانية. ستستمر في المحاولة طويلاً بعد أن تكون A قد أعادت خطأً للمستخدم بالفعل. هذا يُهدر الموارد ويُسبب عمل يتيم (orphaned work).

الحل هو انتشار الموعد النهائي (deadline propagation) — يضبط المُستدعي الأصلي موعداً نهائياً بساعة الحائط ويُمرّره عبر كل قفزة كترويسة طلب (مثل grpc-timeout في gRPC أو ترويسة مخصصة X-Request-Deadline في HTTP). كل خدمة أدنى تتحقق مما إذا كانت هناك ميزانية متبقية قبل بدء العمل، وتلغي الطلب فوراً إذا انتهى الموعد.

Deadline Propagation Across Service Hops Client budget: 1 000 ms API Gateway remaining: 950 ms Order Service remaining: 700 ms Inventory DB remaining: 500 ms deadline: T+1s deadline: T+1s deadline: T+1s 50ms overhead 250ms processing 200ms query كل قفزة تخصم وقتها المنقضي من الموعد النهائي المشترك — الخدمات الأدنى تلغي الطلب مبكرًا إذا نفدت الميزانية.
انتشار الموعد النهائي: يُمرَّر موعد نهائي واحد بساعة الحائط عبر كل قفزة من قفزات الخدمة، مما يُقلل الميزانية المتبقية في كل مرحلة.

التكافؤ: شبكة الأمان للمحاولات المتكررة

إعادة محاولة GET آمنة دائماً — قراءة البيانات مرتين تُعيد نفس النتيجة. أما إعادة محاولة POST /payments بدون ضمانات فتُفرض الرسوم على العميل مرتين. التكافؤ (Idempotency) هو خاصية تجعل تنفيذ عملية أكثر من مرة ينتج نفس النتيجة كما لو نُفِّذت مرة واحدة.

النمط المعتمد: يُنشئ العميل UUID لكل عملية منطقية (وليس لكل محاولة) ويُرسله كترويسة أو حقل في جسم الطلب (Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000). يخزّن الخادم النتيجة مفهرسةً بذلك UUID لفترة قصيرة (عادةً 24 ساعة). عند أي محاولة لاحقة بنفس المفتاح، يُعيد الخادم النتيجة المحفوظة دون إعادة تنفيذ العملية.

خطر — إعادة محاولة استدعاءات غير متكافئة بدون مفاتيح: خدمة دفع أو طلبيات لا تُطبّق مفاتيح التكافؤ ستُضاعف العمليات بصمت عند المحاولة مجدداً. هذا أحد أكثر الأخطاء تكلفةً في الأنظمة الإنتاجية. طبّق دائماً مفاتيح التكافؤ لأي استدعاء بعيد يُغيّر الحالة وقد يُعاد محاولته.

الجمع: نمط استدعاء مرن في الإنتاج

الاستدعاء البعيد الجاهز للإنتاج يجمع الآليات الثلاث:

  1. اضبط مهلة اتصال (1–3 ثوان) ومهلة قراءة بناءً على p99 للخدمة الأدنى.
  2. عند المهلة أو الخطأ القابل للمحاولة: انتظر باستخدام التراجع الأسي مع اضطراب كامل.
  3. حدّد المحاولات بـ2–3 محاولات؛ استسلم وأعد خطأ للمُستدعي.
  4. انشر الموعد النهائي في كل استدعاء أدنى لكيلا تهدر أي قفزة موارد بعد انتهاء المهلة الموجّهة للمستخدم.
  5. استخدم مفاتيح التكافؤ لجميع العمليات غير الآمنة وغير المتكافئة.

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