اعمل Job Queue للإيميلات بـ BullMQ وRedis
هتقلل انتظار المستخدم من 8 ثواني تقريبًا إلى أقل من نصف ثانية لما تنقل إرسال الإيميل من طلب HTTP إلى Job Queue واضحة وقابلة للقياس.
مستوى القارئ: متوسط
المشكلة باختصار
لو المستخدم ضغط زر تسجيل، والسيرفر بدأ يرسل إيميل الترحيب داخل نفس طلب HTTP، فالطلب كله هيستنى خدمة البريد. لو مزود البريد اتأخر 6 أو 8 ثواني، المستخدم هيشوف تحميل طويل رغم إن التسجيل نفسه خلص.
الطريقة دي بتفشل أكثر لما عندك حملة تسجيل، أو 50K زائر في اليوم، أو مزود بريد بيرفض بعض الطلبات مؤقتًا. بدل ما تجعل تجربة المستخدم رهينة خدمة خارجية، حط المهمة في Queue، ورد على المستخدم فورًا، وخلي Worker ينفذ الإرسال في الخلفية.
الفكرة بمثال بسيط
ركز في المثال ده: عندك مطعم صغير. الكاشير لا يدخل المطبخ ليطبخ الطلب بنفسه. هو يسجل الطلب بسرعة، يعطي العميل رقمًا، ثم المطبخ ينفذ. الـ API هنا هو الكاشير، وRedis هو دفتر الطلبات، والـ Worker هو المطبخ.
علميًا، الـ Job Queue تفصل استقبال الطلب عن تنفيذ المهمة البطيئة. BullMQ يستخدم Redis كطبقة تخزين وتنسيق للـ jobs. الـ API يضيف job باسم واضح، والـ Worker يسحبها وينفذها. لو المهمة فشلت، تقدر تستخدم attempts وbackoff بدل ما تضيع المهمة أو تعيدها بعشوائية.
الملفات المطلوبة
الافتراض إن عندك Node.js 20، وDocker، وتطبيق صغير يرسل إيميل بعد التسجيل. هنبدأ بمجلد جديد اسمه email-queue-demo.
mkdir email-queue-demo
cd email-queue-demo
npm init -y
npm install bullmq ioredis
أضف Redis محليًا بملف docker-compose.yml:
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: ["redis-server", "--appendonly", "yes"]
التشغيل:
docker compose up -d
الـ trade-off هنا واضح: Redis يضيف مكونًا جديدًا لازم تراقبه وتعمل له backup لو jobs مهمة. المكسب إن طلب HTTP يبقى سريع، والمهام البطيئة تبقى قابلة للإعادة والتوسيع.
أضف Job من الـ API
اعمل ملف producer.js. في تطبيق حقيقي، الكود ده يكون داخل endpoint التسجيل بعد حفظ المستخدم في قاعدة البيانات.
const { Queue } = require('bullmq');
const emailQueue = new Queue('emails', {
connection: { host: '127.0.0.1', port: 6379 }
});
async function main() {
await emailQueue.add(
'welcome-email',
{ userId: 42, email: 'user@example.com' },
{
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: 1000,
removeOnFail: 5000
}
);
console.log('job queued, respond to user now');
await emailQueue.close();
}
main().catch(console.error);
بالظبط كده، الـ API لا يرسل الإيميل الآن. هو فقط يسجل المهمة. لو مزود البريد وقع دقيقة، المستخدم لا ينتظر دقيقة. المهمة ستفشل ثم تعاد بمحاولات تدريجية.
شغل Worker منفصل
اعمل ملف worker.js. هنا هنحاكي إرسال الإيميل بزمن انتظار 2 ثانية. استبدل الدالة لاحقًا بـ SendGrid أو SES أو Resend.
const { Worker } = require('bullmq');
async function sendEmail(job) {
console.log('sending email to', job.data.email);
await new Promise(resolve => setTimeout(resolve, 2000));
console.log('sent job', job.id);
}
const worker = new Worker('emails', sendEmail, {
connection: { host: '127.0.0.1', port: 6379 },
concurrency: 5
});
worker.on('completed', job => {
console.log(`completed ${job.id}`);
});
worker.on('failed', (job, err) => {
console.error(`failed ${job?.id}`, err.message);
});
شغل worker في terminal، والـ producer في terminal تاني:
node worker.js
node producer.js
لو عندك 100 عملية تسجيل في دقيقة، تقدر تزود concurrency إلى 10 أو تشغل أكثر من Worker process. المكسب throughput أعلى. التكلفة ضغط أكبر على مزود البريد وRedis، فلا تزود الرقم بدون rate limit.
القياس المتوقع
في سيناريو واقعي، إرسال إيميل عبر API خارجي قد يأخذ 2 إلى 8 ثواني حسب الشبكة ومزود الخدمة. بعد نقل المهمة إلى Queue، طلب التسجيل غالبًا يرجع في 200 إلى 500ms لأنه لم يعد ينتظر الإرسال.
الرقم هنا ليس وعدًا مطلقًا. هو قياس تقريبي لتطبيق صغير: قبل النقل 8.5 ثانية لأن الطلب ينتظر مزود البريد، وبعد النقل 0.45 ثانية لأن الطلب يضيف job فقط. أفضل طريقة عندك هي قياس p95 قبل وبعد.
ما الذي يجب الانتباه له
- Idempotency: لا ترسل نفس الإيميل مرتين لو job اتعاد. استخدم مفتاح مثل
welcome:userIdأو سجل حالة الإرسال في قاعدة البيانات. - المراقبة: راقب عدد jobs الفاشلة والمتأخرة. Queue بدون dashboard أو alert تتحول لمكان تخبئ فيه الأخطاء.
- الأولوية: لا تخلط إيميلات الترحيب مع فواتير الدفع في نفس queue لو الفواتير أهم.
- إغلاق worker: في الإنتاج، تعامل مع SIGTERM حتى لا تقطع job في منتصف الإرسال أثناء deploy.
الـ trade-off النهائي: أنت تكسب سرعة وتجربة مستخدم أفضل وإعادة محاولات منظمة. وتخسر بساطة التشغيل، لأن عندك Redis وWorker ومراقبة إضافية.
متى لا تستخدم هذه الطريقة
لا تستخدم BullMQ لو المهمة لازم تنتهي قبل رد HTTP، مثل التحقق الفوري من كود دفع داخل نفس العملية. ولا تستخدمه لو عندك مشروع صغير جدًا يرسل 5 إيميلات في اليوم وتأخير الإرسال لا يضر المستخدم. في الحالة دي، التعقيد أعلى من المكسب.
كذلك لا تعتمد على Redis محلي بدون persistence لو فقدان jobs يسبب مشكلة مالية أو قانونية. استخدم إعداد Redis موثوق، أو خدمة managed، أو Queue مخصصة مثل SQS لو فريقك يعمل أصلًا على AWS.
مصادر اعتمدت عليها
- BullMQ Queues documentation
- BullMQ retrying failing jobs
- Docker Compose services reference
- Redis data structures documentation
الخطوة التالية
افتح أبطأ endpoint عندك، واكتب بجانبه قائمة بالمهام التي لا يحتاجها المستخدم قبل الرد. اختر مهمة واحدة فقط، غالبًا إرسال إيميل أو توليد PDF، وانقلها إلى BullMQ بنفس النمط السابق ثم قارن p95 قبل وبعد.