Go Programming

ساخت سرویس‌های مقاوم در گو: تسلط بر مکانیسم‌های تلاش مجدد و مدارشکن

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

فلسفه مقاومت

هنگامی که یک سرویس به یک وابستگی خارجی—چه پایگاه داده، چه صف پیام، یا چه میکروسرویس دیگر—تماس می‌گیرد، همیشه خطر شکست وجود دارد. تلاش مجدد کورکورانه برای هر شکستی می‌تواند منجر به مشکل «گله طوفانی» (Thundering Herd) شود، جایی که سرویس‌های بازیابی‌شده با سیلی از درخواست‌ها غرق می‌شوند. از سوی دیگر، شکست فوری در اولین خطا، در دسترس بودن را کاهش می‌دهد. هدف یافتن تعادل است: به اندازه کافی تلاش مجدد کنید تا خطاهای گذرا را مدیریت کنید، اما قبل از ایجاد شکست‌های زنجیره‌ای متوقف شوید.

پیاده‌سازی منطق تلاش مجدد در گو

منطق تلاش مجدد از نظر مفهومی ساده است اما در عمل نیاز به پیاده‌سازی دقیق دارد. ما باید بین عملیات هم‌ارز (Idempotent) و غیر هم‌ارز تمایز قائل شویم. تلاش مجدد برای یک عملیات هم‌ارز (مانند درخواست GET) به طور کلی ایمن است، در حالی که تلاش مجدد برای یک عملیات غیر هم‌ارز (مانند درخواست POST) می‌تواند منجر به تکرار داده‌ها شود.

برای این مثال، ما یک استراتژی ساده بازگشت نمایی (Exponential Backoff) را برای یک درخواست HTTP هم‌ارز پیاده‌سازی می‌کنیم. بازگشت نمایی زمان انتظار بین تلاش‌های مجدد را افزایش می‌دهد و بار را بر روی سرویس شکست‌خورده کاهش می‌دهد.

package main

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

// RetryConfig holds the parameters for the retry logic.
type RetryConfig struct {
	MaxRetries int
	BaseDelay  time.Duration
	MaxDelay   time.Duration
}

// RetryWithBackoff executes a function with exponential backoff.
func RetryWithBackoff(ctx context.Context, config RetryConfig, fn func() error) error {
	var err error
	delay := config.BaseDelay

	for attempt := 0; attempt <= config.MaxRetries; attempt++ {
		if attempt > 0 {
			// Calculate next delay with jitter to prevent thundering herd
			jitter := time.Duration(rand.Int63n(int64(delay)))
			time.Sleep(delay + jitter)
			// Increase delay for next iteration
			delay = delay * 2
			if delay > config.MaxDelay {
				delay = config.MaxDelay
			}
		}

		err = fn()
		if err == nil {
			return nil
		}

		// Optional: Check if the context is done (e.g., user cancelled)
		if ctx.Err() != nil {
			return ctx.Err()
		}
	}
	return fmt.Errorf("after %d attempts, last error: %w", config.MaxRetries, err)
}

در کد بالا، ما لرزش (Jitter - تصادفی‌سازی) را به تأخیر اضافه می‌کنیم. بدون لرزش، اگر چندین سرویس همزمان تلاش مجدد کنند، ممکن است همه دقیقاً به همان مقدار منتظر بمانند و سپس همزمان سرور را بمباران کنند. لرزش به پراکنده شدن درخواست‌های تلاش مجدد کمک می‌کند.

الگوی مدارشکن

در حالی که منطق تلاش مجدد برای خطاهای گذرا کمک می‌کند، اما زمانی که یک سرویس کاملاً از دسترس خارج شده یا به طور مداوم شکست می‌خورد، کمکی نمی‌کند. در چنین مواردی، ادامه ارسال درخواست‌ها منابع را هدر می‌دهد و تأخیر را افزایش می‌دهد. الگوی مدارشکن با عمل کردن مانند یک فیوز برق این مشکل را حل می‌کند: اگر تعداد زیادی شکست رخ دهد، مدار «می‌پرد» و برای یک دوره مشخص از ارسال درخواست‌ها خودداری می‌کند.

یک مدارشکن معمولاً سه حالت دارد:

  1. بسته: درخواست‌ها به طور عادی عبور می‌کنند. اگر شکست‌ها از یک آستانه فراتر رود، مدار باز می‌شود.
  2. باز: درخواست‌ها بدون تماس با سرویس پایین‌دستی، فوراً شکست می‌خورند. این کار از بار بیشتر بر روی سرویس شکست‌خورده جلوگیری می‌کند.
  3. نیمه‌باز: پس از یک دوره خنک‌سازی، مدار اجازه می‌دهد تعداد محدودی درخواست آزمایشی عبور کنند. اگر این درخواست‌ها موفق باشند، مدار بسته می‌شود؛ اگر شکست بخورند، دوباره باز می‌شود.

در برنامه‌های گو در محیط تولید، استفاده از کتابخانه‌های تثبیت‌شده مانند sony/gobreaker به شدت توصیه می‌شود به جای نوشتن یک ماشین حالت از صفر، زیرا این کتابخانه‌ها همزمانی و حالات حاشیه‌ای را به طور کارآمد مدیریت می‌کنند.

import (
	"github.com/sony/gobreaker"
	"net/http"
)

var settings = gobreaker.Settings{
	Name:    "ExternalAPICall",
	MaxRequests: 3,
	Interval:  60 * time.Second,
	Timeout:   30 * time.Second,
	ReadyToTrip: func(counts gobreaker.Counts) bool {
		return counts.ConsecutiveFailures >= 5
	},
}

var cb *gobreaker.TypedBreaker[http.Response]

func init() {
	cb = gobreaker.NewTypedBreaker[http.Response](settings)
}

func callExternalAPI(url string) (*http.Response, error) {
	req, _ := http.NewRequest("GET", url, nil)
	
	resp, err := cb.Execute(func() (interface{}, error) {
		return http.DefaultClient.Do(req)
	})
	
	if err != nil {
		// Handle circuit breaker error (request rejected)
		if err == gobreaker.ErrOpenState {
			return nil, fmt.Errorf("service temporarily unavailable")
		}
		return nil, err
	}
	
	return resp.(*http.Response), nil
}

ترکیب تلاش مجدد و مدارشکن

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

نتیجه‌گیری

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

Share: