Distributed Transactions: מ-2PC ועד Saga - איך שומרים על נכונות כשהעסקה חוצה כמה שירותים
מאת צוות מדיה דיל · 05.08.2026 · DevOps · 10 דק׳
כשעסקה עסקית חוצה כמה מיקרו-שירותים, ACID רגיל כבר לא עובד. מדריך מעמיק ל-Two-Phase Commit, Saga Pattern ו-Compensating Transactions.
הזמנה בחנות אונליין נראית פשוטה מהצד של המשתמש - לחיצה אחת על "בצע הזמנה". מאחורי הקלעים, הפעולה הזו צריכה לגעת בשירות המלאי (להוריד כמות), שירות התשלומים (לחייב כרטיס אשראי), שירות המשלוחים (ליצור תווית משלוח) ושירות ההתראות (לשלוח מייל אישור) - ארבעה שירותים נפרדים, לרוב עם מסדי נתונים נפרדים משלהם. אם שלושת הראשונים הצליחו והרביעי נכשל, מה קורה? ואם התשלום הצליח אבל המלאי כבר אזל בדיוק באותו רגע? זו בדיוק הבעיה שטרנזקציה רגילה (BEGIN...COMMIT) פותרת בתוך מסד נתונים בודד, ושהופכת למורכבת פי כמה כשהיא חוצה גבולות שירות.
למה ACID רגיל לא עובד כאן
טרנזקציית ACID מבטיחה Atomicity - הכל קורה או שכלום לא קורה. זה עובד מצוין כשכל הפעולות נמצאות בתוך מסד נתונים אחד, כי מנוע הבסיס נתונים יכול לנעול שורות, לשמור log ולבצע rollback אטומי. אבל ברגע שהפעולות מתפזרות בין כמה שירותים עם מסדי נתונים נפרדים, אין מנגנון מרכזי אחד שיכול "לנעול הכל ולבטל הכל" - כל שירות הוא ריבון על הנתונים שלו, ולעיתים קרובות זה בכוונה ארכיטקטונית (database-per-service), כדי לאפשר לכל צוות לפתח ולפרוס באופן עצמאי. הפתרון חייב להיות ברמת התזמור העסקי, לא ברמת מסד הנתונים.
Two-Phase Commit: הגישה הקלאסית והמגבלות שלה
Two-Phase Commit (2PC) הוא פרוטוקול קלאסי שמנסה לשמר strong consistency גם על פני כמה משתתפים: בשלב הראשון (Prepare), מתאם (coordinator) שואל את כל המשתתפים אם הם מוכנים לבצע commit, וכל אחד נועל את המשאבים הרלוונטיים ומשיב "מוכן" או "לא". רק אם כולם השיבו "מוכן", השלב השני (Commit) שולח פקודת commit בפועל לכולם. הבעיה המעשית של 2PC היא שהיא Blocking - אם המתאם קורס בדיוק אחרי שלב ה-Prepare, כל המשתתפים נשארים נעולים, ממתינים להוראה שלעולם לא תגיע, מה שחוסם משאבים ופוגע קשות בזמינות. בנוסף, 2PC לא מתאים היטב לתקשורת בין שירותים שרצים באזורים גיאוגרפיים שונים, כי הוא דורש שכל המשתתפים יהיו זמינים בו-זמנית ומגיב מהר - בדיוק מה שארכיטקטורת מיקרו-שירותים מודרנית מנסה להימנע ממנו.
Saga Pattern: הפתרון המקובל היום
Saga פותרת את הבעיה בגישה שונה לחלוטין: במקום טרנזקציה אטומית אחת גדולה, העסקה מפורקת לרצף של טרנזקציות מקומיות קטנות, כל אחת בתוך שירות בודד ומאושרת (committed) באופן עצמאי. אם שלב מסוים נכשל, ה-Saga לא מנסה "לבטל" משהו שלא קרה - היא מפעילה Compensating Transactions, פעולות מפצות שמבטלות לוגית את מה שכבר בוצע בשלבים הקודמים. במקום שירות מלאי-תשלום-משלוח שכולם "נעולים יחד", יש: הורד מלאי (מוצלח) → חייב כרטיס (נכשל) → הפעל פיצוי: החזר מלאי. היתרון העצום הוא שאף שירות לא נחסם בזמן שממתין לאחרים - כל שלב מתבצע ומאושר מיידית, וטיפול בכשל קורה אסינכרונית ונפרד.
שתי דרכי מימוש: Choreography מול Orchestration
יש שתי גישות מרכזיות ליישום Saga. ב-Choreography, אין מתאם מרכזי - כל שירות מפרסם אירוע כשהוא מסיים את חלקו (למשל "OrderCreated"), ושירותים אחרים מאזינים לאירועים הרלוונטיים ומגיבים בעצמם, כולל הפעלת פיצוי במקרה כשל. זה מפחית coupling בין שירותים אבל מקשה מאוד לעקוב אחרי "מה קורה בפועל" בעסקה שלמה, כי הלוגיקה מפוזרת בין עשרות handlers שונים. ב-Orchestration, יש שירות מתאם ייעודי (Saga Orchestrator) שמנהל את כל הרצף באופן מפורש - שולח פקודה לכל שירות בתורו, ומחליט מה לעשות בהתאם לתוצאה. זה נותן נראות מרכזית וקלות דיבוג, במחיר coupling גבוה יותר לשירות המתאם ונקודת כשל פוטנציאלית נוספת שצריכה להיות עמידה בעצמה.
Outbox Pattern: הבטחת פרסום אירועים אמין
בעיה עדינה שמופיעה כמעט תמיד ביישום Saga היא: איך מבטיחים שעדכון מסד הנתונים המקומי ופרסום האירוע (ל-Kafka או כל broker אחר) קורים באופן אטומי - כי אם השירות עדכן את הנתון בהצלחה אבל קרס לפני שפרסם את האירוע, שאר ה-Saga לעולם לא ידע להמשיך. הפתרון המקובל הוא Transactional Outbox Pattern: במקום לפרסם ישירות ל-broker, השירות כותב את האירוע לטבלת "outbox" באותה טרנזקציה מקומית שמעדכנת את הנתון עצמו (שתיהן בתוך אותו מסד נתונים, אז ACID רגיל חל עליהן). תהליך נפרד (Message Relay, לרוב מבוסס CDC כמו Debezium) קורא מהטבלה הזו ומפרסם את האירועים בפועל ל-broker, עם ערבות at-least-once. כך מובטח שאף אירוע לא "יאבד" בגלל תזמון לא מוצלח בין כתיבה לפרסום.
Idempotency: הכלי שהופך פיצוי לבטוח
מכיוון שברשת מבוזרת הודעות עלולות להישלח יותר מפעם אחת (retry אחרי timeout, גם אם הפעולה בפועל הצליחה), כל שלב בתוך Saga - כולל הפיצויים עצמם - חייב להיות Idempotent: הפעלה חוזרת של אותה פעולה עם אותו מזהה חייבת להניב את אותה תוצאה בלי תופעות לוואי כפולות. המימוש הנפוץ הוא לשמור Idempotency Key (מזהה ייחודי לכל בקשה) בטבלה ייעודית, ולבדוק לפני ביצוע כל פעולה אם היא כבר בוצעה בעבר עם אותו מזהה. בלי המנגנון הזה, retry אוטומטי שנועד להגביר אמינות עלול בפועל לגרום לחיוב כפול או להורדת מלאי כפולה.
טעויות נפוצות בפרודקשן
הטעות הנפוצה ביותר היא לשכוח שפיצוי הוא לא תמיד סימטרי מבחינה עסקית - "ביטול חיוב כרטיס אשראי" אינו זהה מבחינה חשבונאית ל"לא לבצע את החיוב מלכתחילה", וצריך תיעוד וטיפול מתאים (refund, לא סתם מחיקת רשומה). טעות שנייה היא לתכנן Saga ליניארי בלבד בלי לטפל במקרה שבו כמה שלבים נכשלים בו-זמנית, או שהפיצוי עצמו נכשל - מה שדורש אסטרטגיית retry עם backoff, ולעיתים התראה אנושית (manual intervention) כשאין דרך אוטומטית לפתור. טעות שלישית היא הזנחת ניטור - Saga שנתקעת באמצע (stuck) בלי אזעקה יכולה להשאיר עסקה "תלויה" ימים שלמים, כשלקוח חויב אבל ההזמנה מעולם לא הושלמה, ואף אחד לא שם לב.
TCC: חלופה נוספת ל-2PC ו-Saga
קיימת גישה שלישית, פחות נפוצה אך רלוונטית בתעשיות עם דרישות עסקיות מחמירות במיוחד (כמו סליקה פיננסית) - TCC, ראשי תיבות של Try-Confirm-Cancel. בשלב Try, כל שירות שומר ("מקפיא") את המשאב הרלוונטי בלי לבצע אותו בפועל - למשל, מקצה מלאי אך לא מוריד אותו סופית. אם כל השירותים הצליחו בשלב ה-Try, שלב Confirm מבצע את הפעולה בפועל בכולם; אם אחד נכשל, שלב Cancel משחרר את מה שהוקפא. ההבדל המרכזי מ-Saga הוא שב-TCC יש תמיד שלב "הקפאה" מפורש - מה שנותן ודאות גבוהה יותר שהעסקה תושלם בהצלחה ברגע שעברה את שלב ה-Try, במחיר מורכבות מימוש גבוהה יותר בכל שירות, כי כל אחד צריך לתמוך בשלושת השלבים בנפרד.
מדידה: כמה זמן Saga צריכה להסתיים
Saga, בניגוד לטרנזקציה מקומית שנמשכת מילישניות, יכולה להתפרש על פני שניות ואפילו דקות אם היא כוללת קריאות ל-API חיצוניים (כמו ספק תשלומים או חברת שילוח). זה מחייב תכנון SLA מפורש לכל שלב, וטיפול נכון ב-timeout - אם שלב לא הגיב תוך הזמן שהוגדר, המערכת צריכה להחליט: לנסות שוב (retry), או להתחיל תהליך פיצוי מלא. מדד חשוב לניטור הוא Saga Duration Distribution - כמה עסקאות מסתיימות תוך שניות בודדות מול כמה נתקעות בשלבי ביניים, כי עלייה חדה בזמן הממוצע היא לרוב הסימן המוקדם ביותר לתקלה בשירות downstream לפני שהיא הופכת לגלויה למשתמשים.
דוגמת קוד: פיצוי בסיסי
המבנה הלוגי של שלב פיצוי, ברמת פסאודו-קוד, בדרך כלל נראה כך: כל שלב ב-Saga נרשם ל-log עם פעולת ה-forward שלו ופעולת הפיצוי המקבילה, כך שאם צריך לבטל, המתאם פשוט מריץ את רשימת הפיצויים בסדר הפוך לסדר הביצוע המקורי. זה מזכיר stack: השלב האחרון שהצליח הוא הראשון שמפוצה, ולא ההפך - כדי לשמור על עקביות לוגית של המצב העסקי בזמן הביטול, בדיוק כמו שקוד מנקה משאבים בסדר הפוך לסדר ההקצאה שלהם.
מתי בכלל לא צריך Saga
לפני שממהרים לבנות תשתית Saga מלאה, שווה לבדוק אם אפשר בכלל להימנע מהבעיה - לפעמים העיצוב הנכון הוא לצמצם את הגבולות הטרנזקציוניים כך שרוב הפעולות הקריטיות (למשל הורדת מלאי וחיוב) יתבצעו בתוך אותו שירות ואותו מסד נתונים, גם אם זה אומר גבולות שירות פחות "טהורים" מבחינת Domain-Driven Design. Saga מוסיפה מורכבות תפעולית משמעותית - ניטור, טיפול בפיצויים, idempotency - ולכן היא כלי שכדאי לשלוף כשבאמת אין ברירה, לא כברירת מחדל לכל אינטראקציה שחוצה שני שירותים. במדיה דיל אנחנו תמיד בודקים קודם אם אפשר לצמצם coupling עסקי בין שירותים לפני שבונים תזמור מלא.
קשר לנושאים סמוכים
הבנת Saga דורשת גם הבנה של מודלי consistency שעליהם היא נשענת - מי שרוצה להעמיק בהבדל בין strong ל-eventual consistency ואיך זה משפיע על בחירת גבולות הטרנזקציה, מוזמן למאמר על Eventual Consistency. ומכיוון שרוב מימושי ה-Saga נשענים בפועל על message broker להפצת אירועים בין השירותים, כדאי גם להכיר את הארכיטקטורה שמאחורי הכלים האלה במאמר על Message Broker Architecture.
תיעוד ובדיקות: התנאי להצלחה ארוכת טווח
מעבר לקוד עצמו, Saga מורכבת דורשת תיעוד ויזואלי מפורש - דיאגרמת מצבים (state diagram) שמראה כל שלב אפשרי, כל פיצוי מתאים, וכל מעבר בין מצבים - כי בלי זה, כל מפתח חדש שמצטרף לצוות נאלץ לשחזר את הלוגיקה מקריאת קוד מפוזר בין כמה שירותים. בדיקות אינטגרציה שמדמות כשל בכל שלב אפשרי (לא רק את מסלול ה"הכל הצליח") הן חובה, לא Nice-to-have - כי הערך האמיתי של Saga נבחן בדיוק ברגע שמשהו נכשל, לא כשהכל עובד חלק.
סיכום
טרנזקציות מבוזרות הן אחד התחומים שבהם קיצורי דרך נוקמים במהירות. Saga Pattern, עם Orchestration ברוב המקרים המורכבים ו-Outbox Pattern לאמינות הפצת אירועים, הוא הסטנדרט התעשייתי כיום - לא כי הוא פשוט, אלא כי הוא היחיד שמאזן נכון בין נכונות עסקית לזמינות בעולם מבוזר אמיתי. ההשקעה בתכנון נכון של הפיצויים, ה-idempotency והניטור היא ההבדל בין מערכת שמתמודדת בחן עם כשלים חלקיים, לבין מערכת שמאבדת כסף אמיתי בכל תקלת רשת.
תגיות: Distributed Transactions · Saga Pattern · Two-Phase Commit · Compensating Transaction · Outbox Pattern · Microservices · ACID