مستوى المقال: مبتدئ. محتاج تعرف تكتب دالة بسيطة في Node.js وتشغّل أمر في الترمنال. مش محتاج خبرة سابقة في الطوابير (Queues) ولا Redis — هنبدأ من الصفر بمثال واضح قبل أي كود.
اعمل Background Jobs بـ BullMQ و Redis في Node.js
لو زرار "سجّل" في موقعك بيبعت إيميل ترحيب وبياخد 4 ثواني قبل ما المستخدم يشوف أي رد، انت بتخلّي المستخدم يدفع تمن شغل مالوش دعوة يستناه. الحل إنك تفصل الشغل البطيء عن الـ request، وترجّع للمستخدم فورًا. ده اسمه Background Job، وهنبنيه دلوقتي خطوة بخطوة بـ BullMQ و Redis، وننزّل زمن استجابة الـ API من 4200 مللي ثانية لـ 80 مللي ثانية.
المشكلة باختصار
لما المستخدم يعمل أكشن (تسجيل، رفع صورة، طلب فاتورة)، فيه شغل تقيل وراه: إرسال إيميل، توليد PDF، تصغير صورة، نداء API بتاع طرف تالت. لو عملت الشغل ده جوّه نفس الـ request، المستخدم بيفضل مستني لحد ما كل حاجة تخلص. ولو الخدمة الخارجية (زي سيرفر الإيميل) بطيئة أو وقعت، الـ request بتاعك بيقع معاها.
الفكرة: بدل ما تعمل الشغل ده قدام المستخدم، انت بترميه في "طابور" (queue)، وترجّع رد سريع، وبعدين عامل في الخلفية (worker) بياخد الشغل من الطابور وينفّذه على راحته.
قبل الكود: مثال بسيط جداً
تخيّل مطعم وجبات سريعة. لو الكاشير هو اللي يطبخ كل طلب بإيده قبل ما يستقبل العميل اللي بعده، الطابور هيوصل للشارع. اللي بيحصل فعلاً في أي مطعم محترم: الكاشير بياخد الطلب، يكتبه على ورقة، يعلّقها في المطبخ، وفورًا يقول للعميل "تمام، رقمك 47". المطبخ (الطباخ) بيسحب الورق ويطبخ بالترتيب.
هنا الكاشير = الـ API بتاعك. الورقة المعلّقة = المهمة (job) في الطابور. المطبخ = الـ worker. العميل مش بيستنى الأكل يتطبخ عشان ياخد رقمه.
دلوقتي بالشكل العلمي: Background Job هو وحدة شغل بتتنفّذ بشكل غير متزامن (asynchronous) بعيد عن دورة الطلب والرد. الطابور (Queue) بنية بيانات بتشتغل بمبدأ FIFO (الداخل الأول يخرج الأول) بتخزّن المهام لحد ما يبقى فيه worker فاضي يعالجها. وBullMQ مكتبة Node.js بتدير الطوابير دي وبتخزّنها في Redis (قاعدة بيانات في الذاكرة سريعة جداً)، عشان لو السيرفر اتقفل المهام متضيعش.
الخطوات: من صفر لـ worker شغّال
- شغّل Redis. أسهل طريقة بـ Docker:
النتيجة المتوقعة: عندك Redis شغّال على المنفذ 6379.
docker run -d --name redis -p 6379:6379 redis:7 # تأكد إنه شغّال docker exec -it redis redis-cli ping # المفروض يرد: PONG - نصّب المكتبات.
BullMQ بتحتاج اتصال Redis، و express عشان نعمل الـ endpoint.
npm install bullmq ioredis express - اعمل ملف الاتصال المشترك (connection.js) عشان متكررش الإعداد:
// connection.js const connection = { host: '127.0.0.1', port: 6379, }; module.exports = { connection }; - اعمل الـ Producer — ده الـ API اللي بيضيف المهمة للطابور ويرجّع رد فوري:
النتيجة المتوقعة: لما حد يعمل POST على /register، الرد بيرجع في حوالي 80ms من غير ما يستنى الإيميل.
// server.js const express = require('express'); const { Queue } = require('bullmq'); const { connection } = require('./connection'); const app = express(); app.use(express.json()); // طابور اسمه emails const emailQueue = new Queue('emails', { connection }); app.post('/register', async (req, res) => { const { email } = req.body; // 1) خزّن المستخدم في الداتابيز (سريع) // ... كود حفظ المستخدم هنا ... // 2) ارمي مهمة الإيميل في الطابور بدل ما تبعته هنا await emailQueue.add('welcome', { email }, { attempts: 3, // 3 محاولات لو فشل backoff: { type: 'exponential', delay: 2000 }, // 2s, 4s, 8s removeOnComplete: 1000, // احتفظ بآخر 1000 ناجحة بس removeOnFail: 5000, }); // 3) ارجع فوراً — المستخدم مش هيستنى الإيميل res.status(202).json({ ok: true, message: 'تم التسجيل' }); }); app.listen(3000, () => console.log('API on :3000')); - اعمل الـ Worker في ملف منفصل، وشغّله كـ process مستقل:
النتيجة المتوقعة: شغّل node worker.js في ترمنال منفصل عن node server.js. الـ worker هيبدأ يسحب المهام من الطابور وينفّذها.
// worker.js const { Worker } = require('bullmq'); const { connection } = require('./connection'); const worker = new Worker('emails', async (job) => { if (job.name === 'welcome') { // الشغل التقيل الحقيقي هنا await sendEmail(job.data.email, 'أهلاً بيك'); } }, { connection, concurrency: 5, // عالج 5 مهام في نفس الوقت }); worker.on('completed', (job) => { console.log(`job ${job.id} خلص`); }); worker.on('failed', (job, err) => { console.log(`job ${job?.id} فشل: ${err.message}`); }); async function sendEmail(to, subject) { // مثال: نداء SMTP أو API زي Resend / SendGrid // بياخد من 1 لـ 4 ثواني عادةً } - جرّب الفشل التلقائي. خلّي sendEmail ترمي خطأ عمداً مرة، وشوف BullMQ بيعيد المحاولة 3 مرات بفواصل 2 ثم 4 ثم 8 ثواني (exponential backoff) قبل ما يعتبرها failed. ده اللي بيخلّيك تستحمّل تعطّل مؤقت في سيرفر الإيميل من غير ما تخسر المهمة.
سيناريو واقعي بالأرقام
افرض عندك خدمة تسجيل بتستقبل 12 ألف تسجيل في اليوم، وكل تسجيل بيبعت إيميل ترحيب بياخد 4.2 ثانية في المتوسط (نداء SMTP + رندر القالب). قبل الطابور: زمن استجابة /register كان P95 حوالي 4200ms، وفي ساعات الذروة الـ Node process كان بيتقفل لأن كل الـ requests واقفة مستنية الإيميل.
بعد ما نقلنا الإرسال لـ BullMQ: زمن استجابة /register بقى 80ms (بس وقت إضافة المهمة للطابور)، يعني تحسّن حوالي 52 ضعف في الزمن اللي المستخدم بيحسّه. الإيميلات اتبعتت زي ما هي في الخلفية بمعدل concurrency: 5، والـ throughput بقى تقريباً 71 إيميل/دقيقة لكل worker — كفاية للـ 12 ألف، ولو زاد الحمل بتشغّل worker كمان وخلاص.
الافتراض هنا إن الشغل اللي بتأجّله مش لازم المستخدم يشوف نتيجته فوراً. إرسال إيميل ينفع يتأجّل. حساب رصيد المحفظة وقت الدفع لأ — ده لازم يكون متزامن.
الـ trade-offs اللي لازم تعرفها
- بتكسب: زمن استجابة أسرع، تحمّل لتعطّل الخدمات الخارجية، وقدرة على توزيع الحمل على أكتر من worker. بتخسر: تعقيد إضافي — بقى عندك process تاني تشغّله وتراقبه، ومحتاج Redis شغّال دايماً.
- الاتساق صار نهائي (eventual)، مش فوري. المستخدم بياخد رد "اتسجّلت" قبل ما الإيميل يوصل فعلاً. لو الإيميل فشل بعد 3 محاولات، المستخدم مش هيعرف إلا لو عملت آلية تبليغ.
- التكلفة: Redis بياخد رام. لطابور إيميلات عادي، instance صغير بـ 256MB رام (حوالي 3-5 دولار/شهر على VPS) بيكفي لمئات الآلاف من المهام.
- المهام لازم تكون idempotent قدر الإمكان. يعني لو نفس المهمة اتنفّذت مرتين بالغلط (بسبب إعادة محاولة)، متعملش ضرر مزدوج. خلّي sendEmail تتحقق إن الإيميل ده ماتبعتش قبل كده لو ده مهم.
متى لا تستخدم هذه الطريقة
متعملش طابور لو الشغل سريع أصلاً (أقل من 100ms) — التعقيد مش هيستاهل. وكمان متستخدمهوش لو المستخدم لازم يشوف نتيجة العملية فوراً في نفس الشاشة (زي نتيجة عملية دفع، أو تحقّق من كود OTP). الطوابير للشغل اللي ينفع يستنى. لو احتياجك مجرد تشغيل مهمة مجدولة كل يوم الساعة 3 الفجر، cron job أو systemd timer أبسط بكتير من BullMQ.
الخطوة التالية
افتح أبطأ endpoint عندك دلوقتي، وشوف أبطأ حاجة بيعملها — غالباً نداء خارجي (إيميل، SMS، رفع ملف، نداء API). انقل النداء ده لـ BullMQ بنفس النمط اللي فوق، وقيس زمن الاستجابة قبل وبعد بـ console.time. لو الزمن نزل بشكل واضح، انت في الطريق الصح. ابدأ بطابور واحد بس، ومتعقّدش الموضوع قبل ما تحتاج.
المصادر
- التوثيق الرسمي لـ BullMQ — مفاهيم Queue و Worker و Jobs: docs.bullmq.io
- إعدادات إعادة المحاولة و backoff: BullMQ — Retrying Failing Jobs
- توثيق Redis الرسمي: redis.io/docs
- صورة Docker الرسمية لـ Redis: hub.docker.com/_/redis
- مفهوم المعالجة غير المتزامنة في Node.js: Node.js — Asynchronous Programming