Idempotency ב-Webhooks: איך מונעים עיבוד כפול של אירועים
מאת צוות מדיה דיל · 03.08.2026 · API Engineering · 8 דק׳
מדריך טכני על Idempotency ב-Webhooks: למה כפילויות בלתי נמנעות, איך בונים Idempotency Key אמין, ואילו דפוסי Storage מונעים Race Conditions בעיבוד מקבילי.
נניח שקיבלתם Webhook מ-Stripe שמודיע על תשלום מוצלח, ובעקבותיו המערכת שולחת מייל אישור ומפעילה מנוי. עכשיו נניח שאותו Webhook בדיוק מגיע שוב, שלוש שניות אחר כך. זה לא תקלה נדירה — זו התנהגות צפויה וטבעית בכל מערכת מבוזרת שמיישמת At-Least-Once Delivery, כלומר מבטיחה שהאירוע יגיע לפחות פעם אחת, אבל לא בהכרח בדיוק פעם אחת. אם המערכת שלכם לא בנויה לזהות ולסנן את הכפילות הזו, הלקוח יקבל שני מיילי אישור, ובמקרה הגרוע יותר — יחויב פעמיים. Idempotency היא התכונה שמבטיחה שביצוע אותה פעולה כמה פעמים ייתן בדיוק את אותה תוצאה כמו ביצוע חד-פעמי, וזו לא תוספת נחמדה — היא תנאי סף לכל מערכת שמקבלת Webhooks.
למה כפילויות הן בלתי נמנעות, לא תקלה נדירה
הסיבה העמוקה לכפילויות היא בעיית הגנרלים הביזנטיים בגרסתה הפשוטה: אין דרך לדעת בוודאות אם הודעת אישור (ACK) שאבדה ברשת פירושה שהצד השני לא קיבל את ההודעה המקורית, או שהוא כן קיבל אותה אבל התשובה שלו אבדה בדרך. ברוב המערכות הבחירה העיצובית היא לטעות לכיוון הבטוח — לשלוח שוב במקרה של ספק — ולכן כל ספק Webhooks רציני (Stripe, GitHub, Shopify, PayPal) מתעד במפורש בתיעוד שלו שהלקוח צריך לטפל בכפילויות. מעבר לכך, גם מנגנון ה-Retry שתיארנו במאמר הייעודי לנושא יוצר כפילויות במכוון — אם ניסיון ראשון timeout על אף שהוא הצליח בפועל בצד השרת, מערכת ה-Retry תשלח שוב, כי מבחינתה אין הבדל בין "לא הגיע" ל"הגיע אבל התשובה אבדה". התוצאה: אין דרך לבנות מערכת שמקבלת Webhooks בלי הנחת יסוד שאירועים יגיעו יותר מפעם אחת.
הבחנה בין At-Least-Once, At-Most-Once ו-Exactly-Once
שווה להבין את שלושת המודלים התיאורטיים לפני שממשיכים, כי הבחירה בהם משפיעה על כל שאר העיצוב. At-Most-Once שולח כל אירוע פעם אחת בלבד ולא מנסה שוב — פשוט אבל מסוכן, כי כל תקלת רשת חולפת גורמת לאובדן מידע לצמיתות. At-Least-Once, הסטנדרט הנפוץ ביותר, מבטיח שהאירוע יגיע לפחות פעם אחת גם במחיר כפילויות מדי פעם — וזה מעביר את האחריות למניעת כפילויות לצד המקבל. Exactly-Once הוא האידיאל התיאורטי — כל אירוע מגיע בדיוק פעם אחת — אבל הוא כמעט בלתי אפשרי להשגה אמיתית ברשת מבוזרת ללא הנחות נוספות; מה שרוב המערכות שמכריזות על "Exactly-Once" מספקות בפועל הוא בעצם At-Least-Once בתוספת Idempotency בצד הצרכן, שיוצר אפקט של Exactly-Once מבלי לפתור את הבעיה התיאורטית. ההבנה הזו חשובה כי היא ממקדת את המאמץ ההנדסי במקום הנכון: לא בניסיון (הנידון לכישלון) למנוע כפילויות ברמת הרשת, אלא בבניית שכבת Idempotency אמינה בצד הצרכן.
Idempotency Key: המפתח לזיהוי כפילות
הפתרון הבסיסי הוא שלכל אירוע יש מזהה ייחודי וקבוע — Idempotency Key — שנקבע פעם אחת בעת יצירת האירוע ולא משתנה בין ניסיונות Retry. ברוב הספקים זה מגיע כשדה `id` בגוף ה-JSON (למשל `evt_1NcY...` אצל Stripe). לפני עיבוד כל אירוע, המערכת בודקת אם ה-ID הזה כבר טופל, ואם כן — מחזירה תגובה מוצלחת בלי לחזור על הלוגיקה העסקית. הנקודה הקריטית: הבדיקה והכתיבה חייבות להיות אטומיות. אם קודם בודקים "האם ה-ID קיים" ורק אחר כך כותבים אותו, נפתח חלון זמן (Race Window) שבו שתי בקשות מקבילות (למשל שני ניסיונות Retry שהגיעו כמעט באותו רגע) יכולות שתיהן לעבור את הבדיקה לפני שאחת מהן הספיקה לכתוב, ושתיהן ימשיכו לביצוע כפול.
מימוש נכון עם Unique Constraint
הדרך הבטוחה ביותר למנוע את ה-Race Condition הזה היא לא להסתמך על "בדוק ואז כתוב" ברמת האפליקציה, אלא למנף Unique Constraint ברמת בסיס הנתונים, שמבטיח אטומיות מובנית:
CREATE TABLE processed_webhook_events (
event_id TEXT PRIMARY KEY,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status TEXT NOT NULL DEFAULT 'processing'
);
-- בקוד האפליקציה:
-- 1. נסה להכניס את event_id
-- 2. אם ההכנסה נכשלת עקב הפרת Unique Constraint - זו כפילות, דלג
-- 3. אם ההכנסה הצליחה - זו הפעם הראשונה, בצע את הלוגיקה העסקית
-- 4. עדכן status ל-'completed' רק אחרי הצלחה מלאה
הגישה הזו הופכת את מסד הנתונים לשומר הסף היחיד על ייחודיות, במקום לסמוך על לוגיקה מבוזרת באפליקציה שרצה במספר instances במקביל. שימו לב לצעד הרביעי: אם הלוגיקה העסקית נכשלת אחרי שהרשומה כבר נכתבה כ-'processing', חייבים מנגנון שמזהה רשומות תקועות (למשל, 'processing' יותר מדקה) ומאפשר עיבוד חוזר שלהן — אחרת אירוע שנכשל באמצע יישאר "נעול" לנצח ולעולם לא יעובד שוב.
מה קורה כשהלוגיקה העסקית כוללת כמה פעולות
המקרה המסובך יותר הוא כשעיבוד אירוע בודד כולל כמה פעולות שאינן חלק מאותה טרנזקציה — למשל, לחייב כרטיס אשראי (קריאה לספק חיצוני), ואז לשלוח מייל (קריאה לשירות נפרד), ואז לעדכן מלאי (כתיבה למסד נתונים אחר). אם הפעולה השנייה נכשלת אחרי שהראשונה הצליחה, וניסיון Retry מגיע, אסור לחזור על הפעולה הראשונה. הפתרון הוא לפרק את העיבוד לצעדים אידמפוטנטיים בפני עצמם, ולשמור State Machine שמתעד אילו צעדים כבר הושלמו עבור אירוע נתון. כל צעד בודק את המצב שלו לפני שהוא מתבצע, כך שניסיון חוזר "מדלג" אוטומטית על מה שכבר בוצע וממשיך מהנקודה שבה נעצר. זה דומה מאוד לדפוס ה-Saga המוכר מארכיטקטורות מיקרו-שירותים, ולא בכדי — הבעיה הבסיסית זהה: איך מבטיחים עקביות על פני כמה פעולות בלתי-טרנזקציוניות.
Idempotency ברמת ה-API מול ברמת ה-Database
שאלה נוספת שכדאי להכריע בה מראש: האם בדיקת ה-Idempotency מתבצעת בשכבת ה-Application (למשל, Cache ב-Redis עם TTL) או בשכבת ה-Database (Unique Constraint, כפי שהודגם למעלה)? Redis מהיר יותר לבדיקה בודדת אבל פחות אמין — אם ה-Cache מתאפס (restart, eviction) לפני שהעיבוד הסתיים, אין רישום שהאירוע כבר טופל. Database Constraint איטי יותר במעט אבל עמיד יותר, כי הוא חלק מאותה טרנזקציה עקבית שגם שאר הנתונים העסקיים חיים בה. גישה משולבת נפוצה: Redis כשכבת בדיקה ראשונה ומהירה ל"סינון מוקדם" של רוב הכפילויות הברורות (למשל, Retry שמגיע תוך שניות מהניסיון המקורי), עם Database Constraint כרשת ביטחון סופית שמונעת עיבוד כפול גם אם ה-Cache נכשל.
Idempotency Key שנוצר על ידכם, לא רק כזה שמגיע מהספק
לא כל Webhook כולל Idempotency Key מובנה ואמין. חלק מהספקים הזולים או הפחות בשלים שולחים אירועים בלי מזהה ייחודי כלל, או עם מזהה שמתחלף בכל Retry (מה שהופך אותו לחסר תועלת). במקרה כזה, הפתרון הוא לגזור מזהה ייחודי מתוכן האירוע עצמו — למשל hash של שילוב שדות שאמורים להיות קבועים (מזהה לקוח + מזהה טרנזקציה + timestamp מקורי מהספק, אם קיים). זה פחות אמין ממזהה ספק מובנה כי תוכן זהה יכול תיאורטית לקרות פעמיים בלגיטימיות (למשל שתי הזמנות זהות באותה שנייה), אבל זה עדיף בהרבה מהיעדר הגנה כלשהי. כדאי גם, כשאפשר, לבקש מהספק תמיכה בהוספת מזהה ייחודי — רוב הספקים הבשלים מוסיפים את זה בשמחה כי זה גם מקל עליהם לתמוך בלקוחות שמדווחים על עיבוד כפול.
Race Conditions מעבר לשכבת ה-Insert הבודדת
גם עם Unique Constraint תקין, יש תרחישים עדינים יותר. נניח שני Workers מקבילים שולפים אירועים מאותו תור, ובגלל At-Least-Once Delivery של התור עצמו (כן, גם תורות הודעות סובלים מאותה בעיה), אותו אירוע נשלף פעמיים כמעט בו-זמנית. שני ה-Workers ינסו את ה-INSERT כמעט באותה מיליסקנד — אחד יצליח, השני יקבל שגיאת הפרת ייחודיות ויידע לדלג. אבל אם הלוגיקה העסקית מתבצעת לפני ה-INSERT (למשל, "בדוק תחילה אם קיים, ורק אז כתוב"), שני ה-Workers עלולים לעבור את הבדיקה במקביל ולבצע את הפעולה פעמיים לפני שמישהו מהם הצליח לכתוב. לכן הסדר קריטי: קודם מנסים לכתוב את הרשומה (ומסתמכים על ה-Constraint לכישלון מבוקר), ורק אחרי כתיבה מוצלחת ממשיכים ללוגיקה העסקית. זה הופך את ה-Insert עצמו, ולא בדיקה נפרדת, לנקודת ההכרעה היחידה.
Idempotency בכיוון ההפוך: כשאתם השולחים
עד כה דיברנו על הצד המקבל, אבל הבעיה קיימת גם כשאתם שולחים בקשות ל-API חיצוני (למשל, קריאה ל-Stripe ליצירת חיוב) ומקבלים Timeout. אתם לא יודעים אם החיוב בוצע או לא, ואם תשלחו שוב בלי מחשבה, אתם עלולים ליצור חיוב כפול. הפתרון הסטנדרטי, שרוב ה-APIs הבוגרים תומכים בו, הוא כותרת `Idempotency-Key` שאתם שולחים עם הבקשה — אתם יוצרים מזהה ייחודי פעם אחת (למשל UUID) ושומרים אותו לצד ניסיון ה-Retry, כך שגם אם הבקשה מגיעה לשרת פעמיים, הוא יודע להחזיר את התוצאה המקורית בלי לבצע את הפעולה שוב. זו למעשה אותה תבנית בדיוק, רק הפוכת כיוון — ומי שבונה גם צד שולח וגם צד מקבל של Webhooks (למשל, בתשתית אינטגרציה כמו זו שמדיה דיל בונה ללקוחות) צריך ליישם את שני הכיוונים במקביל.
טעויות נפוצות בפרודקשן
הטעות השכיחה ביותר היא הסתמכות על בדיקה ברמת האפליקציה בלבד ("SELECT ואז INSERT") בלי Unique Constraint במסד הנתונים — זה עובד מצוין בבדיקות ידניות ונשבר בדיוק תחת העומס האמיתי שבו הבעיה קריטית. טעות שנייה היא שמירת רשומות Idempotency ללא תפוגה (TTL), מה שגורם לטבלה לגדול ללא גבול לאורך זמן; רוב המקרים לא צריכים שמירה ליותר מ-30-90 יום, ואפשר להעביר רשומות ישנות לארכיון קר. טעות שלישית היא בלבול בין Idempotency ל-Deduplication ברמת ה-Message Queue — תורות הודעות רבות (כמו SQS Standard) לא מבטיחות Exactly-Once מובנה, ומי שסומך על כך שהתור "כבר טיפל בזה" בלי בדיקה נוספת באפליקציה עלול להיות מופתע. טעות רביעית היא לבצע את בדיקת ה-Idempotency רק על הפעולה הסופית (למשל, החיוב) ולשכוח שגם תופעות לוואי כמו שליחת מייל צריכות את אותה הגנה.
מתי אפשר להקל
למערכות פנימיות עם עיבוד שהוא באמת אידמפוטנטי מטבעו — למשל, עדכון שדה "סטטוס אחרון ידוע" שבו כתיבה כפולה של אותו ערך לא משנה דבר — אפשר לוותר על שכבת Idempotency Key מפורשת ולסמוך על כך שהפעולה עצמה כבר "בטוחה לחזרה" (Naturally Idempotent). אבל בכל פעולה שיש לה תופעת לוואי חד-פעמית במהותה — חיוב, שליחת התראה, יצירת רשומה חדשה — ההשקעה במנגנון Idempotency Key מפורש היא לא אופציונלית.
סיכום
Idempotency ב-Webhooks נובעת ישירות ממודל At-Least-Once Delivery שהוא הסטנדרט בפועל בכל מערכת מבוזרת, ומחייבת מזהה ייחודי וקבוע לכל אירוע, בדיקה אטומית מול מסד הנתונים באמצעות Unique Constraint, ופירוק זהיר של תהליכים רב-שלביים ל-State Machine שיודע להתחדש מהנקודה שבה נעצר. זו אחת הנקודות שבהן פער קטן בין "עובד בבדיקות" ל"עובד בפרודקשן תחת עומס אמיתי" עלול לעלות ללקוחות כסף אמיתי — ולכן זו תמיד חלק מהתכנון הראשוני ולא תיקון שמגיע אחרי תקרית.
תגיות: Idempotency · Webhook · Distributed Systems · Race Condition · At-Least-Once Delivery · API Engineering