הרצת שינוי גודל תמונות בתוך נתיב (route) של Express היא מתכון לאסון. משתמש מעלה תמונה בת עשרת מגה-בייט, השרת שלך מתחיל לעבד פיקסלים, ושלושים שניות מאוחר יותר הבקשה פגה (timeout). תורים לעבודות רקע (background job queues) קיימים בדיוק כדי למנוע את סוג הכאב הזה. באקו-סיסטם של Node.js, Bull ו-BullMQ הפכו לשני הכבדים בתחום לניהול עבודה אסינכרונית באמצעות Redis. הם חולקים DNA משותף אך נבדלים זה מזה באופן חד בגישה ובארגונומיה של העבודה היומיומית. הבחירה בנכון היא קריטית, כי מעבר מאוחר יותר אינו רק עדכון חבילה פשוט.
הבסיס המשותף
שתי הספריות משתמשות ב-Redis כעמוד השדרה שלהן. Redis מטפל בפעולות אטומיות, ב-sorted sets עבור עבודות מושהות (delayed jobs), וב-pub/sub עבור אירועים. אם אתם כבר מריצים Redis לצורך caching או sessions, הוספת תור עבודות אינה דורשת תשתית חדשה. גם Bull וגם BullMQ תומכים בעדיפויות (priorities), ניסיונות חוזרים עם backoff, בקרת מקביליות (concurrency controls) ועבודות חוזרות (repeatable jobs). החפיפה הזו הופכת את הבחירה לקשה יותר, לא קלה יותר. אי אפשר פשוט להסתמך על רשימת תכונות. במקום זאת, עליכם לבחון כיצד כל ספרייה מצפה מכם לבנות את הקוד שלכם.
Bull: הוותיקה שעברה מבחן קרב
Bull קיימת כבר שנים ורצה באלפי אפליקציות בסביבת production. היא עובדת. ה-API עוטף הכל בתוך מופע (instance) יחיד של Queue. אתם יוצרים מופע שלו, מגדירים פונקציית עיבוד ומקשיבים לאירועים – הכל על אותו אובייקט. העיצוב המוניליטי הזה מרגיש מוכר אם אתם מגיעים מדפוסי Node.js ישנים יותר. בסיסי קוד שקדמו לתפוצה הרחבה של async/await מתאימים ל-Bull באופן טבעי, כיוון שהיא גדלה לצד callbacks ולקוחות Redis מוקדמים יותר.
החיסרון הוא צימוד הדוק (tight coupling). כאשר שרת ה-API שלכם יוצר עבודה, הוא מייבא את אותו אובייקט Queue שמכיל את לוגיקת ה-worker. בפועל, זה אומר שתהליך ה-web שלכם גורר איתו תלויות (dependencies) שהוא לעולם לא מריץ. זה לא פגם קטלני, אבל זה מפריע לארכיטקטורה נקייה. בעומסי עבודה פשוטים, אולי לעולם לא תשימו לב. בצוותים גדולים עם עשרות מודולים, החיכוך מצטבר.
BullMQ: בנייה מחדש מהיסוד
BullMQ היא היורשת הרשמית. היא נכתבה מחדש ב-TypeScript מהיום הראשון, כך שהטיפוסים (types) אינם תוספת מאוחרת שנוספה לקוד ה-JavaScript. ה-API מחלק את האחריות למחלקות (classes) נפרדות. Queue מטפלת בהוספת עבודות. Worker מטפל בעיבודן. QueueEvents מטפל ב-observability. ההפרדה הזו משקפת את האופן שבו מערכות מבוזרות מודרניות פועלות בפועל. ה-pods של ה-API שלכם זקוקים רק למחלקת Queue ולחיבור ל-Redis. ה-pods של ה-worker שלכם מייבאים את מחלקת Worker. הגבול הוא פיזי, לא רק קונספטואלי.
השינוי הזה משתלם בצוותים גדולים. מפתח שפורסם פיצ'ר חדש יכול להכניס עבודה לתור (enqueue) מבלי לדעת איזה קובץ מכיל את המעבד (processor). הקומפיילר תופס אי-התאמות בטיפוסים בין נתוני העבודה לבין ה-handlers בשלב מוקדם, במקום בזמן ריצה (runtime). גם ה-API של async/await מרגיש טבעי ב-Node.js מודרני. לא תמצאו את עצמכם נלחמים במוסכמות ישנות (legacy).
זרימת עבודות: מ"טריקים" לאזרחים במעמד ראשון
תהליכי עבודה רב-שלביים חושפים את הפער הרחב ביותר בין שתי הספריות.
נניח שאתם בונים תהליך (pipeline) של הפקת חשבוניות עבור אי-קומרס. לקוח מבצע רכישה. עליכם לשמור מלאי, לחייב כרטיס אשראי, להפיק PDF ולשלוח אימייל. ב-Bull, שרשור השלבים הללו דורש ניהול ידני. ייתכן שמעבד אחד יפעיל את העבודה הבאה, תוך העברת מצב (state) דרך Redis או דרך נתוני עומס (payloads) כבדים. אתם כותבים את התיאום בין "אב" ל"בן" בעצמכם. זה עובד, עד שזה מפסיק לעבוד. לוגיקת הניסיונות החוזרים הופכת למסורבלת. אם שלב ה-PDF נכשל, ביטול החיוב דורש קוד פיצוי (compensation code) מותאם אישית שקל לטעות בו.
BullMQ מציגה את FlowProducer. אתם מגדירים עץ של עבודות שבו ה"הורים" ממתינים אוטומטית ל"ילדים" שלהם. בדוגמת החשבונית, אתם יוצרים עבודת שורש בשם finalize-order עם שלושה ילדים: reserve-inventory, charge-payment, ו-generate-pdf. אתם יכולים להפוך את הודעת האימייל לילד של עבודת ה-PDF. Redis שומר את מבנה הגרף. ה"הורה" מופעל רק כאשר כל התלות מצליחה. אם ילד אחד נכשל, כל הענף נעצר. אתם לא צריכים לכתוב לולאות בדיקה (polling loops) או מפעילים רקורסיביים של עבודות. זה לא רק "סוכר תחבירי" (syntactic sugar). זה משנה את האופן שבו אתם ממדלים (model) לוגיקה עסקית.
הגבלת קצב (Rate Limiting): כלי גס מול סכין מנתחים
שתי הספריות יכולות להגביל את קצב העבודה (throughput), אך רמת הדיוק (granularity) שונה בתכלית.
Bull מחיל מגבלות קצב (rate limits) לכל תור. אם תגדירו תור לעיבוד של מאה משימות בשנייה, התקרה הזו חלה על כל משימה בתור באופן שווה. זה בסדר עבור עומסי עבודה הומוגניים, אך זה קורס בפלטפורמות SaaS מרובות-דיירים (multitenant). דמיינו לקוח "רועש" אחד ששופך מיליון משלוחי webhook לתוך תור משותף. המגבלה ברמת התור של Bull פירושה שאינכם יכולים להאט את הדייר הזה מבלי להאט את כולם האחרים. האפשרויות שלכם מכסאות: להקים תורי Redis נפרדים לכל לקוח ולנהל אותם בצורה דינמית, או לקבל את חוסר ההוגנות.
BullMQ מוסיף הגבלת קצב מבוססת קבוצות. אתם מתייגים כל משימה עם מפתח קבוצה, בדרך כלל מזהה דייר או משתמש, ומגדירים מגבלות לכל קבוצה. אותו תור מעבד משימות עבור כל הדיירים, אך המתזמן (scheduler) מגביל כל קבוצה באופן עצמאי. פרץ (burst) של לקוח א' לא ירעיב את לקוח ב'. אתם נמנעים מהתפשטות תורים (queue sprawl) ושומרים על מרחב המפתחות ב-Redis מסודר. עבור פלטפורמות עם חששות מבעיית "השכן הרועש" (noisy-neighbor), זה לבדו יכול להצדיק את המעבר.
ארכיטקטורה נקייה יותר בפועל
ההפרדה בין התור (Queue) לבין העובד (Worker) היא עדינה עד שמתחילים לדבג תקלה בסביבת ייצור (production). ב-Bull, נפוץ לראות קוד ליצירת משימות עמוק בתוך מטפלי נתיבים (route handlers) שגם מייבאים תלויות עיבוד כבדות. BullMQ מאלץ אתכם להחליט איפה העבודה מתבצעת. שרתי האינטרנט שלכם נשארים רזים. קונטיינרים של ה-workers שלכם אורזים בתוכם את הספריות הכבדות, מעבדי התמונות או דפדפנים ללא ממשק גרפי (headless browsers). אם מופיעה דליפת זיכרון, אתם יודעים בדיוק איזה סוג תהליך דורש פרופיילינג (profile). המודל המנטלי קרוב יותר למערכות כמו Celery או Sidekiq.
קבלת ההחלטה
התחילו עם BullMQ אם אתם מתחילים פרויקט חדש (greenfield). הגדרות ה-TypeScript מדויקות ומלאות. זרימות המשימות (job flows) מבטלות ערימות של קוד אורקסטרציה. הגבלת קצב מבוססת קבוצות פותרת בעיות הוגנות עוד לפני שהן מתחילות. ה-API של async/await מרגיש טבעי. יש מעט סיבה לבחור בספרייה הישנה יותר עבור פרויקט greenfield.
הישארו ב-Bull אם הוא כבר עובד. מעברים (migrations) עולים בזמן ומסכנים את היציבות. אם המשימות שלכם שטוחות ועצמאיות, אתם לא מפספסים פיצ'רים שאתם באמת צריכים. תור ששולח אימיילים לאיפוס סיסמה ומקטין תמונות פרופיל אינו זקוק לגרפי זרימה (flow graphs). כתיבה מחדש של קוד עובד למען טוהר תיאורטי אינה הנדסה. זו תחביבנות.
בדיקת מציאות של המעבר
אם תחליטו לעבור, התייחסו לכך כשינוי בתשתית, ולא כריפקטורינג של קוד. Bull ו-BullMQ משתמשים בסכימות מפתחות Redis שונות. הם לא יכולים לקרוא את נתוני המשימות או את המצב (state) של זה. אי אפשר פשוט להפעיל feature flag ולקוות שהמשימות הישנות יסתיימו. עליכם לרוקן כל תור קיים עד לאפס, לפרוס את ה-workers החדשים, ולהתחיל להכניס משימות (enqueueing) עם BullMQ. תכננו חלון תחזוקה או פריסת blue-green שבה ה-workers הישנים צורכים את התור הישן בזמן שה-workers החדשים מטפלים בתור החדש.
