בונים מערכת תורים פשוטה - הפרויקט שמונע מהשרת שלכם לקרוס תחת עומס¶
יש רגע מוכר בחיים של כל מפתח צד שרת - endpoint שעובד מצוין בבדיקות, ואז נופל בייצור ברגע שהעומס עולה. לרוב הסיבה היא לא באג, אלא ארכיטקטורה - הבקשה מנסה לעשות יותר מדי דברים בו-זמנית, ומחכה שכולם יסתיימו לפני שהיא עונה. מערכת תורים היא הפתרון הקלאסי לבעיה הזאת, וגם אחד הפרויקטים הכי שימושיים שאפשר לבנות כדי להבין ארכיטקטורת צד שרת אמיתית.
למה בכלל צריך תור¶
תארו לעצמכם endpoint של הרשמה שגם יוצר משתמש במסד הנתונים וגם שולח מייל ברוכים הבאים. אם שליחת המייל לוקחת שתי שניות, המשתמש מחכה שתי שניות מיותרות לתשובה - וזה עוד לפני שדיברנו על מה קורה אם שרת המייל דווקא איטי או זמנית לא זמין באותו רגע. הפתרון הוא להפריד - הבקשה המקורית עושה רק את מה שחייב לקרות מיד (יצירת המשתמש), ומעבירה את מה שאפשר לדחות (שליחת המייל) לתור עבודה שמטפל בזה ברקע, בלי שהמשתמש מחכה לזה בכלל.
מעבר למהירות, תורים גם מגינים מפני עומס פתאומי. אם אלף משתמשים נרשמים בבת אחת, השרת לא צריך לשלוח אלף מיילים במקביל - הוא יכול לדחוף אלף עבודות לתור, ולעבד אותן בקצב שהוא יכול לעמוד בו, בלי לקרוס.
דגם producer-consumer¶
הרעיון המרכזי מאחורי כל מערכת תורים הוא הפרדה בין שני תפקידים. ה-producer הוא הקוד שיוצר עבודה חדשה ודוחף אותה לתור - למשל, ה-endpoint של ההרשמה. ה-consumer הוא תהליך נפרד שמאזין לתור, שולף עבודות אחת אחרי השנייה, ומבצע אותן בפועל. השניים לא מכירים זה את זה ישירות - הם מתקשרים רק דרך התור עצמו. זו בדיוק הסיבה שהדגם הזה כל כך שימושי - אפשר להריץ כמה consumers במקביל כדי להאיץ עיבוד, ואפשר להוסיף או להחליף אותם בלי לגעת בקוד שיוצר את העבודות.
דוגמה פשוטה עם BullMQ ו-Redis¶
בפועל, אין צורך לבנות תור מאפס - ספריות כמו BullMQ (שרצה על גבי Redis) עושות את רוב העבודה הקשה:
const { Queue, Worker } = require("bullmq");
const emailQueue = new Queue("emails", { connection });
// producer - ב-endpoint ההרשמה
await emailQueue.add("welcome", { userId: newUser.id });
// consumer - תהליך נפרד
new Worker("emails", async (job) => {
const user = await db.getUser(job.data.userId);
await sendWelcomeEmail(user);
}, { connection });
ה-endpoint עצמו רק דוחף עבודה לתור וחוזר מיד. ה-worker רץ בתהליך משלו, מטפל בעבודות בקצב שלו, ולא חוסם אף בקשה נכנסת.
מה קורה כשעבודה נכשלת¶
בעולם אמיתי, עבודות נכשלות - שירות מייל חיצוני זמנית לא זמין, קובץ שהועלה פגום, קריאת רשת שנפלה. מערכת תורים טובה לא זורקת עבודה כזאת ושוכחת ממנה, אלא מנסה שוב, בדרך כלל עם השהיה שגדלה בין ניסיון לניסיון (exponential backoff), כדי לא להציף שוב מיד שירות שכבר נפל. חשוב גם להגדיר מספר ניסיונות מקסימלי - עבודה שנכשלת שוב ושוב צריכה בסוף לנחות ב"תור כישלונות" (dead letter queue) שמישהו בודק ידנית, ולא להיעלם בשקט.
מוקש נפוץ - לשכוח שעבודות יכולות לרוץ פעמיים¶
בדיוק כמו ב-webhooks, גם כאן ניסיון חוזר אחרי כישלון יכול ליצור מצב שבו אותה עבודה מתבצעת פעמיים - למשל אם ה-worker נפל אחרי ששלח את המייל אבל לפני שסימן את העבודה כמוצלחת. עבודות טובות בנויות להיות אידמפוטנטיות, כך שגם אם הן רצות פעמיים בטעות, התוצאה הסופית זהה ולא נוצרת כפילות מביכה כמו שני מיילי ברוכים הבאים לאותו משתמש.
איפה בונים את זה בפועל¶
להבין את הדגם זה חלק אחד, אבל להרגיש בעצמכם איך שרת שמפריד עבודה כבדה לתור מתנהג אחרת לגמרי תחת עומס מאשר שרת שעושה הכל בבת אחת - זה קורה רק כשבונים את זה בפרויקט אמיתי. בקורס צד-שרת בונים מערכת תורים אמיתית כחלק מארכיטקטורת צד שרת שלמה.
השרת שלכם קורס תחת עומס ואתם לא בטוחים אם תור יפתור את זה? זה בדיוק סוג השאלה שכדאי לשאול בקהילה שלנו.