ההזמנה נשמרה, התשלום נתקע: איך מטפלים בתהליך שחוצה כמה מיקרוסרוויסים?
בתהליך הזמנה בחנות יש כמה פעולות שנראות די פשוטות: שומרים הזמנה, שומרים את המוצרים במלאי עבור הלקוח, ומחייבים אותו.
אם הכול נמצא באותו מסד נתונים, אפשר לעטוף את השינויים בטרנזקציה. אחת הפעולות נכשלה? מבצעים Rollback.
אבל מה קורה כשכל פעולה שייכת למיקרוסרוויס אחר? ומה אם החיוב בכלל מתבצע אצל ספק סליקה חיצוני?
כאן כבר אפשר להגיע למצב שבו ההזמנה נשמרה, המלאי הוקצה, ובקשת התשלום הסתיימה ב־Timeout.
רגע. האם זה אומר שהלקוח לא חויב?
לא בהכרח. וזה בדיוק מה שמסבך את העניין.
שלוש קריאות שלא חולקות טרנזקציה
לצורך הדוגמה, נחלק את התהליך בין שלושה שירותים:
OrderServiceאחראי להזמנות.InventoryServiceאחראי למלאי.PaymentServiceאחראי לתשלומים.
במבט ראשון, אפשר לכתוב משהו כזה:
await orderService.CreateAsync(order); await inventoryService.ReserveAsync(order); await paymentService.ChargeAsync(order);
הקוד קצר, אבל הוא לא אומר מה עושים כשהקריאה השלישית נכשלת אחרי ששתי הראשונות הצליחו.
גם אם כל שירות משתמש בטרנזקציה מקומית, הטרנזקציה שלו לא כוללת אוטומטית את השינויים בשירותים האחרים. ובוודאי שלא את הפעולה שהתבצעה אצל ספק הסליקה.
גם try/catch מסביב לשלוש הקריאות לא ישנה את זה. הוא מאפשר לנו לטפל בשגיאה, אבל לא מבטל את מה שכבר בוצע.
צריך לתכנן את התהליך מתוך הנחה שחלק ממנו עשוי להצליח וחלק אחר להיכשל. לפעמים גם לא נדע מיד איזה חלק הצליח.
Timeout אינו תשובה על תוצאת הפעולה
נניח ששירות התשלומים שלח בקשת חיוב לספק הסליקה. הספק ביצע את החיוב, אבל התשובה לא הגיעה אלינו בזמן.
מבחינתנו התקבל Timeout. מבחינת הלקוח ירד כסף.
אם נשלח עכשיו בקשת חיוב חדשה, אנחנו עלולים לחייב אותו שוב. אם נבטל את ההזמנה מיד, אנחנו עלולים להשאיר אותו עם חיוב ובלי הזמנה.
לכן צריך להבחין בין שני מצבים:
- קיבלנו תשובה מפורשת שהחיוב נדחה.
- לא קיבלנו תשובה, ולכן תוצאת החיוב עדיין אינה ידועה.
במקרה השני אפשר להשאיר את התהליך במצב כמו PaymentPending. זה לא רק שם לסטטוס. צריך להיות מנגנון שיודע להוציא את הפעולה מהמצב הזה.
אם ספק הסליקה תומך בכך, אפשר לקבל ממנו Webhook שמעדכן על תוצאת התשלום. לצד זה אפשר להפעיל בדיקה מתוזמנת, שמאתרת פעולות הממתינות זמן רב ומבררת את מצבן מול הספק לפי מזהה הפעולה.
גם Webhook צריך לאמת ולעבד בזהירות. הוא עשוי להגיע יותר מפעם אחת, ואירועים לא בהכרח מגיעים לפי סדר התרחשותם. קבלת הודעה היא לא סיבה לעדכן את ההזמנה בלי לבדוק באיזה מצב היא נמצאת כרגע. בתיעוד של Stripe יש פירוט על ההתנהגות הזאת.
לנסות שוב, בלי לחייב שוב
כאן נכנס המושג Idempotency.
בהקשר שלנו, המשמעות היא שאפשר לחזור על בקשה לאותה פעולה בלי לבצע שוב את ההשפעה העסקית שלה. שתי בקשות עבור אותו חיוב לא אמורות להפוך לשני חיובים.
לשם כך אפשר לצרף לבקשה מפתח שמזהה את פעולת התשלום:
public record ChargePaymentRequest(
Guid OrderId,
decimal Amount,
string Currency,
string IdempotencyKey);
וכך לבנות את הבקשה:
var request = new ChargePaymentRequest(
order.Id,
order.Total,
order.Currency,
paymentOperation.Id.ToString());
שימו לב שהמפתח מבוסס על paymentOperation.Id, ולא על Guid.NewGuid() שנוצר בכל ניסיון.
את מזהה הפעולה שומרים לפני הניסיון הראשון, ומשתמשים בו שוב כשמנסים לברר או להשלים את אותה פעולה. מפתח חדש בכל Retry יגרום לצד השני לחשוב שמדובר בפעולה חדשה.
בצד השרת צריך לשמור את המפתח ואת מצב הפעולה. אם מגיעה בקשה חוזרת, אפשר להחזיר תוצאה שכבר נשמרה או לציין שהפעולה עדיין מתבצעת. אם אותו מפתח מגיע עם סכום או מטבע אחרים, צריך לדחות את הבקשה.
אבל בדיקה בקוד בנוסח “האם המפתח כבר קיים?” לא מספיקה. שתי בקשות מקבילות עלולות לבדוק באותו רגע, לקבל תשובה שלילית, ולהמשיך יחד.
צריך גם הגנה ברמת מסד הנתונים, למשל אינדקס ייחודי:
modelBuilder.Entity<PaymentOperation>()
.HasIndex(x => x.IdempotencyKey)
.IsUnique();
זו המחשה בהנחה שהמפתח ייחודי בכל המערכת. במערכת שבה הוא ייחודי רק בתוך חשבון מסוים, גם החשבון צריך להיות חלק מהאינדקס.
האינדקס מונע שמירת שתי רשומות עם אותו מפתח. הוא לא מממש לבדו את כל הטיפול בבקשות חוזרות, ובוודאי שלא מונע לבדו חיוב כפול אצל ספק חיצוני.
חשבו על נפילה שמתרחשת אחרי שהספק חייב, אבל לפני ששמרנו אצלנו שהחיוב הצליח. כדי להתמודד עם הפער הזה, צריך להשתמש גם במנגנון ה־Idempotency של הספק, אם הוא תומך בו, ולדעת לברר את תוצאת הפעולה. כך, למשל, Stripe מתעדת בקשות עם מפתח Idempotency.
Saga: לנהל את התהליך בשלבים
במקום להתייחס להזמנה כאל טרנזקציה אחת שחוצה את כל השירותים, אפשר לנהל רצף של פעולות מקומיות. לכל שלב יש תוצאה, ובהתאם אליה מחליטים איך להתקדם.
זה הרעיון של Saga.
בדוגמה שלנו התהליך יכול להיות:
- יצירת הזמנה במצב
Pending. - שמירת מלאי עבור ההזמנה.
- ביצוע התשלום.
- אישור ההזמנה.
אם אין מלאי, מבטלים לפני שמגיעים לחיוב. אם התשלום נדחה באופן סופי, משחררים את המלאי ומבטלים את ההזמנה.
ואם תוצאת התשלום לא ידועה? לא מתייחסים לכך אוטומטית כדחייה. משאירים מצב שמאפשר בירור והמשך טיפול.
חשוב גם להבין ש־Saga לא נותנת לנו את הבידוד של טרנזקציה אחת. בזמן שהתהליך מתבצע, שירותים אחרים עשויים לראות הזמנה שעדיין אינה מאושרת. צריך להחליט אילו פעולות מותר לבצע עליה במצב הזה. למשל, לא להתחיל משלוח רק משום שרשומת ההזמנה כבר קיימת.
יש שתי דרכים מרכזיות לתאם בין השלבים: Orchestration ו־Choreography.
Orchestration: מתאם שמנהל את ההתקדמות
ב־Orchestration יש רכיב שמכיר את התהליך ומחליט מה השלב הבא.
אפשר לחשוב עליו כמנצח בתזמורת. הוא לא מנגן במקום הנגנים, אבל הוא מתאם ביניהם.
המתאם מבקש לשמור מלאי, מקבל תוצאה, ובהתאם מבקש לבצע תשלום. כשהתשלום מצליח הוא מקדם את ההזמנה לאישור. אם מתקבלת דחייה, הוא מפעיל את מסלול הביטול.
הוא לא אמור לדעת איך מחשבים זמינות מלאי או איך מתקשרים עם חברת האשראי. אלה עדיין תחומי האחריות של השירותים עצמם.
היתרון הוא שאפשר להבין במקום אחד את רצף התהליך ואת ההחלטות שלו. אבל גם המתאם עלול ליפול, ולכן ההתקדמות שלו צריכה להישמר באופן עמיד. אם הוא הופעל מחדש, הוא צריך לדעת איזו הזמנה נמצאת באיזה שלב.
יש כאן עוד נקודה שקל לפספס: מה קורה אם המתאם שמר “השלב הבא הוא תשלום”, ונפל לפני ששלח את בקשת התשלום?
גם אצלו קיימת בעיית התיאום בין שמירת מצב לשליחת הודעה. בהמשך נראה איך Outbox עוזר לפתור אותה. עצם הוספת המתאם לא מעלימה את תקלות התקשורת והשמירה.
Choreography: כל שירות מגיב לאירועים
ב־Choreography אין רכיב אחד שמנהל את כל הרצף. השירותים מפרסמים אירועים, ושירותים אחרים מגיבים אליהם.
למשל:
- שירות ההזמנות מפרסם
OrderCreated. - שירות המלאי מגיב, שומר מלאי ומפרסם
InventoryReserved. - שירות התשלומים מגיב ומבצע את החיוב.
- כשהוא מפרסם
PaymentSucceeded, שירות ההזמנות יכול לאשר את ההזמנה.
במסלול כישלון, אירוע כמו PaymentFailed יכול להפעיל את ביטול ההזמנה ואת שחרור המלאי.
כל שירות יודע לאילו אירועים הוא מגיב ומה עליו לבצע. זה יכול להתאים לתהליכים פשוטים יחסית, אבל ככל שמצטברים שלבים ומסלולי כישלון, קשה יותר לראות את התמונה המלאה. כדי להבין מדוע הזמנה נתקעה, צריך לעקוב אחרי כמה שירותים ואירועים.
וגם כאן יש תלות בין השירותים. היא מתבטאת במשמעות האירועים ובמבנה שלהם, ולא בהכרח בקריאה ישירה.
דרך אגב, עצם השימוש בתור הודעות לא אומר שבחרנו Choreography. גם מתאם ב־Orchestration יכול לשלוח פקודות ולקבל תשובות דרך תור. ההבדל הוא מי מחזיק את ההחלטה על המשך התהליך.
בדוגמה הזאת הייתי בוחר ב־Orchestration, בעיקר כדי לרכז את ההחלטות סביב התשלום, ההמתנה והפיצוי. זו בחירה לתהליך המסוים הזה, לא כלל שלפיו Orchestration תמיד עדיפה.
פיצוי אינו Rollback
שמרנו מלאי, אבל התשלום נדחה. עכשיו צריך לשחרר את המלאי.
זו פעולת פיצוי, או Compensating Transaction.
היא לא מחזירה את מסד הנתונים לתמונה שהייתה בו לפני תחילת התהליך. היא מבצעת פעולה עסקית חדשה שמתקנת את ההשפעה של פעולה קודמת.
ההבדל חשוב. אם לפני ההזמנה היו עשרה פריטים במלאי, אי אפשר פשוט להחזיר את הכמות לעשרה. בינתיים אולי בוצעו הזמנות נוספות. צריך לשחרר את ההקצאה המסוימת ששייכת להזמנה שלנו.
גם החזר כספי הוא פעולה חדשה, לא מחיקה של העובדה שהיה חיוב.
ולא כל תקלה צריכה להוביל לפיצוי. אם התשלום הצליח, אבל אישור ההזמנה נכשל בגלל תקלה זמנית במסד הנתונים, ייתכן שהפעולה הנכונה היא לנסות להשלים את האישור. אין סיבה למהר להחזיר כסף על הזמנה שאפשר להשלים. תבנית Saga מבחינה בין פעולות שניתן לפצות עליהן לבין שלבים שממשיכים מהם קדימה.
גם פעולת הפיצוי עצמה יכולה להיכשל. לכן שומרים את מצבה, מנסים שוב כשמדובר בתקלה זמנית, ומגדירים מתי נדרש טיפול ידני. העברת הודעה ל־Dead Letter Queue יכולה לעזור לשמור אותה לבדיקה, אבל היא לא מבצעת את ההחזר במקומנו.
צריך גם להחליט מה עושים כשהתשלום מתברר כהצלחה מאוחרת, אחרי שהקצאת המלאי כבר פגה. זה כבר כלל עסקי: האם מנסים להקצות מחדש, או מבטלים ומחזירים את הכסף? המערכת צריכה לדעת לטפל באפשרות הזאת.
לשמור הזמנה ולפרסם אירוע בלי לאבד אותו
נסתכל על הקוד הבא:
db.Orders.Add(order);
await db.SaveChangesAsync();
await messageBus.PublishAsync(
new OrderCreated(order.Id));
ההזמנה נשמרה. רגע לפני פרסום האירוע, התהליך נפל.
עכשיו יש הזמנה במסד הנתונים, אבל שאר השירותים לא יודעים עליה.
אם נהפוך את הסדר, נקבל בעיה אחרת: האירוע עלול להתפרסם, אבל שמירת ההזמנה תיכשל.
Transactional Outbox מטפל בפער הזה באמצעות שמירת ההודעה באותו מסד נתונים ובאותה טרנזקציה שבה נשמר השינוי העסקי.
דוגמה עקרונית עם EF Core ומסד נתונים רלציוני:
await using var transaction =
await db.Database.BeginTransactionAsync();
db.Orders.Add(order);
db.OutboxMessages.Add(new OutboxMessage
{
Id = Guid.NewGuid(),
Type = nameof(OrderCreated),
Payload = JsonSerializer.Serialize(
new OrderCreated(order.Id)),
CreatedAtUtc = DateTime.UtcNow
});
await db.SaveChangesAsync();
await transaction.CommitAsync();
כאן Orders ו־OutboxMessages נמצאות באותו מסד נתונים ומנוהלות באמצעות אותו DbContext. המחלקה OutboxMessage היא חלק מהמימוש שלנו, לא מנגנון שמתקבל אוטומטית מ־EF Core.
תהליך רקע קורא הודעות מה־Outbox, מפרסם אותן ומסמן שהן נשלחו. אם שירות ההודעות אינו זמין, ההודעות נשארות במסד הנתונים ואפשר לנסות שוב.
אותו עיקרון מתאים גם למתאם: שומרים יחד את שינוי מצב ה־Saga ואת ההודעה שאמורה לקדם אותה. כך לא נשארים עם התקדמות שנשמרה אבל עם בקשה שאבדה לפני השליחה. אפשר להשתמש בתשתית קיימת שמספקת את היכולות האלה, במקום לממש הכול לבד. התיעוד של MassTransit מפרט את השילוב בין שמירת מצב, Outbox ועיבוד הודעות.
טיפול בהודעות כפולות
ה־Outbox פתר את בעיית הפער בין שמירה לפרסום, אבל הוא לא מבטיח שכל הודעה תישלח פעם אחת בלבד.
למה?
כי תהליך הרקע עלול לפרסם הודעה, ואז ליפול לפני שסימן אותה כנשלחה. כשהוא יעלה מחדש, הוא יפרסם אותה שוב.
לכן גם הצד שמקבל את ההודעה צריך להיות מוכן לכפילויות.
אפשר לצרף להודעה MessageId קבוע, ולשמור אצל הצרכן אילו הודעות כבר טופלו. לעיתים קוראים למנגנון הזה Inbox.
אבל גם כאן לא מספיק לבדוק “האם כבר טיפלתי בהודעה?” ואז לבצע את הפעולה. צריך לתאם בין רישום ההודעה כמעובדת לבין השינוי העסקי, למשל באותה טרנזקציה מקומית, ולמנוע מצב שבו שני צרכנים מטפלים במקביל באותה הודעה.
אם הפעולה כוללת חיוב אצל ספק חיצוני, הטרנזקציה המקומית לא כוללת אותו. במקרה הזה עדיין נדרש הטיפול ב־Idempotency שתיארנו קודם.
אלה שכבות שונות של הגנה, וכל אחת מטפלת בפער אחר.
להבין איפה התהליך נתקע
לקוח פונה ואומר שההזמנה שלו לא אושרה. איך מוצאים מה קרה?
אם כל שירות כתב “הפעולה נכשלה”, בלי מזהים שמחברים בין הפעולות, נצטרך לנחש לאיזו הזמנה כל שורה שייכת.
כדאי שלוגים רלוונטיים יכילו שדות כמו:
OrderIdPaymentOperationIdMessageId- מצב התהליך והפעולה שנכשלה
לצד זה, Distributed Tracing מאפשר לעקוב אחרי מעבר הביצוע בין השירותים. בסביבת .NET אפשר להשתמש ב־Activity וב־OpenTelemetry, ולהעביר את הקשר המעקב גם בתקשורת דרך הודעות.
מזהה ההזמנה עדיין חשוב. הזמנה אחת עשויה לעבור כמה ניסיונות, בדיקות רקע ועדכונים מאוחרים, שלא כולם יהיו חלק מאותו Trace.
וגם לוגים ו־Traces לא מספיקים אם אף אחד לא יודע שיש בעיה. צריך להגדיר התראות על הזמנות שנמצאות זמן רב במצב ביניים, הודעות Outbox שלא נשלחות ופיצויים שנכשלים שוב ושוב.
לפני שמסיימים לממש תהליך כזה, כדאי לבדוק אותו דווקא בנקודות הלא נוחות: להפיל את השירות אחרי החיוב ולפני שמירת התוצאה, לשלוח אותה הודעה פעמיים, ולעכב תשובה עד שהבקשה המקורית כבר הסתיימה ב־Timeout.
בכל אחד מהמקרים האלה צריך להיות ברור מה נשמר, מה עדיין לא ידוע, ומי אחראי להמשיך את הטיפול. אם אין לכך תשובה, עוד Retry בקוד לא בהכרח יפתור את הבעיה.