نمط إعادة المحاولة: بناء خدمات صغيرة مرنة

نمط إعادة المحاولة: بناء خدمات صغيرة مرنة

في بنية الخدمات الصغيرة، تتواصل الخدمات عبر الشبكة بدلاً من المكالمات داخل الذاكرة. على الرغم من أن هذا الفصل يتيح إمكانية التوسع الأفقي الهائل وعمليات النشر المستقلة، إلا أنه يقدم أيضًا ثغرة أمنية كبيرة: الشبكة غير موثوقة.

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

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

في هذا الدليل، سوف نستكشف نمط إعادة المحاولة، وكيف يعمل تحت الغطاء، ومخاطر التطبيقات الساذجة، وكيفية تنفيذها بشكل صحيح في Java (Resilience4j) وGo.


القياس على العالم الحقيقي: إعادة الاتصال بخط مزدحم

تخيل أنك تحاول الاتصال بصديق. يمكنك الاتصال برقمهم، لكنك تحصل على إشارة مشغول لأنهم في مكالمة أخرى حاليًا.

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

في النهاية، تنتهي مكالمتهم، وتنجح إعادة المحاولة.

رسم تخطيطي لهندسة نمط إعادة المحاولة يوضح محاولة الطلب، وتأخير التراجع، ومحاولة إعادة المحاولة الثانية الناجحة

في الخدمات المصغرة:

  • الاستدعاء هو طلب API لخدمة المصب.
  • إشارة الانشغال هي خطأ عابر في الشبكة أو استجابة 503 Service Unavailable.
  • إعادة الطلب هي محاولة لإعادة المحاولة.
  • وقت الانتظار هو مدة التراجع.

الخطر: المحاولات الساذجة و"إعادة محاولة العواصف"

يبدو تنفيذ آلية إعادة المحاولة أمرًا تافهًا للوهلة الأولى: ما عليك سوى لف مكالمة HTTP في حلقة for ومواصلة المحاولة حتى تنجح. ومع ذلك، يمكن أن يؤدي تنفيذ إعادة المحاولة الساذجة بسهولة إلى تحويل الفواق البسيط إلى انقطاع كارثي على مستوى النظام.

تخيل أن خدمة المصب تكافح في ظل زيادة مفاجئة في حركة المرور. قاعدة البيانات الخاصة به تعمل بنسبة 99% من استخدام وحدة المعالجة المركزية، وتبدأ مهلة الطلبات في الانتهاء.

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

بدلاً من المساعدة في استعادة الخدمة المتلقية للمعلومات، ستستمر عمليات إعادة المحاولة في دفعها إلى الأسفل، مما يمنعها من اللحاق بالركب على الإطلاق.


الحل: التراجع والارتعاش

لمنع تكرار العواصف، يجب أن يستخدم النظام المرن استراتيجيتين أساسيتين: Backoff وJitter.

1. استراتيجيات التراجع

يحدد التراجع المدة التي يجب أن ينتظرها العميل قبل إجراء محاولة إعادة المحاولة اللاحقة.

  • التراجع الثابت: ينتظر العميل مقدارًا ثابتًا من الوقت (على سبيل المثال، 200 مللي ثانية بالضبط) بين المحاولات. على الرغم من بساطته، إلا أنه لا يزال يواجه خطر ارتفاع حركة المرور المتزامنة.
  • التراجع الأسي: يزداد وقت الانتظار بشكل كبير مع كل محاولة فاشلة (على سبيل المثال، 100 مللي ثانية، 200 مللي ثانية، 400 مللي ثانية، 800 مللي ثانية). وهذا يمنح الخدمة النهائية مزيدًا من الوقت للتعافي مع استمرار الفشل.

2. الارتعاش (العشوائية)

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

الارتعاش يحل هذه المشكلة عن طريق إضافة تباين عشوائي إلى تأخير التراجع.

Without Jitter (Synchronized Waves):
Time: 0ms   -> [1000 requests fail]
Time: 100ms -> [1000 retries hit simultaneously]
Time: 200ms -> [1000 retries hit simultaneously]

With Jitter (Distributed Traffic):
Time: 0ms   -> [1000 requests fail]
Time: 92ms  -> [85 retries]
Time: 105ms -> [120 retries]
Time: 118ms -> [95 retries]
... (Traffic is smoothed out over time)

ومن خلال نشر عمليات إعادة المحاولة عبر فترة زمنية عشوائية، يتم تخفيف الحمل على خدمة نقل البيانات، مما يسمح لها بالتعافي بأمان.


القاعدة الذهبية لإعادة المحاولة: العجز

قبل تطبيق عمليات إعادة المحاولة على أي نقطة نهاية لواجهة برمجة التطبيقات (API)، يجب عليك طرح سؤال مهم: هل هذه العملية غير فعالة؟

العملية الفعالة هي العملية التي يكون فيها تقديم طلبات متطابقة متعددة له نفس التأثير تمامًا مثل تقديم طلب واحد.

  • غير فعال: قراءة ملف تعريف المستخدم (GET /users/123)، أو تحديث حقل بريد إلكتروني بالكامل (PUT /users/123/email)، أو حذف عنصر (DELETE /items/456).
  • غير مؤهل: إنشاء طلب جديد (POST /orders)، أو معالجة رسوم بطاقة الائتمان (POST /payments).

لنفترض أنك اتصلت بـ POST /payments لتحصيل مبلغ 50 دولارًا من العميل. تتلقى بوابة الدفع النهائية الطلب، وتخصم الرسوم من بطاقة الائتمان بنجاح، ولكن بعد ذلك يحدث خلل في الشبكة قبل أن تتمكن من إرسال استجابة 200 OK إليك مرة أخرى.

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

[!تحذير] لا تقم مطلقًا بإعادة محاولة العمليات غير الفعالة ما لم تدعم الخدمة النهائية مفاتيح العجز (معرفات الطلب الفريدة المستخدمة لاكتشاف المعاملات المكررة وتجاهلها).


أمثلة التنفيذ

دعونا نلقي نظرة على كيفية تنفيذ نموذج إعادة المحاولة في Java وGo.

1. جافا (Resilience4j وSpring Boot)

يوفر Resilience4j وحدة إعادة محاولة قوية وقابلة للتكوين بدرجة عالية. فيما يلي كيفية تكوينه لخدمة الفوترة الخارجية.

التكوين (application.yml)

resilience4j.retry:
  instances:
    billingService:
      maxAttempts: 3
      waitDuration: 100ms
      enableExponentialBackoff: true
      exponentialBackoffMultiplier: 2.0
      enableRandomizedWait: true
      randomizedWaitFactor: 0.5
      retryExceptions:
        - org.springframework.web.client.ResourceAccessException
        - io.netty.channel.ConnectTimeoutException
      ignoreExceptions:
        - org.springframework.web.client.HttpClientErrorException # E.g., 400 Bad Request shouldn't be retried

تنفيذ التعليمات البرمجية

import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class PaymentProcessor {

    private final RestTemplate restTemplate;

    public PaymentProcessor(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    // Apply the configured retry policy with a fallback method
    @Retry(name = "billingService", fallbackMethod = "billingFallback")
    public String chargeUser(BillingRequest request) {
        return restTemplate.postForObject("http://billing-service/charge", request, String.class);
    }

    // Executed when all retry attempts fail
    public String billingFallback(BillingRequest request, Throwable throwable) {
        return "Billing service is currently unavailable. Your transaction will be queued.";
    }
}

2. اذهب (جولانج)

في Go، يمكننا كتابة مخطط إعادة محاولة أنيق يتميز بالتراجع الأسي والارتعاش العشوائي باستخدام مؤقتات المكتبة القياسية.

package main

import (
	"context"
	"errors"
	"fmt"
	"math/rand"
	"time"
)

// RetryConfig holds the policies for our retry attempts
type RetryConfig struct {
	MaxAttempts int
	MinBackoff  time.Duration
	MaxBackoff  time.Duration
}

// Execute runs the operation using exponential backoff with full jitter
func Execute(ctx context.Context, config RetryConfig, operation func() error) error {
	var err error
	
	for attempt := 1; attempt <= config.MaxAttempts; attempt++ {
		err = operation()
		if err == nil {
			return nil // Success!
		}

		if attempt == config.MaxAttempts {
			break
		}

		// Calculate exponential backoff
		// wait = min(MaxBackoff, MinBackoff * 2^(attempt-1))
		backoff := config.MinBackoff * (1 << (attempt - 1))
		if backoff > config.MaxBackoff || backoff <= 0 {
			backoff = config.MaxBackoff
		}

		// Apply Full Jitter: wait randomly between 0 and backoff
		jitter := time.Duration(rand.Int63n(int64(backoff)))

		fmt.Printf("[Attempt %d/%d] Failed: %v. Retrying in %v...\n", attempt, config.MaxAttempts, err, jitter)

		select {
		case <-time.After(jitter):
		case <-ctx.Done():
			return ctx.Err()
		}
	}

	return fmt.Errorf("operation failed after %d attempts: %w", config.MaxAttempts, err)
}

func main() {
	config := RetryConfig{
		MaxAttempts: 4,
		MinBackoff:  100 * time.Millisecond,
		MaxBackoff:  2000 * time.Millisecond,
	}

	// Mock function that fails 3 times and succeeds on the 4th
	attempts := 0
	mockAPI := func() error {
		attempts++
		if attempts < 4 {
			return errors.New("network timeout (503)")
		}
		return nil
	}

	ctx := context.Background()
	err := Execute(ctx, config, mockAPI)
	if err != nil {
		fmt.Printf("Final Outcome: %v\n", err)
	} else {
		fmt.Println("Final Outcome: Successfully connected on attempt", attempts)
	}
}

إعادة المحاولة مقابل قاطع الدائرة: متى يجب استخدام أي منهما؟

غالبًا ما يخلط المطورون بين نمط إعادة المحاولة ونمط قاطع الدائرة. في حين أن كلاهما يهدف إلى تحسين مرونة النظام، إلا أنهما يتعاملان مع أنواع مختلفة بشكل أساسي من حالات الفشل:

متري إعادة المحاولة نمط قواطع دوائر
الهدف الأساسي يتعافى من حالات الفشل العابرة (قصيرة الأمد) تلقائيًا. يمنع حالات الفشل المستمرة (طويلة الأمد) من إسقاط المتصل.
الاستراتيجية حاول مرة أخرى على الفور أو بعد فترة انتظار قصيرة. فشل سريع على الفور دون استدعاء الخدمة المستهدفة.
** التأثير على المصب ** يزيد الحمل على خدمة المصب مؤقتًا. يحمي الخدمة النهائية من حركة المرور، مما يسمح لها بالتعافي.
المشغلات النموذجية قطع اتصال قصير بالشبكة، وانتهاء مهلة قاعدة البيانات، وسقوط مأخذ التوصيل. خدمة المصب معطلة تمامًا، مما يؤدي إلى إرجاع 500 ثانية أو مهلات متواصلة.

الثنائي القوي: الجمع بين الاثنين

في أنظمة الإنتاج، تم تصميم هذين النمطين لاستخدامهما معًا.

عند تقديم طلب في اتجاه المتلقين للمعلومات، يجب أن يمر عبر Circuit Breaker أولاً، ثم غلاف Retry.

في حالة حدوث خلل عابر، تعترضه آلية إعادة المحاولة وتعيد المحاولة. ومع ذلك، إذا توقفت خدمة المصب، فسوف تتراكم حالات الفشل. يكتشف قاطع الدائرة حالات الفشل المتتالية، ويفتح “الرحلات”، ويحظر جميع المكالمات المستقبلية.

الآن، عندما تحاول الاتصال بالخدمة، يفشل قاطع الدائرة بسرعة على الفور، مما يؤدي إلى تجاوز حلقة إعادة المحاولة بالكامل وتوفير موارد الكمبيوتر لديك.


أفضل الممارسات لنمط إعادة المحاولة

  1. إعادة محاولة الأخطاء العابرة فقط: افحص رموز حالة HTTP. أعد محاولة 503 Service Unavailable، 429 Too Many Requests (مع مراعاة رؤوس Retry-After إذا كانت موجودة)، وانتهاء مهلات الشبكة. لا لا تعيد محاولة 400 Bad Request أو 401 Unauthorized أو 404 Not Found — فلن تنجح هذه المحاولة أبدًا عند إعادة المحاولة.
  2. تطبيق الارتعاش: لا تستخدم مطلقًا مؤقتًا ثابتًا لإعادة المحاولة دون تقديم ارتعاش عشوائي.
  3. الحد الأقصى للمحاولات: توقف عن إعادة المحاولة بعد حد معقول (عادةً من 3 إلى 5 محاولات). تستهلك عمليات إعادة المحاولة غير المحدودة الموارد وتؤدي إلى انخفاض زمن الوصول.
  4. كن حذرًا عند استخدام المحاولات المتتالية: إذا كانت الخدمة A تستدعي الخدمة B (التي تعيد المحاولة 3 مرات)، وتستدعي الخدمة B الخدمة C (التي تعيد المحاولة 3 مرات)، فقد يؤدي الفشل في C إلى $3 \times 3 = 9$ مكالمات متتالية. قم بتطبيق عمليات إعادة المحاولة فقط على الحدود التي تكون أكثر منطقية.
  5. قم دائمًا بتعيين مهلات صارمة: تأكد من أن مهلات الشبكة لديك أقصر من فترات التراجع، حتى لا يتم تعليق سلاسل الرسائل إلى أجل غير مسمى.

خاتمة

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