الگوی حفره: طراحی میکروسرویس های مقاوم در برابر خطا

الگوی حفره: طراحی میکروسرویس های مقاوم در برابر خطا

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

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

این شکست آبشاری به عنوان اثر دومینو شناخته می شود. برای جلوگیری از آن، معماران سیستم از Bulkhead Pattern استفاده می کنند.

در این راهنما، ما بررسی خواهیم کرد که الگوی Bulkhead چیست، چگونه کار می‌کند و چگونه آن را با استفاده از تشابه‌های ساده، مفاهیم معماری و نمونه‌های کد در جاوا (Resilience4j) و Go پیاده‌سازی کنیم.


قیاس دنیای واقعی: دیواره های کشتی های ضد آب

نام این الگو از صنعت کشتی سازی گرفته شده است.

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

نمودار معماری الگوی دیواری که استخرهای نخ مشترک و جدا شده را نشان می دهد

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

بدون دیواره ها، آب آزادانه در سراسر بدنه جریان می یابد و در نهایت کشتی را غرق می کند.

در مهندسی نرم افزار:

  • کشتی کل برنامه یا خدمات شماست.
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              • *****.
  • Hull Breach یک شکست یا کندی در یک میکروسرویس پایین دست است.
  • سیل فرسودگی منابع است.

مشکل: منابع مشترک و فرسودگی نخ

برای درک اینکه چرا دیوارها ضروری هستند، بیایید ببینیم وقتی منابع در سطح جهانی به اشتراک گذاشته می شوند چه اتفاقی می افتد.

تصور کنید که یک API Gateway یا یک وب سرور به درخواست های کاربر رسیدگی می کند. دارای یک مخزن سراسری از 100 رشته برای پردازش تمام تماس های دریافتی است. سرور با سه سرویس پایین دستی تعامل دارد:

  1. سرویس کاتالوگ (سریع، لیست محصولات را می خواند)
  2. خدمات پرداخت (سریع، پرداخت فرآیندها)
  3. سرویس توصیه (آهسته، موارد شخصی شده را محاسبه می کند)

به طور معمول، همه چیز خوب کار می کند. اما فرض کنید Recommendation Service از بن بست پایگاه داده رنج می برد و شروع به پاسخگویی به جای 200 میلی ثانیه 30 ثانیه می کند.

در اینجا چیزی است که اتفاق می افتد:

  1. کاربران به بازدید از صفحه اصلی ادامه می‌دهند و درخواست‌هایی را برای سرویس توصیه‌ها آغاز می‌کنند.
  2. سرور به هر درخواست یک رشته از استخر جهانی اختصاص می دهد.
  3. از آنجایی که Recommendation Service کند است، این موضوعات در انتظار پاسخ می مانند.
  4. در عرض چند ثانیه، همه 100 رشته در استخر در انتظار سرویس توصیه هستند.
  5. هنگامی که یک کاربر جدید سعی می کند کاتالوگ را بررسی کند یا آن را مشاهده کند، سرور هیچ رشته ای برای پردازش درخواست او باقی نمی ماند.

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


راه حل: الگوی کلبه

Bulkhead Pattern این مشکل را با پارتیشن بندی منابع مخزن حل می کند تا خرابی در یک ناحیه بر قسمت های دیگر تأثیر نگذارد.

به جای یک استخر جهانی واحد، استخرهای مجزا و محدود را برای هر سرویس یا وابستگی پایین دستی اختصاص می دهیم.

اگر 10 رشته را به طور خاص برای سرویس توصیه اختصاص دهیم، حداکثر 10 رشته می توانند در انتظار روی آن مسدود شوند. اگر سرویس توصیه کند شود، آن 10 رشته تمام می‌شود و درخواست‌های توصیه بعدی فوراً رد می‌شوند.

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


انواع جداسازی بالک

دو راه اصلی برای پیاده سازی دیواره ها در سیستم های نرم افزاری وجود دارد:

1. جداسازی Thread Pool

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

نمودار جداسازی Thread Pool که درخواست های دریافتی را در صف برای رشته های کارگر نشان می دهد
  • چگونه کار می کند: برنامه اصلی کار را به یک Thread Pool خاص منتقل می کند. اگر استخر پر باشد، درخواست یا در صف قرار می گیرد یا رد می شود.
  • ** مزایا **: ایزوله کامل را فراهم می کند. اگر یک سرویس کند شود، فقط Thread Pool آن تحت تأثیر قرار می گیرد. Thread ها در سطح سیستم عامل/JVM ایزوله می شوند.
  • معایب: سربار اضافی CPU را به دلیل زمانبندی رشته، تغییر زمینه و مدیریت صف معرفی می کند.

2. جداسازی سمافور

به جای ایجاد thread pool های جدید، جداسازی سمافور از یک شمارنده (یک سمافور) برای محدود کردن تعداد تماس های همزمان مجاز به یک سرویس خاص استفاده می کند.

نمودار جداسازی سمافور که رشته‌های درخواستی را نشان می‌دهد که وظایف را پس از کسب مجوز انجام می‌دهند.
  • چگونه کار می کند: وقتی درخواستی شروع می شود، سعی می کند از سمافور مجوز بگیرد. اگر مجوز در دسترس باشد، درخواست را در رشته فراخوانی اجرا می‌کند و پس از پایان مجوز مجوز را آزاد می‌کند. اگر هیچ مجوزی در دسترس نباشد، درخواست بلافاصله رد می شود.
  • مزایای: بسیار سبک وزن با سربار تقریباً صفر، زیرا هیچ تغییری در زمینه موضوع وجود ندارد.
  • ** معایب **: بدون جدا شدن نخ. اگر تماسی در سوکت شبکه بدون مهلت زمانی مناسب مسدود شود، همچنان می‌تواند رشته تماس را مسدود کند.

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

بیایید نحوه پیاده سازی bulkheads در دو زبان محبوب Back-end را بررسی کنیم.

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

Resilience4j یک کتابخانه تحمل خطا سبک وزن و با کاربرد آسان است که برای جاوا طراحی شده است. در زیر نحوه پیکربندی یک بخش برای سرویس پرداخت پایین دستی در یک برنامه Spring Boot آورده شده است.

پیکربندی (application.yml)

resilience4j.bulkhead:
  instances:
    paymentService:
      maxConcurrentCalls: 10
      maxWaitDuration: 10ms

resilience4j.threadpoolbulkhead:
  instances:
    paymentService:
      maxThreadPoolSize: 10
      coreThreadPoolSize: 5
      queueCapacity: 20

پیاده سازی کد

import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class OrderService {

    private final RestTemplate restTemplate;

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

    // Apply semaphore bulkhead
    @Bulkhead(name = "paymentService", fallbackMethod = "paymentFallback")
    public String processPayment(OrderDetails details) {
        return restTemplate.postForObject("http://payment-service/charge", details, String.class);
    }

    // Fallback method executed when the bulkhead is full
    public String paymentFallback(OrderDetails details, Throwable throwable) {
        return "Payment service is currently busy. Please try again later.";
    }
}

2. برو (گولنگ)

در Go، ما لزوماً به یک فریمورک سنگین نیاز نداریم زیرا این زبان مقدمات همزمانی اولیه مانند Goroutines و کانال‌های بافر را ارائه می‌کند. ما می توانیم با استفاده از یک کانال بافر، یک دیوار سمافور تمیز را پیاده سازی کنیم:

package main

import (
	"errors"
	"fmt"
	"net/http"
	"time"
)

// Bulkhead represents a concurrency limiter
type Bulkhead struct {
	semaphore chan struct{}
}

// NewBulkhead initializes a bulkhead with a max concurrency limit
func NewBulkhead(maxConcurrency int) *Bulkhead {
	return &Bulkhead{
		semaphore: make(chan struct{}, maxConcurrency),
	}
}

// Execute runs the task if resource permit is available, otherwise returns error
func (b *Bulkhead) Execute(task func() error) error {
	select {
	case b.semaphore <- struct{}{}:
		// Acquired permit
		defer func() { <-b.semaphore }() // Release permit
		return task()
	default:
		// Bulkhead is full, reject immediately
		return errors.New("bulkhead is full: request rejected")
	}
}

func main() {
	// Allow maximum of 3 concurrent calls
	paymentBulkhead := NewBulkhead(3)

	mockTask := func() error {
		fmt.Println("Processing payment...")
		time.Sleep(2 * time.Second) // Simulate network delay
		return nil
	}

	// Simulate 5 rapid requests
	for i := 1; i <= 5; i++ {
		go func(reqID int) {
			err := paymentBulkhead.Execute(mockTask)
			if err != nil {
				fmt.Printf("Request %d failed: %v\n", reqID, err)
			} else {
				fmt.Printf("Request %d completed successfully\n", reqID)
			}
		}(i)
	}

	// Keep main alive to watch output
	time.Sleep(3 * time.Second)
}

موارد استفاده متداول برای الگوی بالک

در اینجا چند سناریو معمولی وجود دارد که در آن پیاده‌سازی یک الگوی دیواری حیاتی است:

  • API Gateway Routing: جداسازی مسیرها برای خدمات باطن مختلف. اگر Recommendation Service از کار بیفتد، مسیرهای Order Service در Gateway کاملاً فعال می‌مانند.
  • پولهای اتصال پایگاه داده: تقسیم استخرهای اتصال پایگاه داده بر اساس سرویس یا مستاجر. افزایش درخواست‌های تحلیلی سنگین از سوی یک مستاجر، تمام دستگیره‌های اتصال موجود را تخلیه نمی‌کند، و باعث صرفه‌جویی در پرس‌وجوهای تراکنشی برای سایر مستاجرین می‌شود.
  • **برنامه های SaaS چند مستاجر **: جداسازی منابع محاسباتی یا صف های اجرا برای مستاجران حق بیمه در مقابل مستاجران رایگان. افزایش منابع ردیف رایگان، درخواست‌های سطح برتر CPU یا حافظه را از بین نمی‌برد.
  • یکپارچه سازی API شخص ثالث: اختصاص استخرهای مشتری HTTP جداگانه برای دروازه های پرداخت خارجی، ارائه دهندگان حمل و نقل یا موتورهای اعلان. اگر یکی از سرویس های شخص ثالث کند شود، سایر تعاملات خارجی بدون انسداد ادامه می یابد.

چرا کارگزاران کافکا/پیام نمی توانند الگوی سرپوش را جایگزین کنند

یک سوال رایج این است: “اگر ما واسطه های پیام مانند آپاچی کافکا داریم، چرا به الگوی Bulkhead نیاز داریم؟ آیا نمی توانیم فقط از صف ها برای بافر درخواست ها استفاده کنیم؟”

در حالی که کارگزاران پیام سیستم ها را جدا می کنند، ** نمی توانند الگوی Bulkhead را جایگزین کنند. در اینجا دلیل آن است:

1. ارتباط همزمان در مقابل ارتباط ناهمزمان

کافکا برای معماری های ناهمزمان و رویداد محور طراحی شده است. تولیدکننده پیامی را به موضوعی ارسال می کند و مصرف کننده در نهایت آن را پردازش می کند. با این حال، برنامه های کاربردی رو به روی کاربر اغلب به ارتباط همزمان (درخواست پاسخ) نیاز دارند (به عنوان مثال، بارگیری کاتالوگ محصول یا شارژ کارت اعتباری از طریق یک API REST/gRPC). معرفی کافکا در اینجا مستلزم الگوهای پیچیده درخواست-پاسخ، اضافه کردن تأخیر بالا و سربار است. باک هدها به طور خاص برای محافظت از این رشته های اجرایی همزمان در زمان واقعی طراحی شده اند.

2. گرسنگی نخی در مصرف کنندگان کافکا

حتی اگر سیستم شما کاملاً رویداد محور باشد و از کافکا استفاده کند، باز هم به بالکهد نیاز دارید! فرض کنید یک میکروسرویس مصرف کننده به چندین موضوع کافکا گوش می دهد (به عنوان مثال، user-registrations و video-transcoding). اگر مصرف کننده تمام رشته های کارگر داخلی خود را برای پردازش دسته ای عظیم از کارهای کند video-transcoding اختصاص دهد، گرسنگی نخ را تجربه خواهد کرد. مصرف کننده نمی تواند پیام های سبک وزن user-registrations را پردازش کند، حتی اگر آن پارتیشن سالم باشد. برای جداسازی کار همچنان به دیوارهای داخلی (حوضچه های نخ مجزا) در داخل سرویس مصرف کننده نیاز دارید.

3. سربار سمت مشتری و نیاز سریع به شکست

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

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


بهترین روش ها هنگام استفاده از بالک هد

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

نتیجه گیری

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