Event Sourcing: כשה-log של השינויים הוא מקור האמת, לא הטבלה
מאת צוות מדיה דיל · 05.08.2026 · Data Engineering · 13 דק׳
מדריך מעמיק ל-Event Sourcing: איך שומרים append-only log של אירועים כמקור אמת, מתי זה משתלב עם CQRS, ומה המחיר האמיתי של הגישה הזו בפרודקשן.
ברוב האפליקציות, כשמעדכנים רשומה בבסיס נתונים, המידע הקודם פשוט נעלם — UPDATE orders SET status = 'shipped' WHERE id = 123 מוחק כל עקבה למצב הקודם. זה עובד מצוין עד שמישהו שואל 'למה ההזמנה הזו במצב הזה?' או 'מה קרה בדיוק ב-14:32?', ואין תשובה — כי ההיסטוריה נמחקה. Event Sourcing הוא דפוס ארכיטקטוני שהופך את הגישה הזו על הראש: במקום לשמור את המצב הנוכחי, שומרים את רצף האירועים שהוביל אליו, והמצב הנוכחי הוא רק תוצר נגזר, ניתן לחישוב מחדש בכל רגע.
העיקרון הבסיסי: state כפועל יוצא של history
בגישה מסורתית, ה-DB שומר את המצב הנוכחי בלבד. ב-Event Sourcing, ה-source of truth הוא event log — רשימה append-only של עובדות שקרו: OrderCreated, ItemAdded, PaymentReceived, OrderShipped. כל אירוע הוא immutable — לעולם לא נמחק ולא משתנה. המצב הנוכחי של ההזמנה מתקבל על ידי 'folding' (החלה עוקבת) של כל האירועים לפי הסדר. זה נשמע כמו overhead מיותר, אבל היתרון מהותי: יש audit trail מלא ואמין מטבעו, לא כתוספת נפרדת שצריך לתחזק (כמו audit log נפרד שיכול להתפספס).
Aggregates: יחידת העקביות
ב-Event Sourcing, אירועים מאורגנים סביב aggregates — יחידות עסקיות עם גבול עקביות ברור (למשל 'הזמנה בודדת', לא 'כל ההזמנות'). כל aggregate הוא stream נפרד של אירועים, מזוהה על ידי aggregate ID. כשמבצעים command (כמו 'הוסף פריט להזמנה'), הלוגיקה טוענת את כל האירועים הקיימים של אותו aggregate, בונה מחדש את המצב הנוכחי (rehydration), מוודאת שהפעולה חוקית לפי המצב הזה, ורק אז מוסיפה אירוע חדש ל-stream. זו נקודת האכיפה של invariants עסקיים — למשל, לא ניתן להוסיף פריט להזמנה שכבר נשלחה.
Snapshotting: הפתרון לבעיית ה-rehydration האיטית
הבעיה המעשית הברורה: אם aggregate צבר 50,000 אירועים לאורך שנים (חשבון בנק פעיל, למשל), טעינה ו-replay של כולם בכל query היא בזבזנית ואיטית. הפתרון הוא snapshotting — שמירת מצב מחושב מראש כל N אירועים (למשל כל 100), כך שב-rehydration טוענים את ה-snapshot האחרון ורק את האירועים שאחריו. הטריק הארכיטקטוני: snapshots הם אופטימיזציה בלבד, לא מקור אמת — אפשר תמיד למחוק אותם ולחשב מחדש מה-log המלא, וזו בדיוק הנקודה שמבדילה snapshot נכון מ-derived state לא נכון שהופך בטעות למקור אמת בעצמו.
הקשר ל-CQRS: לא אותו דבר, אבל שותפים טבעיים
Event Sourcing ו-CQRS (Command Query Responsibility Segregation) לעיתים קרובות מוזכרים יחד, אבל הם עקרונות נפרדים. CQRS אומר: הפרד את נתיב הכתיבה (commands) מנתיב הקריאה (queries). Event Sourcing אומר: שמור אירועים, לא state. השילוב טבעי כי אירועים הם פורמט כתיבה מצוין (append-only, מהיר) אבל פורמט קריאה גרוע (לא נוח לבנות ממנו UI queries). לכן, בונים projections — views נגזרים מהאירועים, מותאמים לצרכי הקריאה הספציפיים, שמתעדכנים באופן א-סינכרוני כשמגיעים אירועים חדשים (בדיוק כמו CDC consumer). ניתן לבנות כמה projections שונים מאותם אירועים — טבלת SQL רגילה לדשבורד ניהולי, מסמך ב-Elasticsearch לחיפוש, cache ב-Redis לתגובה מהירה — כל אחד בפורמט האופטימלי לצריכה שלו.
Schema evolution: הבעיה שהכי קל לפספס
אירועים הם immutable, אבל דרישות עסקיות משתנות. מה קורה כשמגלים שאירוע OrderCreated צריך שדה נוסף שלא היה קיים לפני שנתיים? אי אפשר לשנות אירועים היסטוריים — הם immutable מעצם ההגדרה. הפתרון הוא upcasting: שכבת תרגום שממירה אירועים בגרסה ישנה לפורמט הנוכחי בזמן קריאה, לרוב על ידי החלת ברירות מחדל סבירות לשדות חדשים. פרויקטים שמתעלמים מ-schema evolution מההתחלה מוצאים את עצמם, אחרי שנה-שנתיים, עם קוד rehydration מלא ב-if-else מכוער שמנסה להתמודד עם כל הגרסאות ההיסטוריות בבת אחת — לכן ניהול גרסאות אירועים מפורש (event versioning) הוא דרישה, לא nice-to-have.
Eventual consistency: המחיר האמיתי
מכיוון ש-projections מתעדכנים א-סינכרונית מה-event log, יש חלון זמן (בדרך כלל מילישניות עד שניות בודדות) שבו כתיבה חדשה עדיין לא משתקפת בכל ה-read models. זה אומר ש-UI יכול, תיאורטית, להראות מידע מעט מיושן מיד אחרי פעולת כתיבה — בעיה קלאסית של read-your-writes consistency. הפתרונות: קריאה מה-write model (aggregate עצמו) מיד אחרי כתיבה כשצריך עקביות מיידית, או UI patterns אופטימיסטיים שמניחים הצלחה ומעדכנים locally עד שה-projection מתעדכן בפועל. זו לא בעיה שאפשר להתעלם ממנה — היא צריכה החלטת UX מפורשת.
טעויות נפוצות בפרודקשן
הטעות הראשונה והכי נפוצה: להשתמש ב-Event Sourcing לכל הדומיין העסקי, כולל חלקים שהם פשוט CRUD טהור בלי צורך אמיתי בהיסטוריה — זה overhead הנדסי מיותר. הטעות השנייה: לא לתכנן snapshotting מההתחלה, ואז לגלות בפרודקשן ש-aggregates ותיקים לוקחים שניות לטעון. הטעות השלישית: להתעלם מ-idempotency ב-event handlers שבונים projections — אם handler מעבד את אותו אירוע פעמיים (בגלל retry), הוא לא צריך להכפיל מונים או ליצור רשומות כפולות. הטעות הרביעית: לשכוח ש-deletion בעולם Event Sourcing היא בעצמה אירוע (למשל ItemRemoved), לא מחיקה פיזית — וזה משנה איך חושבים על GDPR ו-right to erasure, שדורש טכניקות ייעודיות כמו crypto-shredding (מחיקת מפתח ההצפנה במקום הנתונים עצמם).
איפה שומרים את ה-event log בפועל
הבחירה הטכנולוגית ל-event store קובעת הרבה מהמורכבות ההנדסית בהמשך. יש שלוש אפשרויות מרכזיות. הראשונה, event store ייעודי כמו EventStoreDB, בנוי מהיסוד סביב הסמנטיקה של aggregates ו-streams, עם תמיכה מובנית ב-optimistic concurrency control (מניעת race condition כששני commands מנסים לכתוב אירוע לאותו aggregate בו-זמנית, על ידי בדיקת גרסת ה-stream) ו-subscriptions לצריכת אירועים חדשים. השנייה, Kafka כ-event store — עם compacted topics (שמורידים רק את הגרסה האחרונה לכל key, אבל אפשר להגדיר גם retention אינסופי) — פופולרי כשממילא יש תשתית Kafka בארגון, אבל דורש בניית לוגיקת optimistic concurrency בעצמכם, כי Kafka לא נותן את זה מהקופסה. השלישית, relational DB רגיל (PostgreSQL, למשל) עם טבלת events פשוטה (aggregate_id, version, event_type, payload, timestamp) ואינדקס ייחודי על (aggregate_id, version) שמונע race conditions בצורה טבעית דרך constraint ברמת ה-DB. הגישה הזו הכי פשוטה להתחיל איתה כי היא לא דורשת תשתית חדשה, ומתאימה מצוין לרוב הפרויקטים שלא צריכים throughput עצום.
הבחירה הנכונה תלויה בקנה המידה ובתשתית הקיימת: ארגון עם Kafka ותיק ומבוסס ילך טבעי לכיוון compacted topics, בעוד סטארטאפ שרק מתחיל ובונה MVP ירוויח יותר מפשטות של טבלת PostgreSQL רגילה, ויוכל לעבור ל-event store ייעודי מאוחר יותר אם וכאשר קנה המידה ידרוש זאת בפועל.
Read models מרובים: היתרון התפעולי שקל לפספס
יתרון פרקטי של Event Sourcing שלא תמיד מוזכר מספיק: כשמגלים באג ב-projection אחד, אין צורך ב-migration מסובך של נתונים קיימים. פשוט מוחקים את ה-read model הפגום, מתקנים את קוד ה-projection, ובונים אותו מחדש מ-replay מלא של האירועים. זה הופך תיקוני באגים ב-read models מפעולה מסוכנת (עם risk של איבוד נתונים) לפעולה שגרתית וברת-חזרה. באותה מידה, הוספת יכולת אנליטית חדשה — למשל 'רוצים לראות התפלגות של זמן בין יצירת הזמנה למשלוח' — לא דורשת שינוי בקוד הכתיבה הקיים בכלל, רק projection חדש שקורא את אותו log ההיסטורי שכבר קיים. זו בדיוק הגמישות שהופכת Event Sourcing לאטרקטיבי במיוחד במוצרים שצפויים להתפתח משמעותית עם הזמן.
Sagas: תיאום בין כמה aggregates
שאלה שעולה כמעט תמיד: מה קורה כשפעולה עסקית אחת צריכה לגעת בכמה aggregates שונים — למשל 'ביצוע הזמנה' שצריך גם ליצור אירוע ב-Order aggregate וגם להוריד מלאי ב-Inventory aggregate? מכיוון שכל aggregate הוא גבול טרנזקציה נפרד, אי אפשר פשוט לעטוף את שניהם בטרנזקציית DB אחת כמו בעולם ה-CRUD המסורתי. הפתרון המקובל הוא דפוס Saga: רצף של שלבים, כל אחד עם אירוע compensating (מפצה) משלו למקרה שהשלב הבא נכשל. אם ההזמנה נוצרה בהצלחה אבל הורדת המלאי נכשלת (כי אין מספיק מלאי), ה-saga מפעילה אירוע compensating שמבטל את ההזמנה — OrderCancelled — במקום rollback טרנזקציוני קלאסי. זה דורש חשיבה עסקית מפורשת על כל תרחיש כשל אפשרי מראש, ולא ניתן 'להסתמך על ה-DB' כמו בעולם הטרנזקציות המסורתי; זה המחיר האמיתי של distributed aggregates, ולא רק עניין טכני של איך שומרים אירועים.
Optimistic concurrency control: מניעת race conditions בין commands
נקודה טכנית קריטית שקל לפספס: מה קורה כששני commands מנסים לפעול על אותו aggregate בו-זמנית — למשל שני משתמשים שמנסים לעדכן את אותה הזמנה במקביל? הפתרון הסטנדרטי הוא optimistic concurrency control מבוסס גרסה: כל aggregate נושא מספר גרסה (expected version), וכשמנסים להוסיף אירוע חדש, הכתיבה מצליחה רק אם הגרסה הנוכחית ב-store תואמת לגרסה שהקוד ציפה לה בזמן הטעינה. אם לא — כלומר, מישהו אחר כבר כתב אירוע חדש בינתיים — הכתיבה נכשלת עם conflict error, וה-caller צריך לטעון מחדש את המצב העדכני ולנסות שוב. זו בדיוק הסיבה שטבלת PostgreSQL עם unique constraint על (aggregate_id, version) היא מימוש כל כך פשוט ואלגנטי — ה-DB עצמו אוכף את זה בלי קוד נוסף. חוסר תשומת לב לנקודה הזו הוא מקור נפוץ ל-lost updates בשקט — מצב שבו שינוי אחד 'דורס' שינוי אחר בלי שאף אחד שם לב, בדיוק הבעיה ש-Event Sourcing אמור למנוע.
מתי כן ומתי לא
Event Sourcing שווה את המחיר כשיש דרישה אמיתית ל-audit trail מלא (פיננסים, בריאות, compliance), כשצריך debugging עמוק של 'איך הגענו למצב הזה', או כשיש כמה consumers שונים שצריכים views שונים מאותם נתונים. הוא לא מתאים לרוב האפליקציות ה-CRUD הפשוטות שבהן ההיסטוריה פשוט לא רלוונטית עסקית — שם המורכבות הנוספת (projections, snapshotting, eventual consistency) היא עלות בלי תמורה אמיתית.
סיכום
Event Sourcing הופך את ה-log מכלי דיבוג לתשתית ליבה — אבל זו החלטה ארכיטקטונית עמוקה, לא תבנית עיצוב קלה להטמעה. כשעושים את זה נכון, עם snapshotting מתוכנן, event versioning ברור, ו-projections idempotent, מקבלים מערכת עם היסטוריה מלאה ויכולת שחזור מוחלטת. כשעושים את זה בלי מחשבה, מקבלים מורכבות מיותרת בלי היתרונות.
תגיות: Event Sourcing · CQRS · aggregates · snapshotting · projections · event versioning · eventual consistency