الگوی امتحان مجدد: ساخت میکروسرویس های انعطاف پذیر

الگوی امتحان مجدد: ساخت میکروسرویس های انعطاف پذیر

در معماری میکروسرویس‌ها، سرویس‌ها به جای تماس‌های درون حافظه، از طریق شبکه ارتباط برقرار می‌کنند. در حالی که این جداسازی مقیاس افقی گسترده و استقرار مستقل را امکان پذیر می کند، یک آسیب پذیری بزرگ را نیز معرفی می کند: شبکه غیرقابل اعتماد است.

در هر لحظه، یک سرویس پایین دستی ممکن است با یک مشکل کوتاه در شبکه، یک جهش موقت CPU، یک کشمکش سریع قفل پایگاه داده یا راه اندازی مجدد به روز رسانی رو به رو شود. این خرابی های موقت به نام عیب های گذرا شناخته می شوند.

اگر سرویس شما بلافاصله با خطا مواجه شد و در لحظه ای که تماس پایین دستی ناموفق بود، درخواست را انجام نداد، یک تجربه کاربری شکننده ایجاد می کنید. در عوض، بسیاری از این خطاهای گذرا را می توان با یک لحظه صبر و تلاش مجدد به طور خودکار حل کرد. اینجاست که تکرار مجدد الگو وارد می شود.

در این راهنما، الگوی Retry، نحوه عملکرد آن در زیر هود، خطرات پیاده سازی های ساده و نحوه پیاده سازی صحیح آن در جاوا (Resilience4j) و Go را بررسی خواهیم کرد.


قیاس دنیای واقعی: شماره گیری مجدد یک خط مشغول

تصور کنید می خواهید با یک دوست تماس بگیرید. شماره آن‌ها را می‌گیرید، اما سیگنال اشغالی دریافت می‌کنید، زیرا آنها در حال حاضر در تماس دیگری هستند.

آیا بلافاصله تسلیم می‌شوید، مخاطب آنها را حذف می‌کنید و تصور می‌کنید که دیگر نمی‌توانید با آنها صحبت کنید؟ البته نه. تلفن را قطع می کنید، یک دقیقه صبر می کنید و دوباره شماره آنها را می گیرید. اگر هنوز مشغول هستند، ممکن است پنج دقیقه صبر کنید تا دوباره امتحان کنید.

در نهایت تماس آنها به پایان می رسد و تلاش مجدد شما با موفقیت انجام می شود.

تلاش مجدد نمودار معماری الگو که تلاش درخواست، تاخیر عقب‌نشینی و دومین تلاش مجدد موفق را نشان می‌دهد.

در میکروسرویس ها:

  • تماس یک درخواست API به یک سرویس پایین دستی است.
  • سیگنال اشغال یک خطای شبکه گذرا یا یک پاسخ 503 Service Unavailable است.
  • شماره گیری مجدد یک تلاش مجدد است.
  • زمان انتظار مدت زمان عقب نشینی است.

The Danger: Naive Retries & “Retry Storms”

پیاده‌سازی مکانیسم تلاش مجدد در نگاه اول بی‌اهمیت به نظر می‌رسد: فقط تماس HTTP خود را در یک حلقه for بپیچید و تا زمانی که موفق شود به تلاش خود ادامه دهید. با این حال، یک اجرای مجدد ساده و ساده می‌تواند به راحتی یک سکسکه جزئی را به یک اختلال فاجعه‌بار در سراسر سیستم تبدیل کند.

یک سرویس پایین دستی را تصور کنید که تحت یک موج ناگهانی ترافیک با مشکل مواجه است. پایگاه داده آن با 99٪ استفاده از CPU در حال اجرا است و درخواست ها شروع به اتمام می کنند.

اگر 100 سرویس مشتری همگی یک مهلت زمانی را تشخیص دهند و بلافاصله 3 بار بدون انتظار دوباره امتحان کنند، ناگهان حجم ترافیکی را که به سرویس پایین دستی که از قبل خفه شده بود، سه برابر می کنند. این تقویت ناگهانی ترافیک به عنوان ** طوفان مجدد** (یا مشکل گله رعد) شناخته می شود.

به جای کمک به بازیابی سرویس پایین‌دستی، تلاش‌های مجدد شما همچنان آن را پایین می‌آورد و از رسیدن آن به عقب جلوگیری می‌کند.


راه حل: Backoff & Jitter

برای جلوگیری از طوفان های مجدد، یک سیستم انعطاف پذیر باید از دو استراتژی ضروری استفاده کند: Backoff و Jitter.

1. استراتژی های عقب نشینی

Backoff تعیین می کند که مشتری چه مدت باید قبل از تلاش مجدد بعدی صبر کند.

  • بازگشت ثابت: مشتری مقدار ثابتی از زمان (مثلاً دقیقاً 200 میلی ثانیه) بین تلاش ها منتظر می ماند. اگرچه ساده است، اما همچنان خطر افزایش ترافیک همگام را دارد.
  • **بازگشت نمایی **: زمان انتظار با هر تلاش ناموفق به طور تصاعدی افزایش می یابد (مثلاً 100 میلی ثانیه، 200 میلی ثانیه، 400 میلی ثانیه، 800 میلی ثانیه). این امر به سرویس پایین دستی به تدریج زمان بیشتری برای بازیابی با تداوم خرابی می دهد.

2. عصبانیت (تصادفی)

حتی با عقب‌نشینی تصاعدی، اگر یک خطای شبکه باعث شود که 1000 درخواست در همان لحظه با شکست مواجه شوند، همه 1000 مشتری دقیقاً همان تأخیر برگشت را محاسبه می‌کنند. در نتیجه، همه آنها به طور همزمان در امواج دوباره تلاش می کنند، و سرویس پایین دستی را با اسپک های همگام سازی شده ضربه می زنند.

Jitter این مشکل را با اضافه کردن واریانس تصادفی به تاخیر برگشتی حل می کند.

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، باید یک سوال مهم بپرسید: آیا این عملیات ضعیف است؟

عملیات idempotent عملیاتی است که در آن ایجاد چندین درخواست یکسان دقیقاً همان تأثیری را که ایجاد یک درخواست واحد دارد دارد.

  • Idempotent: خواندن نمایه کاربر (GET /users/123)، به‌روزرسانی کل فیلد ایمیل (PUT /users/123/email)، یا حذف یک مورد (DELETE /items/456).
  • **غیر توانمند **: ایجاد یک سفارش جدید (POST /orders)، یا پردازش هزینه کارت اعتباری (POST /payments).

فرض کنید با POST /payments تماس می گیرید تا 50 دلار از مشتری دریافت کنید. درگاه پرداخت پایین‌دستی درخواست را دریافت می‌کند، کارت اعتباری را با موفقیت شارژ می‌کند، اما قبل از اینکه بتواند پاسخ 200 OK را برای شما ارسال کند، یک مشکل شبکه رخ می‌دهد.

سرویس شما مهلت زمانی را ثبت می کند، فرض می کند که تماس ناموفق بوده و به طور خودکار دوباره تلاش می کند. اگر درگاه پرداخت برای رسیدگی به درخواست‌های تکراری طراحی نشده باشد، از مشتری دو بار هزینه دریافت می‌شود.

[!هشدار] هرگز مجدداً عملیات‌های غیر توانمند را امتحان نکنید، مگر اینکه سرویس پایین‌دستی کلیدهای Idempotency را پشتیبانی کند (شناسه‌های درخواست منحصربه‌فرد که برای شناسایی و کنار گذاشتن تراکنش‌های تکراری استفاده می‌شوند).


مثال های پیاده سازی

بیایید نحوه پیاده سازی Retry Pattern در Java and 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)
	}
}

سعی مجدد در مقابل مدار شکن: چه زمانی از کدام استفاده کنیم؟

توسعه دهندگان اغلب الگوی دوباره امتحان کنید را با الگوی قطع کننده مدار اشتباه می گیرند. در حالی که هدف هر دو بهبود انعطاف پذیری سیستم است، انواع مختلفی از خرابی ها را مدیریت می کنند:

متریک سعی مجدد الگو الگوی مدار شکن
هدف اولیه خرابی های گذرا (کوتاه مدت) به طور خودکار بازیابی می شود. از شکست مداوم (طولانی مدت) از حذف تماس گیرنده جلوگیری می کند.
استراتژی بلافاصله یا پس از یک انتظار کوتاه دوباره امتحان کنید. بدون فراخوانی سرویس هدف، فوراً از کار بیفتید.
تاثیر پایین دست بار سرویس پایین دست را به طور موقت افزایش می دهد. از سرویس پایین دستی در برابر ترافیک محافظت می کند و به آن امکان بازیابی می دهد.
** محرک های معمولی ** قطع کوتاه شبکه، وقفه زمانی پایگاه داده، افت سوکت. سرویس Downstream کاملاً خاموش است و 500 ثانیه یا وقفه های زمانی پیوسته را برمی گرداند.

زوج قدرت: ترکیب هر دو

در سیستم های تولید، این دو الگو به گونه ای طراحی شده اند که با هم مورد استفاده قرار گیرند.

وقتی یک درخواست پایین‌دستی می‌کنید، ابتدا باید از Circuit Breaker و سپس از wrapper امتحان مجدد عبور کند.

اگر یک اشکال گذرا رخ دهد، مکانیسم Retry آن را قطع می کند و دوباره تلاش می کند. با این حال، اگر سرویس پایین دستی از بین رفته باشد، خرابی ها انباشته می شوند. Circuit Breaker خرابی های متوالی را تشخیص می دهد، باز می شود و تمام تماس های آینده را مسدود می کند.

اکنون، زمانی که می‌خواهید با سرویس تماس بگیرید، Circuit Breaker به سرعت از کار می‌افتد و حلقه Retry را به طور کامل دور می‌زند و منابع محاسباتی شما را ذخیره می‌کند.


بهترین تمرین برای الگوی امتحان مجدد

  1. فقط خطاهای گذرا را دوباره امتحان کنید: کدهای وضعیت HTTP را بررسی کنید. دوباره سعی کنید 503 Service Unavailable، 429 Too Many Requests (با رعایت سرصفحه های Retry-After در صورت وجود)، و وقفه های شبکه. ********** دوباره 400 Bad Request، 401 Unauthorized، یا 404 Not Found را امتحان نکنید—اینها با تلاش مجدد هرگز موفق نخواهند شد.
  2. Apply Jitter: هرگز از یک تایمر مجدد ثابت بدون معرفی جیتر تصادفی استفاده نکنید.
  3. محدود کردن حداکثر تلاش: پس از یک آستانه معقول (معمولاً 3 تا 5 بار) تلاش مجدد را متوقف کنید. تکرار نامحدود منابع را مصرف می کند و تأخیر را کاهش می دهد.
  4. مراقب تکرارهای آبشاری باشید: اگر سرویس A با سرویس B (که 3 بار تکرار می شود) و سرویس B با سرویس C (که 3 بار تکرار می شود) تماس بگیرد، شکست در C می تواند منجر به 3 $ \ برابر 3 = 9 $ تماس های آبشاری شود. سعی‌های مجدد را فقط در مرزهایی اعمال کنید که بیشترین معنا را دارند.
  5. همیشه بازه های زمانی دقیق تنظیم کنید: مطمئن شوید که وقفه های زمانی شبکه شما کوتاهتر از دوره های عقب نشینی شما باشد، بنابراین تاپیک ها به طور نامحدود نگهداری نمی شوند.

نتیجه گیری

تکرار مجدد الگو اولین خط دفاعی قدرتمند در برابر عدم اطمینان شبکه در معماری های میکروسرویس است. وقتی با Backoff نمایی، Jitter، و Circuit Breaker جفت می‌شوید، می‌توانید سیستم‌های خود را از قطعی‌های آبشاری محافظت کنید و تجربه‌ای بدون درز و خوددرمانی برای کاربران خود ایجاد کنید.