Background Processing Architecture: איך בונים שכבת עיבוד אסינכרוני שלא קורסת בעומס

מאת צוות מדיה דיל · 09.08.2026 · DevOps · 10 דק׳

מפני מה שרת שקורא לAPI חיצוני בתוך בקשת HTTP סופו לקרוס, ואיך בונים שכבת עיבוד רקע עם idempotency, DLQ ו-backpressure שמחזיקה מעמד בעומס אמיתי.

בקשת HTTP שנכנסת לשרת שלכם ומחכה 45 שניות עד שנוצר קובץ PDF, נשלח מייל אימות, או עובד תמלול וידאו - היא לא רק בעיית UX. היא בעיה ארכיטקטונית מסוכנת: תהליך (worker process) שתפוס במלואו בעבודה כבדה לא יכול לענות לבקשות אחרות, load balancer מתחיל להחזיר 504 Gateway Timeout, ומשתמשים לוחצים שוב ושוב על אותו כפתור כי הם לא מקבלים משוב - מה שיוצר עומס כפול על מערכת שכבר קורסת. הפתרון הוא הפרדה ברורה בין תגובה מיידית למשתמש לבין ביצוע בפועל של העבודה הכבדה, וזו בדיוק המשימה של ארכיטקטורת Background Processing.

הבעיה: כשעיבוד סינכרוני הופך לצוואר בקבוק

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

אנטומיה של ארכיטקטורת Background Processing

מערכת עיבוד רקע בנויה מארבעה רכיבים עיקריים. ה-Producer הוא הקוד שמזהה שיש עבודה שצריך לבצע ברקע, ובמקום לבצע אותה בעצמו, הוא רק שולח הודעה (job) לתור. ה-Queue/Broker (Redis, RabbitMQ, SQS, Kafka) מחזיק את ההודעות בצורה עמידה עד שמישהו מוכן לעבד אותן - זו נקודת ההפרדה הקריטית שמאפשרת ל-Producer להמשיך הלאה בלי לחכות. ה-Worker Pool הם תהליכים נפרדים, לרוב רצים במכונות או קונטיינרים ייעודיים, שמושכים jobs מהתור ומבצעים אותם בפועל - הם יכולים לגדול ולהצטמצם באופן עצמאי מהשרתים שמשרתים בקשות HTTP. ולבסוף, Result Store - מקום שבו התוצאה או הסטטוס של העבודה נשמרים כדי שהמשתמש (או מערכת אחרת) יוכל לבדוק מה קרה, בין אם זו טבלה במסד נתונים, webhook שנשלח בסיום, או ערוץ WebSocket שמעדכן בזמן אמת.

דפוסי עיבוד: Fire-and-Forget, Scheduled ו-Batch

יש כמה דפוסים מרכזיים לעיבוד רקע, וכל אחד מתאים למקרה שימוש שונה. Fire-and-Forget הוא הפשוט ביותר - שולחים job ולא מחכים לתוצאה כלל (למשל שליחת אירוע אנליטיקס). Request-Async-Response מאפשר למשתמש לקבל מזהה job מיידית ולבדוק סטטוס מאוחר יותר (polling או webhook) - זה הדפוס הנכון ליצירת דוח כבד או עיבוד וידאו. Scheduled Jobs (cron-style) רצים בזמנים קבועים ללא טריגר חיצוני - ניקוי טבלאות ישנות, שליחת דוחות יומיים. Batch Processing אוסף כמות גדולה של פעולות קטנות ומעבד אותן יחד לצורך יעילות (למשל שליחת אלף מיילים בבת אחת דרך API של ספק דוא"ל, במקום קריאה נפרדת לכל מייל). הבחירה הנכונה בין הדפוסים משפיעה ישירות על חוויית המשתמש ועל עלות התשתית.

Idempotency: התנאי שבלעדיו Retry הורס נתונים

עיבוד רקע כמעט תמיד כולל ניסיונות חוזרים (retries) - worker שקרס באמצע עיבוד, חיבור רשת שנכשל, timeout מול API חיצוני. הבעיה היא שאם הפעולה עצמה לא Idempotent (כלומר, הרצה שלה פעמיים לא שווה להרצה פעם אחת), retry עלול לגרום לתופעות חמורות: מייל שנשלח פעמיים, כרטיס אשראי שחויב פעמיים, רשומה שנוצרה כפולה. הפתרון הוא Idempotency Key - מזהה ייחודי שנוצר פעם אחת עבור כל בקשה עסקית (לא לכל ניסיון!), ונשמר בטבלה ייעודית. לפני שה-worker מבצע את הפעולה בפועל, הוא בודק אם המזהה הזה כבר טופל - אם כן, הוא פשוט מחזיר את התוצאה השמורה במקום לבצע שוב. כך ה-worker יכול לנסות שוב בביטחון מלא, כי ה-retry עצמו הפך לבטוח (safe).

async function processJob(job) {
  const existing = await db.idempotencyKeys.findOne({ key: job.idempotencyKey });
  if (existing) return existing.result; // כבר טופל - אל תבצע שוב

  const result = await sendEmail(job.payload);
  await db.idempotencyKeys.insert({ key: job.idempotencyKey, result });
  return result;
}

Scaling Workers ו-Backpressure

אחד היתרונות המרכזיים של הפרדת עיבוד רקע הוא היכולת להרחיב (scale) את ה-workers באופן עצמאי מהשרתים שמשרתים בקשות משתמש. אם עומק התור (queue depth) גדל - מוסיפים workers; אם הוא ריק - מצמצמים, כדי לחסוך עלות. עם זאת, יש כאן מלכודת: אם הרחבת ה-workers היא ליניארית ובלתי מוגבלת, ועומס פתאומי (למשל קמפיין שיווקי שמייצר עשרות אלפי jobs בבת אחת) יכול לגרום להם להציף מערכת downstream - מסד נתונים, API חיצוני עם rate limit, שירות תשלומים. כאן נכנס עקרון ה-Backpressure: להגביל את קצב המשיכה מהתור (concurrency limit לכל worker, ו-worker pool max size גלובלי), כך שהמערכת מווסתת את עצמה במקום להעביר את העומס הלאה בצורה בלתי מבוקרת. תור שגדל זמנית הוא תופעה בריאה של backpressure עובד; תור שגדל ולא נעצר לעולם הוא סימן שקצב היצירה עולה על קצב העיבוד המקסימלי האפשרי, ואין ברירה אלא להוסיף קיבולת עיבוד אמיתית.

Trade-offs: Latency מול Throughput מול מורכבות תפעולית

המחיר של ארכיטקטורת עיבוד רקע הוא לא רק תשתיתי - הוא גם קוגניטיבי. במקום זרימת קוד ליניארית וקלה למעקב (בקשה נכנסת, מעובדת, מוחזרת תשובה), יש עכשיו שני נתיבי ביצוע נפרדים שצריך לתאם ביניהם: מתי המשתמש רואה "בטיפול", איך הוא נודע כשהעבודה הסתיימה, ומה קורה אם היא נכשלת אחרי שהוא כבר עזב את העמוד. Throughput כולל עולה משמעותית - אפשר לעבד אלפי jobs במקביל בלי לחסום אף בקשת HTTP - אבל latency-per-job בפועל (מרגע היצירה ועד לסיום בפועל) עשוי להיות גבוה יותר מעיבוד סינכרוני מיידי, כי יש תור, המתנה ל-worker פנוי, ולפעמים retry. הבחירה הנכונה תלויה בשאלה: האם המשתמש חייב לדעת את התוצאה מיידית (אז אולי סינכרוני זול ופשוט יותר, לפחות עד סף עומס מסוים), או שהוא יכול לחכות כמה שניות עד דקות (אז background processing הוא הבחירה הנכונה כמעט תמיד).

Dead Letter Queue וטיפול בכשלים חוזרים

לא כל job מצליח, גם אחרי כמה ניסיונות. job עם payload פגום, קריאה ל-API חיצוני שתמיד נכשל בגלל שינוי בממשק, או באג בקוד עצמו - כל אלה יגרמו לכישלון עקבי. בלי מנגנון בקרה, job כזה יכול להיכנס ללולאת retry אינסופית שצורכת משאבים ומעכבת jobs אחרים בתור. הפתרון הסטנדרטי הוא Dead Letter Queue (DLQ) - אחרי מספר מוגדר של ניסיונות כושלים (למשל 5, עם exponential backoff ביניהם), ה-job מועבר לתור נפרד ייעודי לכישלונות, ומופסק מניסיונות אוטומטיים נוספים. צוות התפעול מקבל התראה, בוחן את ה-DLQ, ומחליט - האם לתקן את הבעיה ולשלוח את ה-job מחדש (replay), או לזנוח אותו לצמיתות אם הוא כבר לא רלוונטי. בלי DLQ, כישלונות שקטים מצטברים בלי שאף אחד שם לב, עד שמישהו מגלה בטעות שאלפי מיילי אימות מעולם לא נשלחו. חשוב גם להבחין בין כישלונות בני-ניסיון-חוזר (transient - למשל timeout רגעי) לכישלונות קבועים (permanent - למשל payload לא תקין מבחינה סכמטית) - job מהסוג השני לא צריך בכלל להיכנס למעגל ה-retry, אלא לעבור ל-DLQ מיידית, כדי לא לבזבז זמן עיבוד יקר על ניסיון שידוע מראש שייכשל.

Observability: איך יודעים שהעיבוד ברקע בכלל קורה

הבעיה המחקרית הגדולה ביותר בעיבוד רקע היא שהוא, מטבעו, בלתי נראה מרגע שהתגובה חזרה למשתמש. בלי מדדים ייעודיים, קל מאוד לפספס תקלה שקטה - job שנתקע, worker שקרס בלי לרשום שגיאה, תור שגדל בהדרגה במשך שבוע שלם עד שהוא מתפוצץ. שלושה מדדים חייבים להיות בלוח בקרה מרכזי: עומק תור (queue depth) בזמן אמת, זמן המתנה (מרגע היצירה ועד תחילת עיבוד בפועל - lag), ו-אחוז כשלונות ביחס לסך ה-jobs שעובדו בחלון זמן נתון. מעבר למדדים, כל job צריך לשאת עמו trace context (מזהה שמקשר בין הבקשה המקורית של המשתמש לבין ה-job שנוצר ממנה ברקע), כדי שכשמשהו משתבש, אפשר יהיה לעקוב אחורה מהשגיאה ב-worker ועד לבקשת ה-API שיצרה אותה - בלי זה, כל תקלה בעיבוד רקע הופכת לחיפוש מחט בערימת שחת בלוגים לא מקושרים.

בדיקות ל-Jobs אסינכרוניים

בדיקת יחידה רגילה (unit test) בודקת פונקציה סינכרונית עם קלט ופלט ברורים - אבל job אסינכרוני מחייב סוג נוסף של בדיקה: בדיקת אינטגרציה שמדמה את מחזור החיים המלא. תרחיש טוב כולל יצירת job, הרצתו על ידי worker אמיתי (או simulator שלו), אימות שהתוצאה נשמרה נכון, ולאחר מכן - הדמיית כישלון (לדוגמה, ניתוק רשת מדומה מול שירות חיצוני) כדי לוודא שה-retry וה-idempotency אכן עובדים כמצופה ולא יוצרים תופעות לוואי. צוותים בשלים גם מריצים "load test" ייעודי לתור - שולחים אלפי jobs בבת אחת ובודקים איך המערכת מתנהגת תחת עומס: האם ה-autoscaling של ה-workers מגיב מהר מספיק, והאם ה-backpressure עוצר בעדינות במקום לקרוס.

טעויות נפוצות בפרודקשן

  • אין timeout ל-job: worker שנתקע (למשל בקריאת רשת שלא חוזרת) חוסם slot עיבוד לנצח - צריך timeout מפורש לכל job, עם סימון ככישלון אם הוא חורג.
  • Payload גדול מדי בתור: שולחים בגוף ה-job נתונים כבדים (קובץ שלם, אובייקט ענק) במקום לשלוח רק מזהה (reference) ולתת ל-worker למשוך את הנתון בעצמו ממקור חיצוני - זה מעמיס את הברוקר ומאט את כל התור.
  • אין ניטור לעומק תור: בלי alert על עומק תור חורג, מגלים שיש בעיה רק כשמשתמשים מתלוננים שהמייל שלהם לא הגיע אחרי שעתיים.
  • Retry בלי exponential backoff: ניסיון חוזר מיידי ורצוף מציף שירות downstream שכבר מתקשה, במקום לתת לו זמן להתאושש.

מתי כדאי, מתי לא, וסיכום

Background Processing הוא הבחירה הנכונה כמעט בכל פעולה שלוקחת יותר מ-1-2 שניות, שלא חייבת תשובה מיידית מדויקת, או שכוללת תלות בשירות חיצוני לא-אמין (ספק מייל, API תשלומים, שירות תמלול). הוא פחות מתאים לפעולות קריאה פשוטות שצריכות תשובה מיידית ומדויקת (בדיקת יתרה לפני חיוב), או למערכות קטנות מאוד שבהן התקורה התפעולית של תור ו-workers נפרדים לא מצדיקה את המורכבות הנוספת - שם עדיין אפשר להתחיל בפשטות עם תור פשוט מבוסס מסד נתונים בטרם קופצים ישר ל-Kafka. הכלל המעשי: אם הפעולה יכולה להיכשל בגלל תלות חיצונית, ואתם לא רוצים שהכישלון הזה יתרגם ל-504 למשתמש - זה סימן ברור שהיא שייכת לרקע, לא לנתיב הסינכרוני. בפרויקטים שאנחנו בונים במדיה דיל, השאלה הראשונה שאנחנו שואלים על כל פעולה חדשה היא בדיוק זו - מה קורה למשתמש אם הצעד הזה ייקח כמה שניות נוספות, ומה קורה אם הוא ייכשל זמנית; אם התשובה לשתי השאלות היא "אין בכך נזק, קיים מנגנון התאוששות", זהו סימן ברור שהפעולה שייכת לעולם העיבוד האסינכרוני. לקריאה משלימה על הברוקר עצמו, כדאי לעיין במאמר על Message Broker Architecture ובמאמר הממוקד על Job Queue Architecture.

תגיות: Background Processing · Job Queue · Idempotency · Dead Letter Queue · Backpressure · Worker Pool · Async Processing

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