Saga Pattern Microservices: טרנזקציות מבוזרות בלי נעילה גלובלית — כוריאוגרפיה מול תזמור
כשמחלקים מערכת עסקית למיקרו־שירותים, מגלים מהר מאוד שטרנזקציה אחת פשוטה במסד נתונים כבר לא מספיקה. הזמנה באיקומרס, פתיחת חשבון, אישור תשלום או שריון מלאי עשויים לעבור דרך כמה שירותים עצמאיים. כאן נכנס לתמונה saga pattern microservices: דרך לתאם תהליך עסקי ארוך בין שירותים, בלי לנעול את כל המערכת ובלי להעמיד פנים שיש לנו טרנזקציה אחת גלובלית.
תבנית Saga אינה תחליף קסם ל־ACID, וגם לא “עוד Queue”. היא מודל חשיבה לתכנון טרנזקציות מבוזרות באמצעות סדרת צעדים מקומיים, כאשר לכל צעד משמעותי מוגדרת פעולת פיצוי. במקום לומר “או שהכול מצליח יחד או שהכול נכשל יחד”, Saga אומרת: נתקדם בשלבים, נשמור מצב, ואם משהו נכשל — נפעיל פעולות מתקנות לפי הסדר ההפוך או לפי לוגיקה עסקית מוגדרת.
למה בכלל צריך Saga במיקרו־שירותים?
במערכת מונוליטית, פעולה כמו “בצע הזמנה” יכולה להתבצע בתוך טרנזקציה אחת: עדכון טבלת הזמנות, הפחתת מלאי, חיוב לקוח וכתיבת לוג. אם אחד השלבים נכשל, מסד הנתונים יודע לבצע rollback.
בארכיטקטורת מיקרו־שירותים, כל שירות מחזיק בדרך כלל את הדאטה שלו. שירות ההזמנות אינו אמור לעדכן ישירות את בסיס הנתונים של שירות המלאי, ושירות התשלומים אינו צריך להכיר את טבלאות המשלוחים. ההפרדה הזו טובה לעצמאות, לפריסה נפרדת ולתחזוקה, אבל היא שוברת את הנוחות של טרנזקציה מקומית אחת.
הפיתוי הראשון הוא להשתמש בטרנזקציה מבוזרת עם נעילה גלובלית, למשל בסגנון two-phase commit. בפועל, במערכות רבות זה יוצר תלות חזקה, זמני המתנה, נקודות כשל וחוויית תפעול קשה. Saga מציעה גישה אחרת: כל שירות מבצע טרנזקציה מקומית קצרה, מפרסם או מחזיר תוצאה, והתהליך הכולל מתקדם לפי מצב ידוע.
הרעיון המרכזי: צעדים מקומיים ופעולות פיצוי
Saga מורכבת מרצף פעולות עסקיות. כל פעולה משנה מצב באחד השירותים, ולרוב יש לה פעולה מפצה.
דוגמה להזמנה:
- יצירת הזמנה במצב
PENDING. - שריון מלאי.
- חיוב תשלום.
- יצירת משלוח.
- סימון ההזמנה כמאושרת.
אם חיוב התשלום נכשל אחרי שהמלאי שוריין, צריך לשחרר את השריון. אם המשלוח נכשל אחרי שהתשלום בוצע, אולי צריך לזכות את הלקוח ולשחרר מלאי. אלו אינן פעולות rollback טכניות. הן פעולות עסקיות חדשות: releaseInventory, refundPayment, cancelShipment.
הנקודה הזו חשובה: Saga לא מחזירה את הזמן לאחור. היא מתמודדת עם עולם שבו דברים כבר קרו, נרשמו, נשלחו לאירועים אחרים ואולי אף נצפו על ידי משתמשים. לכן התכנון שלה חייב להיות עסקי ולא רק טכני.
saga pattern microservices: כוריאוגרפיה מול תזמור
יש שתי דרכים נפוצות לבנות Saga במיקרו־שירותים: כוריאוגרפיה ו־תזמור. שתיהן תקפות, אבל הן מתאימות להקשרים שונים.
כוריאוגרפיה: כל שירות מגיב לאירועים
בכוריאוגרפיה אין מנהל מרכזי אחד. שירות ההזמנות מפרסם אירוע כמו OrderCreated. שירות המלאי מאזין, משריין מלאי ומפרסם InventoryReserved. שירות התשלומים מאזין לאירוע הזה, מחייב את הלקוח ומפרסם PaymentCompleted. וכך הלאה.
היתרונות:
- צימוד נמוך לכאורה: כל שירות מכיר אירועים, לא בהכרח שירותים אחרים.
- התאמה טובה ל־event-driven architecture.
- הרחבה קלה יחסית כאשר מוסיפים צרכני אירועים שאינם משנים את התהליך המרכזי.
החסרונות:
- קשה לראות את התהליך כולו במקום אחד.
- שינוי בסדר הצעדים עלול לדרוש תיאום בין כמה שירותים.
- טיפול בכישלונות עלול להתפזר.
- קל להגיע ל”ספגטי אירועים” אם אין משמעת בתכנון האירועים והמצבים.
כוריאוגרפיה עובדת היטב כאשר התהליך קצר, הקשרים בין השירותים פשוטים, וכל שירות יכול לקבל החלטה מקומית ברורה בעקבות אירוע.
תזמור: מתאם אחד מנהל את התהליך
בתזמור יש רכיב אחד — לעיתים שירות ייעודי או מודול בתוך שירות עסקי — שמנהל את רצף ה־Saga. הוא קורא לשירותים, בודק תשובות, שומר מצב ומחליט אילו פעולות פיצוי להפעיל.
היתרונות:
- קל להבין את התהליך מקצה לקצה.
- נקודת שליטה ברורה לכישלונות, retries ופיצויים.
- מתאים לתהליכים מורכבים עם הסתעפויות.
- נוח יותר לבדיקה כאשר יש הרבה צעדים.
החסרונות:
- המתזמר עלול להפוך לרכיב מרכזי מדי.
- יש סכנה לצימוד חזק אם הוא מכיר יותר מדי פרטים פנימיים.
- צריך לשמור בזהירות על גבולות השירותים ולא להפוך אותו למונוליט מוסווה.
תזמור מתאים כאשר התהליך העסקי חשוב, רגיש, רב־שלבי או דורש תמונת מצב אחת ברורה. הרבה צוותים מתחילים בכוריאוגרפיה, ורק לאחר שהכאוס גדל מגלים שהיו צריכים מתזמר מוקדם יותר.
השוואה מהירה בין הגישות
| נושא | כוריאוגרפיה | תזמור |
|---|---|---|
| מי מנהל את הזרימה | אין מנהל יחיד; שירותים מגיבים לאירועים | מתזמר מרכזי מנהל את הצעדים |
| נראות התהליך | מפוזרת בין שירותים | מרוכזת במקום אחד |
| צימוד | צימוד דרך אירועים | צימוד לממשקי שירותים |
| התאמה | תהליכים קצרים ופשוטים | תהליכים ארוכים, רגישים או מסועפים |
| קושי עיקרי | מעקב, דיבוג ושינוי סדר | מניעת רכיב מרכזי מנופח |
| פיצוי | מבוזר בין השירותים | מנוהל על ידי המתזמר |
מה צריך להכין לפני שמיישמים Saga
לפני שכותבים קוד, צריך להגדיר את הגבולות העסקיים. Saga שנכתבת מוקדם מדי, בלי הבנה של המצבים, הופכת מהר לתיקון על תיקון.
כדאי להכין:
- רשימת צעדים עסקיים: מה באמת קורה בתהליך, ובאיזה סדר.
- בעלות על דאטה: איזה שירות אחראי על כל שינוי מצב.
- פעולת פיצוי לכל צעד משמעותי: לא תמיד אפשר לפצות, ואז צריך לדעת זאת מראש.
- מזהה קורלציה: למשל
orderIdאוsagaId, כדי לעקוב אחרי כל התהליך. - מדיניות retry: אילו כשלים זמניים מצדיקים ניסיון חוזר, וכמה פעמים.
- אידמפוטנטיות: קריאה חוזרת לאותה פעולה לא צריכה לחייב פעמיים או לשחרר מלאי פעמיים.
- מצבי ביניים גלויים: למשל
PAYMENT_PENDING,COMPENSATING,FAILED.
כאן יש קשר ישיר לעיצוב מונחה עצמים. שמות טובים, גבולות אחריות ברורים והפרדה בין לוגיקה עסקית לתשתית חשובים יותר מהבחירה בכלי הודעות מסוים. מי שרוצה לרענן את הבסיס של תבניות עיצוב יכול להתחיל ב־מבוא לתבניות עיצוב, אך חשוב לזכור ש־Saga עצמה אינה אחת מתבניות GoF הקלאסיות.
דוגמת Java: Saga מתוזמרת להזמנה
הדוגמה הבאה מדגימה תזמור פשוט. היא אינה מדריך ל־Java או למסגרת עבודה כלשהי. המטרה היא להראות את מבנה האחריות: שירותים מקומיים, מתזמר, ופעולות פיצוי שמופעלות כאשר שלב מאוחר נכשל.
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
InventoryService inventory = new InventoryService();
PaymentService payment = new PaymentService(false);
ShippingService shipping = new ShippingService();
OrderSaga saga = new OrderSaga(inventory, payment, shipping);
saga.placeOrder("order-123");
}
}
class OrderSaga {
private final InventoryService inventory;
private final PaymentService payment;
private final ShippingService shipping;
private final List<Runnable> compensations = new ArrayList<>();
OrderSaga(InventoryService inventory, PaymentService payment, ShippingService shipping) {
this.inventory = inventory;
this.payment = payment;
this.shipping = shipping;
}
void placeOrder(String orderId) {
try {
inventory.reserve(orderId);
compensations.add(() -> inventory.release(orderId));
payment.charge(orderId);
compensations.add(() -> payment.refund(orderId));
shipping.createShipment(orderId);
compensations.add(() -> shipping.cancelShipment(orderId));
System.out.println("Order approved: " + orderId);
} catch (RuntimeException failure) {
System.out.println("Saga failed: " + failure.getMessage());
compensate();
System.out.println("Order rejected: " + orderId);
}
}
private void compensate() {
for (int i = compensations.size() - 1; i >= 0; i--) {
try {
compensations.get(i).run();
} catch (RuntimeException compensationFailure) {
System.out.println("Compensation failed: " + compensationFailure.getMessage());
}
}
}
}
class InventoryService {
void reserve(String orderId) {
System.out.println("Inventory reserved for " + orderId);
}
void release(String orderId) {
System.out.println("Inventory released for " + orderId);
}
}
class PaymentService {
private final boolean shouldApprove;
PaymentService(boolean shouldApprove) {
this.shouldApprove = shouldApprove;
}
void charge(String orderId) {
if (!shouldApprove) {
throw new RuntimeException("Payment declined for " + orderId);
}
System.out.println("Payment charged for " + orderId);
}
void refund(String orderId) {
System.out.println("Payment refunded for " + orderId);
}
}
class ShippingService {
void createShipment(String orderId) {
System.out.println("Shipment created for " + orderId);
}
void cancelShipment(String orderId) {
System.out.println("Shipment canceled for " + orderId);
}
}בדוגמה, OrderSaga הוא המתזמר. הוא יודע את סדר הצעדים, שומר רשימת פעולות פיצוי ומפעיל אותן בסדר הפוך במקרה של כישלון. InventoryService, PaymentService ו־ShippingService מייצגים שירותים עסקיים עם טרנזקציות מקומיות. במערכת אמיתית, השירותים האלה יהיו לרוב תהליכים נפרדים עם API, מסד נתונים משלהם ותיעוד אירועים.
שימו לב מה הדוגמה לא עושה: היא לא פותרת הפצה, נעילות, הודעות, observability או retry. אלו שכבות חשובות, אבל תבנית החשיבה מתחילה מהשאלה מי אחראי על הצעד, ומה עושים אם הצעד הבא נכשל.
תכנון API עבור Saga
ב־Saga, API טוב אינו רק “endpoint שמחזיר 200”. הוא חוזה עסקי שמאפשר התקדמות, כישלון ופיצוי.
עקרונות שימושיים:
- פעולה שמתחילה שינוי צריכה להחזיר מזהה ברור:
reservationId,paymentIdאוshipmentId. - פעולת פיצוי צריכה לקבל מזהה יציב, לא להסתמך על חיפוש עמום.
- כדאי להפריד בין “בקשה התקבלה” לבין “הפעולה הסתיימה”.
- יש לתעד מצבי כישלון עסקיים: מלאי חסר, תשלום נדחה, כתובת לא חוקית.
- כל פעולה שיכולה להתקבל פעמיים צריכה להיות אידמפוטנטית.
לדוגמה, במקום API כמו POST /pay בלבד, עדיף לחשוב על פעולות כמו POST /payments ליצירת חיוב, GET /payments/{id} לבירור מצב, ו־POST /payments/{id}/refund לפיצוי עסקי. שמות ה־URL עצמם פחות חשובים מהבהירות של החוזה.
UML: איך לתעד Saga בלי לטבוע בדיאגרמות
UML יכול לעזור, במיוחד כאשר התהליך עובר בין כמה צוותים. לא צריך לצייר ספר שלם. מספיק לבחור את הדיאגרמה שמתאימה לשאלה.
- Sequence Diagram: מצוין להצגת סדר קריאות בין שירותים.
- State Machine Diagram: שימושי להצגת מצבי הזמנה לאורך ה־Saga.
- Class Diagram: מתאים כאשר רוצים להראות מתזמר, ממשקי שירותים ואובייקטי פקודה.
- Activity Diagram: טוב לזרימה עסקית עם הסתעפויות.
בדיאגרמת sequence לתזמור נראה את OrderSaga קורא למלאי, לתשלום ולמשלוח. בכוריאוגרפיה נראה אירועים שעוברים דרך תווך הודעות, ושירותים שמאזינים להם. ההבדל הוויזואלי עוזר לגלות מוקדם אם התהליך מפוזר מדי או מרוכז מדי.
קשר ל־SOLID ולתבניות עיצוב
Saga אינה תבנית GoF כמו Factory Method, Observer או Decorator, אבל היא נהנית מאותם עקרונות עיצוב. למשל, עקרון האחריות היחידה אומר שהמתזמר צריך לנהל את הזרימה, לא לחשב מחירי משלוח ולא להחליט האם כרטיס אשראי תקין. עקרון היפוך התלויות מעודד את המתזמר לעבוד מול ממשקים עסקיים ולא מול פרטי מימוש.
יש גם קשר עקיף לתבניות יצירה. כאשר תהליך Saga יוצר אובייקטי פקודה, אירועים או מתאמים לשירותים חיצוניים, תבניות כמו Factory Method או Abstract Factory עשויות לסייע בארגון הקוד. הרחבה על המשפחה הזו נמצאת ב־תבניות יצירה, אך חשוב לא לערבב בין יצירת אובייקטים לבין ניהול טרנזקציה מבוזרת.
בכוריאוגרפיה אפשר לזהות דמיון ל־Observer: שירות מפרסם אירוע, ושירותים אחרים מגיבים. אבל הדמיון מוגבל. Observer הקלאסית מתארת יחסי אובייקטים בתוך עיצוב תוכנה, בעוד Saga מתארת תהליך עסקי מבוזר עם כישלונות, עקביות זמנית ופיצוי.
איפה נתקעים בדרך כלל?
התקלה הנפוצה ביותר היא להגדיר פיצוי טכני במקום פיצוי עסקי. למשל, “נמחק את הרשומה” אינו תמיד נכון. אם המשתמש כבר קיבל אישור ביניים, אם אירוע כבר נשלח, או אם מערכת אחרת הסתמכה על הנתון, מחיקה עלולה להחמיר את הבעיה.
בעיות נפוצות נוספות:
- חיוב כפול בגלל retry לא אידמפוטנטי.
- אירועים כפולים שמפעילים את אותו שלב פעמיים.
- מצבים תקועים כמו
PAYMENT_PENDINGללא timeout. - פיצוי שנכשל ואין מי שמתריע עליו.
- חוסר קורלציה בלוגים, כך שאי אפשר להבין מה קרה להזמנה אחת.
- הנחה שכל כישלון הוא זמני, למרות שיש כשלים עסקיים סופיים.
מה עושים? מתחילים ממיפוי מצבים. לכל מצב מגדירים מי רשאי להעביר אותו קדימה, מי רשאי לבטל, מה קורה אחרי timeout, ומה מוצג למשתמש. לאחר מכן מוסיפים מדיניות retries מתונה, מזהי idempotency, ולוגים עם sagaId עקבי.
Trade-offs: המחיר של Saga
Saga פותרת בעיה אמיתית, אבל היא מוסיפה מורכבות. במקום טרנזקציה אחת יש תהליך עם מצבים, כישלונות ביניים ופיצויים. המערכת עוברת ממודל של עקביות מיידית למודל של עקביות בסופו של דבר. זה לא רק שינוי טכני; זה שינוי בתפיסה העסקית.
צריך לקבל מראש שייתכן מצב שבו הזמנה נוצרה אך עדיין לא אושרה, תשלום בוצע אך המשלוח עוד לא נפתח, או פיצוי ממתין לטיפול. המערכת חייבת לדעת להסביר את המצבים האלה למפעילים, ולעיתים גם למשתמשים.
המחיר העיקרי:
- יותר מצבי ביניים.
- יותר בדיקות אינטגרציה.
- צורך בניטור טוב יותר.
- תלות באיכות חוזי API.
- קושי לשנות תהליך שכבר פועל בפרודקשן עם אירועים היסטוריים.
מצד שני, החלופה של נעילה גלובלית או תלות ישירה במסדי נתונים של שירותים אחרים עלולה להיות גרועה בהרבה.
מתי לא להשתמש ב־Saga?
לא כל פעולה מבוזרת צריכה Saga. לפעמים הפתרון הנכון הוא פשוט יותר.
לא כדאי להשתמש ב־Saga כאשר:
- כל הנתונים שייכים בבירור לשירות אחד ויכולים להישאר שם.
- אין צורך אמיתי במיקרו־שירותים עבור התהליך.
- אין פעולת פיצוי עסקית סבירה.
- מדובר בפעולה קצרה ולא קריטית שאפשר לבצע באופן אסינכרוני פשוט.
- הצוות עדיין לא מחזיק ניטור, לוגים וקורלציה בסיסיים.
- הדרישה העסקית מחייבת עקביות מיידית מוחלטת שאינה ניתנת לפשרה.
במקרים כאלה, עדיף לשקול גבול שירות אחר, מונוליט מודולרי, או עיצוב שבו פעולה אחת היא מקור האמת ושאר המערכות מתעדכנות בהמשך.
איך להתחיל בצורה מעשית
דרך טובה להתחיל היא לבחור תהליך אחד כואב, לא את כל המערכת. מציירים את הזרימה הקיימת, מסמנים בעלות על דאטה, ואז כותבים טבלה פשוטה:
| צעד | שירות אחראי | הצלחה | כישלון | פיצוי |
|---|---|---|---|---|
| שריון מלאי | מלאי | מלאי שוריין | אין מלאי | אין צורך, התהליך נעצר |
| חיוב תשלום | תשלומים | חיוב בוצע | תשלום נדחה | שחרור מלאי |
| יצירת משלוח | משלוחים | משלוח נוצר | כתובת לא תקינה | זיכוי תשלום ושחרור מלאי |
לאחר מכן מחליטים: אם התהליך קצר וברור, כוריאוגרפיה יכולה להספיק. אם יש הרבה הסתעפויות, רגולציה פנימית, טיפול ידני או דרישות מעקב קפדניות — עדיף תזמור.
בשלב הבא מגדירים API אידמפוטנטי, מוסיפים מזהה Saga, מתעדים מצבים, ומוודאים שכל כישלון מוביל לאחד משלושה מסלולים: ניסיון חוזר, פיצוי, או מצב תקוע שמייצר התרעה לטיפול.
שאלות שקוראים באמת שואלים
האם Saga מבטיחה עקביות?
כן, אבל לא עקביות מיידית כמו טרנזקציה מקומית. Saga מכוונת לעקביות בסופו של דבר, בהנחה שכל הצעדים, הפיצויים וה־retries מתוכננים היטב. אם פיצוי נכשל ואין ניטור, אין קסם שיציל את התהליך.
האם עדיף תמיד תזמור?
לא. תזמור נותן נראות ושליטה, אבל עלול להפוך לנקודת ריכוז כבדה. בכוריאוגרפיה יש פחות מרכזיות, אבל יותר קושי להבין את התמונה הכוללת. הבחירה תלויה במורכבות התהליך וביכולת התפעולית של הצוות.
האם Saga היא תבנית עיצוב קלאסית?
לא במובן של GoF. הספר Design Patterns, Gamma et al., 1994 מתאר תבניות לעיצוב אובייקטים ומחלקות. Saga היא תבנית ארכיטקטונית לניהול תהליכים מבוזרים. עדיין, חשיבה על אחריות, הפרדה וצימוד נמוך נשענת על אותם יסודות עיצוביים שנידונים גם ב־מבוא לתבניות עיצוב.
סיכום: פחות נעילות, יותר אחריות תכנונית
Saga היא דרך פרגמטית לנהל טרנזקציות מבוזרות במיקרו־שירותים בלי נעילה גלובלית. היא מחייבת לוותר על האשליה שכל העולם מתנהג כמו מסד נתונים אחד, ובמקום זה לתכנן תהליך עסקי עמיד: צעדים מקומיים, פעולות פיצוי, מצבי ביניים, retries ואידמפוטנטיות.
כוריאוגרפיה מתאימה לתהליכים פשוטים ומבוססי אירועים. תזמור מתאים כאשר צריך לראות, לשלוט ולשנות תהליך מורכב. בשני המקרים, ההצלחה תלויה פחות בשם התבנית ויותר בשאלה האם ברור מי אחראי לכל צעד, מה קורה כשמשהו נכשל, ואיך המערכת חוזרת למצב עסקי הגיוני.


