דפוס הסאגה: עסקאות מבוזרות בארכיטקטורת שירותי מיקרו

דפוס הסאגה: עסקאות מבוזרות בארכיטקטורת שירותי מיקרו

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

עם זאת, כאשר עוברים ל-Microservices Architecture מודרנית, ניהול הנתונים משתנה באופן מהותי. כדי להבטיח אוטונומיה של תחום ומדרגיות עצמאית, כל מיקרו-שירות הוא הבעלים של מסד הנתונים הפרטי שלו. פעולה עסקית אחת - כגון עיבוד קופה של מסחר אלקטרוני - משתרעת כעת על מספר גבולות שירות ומנועי מסד נתונים (למשל, PostgreSQL להזמנות, DynamoDB for Payments, Redis for Inventory).

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

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

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


בעיית השורש: מדוע התחייבות דו-שלבית (2PC) נכשלת בשירותי מיקרו

לפני אימוץ דפוס הסאגה, מהנדסים שואלים לעתים קרובות: מדוע איננו יכולים להשתמש ב-Dou-Phase Commit מסורתי (2PC / XA) ​​בשירותי המיקרו שלנו?

המכניקה של 2PC

Two-Phase Commit מתאם עסקה מבוזרת על פני מספר צמתים של מסד נתונים באמצעות מנהל טרנזקציות מרכזי בשני שלבים:

  1. הכנה לשלב: מנהל העסקאות מבקש מכל צמתי מסד הנתונים המשתתפים להכין ולנעול את השורות הנדרשות. המשתתפים מצביעים YES או NO.
  2. שלב ההתחייבות: אם כל המשתתפים הצביעו YES, המנהל מוציא פקודת COMMIT לכל הצמתים. אם צומת כלשהו הצביע NO או פג פסק זמן, הוא מנפיק ROLLBACK.
Client ----> Transaction Manager
                   |
     +-------------+-------------+
     | (Prepare)   | (Prepare)   | (Prepare)
     v             v             v
 Order DB     Payment DB    Inventory DB

מדוע 2PC הוא אנטי-תבנית עבור שירותי מיקרו

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

  1. חסימה וטענת משאבים: שורות מסד הנתונים נשארות נעולות לאורך כל לחיצת היד של הרשת הרב-שלבית. אם זמן האחזור של הרשת עולה או שירות מאט, מנעולים נשארים פתוחים, צורכים במהירות מאגרי חיבור וגורמים לכשל מערכת מדורגת.
  2. צוואר בקבוק זמינות: ב-2PC, זמינות המערכת מוגבלת על ידי מוצר של כל הזמינות של המשתתפים ($A_{total} = A_1 \times A_2 \times \dots \times A_n$). אם כל שירות או צומת מסד נתונים בודדים עוברים לא מקוון במהלך שלב ההכנה, כל העסקה הגלובלית נחסמת ללא הגבלת זמן.
  3. אובדן אוטונומיה של שירות: 2PC מאלץ שירותים לחשוף פרוטוקולי XA ברמת מסד הנתונים על פני ממשקי API של רשת, תוך צימוד ישיר של מנועי מסד הנתונים שלהם.
  4. אילוצי ברוקרים: מתווכים להודעות בתפוקה גבוהה כמו Apache Kafka או RabbitMQ אינם משתתפים באופן מקורי בעסקאות XA 2PC מסורתיות על פני מסדי נתונים יחסיים.

על פי משפט CAP, על מערכות מבוזרות לבחור בין עקביות חזקה (C) לבין זמינות גבוהה (A) תחת מחיצות רשת (P). מערכות מיקרו-שירות מודרניות מתעדפות את זמינות וסובלנות למחיצות, ומחליפות עקביות מיידית עבור עקביות סופנית (בסיס: זמין באופן בסיסי, מצב רך, עקביות בסופו של דבר).


מהי דפוס הסאגה?

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

במקום לעטוף את כל הזרימה מרובת השירותים בתוך מנעול גלובלי יחיד, Saga מבצעת תהליך עסקי כסדרה של תנועות מסד נתונים מקומיות עצמאיות ($T_1, T_2, \dots, T_n$).

כל עסקה מקומית מעדכנת נתונים במסד נתונים של מיקרו-שירות בודד ומפרסמת אירוע או הודעה בתחום. אירוע זה מפעיל את העסקה המקומית הבאה ($T_{i+1}$) בשירות במורד הזרם.

[Order Service]         [Payment Service]       [Inventory Service]
  Local Tx T1 -------------> Local Tx T2 -------------> Local Tx T3
(Create Order)              (Process Payment)            (Reserve Stock)

ביצוע קדימה לעומת פיצוי לאחור

אם כל העסקאות המקומיות יצליחו, הסאגה מסתיימת בהצלחה (ביצוע קדימה).

עם זאת, אם עסקה מקומית נכשלת באמצע הדרך (לדוגמה, $T_3$ נכשל כי פריט אזל מהמלאי), הסאגה לא יכולה פשוט לקרוא למסד נתונים ROLLBACK עבור השלבים הקודמים ($T_1, T_2$) מכיוון שהעסקאות המקומיות הללו כבר התחייבו לבסיסי הנתונים המתאימים שלהם.

כדי לבטל עסקאות מקומיות שכבר התחייבו, הסאגה חייבת לבצע עסקאות פיצוי ($C_{n-1}, \dots, C_1$) בסדר הפוך.

Forward Flow:    T1 (Create Order) ---> T2 (Charge Card) ---> T3 (Reserve Inventory - FAILS)
                                                                       |
Backward Rollback:  C1 (Cancel Order) <--- C2 (Refund Card) <----------+

סיווג עסקאות סאגה

כדי לעצב זרימת עבודה חזקה של Saga, כל שלב ברצף העסקאות חייב להיות מסווג לאחד משלושה סוגים מבניים:

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

פירוט דוגמה לתשלום מסחר אלקטרוני

שקול ביצוע הזמנה של מסחר אלקטרוני המורכב מארבעה שלבים:

  1. $T_1$: צור הזמנה ממתינה (ניתן לפיצוי) $\to$ בטל באמצעות $C_1$: בטל הזמנה.
  2. $T_2$: אשר תשלום (ניתן לפיצוי) $\to$ בטל באמצעות $C_2$: החזר תשלום.
  3. $T_3$: מלאי שמור (עסקת Pivot) $\to$ אם הקצאת המלאי תצליח, ההזמנה תושלם. אם זה נכשל, הפעל את $C_2$ ו-$C_1$.
  4. $T_4$: בקשת משלוח לשליחת (ניתנת לשחזור) $\to$ מבוצעת לאחר הציר; ניסו מחדש עד המסירה.

סאגה סגנונות אדריכליים: כוריאוגרפיה לעומת תזמור

ישנם שני סגנונות אדריכליים עיקריים ליישום דפוס הסאגה במערכות מבוזרות: כוריאוגרפיה (מבוזרת) ותזמורת (ריכוזית).

סגנונות יישום דפוסי סאגה: דיאגרמת ארכיטקטורת כוריאוגרפיה לעומת תזמורת

סגנון 1: כוריאוגרפיה (ביזור מונחה אירועים)

בסאגה מבוססת כוריאוגרפיה, אין בקר או רכז מרכזי. במקום זאת, שירותי מיקרו מתקשרים באופן אסינכרוני על ידי האזנה לאירועי תחום שפורסמו באפיק אירועים מרכזי (כגון Apache Kafka, NATS או RabbitMQ).

מכניקת זרימת עבודה

  1. שירות ההזמנה מבצע $T_1$ (יוצר הזמנה ממתינה) ומשדר אירוע OrderCreated לקפקא.
  2. שירות התשלומים מאזין ל-OrderCreated, מבצע $T_2$ (גובה כרטיס אשראי), ומשדר אירוע PaymentCompleted (או PaymentFailed).
  3. שירות המלאי מקשיב ל-PaymentCompleted, מבצע $T_3$ (שומר פריטים). אם הפריטים אזלו מהמלאי, הוא פולט InventoryReservationFailed.
  4. שירות התשלומים מאזין ל-InventoryReservationFailed ומבצע $C_2$ (מוציא החזר כספי).
  5. שירות ההזמנה מקשיב ל-PaymentRefunded ומבצע $C_1$ (מסמן הזמנה כמבוטלת).

יתרונות הכוריאוגרפיה

  • צימוד רופף: שירותים מנויים רק לנושאי אירועים; הם לא יודעים על יישומי שירות אחרים.
  • תפוקה וביזור גבוהה: פאב/סאב לאירועים ישירים מבטל צווארי בקבוק של מתזמר מרכזי.
  • פשטות עבור זרימות עבודה קצרות: קלה להגדרה עבור זרימות עבודה פשוטות של 2-3 שלבים.

חסרונות של כוריאוגרפיה

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

סגנון 2: תזמור (ניהול זרימת עבודה מרוכזת)

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

מכניקת זרימת עבודה

  1. הלקוח שולח בקשת הזמנה לתזמורת סאגה.
  2. Orchestrator שולח פקודה CreateOrder אל שירות ההזמנה. שירות ההזמנות מחזיר את OrderCreated.
  3. Orchestrator מעדכן את מכונת המצב שלו ושולח פקודת ProcessPayment אל שירות התשלומים. שירות התשלומים מחזיר את PaymentSuccessful.
  4. Orchestrator שולח פקודה ReserveInventory אל שירות מלאי. שירות המלאי מחזיר את InventoryFailed (Out of Stock).
  5. התזמורת מזהה כישלון, יוזמת זרימת פיצוי:
    • שולח פקודת RefundPayment אל שירות התשלומים.
    • שולח פקודת CancelOrder אל שירות ההזמנה.
  6. תזמורת מסמן את ביצוע הסאגה כ-FAILED.

יתרונות התזמורת

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

חסרונות של תזמורת

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

מטריקס השוואתי: כוריאוגרפיה לעומת תזמור

תכונה כוריאוגרפיה תזמור
מבנה שליטה מבוזר (פאב אירועים/סאב) מרכזי (רכז סאגה / מכונת מדינה)
צימוד נמוך במיוחד (שירותים צורכים אירועים) בינוני (השירותים מקבלים פקודות מהרכז)
נראות תהליך נמוך (מופץ על פני קובצי יומן) גבוה (חנות יחידה מדמיינת זרימת עבודה)
המתאים ביותר ל זרימות עבודה פשוטות (2 עד 4 שלבי שירות) תהליכי עבודה מורכבים ארגוניים (5+ שלבים, היגיון מסועף)
כלים / מסגרות קפקא, RabbitMQ, NATS, AWS EventBridge Temporal.io, AWS Step Functions, Camunda, Axon

אתגרי בידוד ואמצעי נגד (טיפול ב-“ACID מינוס I”)

מכיוון שעסקאות מקומיות בסאגה מתחייבות מיד למאגרי המידע המקומיים שלהן, דפוס הסאגה חסר בידוד (I) מערבות ACID מסורתיות.

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

חריגות נפוצות הנגרמות מחוסר בידוד

  1. עדכונים אבודים: סאגה A מעדכנת שיא. לפני שסאגה א’ מסתיימת, סאגה ב’ מחליפה את אותו רשומה. אם סאגה א’ נכשלת ומוציאה לפועל פיצוי, היא מחליפה את העדכון של סאגה ב'.
  2. Dirty Reads: לקוח קורא מלאי זמין שעודכן על ידי Saga A ($T_1$). Saga A נכשל במורד הזרם ($T_3$) ומשחזר מלאי ($C_1$), אבל הלקוח כבר ביצע הזמנה על סמך נתונים מיושנים.
  3. קריאות שאינן ניתנות לחזרה: שירות קורא נתונים בשלב $T_1$ וקורא אותם שוב בשלב $T_3$, אבל סאגה נוספת במקביל שינתה את הנתונים ביניהם.

אמצעי נגד ואסטרטגיות הפחתה

כדי לשמור על שלמות הנתונים למרות היעדר הבידוד, אדריכלי תוכנה מיישמים דפוסי עיצוב ספציפיים של בידוד:

1. נעילה סמנטית (בהמתנה / מצב מסומן)

כאשר העסקה המקומית $T_1$ מעדכנת רשומת מסד נתונים, היא מגדירה שדה סטטוס ל-PENDING או APPROVAL_REQUIRED (למשל, ORDER_PENDING_PAYMENT).

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

2. ביצוע הזמנה

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

3. אימות קריאה חוזר (בקרת מקביליות אופטימית)

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

4. השקפה פסימית

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


זרימת רצף ייצור: עסקאות פיצוי

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

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

יישומי קוד מעשיים

בוא נחקור דוגמאות ליישום מוכנות להפקה הן עבור כוריאוגרפיה (ב-Go) והן עבור תזמורת (ב-Java Spring Boot).

יישום 1: סאגה מבוססת כוריאוגרפיה ב-Go

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

package saga

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"time"
)

// Event definitions
type OrderCreatedEvent struct {
	OrderID   string  `json:"order_id"`
	CustomerID string `json:"customer_id"`
	Amount    float64 `json:"amount"`
}

type PaymentFailedEvent struct {
	OrderID string `json:"order_id"`
	Reason  string `json:"reason"`
}

// OrderRepository handles local DB operations
type OrderRepository interface {
	CreateOrder(ctx context.Context, orderID string, amount float64) error
	UpdateOrderStatus(ctx context.Context, orderID string, status string) error
}

// EventBus abstraction for message broker (e.g., Kafka / NATS)
type EventBus interface {
	Publish(topic string, payload []byte) error
	Subscribe(topic string, handler func(payload []byte)) error
}

type OrderSagaChoreographer struct {
	repo     OrderRepository
	eventBus EventBus
}

func NewOrderSagaChoreographer(repo OrderRepository, bus EventBus) *OrderSagaChoreographer {
	c := &OrderSagaChoreographer{repo: repo, eventBus: bus}
	c.registerSubscriptions()
	return c
}

// Step 1: Forward Transaction (T1)
func (s *OrderSagaChoreographer) StartOrderSaga(ctx context.Context, orderID, customerID string, amount float64) error {
	// Execute local database transaction
	err := s.repo.CreateOrder(ctx, orderID, amount)
	if err != nil {
		return fmt.Errorf("failed local DB transaction T1: %w", err)
	}

	// Emit domain event for downstream Payment Service
	event := OrderCreatedEvent{OrderID: orderID, CustomerID: customerID, Amount: amount}
	bytes, _ := json.Marshal(event)
	
	log.Printf("[SAGA][T1] Order %s created. Publishing OrderCreatedEvent...", orderID)
	return s.eventBus.Publish("orders.created", bytes)
}

// Register subscription for compensating events
func (s *OrderSagaChoreographer) registerSubscriptions() {
	_ = s.eventBus.Subscribe("payments.failed", func(payload []byte) {
		var event PaymentFailedEvent
		if err := json.Unmarshal(payload, &event); err != nil {
			log.Printf("[ERROR] Corrupt payment event: %v", err)
			return
		}

		// Execute Compensating Transaction (C1)
		s.handlePaymentFailed(context.Background(), event)
	})
}

// Step C1: Compensating Transaction
func (s *OrderSagaChoreographer) handlePaymentFailed(ctx context.Context, event PaymentFailedEvent) {
	log.Printf("[SAGA][C1] Payment failed for Order %s (Reason: %s). Rolling back local order...", event.OrderID, event.Reason)
	
	// Revert order status to CANCELLED in local DB
	err := s.repo.UpdateOrderStatus(ctx, event.OrderID, "CANCELLED_PAYMENT_FAILED")
	if err != nil {
		log.Printf("[CRITICAL] Failed to execute compensating transaction C1 for Order %s: %v", event.OrderID, err)
		// Trigger alert or write to Dead Letter Queue (DLQ)
		return
	}
	
	log.Printf("[SAGA][SUCCESS] Order %s successfully compensated and cancelled.", event.OrderID)
}

יישום 2: סאגה מבוססת תזמורות בג’אווה (אתחול אביב)

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

package com.ghaznix.saga.orchestrator;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.util.UUID;

public enum SagaState {
    STARTED,
    ORDER_CREATED,
    PAYMENT_PROCESSED,
    INVENTORY_RESERVED,
    COMPLETED,
    COMPENSATING_PAYMENT,
    COMPENSATING_ORDER,
    FAILED
}

@Service
public class OrderSagaOrchestrator {

    private static final Logger log = LoggerFactory.getLogger(OrderSagaOrchestrator.class);

    private final OrderServiceClient orderClient;
    private final PaymentServiceClient paymentClient;
    private final InventoryServiceClient inventoryClient;

    public OrderSagaOrchestrator(OrderServiceClient orderClient,
                                PaymentServiceClient paymentClient,
                                InventoryServiceClient inventoryClient) {
        this.orderClient = orderClient;
        this.paymentClient = paymentClient;
        this.inventoryClient = inventoryClient;
    }

    public boolean executeOrderSaga(String customerId, String productId, double amount, int quantity) {
        String sagaId = UUID.randomUUID().toString();
        log.info("[SAGA {}] Starting Order Saga Workflow...", sagaId);
        
        SagaState currentState = SagaState.STARTED;
        String orderId = null;
        String paymentId = null;

        try {
            // Step 1: Forward Local Tx T1 - Create Order
            log.info("[SAGA {}][T1] Sending CreateOrder command...", sagaId);
            orderId = orderClient.createOrder(customerId, productId, amount);
            currentState = SagaState.ORDER_CREATED;

            // Step 2: Forward Local Tx T2 - Process Payment
            log.info("[SAGA {}][T2] Sending ProcessPayment command for Order {}...", sagaId, orderId);
            paymentId = paymentClient.chargePayment(orderId, customerId, amount);
            currentState = SagaState.PAYMENT_PROCESSED;

            // Step 3: Forward Local Tx T3 (Pivot Step) - Reserve Inventory
            log.info("[SAGA {}][T3] Sending ReserveInventory command for Product {}...", sagaId, productId);
            boolean stockReserved = inventoryClient.reserveStock(productId, quantity);

            if (!stockReserved) {
                throw new InventoryAllocationException("Stock allocation failed: Item out of stock.");
            }

            currentState = SagaState.INVENTORY_RESERVED;
            log.info("[SAGA {}][SUCCESS] Saga completed successfully!", sagaId);
            return true;

        } catch (Exception ex) {
            log.error("[SAGA {}][FAILURE] Step failed during state {}. Triggering Compensation...", sagaId, currentState, ex);
            rollbackSaga(sagaId, currentState, orderId, paymentId);
            return false;
        }
    }

    private void rollbackSaga(String sagaId, SagaState failedState, String orderId, String paymentId) {
        log.info("[SAGA {}] Initiating backward compensating transactions from state: {}", sagaId, failedState);

        // Compensate Step 2 if payment was processed
        if (failedState == SagaState.PAYMENT_PROCESSED || failedState == SagaState.INVENTORY_RESERVED) {
            try {
                log.info("[SAGA {}][C2] Executing Payment Refund compensation for Payment {}...", sagaId, paymentId);
                paymentClient.refundPayment(paymentId);
            } catch (Exception e) {
                log.error("[CRITICAL][SAGA {}] Payment refund C2 failed! Manual intervention or DLQ required.", sagaId, e);
            }
        }

        // Compensate Step 1 if order was created
        if (failedState != SagaState.STARTED && orderId != null) {
            try {
                log.info("[SAGA {}][C1] Executing Order Cancel compensation for Order {}...", sagaId, orderId);
                orderClient.cancelOrder(orderId);
            } catch (Exception e) {
                log.error("[CRITICAL][SAGA {}] Order cancellation C1 failed!", sagaId, e);
            }
        }

        log.info("[SAGA {}] Saga compensation workflow finished. Final State: FAILED.", sagaId);
    }
}

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

הן בכוריאוגרפיה והן בתזמורת, ביצוע עסקה מקומית ($T_i$) מחייב פרסום אירוע או פקודה בתחום התחום ברשת.

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

כדי לפתור זאת, יש להתאים את סאגות עם דפוס תיבת הדואר היוצא של עסקאות:

[Service Database Transaction Boundary]
+-----------------------------------------------------+
| 1. INSERT INTO business_table (orders/payments)    |
| 2. INSERT INTO outbox_table (event_payload)        |
+-----------------------------------------------------+
                          |
             (CDC / Polling Message Relay)
                          |
                          v
               [Message Broker / Kafka]

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

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


רשימת החלטות אדריכלית

השתמש במטריצת ההחלטות המעשית הזו בעת תכנון עסקאות מבוזרות עבור יישומי שירות מיקרו:

                  Do you need cross-service data consistency?
                                     |
                    +----------------+----------------+
                    | No                              | Yes
                    v                                 v
         Standard Single Service            Can you accept Eventual
            Local Database                    Consistency (BASE)?
                                                      |
                                     +----------------+----------------+
                                     | No                              | Yes
                                     v                                 v
                          Use Monolithic Core            Adopt Saga Pattern
                          with Single ACID DB                 |
                                                              |
                                             How complex is the workflow?
                                                              |
                                            +-----------------+-----------------+
                                            | Simple (2-3 steps)                | Complex (4+ steps/branches)
                                            v                                   v
                                   Choreography Saga                   Orchestration Saga
                                   (Event-Driven Bus)                  (Temporal/Custom State Machine)

מסקנה ונקודות חשובות

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

רשימת סיכום:

  1. נטוש 2PC/XA ב-Cloud-Native Microservices: Two-Phase Commit גורם לנעילה הדוקה, זמן אחזור גבוה ולצווארי בקבוק חמורים בזמינות.
  2. פרק עסקאות לשלבים מקומיים: חלק פעולות גלובליות לעסקאות מקומיות ($T_1 \dots T_n$) בשילוב עם עסקאות פיצוי היפוכות ($C_1 \dots C_{n-1}$).
  3. בחר את הסגנון האדריכלי הנכון:
    • השתמש בכוריאוגרפיה לזרימות פשוטות, 2-3 שלבים מונעות אירועים עם צימוד רופף.
    • השתמש בתזמור עבור זרימות עבודה עסקיות מורכבות הדורשות נראות מרוכזת, הסתעפות ומעקב אחר מכשירי מדינה.
  4. יישם אמצעי נגד לבידוד: הגן מפני קריאות מלוכלכות ועדכונים שאבדו על ידי שימוש במנעולים סמנטיים (דגלים PENDING) ו-Read Optimistic Locking.
  5. הבטחת העברת הודעות מהימנות: תמיד בצע התאמה של יישומי Saga עם דפוס תיבת הדואר הטרנסאקציונלית ואכפת צרכנים חזקים כדי לטפל בניסיונות חוזרים בבטחה.

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