Retry צריך לחזור על הכוונה, לא לשכפל נתונים

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

פורסם 2026-09-06. תגיות: Backend, Data, Reliability.

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

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

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

הבעיה: כשל במייל אינו בהכרח כשל ביצירה

תהליך הזמנה כולל בדרך כלל לפחות שתי פעולות שונות:

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

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

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

Create member
Send invitation

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

האבחון: צריך להגדיר מהי הזהות העסקית

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

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

הנרמול חשוב. מבחינת רוב מסלולי ההזמנה, הכתובות הבאות צריכות להתייחס לאותו אדם:

[email protected]
 [email protected] 

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

שכבה ראשונה: שימוש חוזר ברשומה הקיימת

מסלול היצירה מתחיל בחיפוש רשומה פעילה לפי המפתח העסקי המנורמל.

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

ההתנהגות הרצויה נראית כך:

ניסיון ראשון:
רשומה נוצרה → שליחת ההזמנה נכשלה

ניסיון שני:
אותה רשומה נמצאה → ההזמנה נשלחה מחדש

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

שכבה שנייה: מסד הנתונים צריך להגן מפני מרוץ

בדיקת "מצא ואז צור" פותרת את המסלול הרגיל, אבל לא את תרחיש המרוץ:

Request A: לא נמצאה רשומה
Request B: לא נמצאה רשומה
Request A: יוצר רשומה
Request B: יוצר רשומה נוספת

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

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

השילוב בין שתי השכבות חשוב:

למה לא לבצע rollback על הכול?

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

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

במקום transaction מדומה שחוצה מסד נתונים וספק חיצוני, עדיף לשמור מצב מפורש:

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

הבדיקה שמוכיחה את החוזה

בדיקת הרגרסיה החשובה אינה רק "יצירה אחת מצליחה". היא צריכה להריץ את הסיפור המלא:

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

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

נסו את חוזה הניסיון החוזר

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

מה למדתי

Idempotency אינה תכונה ששייכת רק ל־payment APIs או למערכות מבוזרות גדולות. היא חשובה בכל מקום שבו משתמש יכול ללחוץ שוב, דפדפן יכול לשלוח בקשה מחדש או worker יכול לחזור על עבודה לאחר timeout.

הכלל שאני לוקח מהתיקון הזה הוא פשוט:

ניסיון חוזר צריך לחזור על הכוונה, לא לשכפל את התוצאה.

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

כפתור Retry נראה קטן. החוזה שמאחוריו הוא חלק מהאמינות של המוצר כולו.