المستوى المطلوب: متوسط. لو انت مبتدئ، مكمّل معانا عادي — كل مفهوم هنا متشرح بمثال بسيط الأول، وبعده بالشكل التقني الدقيق.
خلّي الطلب يرد فورًا وسيب الشغل التقيل للطابور
لو الـ endpoint اللي بيعمل تسجيل مستخدم بياخد 30 ثانية عشان بيبعت إيميل ترحيب ويعالج صورة، المستخدم بيقعد يبصّ في شاشة بيضا، وتحت الضغط بتبدأ أخطاء 504 timeout. الحل مش سيرفر أقوى. الحل إنك تفصل الشغل التقيل عن الطلب: الـ API يضيف "مهمة" في طابور ويرد فورًا، وعملية تانية (Worker) تنفّذها في الخلفية.
المشكلة باختصار
تخيّل مطعم. الكاشير اللي بياخد الطلب هو نفسه اللي بيطبخ. كل زبون لازم يستنى الأكل يخلص قبل ما الكاشير يشوف اللي بعده. الطابور بيطول والناس بتزهق. الحل البديهي: الكاشير ياخد الطلب، يعلّقه في مشبك المطبخ، ويقول "تحت التنفيذ"، والطباخين ورا يشتغلوا بالتوازي.
بالتفاصيل التقنية: Node.js بيشتغل على event loop بخيط واحد. أي شغل متزامن تقيل (إرسال SMTP، معالجة صورة بـ Sharp، توليد PDF) بيحجز الخيط ويمنع باقي الطلبات من الرد. لما تحوّل الشغل ده لمهمة في طابور، الـ API بيرجع رد 202 Accepted في مللي ثانية، والـ Worker بيقرأ المهمة من Redis وينفّذها في عملية منفصلة.
الافتراض هنا: إن شغلك التقيل (إيميلات، صور، تقارير) مش لازم يخلص قبل ما ترد على المستخدم، وإن Redis واحد يكفي لحملك — وده صحيح لعشرات آلاف المهام في الدقيقة على جهاز متواضع. لو محتاج نتيجة فورية في نفس الطلب، ده سيناريو تاني نتكلم عنه في الآخر.
الخطوات: ابنيه في أقل من 60 سطر
هنستخدم BullMQ — أشهر مكتبة طوابير في Node.js، مبنية فوق Redis. السيناريو: موقع فيه 50 ألف تسجيل في اليوم، كل تسجيل بيبعت إيميل ترحيب.
- شغّل Redis وثبّت المكتبة. Redis هو مخزن المهام. أسرع طريقة بـ Docker.
docker run -d --name redis -p 6379:6379 redis:7
npm install bullmq- عرّف الطابور (Producer side). ده اللي الـ API هيستخدمه عشان يضيف مهام.
// queue.js
import { Queue } from 'bullmq';
const connection = { host: '127.0.0.1', port: 6379 };
export const emailQueue = new Queue('emails', { connection });- ضيف المهمة من الـ endpoint وارجع فورًا. لاحظ
202وattemptsوbackoff.
// routes/signup.js
import { emailQueue } from '../queue.js';
app.post('/signup', async (req, res) => {
const user = await createUser(req.body); // شغل سريع وضروري
await emailQueue.add('welcome', { // شغل تقيل -> للطابور
userId: user.id,
email: user.email,
}, {
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: 1000,
removeOnFail: 5000,
});
res.status(202).json({ status: 'queued' }); // الرد في ~200ms
});- اكتب الـ Worker اللي بينفّذ. عملية منفصلة، بتسحب المهام وتشتغل عليها بالتوازي.
// worker.js
import { Worker } from 'bullmq';
const connection = { host: '127.0.0.1', port: 6379 };
const worker = new Worker('emails', async (job) => {
const { email } = job.data;
await sendWelcomeEmail(email); // شغلك الحقيقي هنا
}, { connection, concurrency: 5 });
worker.on('completed', (job) => console.log(`done ${job.id}`));
worker.on('failed', (job, err) =>
console.error(`failed ${job?.id}: ${err.message}`));- شغّل الـ Worker في عملية مستقلة عن سيرفر الـ API:
node worker.js. كده تقدر تعمل scale للـ Workers لوحدها من غير ما تلمس الـ API.
إعادة المحاولة الذكية: ليه الـ Backoff مهم
لو خدمة الإيميل وقعت لثانية، الطريقة الغلط إنك تعيد المحاولة فورًا 5 مرات ورا بعض — ده بيقصف الخدمة المعطوبة وهي بتحاول تقوم. الطريقة الصح: باعد بين المحاولات بشكل متزايد. ده اسمه exponential backoff، والمعادلة: delay = base × 2^(attempt-1).
في الكود فوق، backoff: { type: 'exponential', delay: 1000 } بيخلّي BullMQ يستنى ثانية، بعدين 2، بعدين 4، لحد 5 محاولات. المهمة اللي فشلت كلها بتروح لـ "failed" وتفضل محفوظة عشان تراجعها، مش بتضيع في الهوا.
التحكم في التزامن (Concurrency)
الرقم concurrency: 5 معناه إن الـ Worker الواحد بينفّذ 5 مهام في نفس الوقت. لو رفعته لـ 50، هتخلّص أسرع بس هتحط ضغط أكبر على خدمة الإيميل وممكن توصل لحدود المعدل بتاعتها. الـ trade-off هنا: إنتاجية أعلى مقابل ضغط أكبر على الخدمات الخارجية. ابدأ بـ 5، وارفعه بالتدريج وانت بتراقب معدل الفشل.
التحقق من أنه يعمل
ابعت طلب /signup، وقيس زمن الرد — المفروض ينزل من ثوانٍ لـ ~200ms. وشوف المهام في Redis:
# الطلب رد بسرعة؟
curl -w "\ntime: %{time_total}s\n" -X POST localhost:3000/signup \
-H "Content-Type: application/json" -d '{"email":"a@b.com"}'
# المهام موجودة في Redis؟
redis-cli keys "bull:emails:*"الأرقام والـ trade-offs
المكسب: زمن رد ثابت ومنخفض (من ~30 ثانية لـ ~200 مللي ثانية في مثالنا)، ومقاومة للفشل (المهمة بتتعاد أوتوماتيك)، وقدرة على استيعاب موجات الضغط لأن المهام بتتخزّن وتتنفّذ بالتدريج. الخسارة: تعقيد تشغيلي — دلوقتي عندك Redis لازم تراقبه وعملية Worker تانية تشغّلها وتتابعها. وكمان النتيجة بقت eventual: المستخدم بيعرف إن الإيميل "في الطريق"، مش "اتبعت دلوقتي".
متى لا تستخدم هذه الطريقة
متعملش طابور لو نتيجة العملية لازم ترجع في نفس الطلب — زي التحقق من دفعة قبل ما تأكد الأوردر. وكمان لو حملك بسيط جدًا (بضع طلبات في الدقيقة) والعملية سريعة أصلًا، إضافة Redis وWorker بتزوّد التعقيد من غير مكسب حقيقي. القاعدة: الطابور بيحل مشكلة الشغل التقيل غير الفوري، مش كل شغل.
الخطوة التالية
افتح أبطأ endpoint عندك، وحدّد أتقل عملية فيه (غالبًا إرسال إيميل أو معالجة صورة). حوّلها لسطر واحد queue.add(...)، وشغّل worker.js في عملية منفصلة، وقيس زمن الرد قبل وبعد. لو نزل بشكل واضح، كرّر نفس النمط على باقي العمليات التقيلة.
المصادر
- توثيق BullMQ الرسمي (Queues / Workers): docs.bullmq.io
- إعادة المحاولة والـ Backoff في BullMQ: docs.bullmq.io/guide/retrying-failing-jobs
- Node.js — لا تحجب الـ Event Loop: nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop
- Exponential Backoff and Jitter — AWS Architecture Blog: aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter
- رمز الحالة 202 Accepted — MDN: developer.mozilla.org/.../Status/202
- توثيق Redis الرسمي: redis.io/docs/latest