ארכיטקטורת Event API: תכנון APIs מונחי אירועים בקנה מידה

מאת צוות מדיה דיל · 01.08.2026 · API Engineering · 8 דק'

מדריך מעמיק לבניית Event API: בחירת message broker, אחריות delivery, סכימות משתנות, idempotency ו-observability במערכות שמייצרות ומגיבות לאירועים בזמן אמת.

צוות פיתוח שבנה מערכת הזמנות ל-e-commerce ישראלי גילה יום אחד שמלאי המוצרים בסטור לא תואם למציאות. הסיבה: ה-API הראשי חשף endpoint סינכרוני ליצירת הזמנה, ובתוך הבקשה עצמה קרא לשירות מלאי, שירות תשלומים, שירות משלוחים ושירות התראות - ברצף. כשאחד מהם היה איטי, כל הבקשה נתקעה; כשאחד נכשל, ההזמנה נשארה ב-state לא ברור. הפתרון לא היה לשפר את ה-retry logic של הקריאות הסינכרוניות, אלא לעצב מחדש את המערכת סביב Event API - ממשק שבו רכיבים לא מבקשים אחד מהשני לבצע פעולה, אלא מפרסמים עובדות שקרו ומגיבים לעובדות שאחרים פרסמו. המעבר הזה משנה כמעט כל החלטה ארכיטקטונית במערכת, ולכן שווה להבין אותו לעומק לפני שקופצים למימוש.

ההבדל בין Request-Driven ל-Event-Driven API

ב-API מסורתי מבוסס REST או RPC, הצרכן (client) יוזם את הפעולה ומחכה לתשובה. זה מודל טבעי לפעולות קריאה (GET) ולפעולות שדורשות תשובה מיידית. הבעיה מתחילה כשמשתמשים במודל הזה גם לתקשורת בין שירותים פנימיים שאין להם צורך אמיתי בתשובה סינכרונית - שירות ההזמנות לא צריך לדעת אם שירות ההתראות הצליח לשלוח מייל, הוא רק צריך לדעת שהוא ניסה. Event API הופך את הכיוון: השירות שמייצר את השינוי (למשל order.created) מפרסם אירוע לערוץ משותף, וכל שירות שמתעניין בו נרשם כ-consumer ומגיב בקצב ובאופן משלו. אין coupling ישיר בין המפרסם לצרכנים - המפרסם אפילו לא יודע כמה צרכנים יש, אם בכלל.

ההבדל הזה לא רק טכני אלא ארגוני. ב-Request-Driven, כל שינוי בשירות דורש תיאום עם כל מי שקורא לו. ב-Event-Driven, אפשר להוסיף צרכן חדש (למשל שירות אנליטיקס שרוצה לדעת על כל הזמנה) בלי לגעת כלל בשירות שמפרסם. זה בדיוק ה-decoupling שמאפשר לצוותים גדולים לעבוד במקביל בלי לחכות זה לזה.

בחירת ה-Message Broker: לא כל תור נולד שווה

הבחירה בין Kafka, RabbitMQ, AWS SNS/SQS, Google Pub/Sub או NATS משנה את כל האפשרויות שיש למערכת. Kafka מתאים למקרים שבהם צריך event log אמיתי - שמירת היסטוריה מלאה של אירועים, יכולת להריץ consumer חדש שיקרא מההתחלה (replay), ותפוקה גבוהה מאוד. המחיר: מורכבות תפעולית משמעותית, ניהול partitions, ו-consumer groups שדורשים הבנה עמוקה כדי לא ליצור hot partitions. RabbitMQ, לעומת זאת, מתאים יותר למודל task-queue קלאסי - הודעה נצרכת פעם אחת ונעלמת, עם routing גמיש דרך exchanges. הוא פשוט יותר לתפעול אבל לא נועד לשמש כ-source of truth היסטורי.

שירותים מנוהלים כמו SNS/SQS או EventBridge מסירים הרבה מהנטל התפעולי אבל מגבילים גמישות - למשל SQS לא תומך out-of-the-box ב-replay של הודעות שכבר נצרכו. הבחירה הנכונה תלויה בשאלה: האם אתם צריכים event sourcing אמיתי (ואז Kafka או Pulsar), או שאתם רק צריכים לנתק שירותים זה מזה בעזרת תור אמין (ואז SQS או RabbitMQ מספיקים ופשוטים משמעותית לתחזוקה). טעות נפוצה היא לבחור Kafka "כי כולם משתמשים בו" בלי צורך אמיתי ב-replay או בתפוקה של מיליוני הודעות בשנייה - ואז לשלם מחיר תפעולי גבוה על יכולת שלא מנוצלת.

Delivery Guarantees: at-least-once, exactly-once וסדר

אף מערכת מבוזרת אמיתית לא נותנת exactly-once delivery באמת ברמת הרשת - מה שקיים בפועל הוא at-least-once (עם אפשרות לכפילויות) או at-most-once (עם אפשרות לאובדן הודעות). מערכות כמו Kafka מציעות "exactly-once semantics" רק בהקשר מוגבל - בתוך אותה מערכת Kafka, עם transactional producers/consumers - אבל ברגע שהאירוע יוצא לעולם החיצוני (כתיבה למסד נתונים, קריאה ל-API חיצוני), חוזרים לצורך בטיפול ידני בכפילויות.

המשמעות המעשית: כל consumer חייב להיות כתוב כאילו הוא יקבל את אותו אירוע פעמיים. זה אומר idempotency ברמת הלוגיקה העסקית - לא מספיק לבדוק "עשיתי את זה כבר" ברמת ה-message broker, כי בין הבדיקה לביצוע יכול לקרות race condition. הדפוס הנכון הוא לשמור מזהה ייחודי של האירוע (event_id) בטבלה ייעודית בתוך אותה טרנזקציה שמבצעת את הפעולה העסקית, כך שאם שני threads מנסים לעבד את אותו אירוע, רק אחד מהם יצליח לכתוב את ה-event_id ולכן רק אחד יבצע את הפעולה.

BEGIN;
INSERT INTO processed_events (event_id) VALUES ($1)
  ON CONFLICT (event_id) DO NOTHING
  RETURNING event_id;
-- אם לא הוחזרה שורה, האירוע כבר טופל - COMMIT ריק וצא
-- אחרת, בצע את הפעולה העסקית באותה טרנזקציה
COMMIT;

לגבי סדר (ordering): רוב ה-message brokers לא מבטיחים סדר גלובלי, אלא סדר בתוך partition או queue יחיד. אם הסדר בין אירועים מסוג מסוים חשוב (למשל order.created לפני order.shipped לאותה הזמנה), חייבים לוודא שכל האירועים של אותה הזמנה מגיעים לאותו partition - בדרך כלל על ידי שימוש ב-order_id כ-partition key.

The Outbox Pattern: הבעיה שכולם נתקלים בה

אחת הבעיות המסוכנות ביותר ב-Event API היא dual-write: השירות כותב שינוי למסד הנתונים שלו, ואז מפרסם אירוע למערכת ההודעות. מה קורה אם הכתיבה למסד הצליחה אבל הפרסום נכשל (רשת נפלה, תור לא זמין)? המערכת נכנסת ל-state לא עקבי - השינוי קרה, אבל אף אחד לא שמע עליו. הפתרון המקובל הוא Transactional Outbox Pattern: במקום לפרסם ישירות, כותבים את האירוע לטבלת outbox באותה טרנזקציה של השינוי העסקי, ותהליך נפרד (relay) קורא מהטבלה ומפרסם לתור, עם מחיקה או סימון רק לאחר אישור פרסום מוצלח. כך הכתיבה למסד והרישום של הכוונה לפרסם הם אטומיים - אין מצב שבו אחד קרה בלי השני.

סכימות ואבולוציה: Schema Registry ותאימות לאחור

כשעשרות שירותים צורכים אותו סוג אירוע, שינוי בסכימה הוא נקודת כשל קלאסית. אם שירות מפרסם משנה שם שדה או מוחק שדה, כל צרכן שמסתמך עליו נשבר בלי אזהרה מוקדמת. הפתרון הוא Schema Registry (כמו Confluent Schema Registry עבור Avro/Protobuf) שאוכף כללי תאימות: backward compatibility (צרכן ישן יכול לקרוא הודעה חדשה), forward compatibility (צרכן חדש יכול לקרוא הודעה ישנה), או full compatibility (שניהם). הכלל המעשי הכי חשוב: להוסיף שדות אך ורק כ-optional עם ערך ברירת מחדל, לעולם לא למחוק או לשנות סוג של שדה קיים - אלא ליצור גרסה חדשה של האירוע (order.created.v2) ולתמוך בשתי הגרסאות במקביל תקופת מעבר.

גם ב-JSON, בלי Schema Registry פורמלי, שווה להטמיע בדיקת סכימה (JSON Schema validation) בשכבת ה-ingestion, ולתעד כל שינוי בגרסת האירוע. חוסר בגרסאות אירועים הוא מקור נפוץ לכשלים שקטים - שירות ממשיך לרוץ, אבל מפרש שדה לא נכון או מקבל null במקום ערך שהוא לא ציפה לו.

Dead-Letter Queues ו-Retry Strategies

מה קורה כשcConsumer נכשל שוב ושוב לעבד אירוע מסוים - למשל בגלל bug בלוגיקה או תלות חיצונית שנפלה? בלי מנגנון מוגדר, יש שתי אפשרויות רעות: המערכת חוסמת את שאר התור (head-of-line blocking) עד שהאירוע הבעייתי יעבור, או שהאירוע פשוט נזרק ואבד. הפתרון הנכון הוא Dead-Letter Queue (DLQ): אחרי N ניסיונות כושלים עם exponential backoff, האירוע עובר לתור נפרד לבדיקה ידנית או אוטומטית, והתור הראשי ממשיך לזרום. חשוב להגדיר גם alerting על תור ה-DLQ - תור מלא באירועים שנכשלו הוא תמיד סימן לבעיה שדורשת התייחסות, לא משהו להתעלם ממנו.

שכבת retry צריכה להבחין בין כשלים זמניים (timeout, 503 מהשירות היעד) לכשלים קבועים (400, נתונים לא תקינים). retry אוטומטי על כשל קבוע רק מבזבז משאבים ומעכב את זיהוי הבעיה האמיתית.

Observability: לדבג מערכת שאין בה קריאה ליניארית

ב-API סינכרוני, דיבוג הוא פשוט יחסית - יש stack trace אחד ליניארי. ב-Event API, פעולה בודדת של משתמש יכולה להפעיל שרשרת אירועים דרך חמישה שירותים שונים, בזמנים שונים, בלי קשר סינכרוני ביניהם. בלי distributed tracing (כמו OpenTelemetry) עם trace_id שעובר מאירוע לאירוע, אין דרך מעשית להבין למה הזמנה "נתקעה" - צריך לחפש ידנית בלוגים של כל שירות. כל אירוע חייב לשאת correlation_id שמקשר אותו לשרשרת המקורית, וכל שירות חייב לרשום את ה-id הזה בכל log entry רלוונטי. השקעה בזה מראש חוסכת ימי דיבוג בהמשך.

חשיפת Event API כלפי חוץ: Webhooks ללקוחות

עד כה דיברנו על אירועים פנימיים בין שירותים, אבל הרבה מוצרי SaaS צריכים לחשוף אירועים גם ללקוחות חיצוניים - למשל להודיע למערכת CRM של הלקוח כשמשתמש ביצע פעולה. כאן המורכבות עולה מדרגה, כי אין שליטה על הקוד שצורך את האירוע. חייבים לחתום כל payload (HMAC signature עם secret ייחודי ללקוח) כדי שהלקוח יוכל לוודא שהבקשה אכן הגיעה מכם ולא מתחזה. חייבים גם לתעד ברור סמנטיקת retry - כמה פעמים תנסו לשלוח webhook שנכשל, באיזה backoff, ומתי תוותרו ותסמנו אותו כ-failed לצמיתות. מומלץ לספק ללקוחות גם endpoint לבדיקת סטטוס משלוחים ואפשרות replay ידני, כי לקוחות תמיד יתקלו בתקופות שבהן ה-endpoint שלהם היה למטה.

הבדל מהותי בין אירועים פנימיים לחיצוניים: כלפי חוץ אי אפשר לשנות סכימה בלי לתאם עם כל צרכן חיצוני, ולכן גרסאות ה-API (v1, v2) חייבות להיות מפורשות בכתובת ה-webhook או ב-header ייעודי, עם חלון תמיכה ארוך משמעותית מהאירועים הפנימיים.

מתי Event API הוא הבחירה הנכונה - ומתי לא

Event API מתאים כשיש ריבוי צרכנים לאותו שינוי, כשצריך decoupling אמיתי בין צוותים, וכשיש ערך ב-audit trail היסטורי. הוא לא מתאים כשצריך תשובה מיידית וסינכרונית (למשל בדיקת מלאי לפני אישור תשלום בזמן אמת), וכשהמורכבות התפעולית הנוספת לא מוצדקת על ידי היקף המערכת - סטארטאפ עם שלושה שירותים לרוב לא צריך Kafka cluster, הוא צריך HTTP רגיל או תור פשוט. הכלל המעשי: להתחיל עם הפתרון הפשוט ביותר שעונה על הצורך, ולעבור ל-Event API כשה-coupling הסינכרוני הופך לצוואר בקבוק אמיתי - לא לפני.

נקודה נוספת שכדאי לזכור בתכנון תקציב: broker מנוהל כמו EventBridge או Pub/Sub עולה יותר לבקשה בודדת אבל חוסך שעות הנדסה בתפעול, בעוד Kafka עצמאי זול יותר בסקייל גדול אבל דורש צוות SRE שמכיר את הכלי לעומק - כולל ניטור consumer lag, ניהול partitions מחדש (rebalancing) וטיפול ב-broker שנופל. ההחלטה הזו היא לא רק טכנית אלא ארגונית: יש לכם את היכולת הפנימית לתפעל Kafka, או שעדיף לשלם פרמיה על שירות מנוהל ולשמור את זמן הצוות לפיתוח פיצ'רים?

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

תגיות: Event API · Event-Driven Architecture · Kafka · Message Broker · Outbox Pattern · Idempotency · Webhooks · אירועים API

← חזרה לבלוג · צור קשר