תבנית המחיצה: תכנון מיקרו-שירותים עמידים בפני תקלות

תבנית המחיצה: תכנון מיקרו-שירותים עמידים בפני תקלות

בארכיטקטורת מיקרו-שירותים, אפליקציה בודדת מחולקת לעשרות או מאות שירותים עצמאיים המשתפים פעולה. העיצוב הזה אמנם משפר את המודולריות ואת יכולת הרחבה, אבל הוא גם מציג סיכון גדול: כשל בשירות אחד יכול להפיל את המערכת כולה.

אם שירות במורד הזרם הופך להיות איטי או לא מגיב, בקשות נכנסות לשירותי במעלה הזרם שלך יתחילו להיערם. אם כולם חולקים את אותו זיכרון, מעבד או מאגר שרשורים, תלות איטית עלולה למצות במהירות את כל המשאבים הזמינים, ולגרום לכל האפליקציה שלך לקרוס.

כשל מדורג זה ידוע כאפקט הדומינו. כדי למנוע זאת, ארכיטקטי המערכת משתמשים ב-תבנית מחיצה.

במדריך זה, נחקור מהו דפוס Bulkhead, כיצד הוא עובד וכיצד ליישם אותו באמצעות אנלוגיות פשוטות, מושגים ארכיטקטוניים ודוגמאות קוד ב-Java (Resilience4j) ו-Go.


האנלוגיה של העולם האמיתי: מחיצות ספינה אטומות למים

השם של דפוס זה מגיע מתעשיית בניית הספינות.

מחיצה הוא קיר אטום למים שנבנה בתוך גוף הספינה. במקום שיהיה חלל פתוח יחיד ומסיבי בתוך גוף הספינה, החלק הפנימי מחולק למספר תאים אטומים ועצמאיים.

תרשים ארכיטקטורת דפוסי מחיצה המציג בריכות חוטים משותפות לעומת מבודדות

אם הספינה תתנגש במכשול והגוף שלה נפרץ, מים יציפו לתוך התא הפגוע. עם זאת, בגלל המחיצות האטומות למים, המים מוכלים לאותו תא יחיד. שאר הספינה נשארת יבשה וציפה, מה שמאפשר לה להישאר צף ולהגיע למקום מבטחים.

ללא מחיצות, המים היו זורמים בחופשיות לאורך כל הגוף, ובסופו של דבר יטביעו את הספינה.

בהנדסת תוכנה:

  • הספינה היא כל האפליקציה או השירות שלך.
  • התאים הם מאגרי משאבים מבודדים (חוטים, חיבורים, מעבד).
  • The Hull Breach היא כשל או האטה במיקרו-שירות במורד הזרם.
  • ההצפה היא מיצוי משאבים.

הבעיה: מאגרי משאבים משותפים ומיצוי שרשור

כדי להבין מדוע נחוצים מחיצות, הבה נבחן מה קורה כאשר משאבים משותפים ברחבי העולם.

דמיינו לעצמכם שער API או שרת אינטרנט שמטפל בבקשות משתמשים. יש לו מאגר שרשורים עולמי יחיד של 100 שרשורים לעיבוד כל השיחות הנכנסות. השרת מקיים אינטראקציה עם שלושה שירותים במורד הזרם:

  1. שירות קטלוג (מהיר, קורא רשימת מוצרים)
  2. שירות תשלומים (מהיר, תהליכי תשלום)
  3. שירות המלצות (איטי, מחשב פריטים מותאמים אישית)

בדרך כלל, הכל עובד בסדר. אבל נניח ששירות ההמלצות סובל ממבוי סתום במסד הנתונים ומתחיל לקחת 30 שניות להגיב במקום 200 מילישניות.

הנה מה שקורה:

  1. משתמשים ממשיכים לבקר בדף הבית, ומעוררים בקשות לשירות ההמלצות.
  2. השרת מקצה שרשור מהמאגר הגלובלי לכל בקשה.
  3. מכיוון ששירות ההמלצות איטי, השרשורים האלה יושבים ומחכים לתגובות.
  4. תוך שניות, כל 100 השרשורים בבריכה ממתינים לשירות ההמלצות.
  5. כאשר משתמש חדש מנסה לבצע תשלום או לצפות בקטלוג, לשרת לא נותרו שרשורים לעיבוד בקשתו.

למרות ששירותי הקטלוג והתשלום בריאים לחלוטין, כעת אי אפשר להגיע אליהם מכיוון ששירות ההמלצות האיטי מיצה את מאגר השרשורים המשותף. המערכת כולה הפכה לא מקוונת.


הפתרון: תבנית המחיצה

תבנית Bulkhead פותר בעיה זו על ידי חלוקת מאגרי משאבים כך שכשל באזור אחד לא ישפיע על האחרים.

במקום מאגר עולמי יחיד, אנו מקצים מאגרים נפרדים ומוגבלים עבור כל שירות או תלות במורד הזרם.

אם נקצה 10 שרשורים במיוחד עבור שירות ההמלצות, אז לכל היותר 10 שרשורים יכולים אי פעם להיחסם בהמתנה עליו. אם שירות ההמלצות יאט את הקצב, 10 השרשורים הללו ימוצו, ובקשות ההמלצות הבאות יידחו מיד (כישלון מהיר).

עם זאת, 90 האשכולות הנותרים עדיין שמורים לשירותי הקטלוג והתשלום. משתמשים עדיין יכולים לעיין במוצרים ולבצע רכישות, גם אם ווידג’ט ההמלצה אינו זמין באופן זמני.


סוגי בידוד מחיצות

ישנן שתי דרכים עיקריות ליישם מחיצות במערכות תוכנה:

1. בידוד בריכת חוטים

במודל זה, לכל תלות במורד הזרם מוקצים מאגר חוטים ייעודי משלו ותור ביצוע.

תרשים בידוד מאגר שרשורים מציג בקשות נכנסות בתור לשרשורי עובדים
  • איך זה עובד: שרשור היישום הראשי מעביר את המשימה למאגר שרשורים ספציפי. אם המאגר מלא, הבקשה מועברת בתור או נדחתה.
  • יתרונות: מספק בידוד מוחלט. אם שירות נהיה איטי, רק מאגר השרשורים שלו מושפע. השרשורים מבודדים ברמת מערכת ההפעלה/JVM.
  • חסרונות: מציג תקורה נוספת של CPU עקב תזמון שרשור, החלפת הקשר וניהול תורים.

2. בידוד סמפור

במקום ליצור מאגרי חוטים חדשים, בידוד סמפור משתמש במונה (סמפור) כדי להגביל את מספר השיחות המותרות במקביל לשירות ספציפי.

דיאגרמת בידוד סמפור המציגה שרשורי בקשות המבצעים משימות לאחר רכישת אישור
  • איך זה עובד: כאשר בקשה מתחילה, היא מנסה להשיג אישור מהסמפור. אם קיים אישור, הוא מבצע את הבקשה בשרשור הקורא ומשחרר את האישור בסיום. אם אין היתרים, הבקשה נדחית לאלתר.
  • יתרונות: קל משקל מאוד עם כמעט אפס תקורה, מכיוון שלא מעורב בהחלפת הקשר של חוט.
  • חסרונות: אין הפרדת חוטים. אם שיחה נחסמת בשקע רשת ללא פסק זמן מתאים, היא עדיין יכולה לחסום את שרשור השיחות.

דוגמאות ליישום

בואו נסתכל כיצד ליישם מחיצות בשתי שפות עורפיות פופולריות.

1. Java (Resilience4j & Spring Boot)

Resilience4j היא ספריית סובלנות תקלות קלת משקל ונוחה לשימוש המיועדת ל-Java. להלן כיצד תגדיר מחיצה עבור שירות תשלום במורד הזרם באפליקציית 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: בידוד מסלולים עבור שירותי backend שונים. אם שירות ההמלצות נופל, מסלולי שירות ההזמנות בשער יישארו פעילים במלואם.
  • מאגרי חיבור למסד נתונים: חלוקת מאגרי חיבור למסד נתונים לפי שירות או דייר. גל של שאילתות אנליטיות כבדות מדייר אחד לא ימחק את כל נקודות האחיזה הזמינות לחיבור, ויחסוך שאילתות עסקאות עבור דיירים אחרים.
  • אפליקציות SaaS מרובות דיירים: הפרדת משאבי מחשוב או תורי ביצוע עבור שוכרי פרימיום לעומת דיירים בחינם. עליות משאבים בשכבות חינם לא ירעבו בקשות שכבת פרימיום של מעבד או זיכרון.
  • שילובי API של צד שלישי: הקדשת מאגרי לקוח HTTP נפרדים לשערי תשלום חיצוניים, ספקי משלוח או מנועי הודעות. אם שירות אחד של צד שלישי מאט, אינטראקציות חיצוניות אחרות נמשכות ללא חסימות.

מדוע קפקא/מתווכי הודעות לא יכולים להחליף את תבנית המחיצה

שאלה נפוצה היא: “אם יש לנו מתווכים של הודעות כמו אפאצ’י קפקא, למה אנחנו צריכים את דפוס Bulkhead? האם לא נוכל להשתמש רק בתורים כדי לאמץ בקשות?”

בעוד שסוכני מסרים מנתקים מערכות, הם לא יכולים להחליף את דפוס Bulkhead. הנה הסיבה:

1. תקשורת סינכרונית לעומת אסינכרונית

קפקא מיועד לארכיטקטורות אסינכרוניות, מונעות אירועים. המפיק דוחף מסר לנושא, והצרכן מעבד אותו בסופו של דבר. עם זאת, אפליקציות הפונות למשתמש דורשות לעתים קרובות תקשורת סינכרונית (תגובה לבקשה) (למשל, טעינת קטלוג מוצרים או חיוב כרטיס אשראי באמצעות API של REST/gRPC). הצגת קפקא כאן דורשת דפוסי תשובה מורכבים לבקשות, הוספת חביון גבוה ותקורה. מחיצות תוכננו במיוחד כדי להגן על חוטי ביצוע סינכרוניים אלה בזמן אמת.

2. שרשור הרעבה בתוך קפקא צרכני

Even if your system is entirely event-driven and uses Kafka, you still need bulkheads! נניח שמיקרו-שירות צרכן יחיד מקשיב למספר נושאים של קפקא (למשל, user-registrations וvideo-transcoding). אם הצרכן יקצה את כל שרשורי העובדים הפנימיים שלו לעיבוד אצווה עצומה של עבודות video-transcoding איטיות, הוא יחווה הרעבה של חוטים. הצרכן לא יוכל לעבד הודעות user-registrations קלות משקל, למרות שהמחיצה הזו בריאה. You still need internal bulkheads (separate thread pools) inside the consumer service to isolate work.

3. תקורה בצד הלקוח ודרישת תקלה מהירה

כאשר שירות במורד הזרם מושבת, מחיצה מאפשרת לשירות המתקשר להיכשל במהירות ולהחזיר תגובה שנייה באופן מיידי. אם תעמיד הכל בתור בקפקא במקום זאת, התור עלול לגדול לאין שיעור, מה שיוביל לבקשות מעופשות, צריכת זיכרון גבוהה ופסקי זמן מושהים כאשר המערכת תתאושש.

בקיצור, קפקא מנתקת תקשורת בין מערכות על גבי הרשת, בעוד שמחיצות מבודדות ביצוע משאבים בתוך מופע יישום פועל. הם משלימים, לא סותרים זה את זה.


שיטות עבודה מומלצות בעת שימוש במחיצות

  • הגדר תמיד תקופות זמן קצובות: מחיצה מגבילה את המקיפות, אבל היא לא פותרת קריאות שקע איטיות. שלב מחיצות עם פסקי זמן קפדניים ברשת כדי לשחרר שרשורים מהר ככל האפשר.
  • שילוב עם מפסקים: השתמשו במחיצות לצד מפסקים. אם מחיצה מתחילה לדחות בקשות באופן עקבי, המפסק צריך להפסיק לעצור את התנועה לחלוטין ולתת לחדר השירות במורד הזרם להתאושש.
  • עקוב אחר רווית הבריכה: יישם התראות על אורכי התורים שלך וספירת חוטים פעילים. אם מחיצה מלאה כל הזמן, ייתכן שתצטרך להרחיב את התשתית שלך או לייעל את השירות במורד הזרם.
  • כוונן גדלים בנפרד: אל תשתמש במגבלה של גודל אחד שמתאים לכולם. מדוד את זמן האחזור וקצב הבקשות של כל תלות כדי לקבוע את מגבלות המחיצה הנכונות.

מסקנה

תבנית המחיצה היא תבנית עיצוב חיונית לבניית מערכות גמישות בקנה מידה ענן. על ידי חלוקת המשאבים שלך, אתה מבודד כשלים, מונע השפעות מדורגות ומבטיח שבאג מקומי לא יהפוך להפסקה גלובלית.