سؤال «کافکا بهتر است یا RabbitMQ؟» را زیاد میشنوم، و معمولاً جواب کوتاهش این است که سؤال درست پرسیده نشده. این دو ابزار، و صفهایی که روی Redis ساخته میشوند، ظاهر مشابهی دارند: یک طرف پیام میفرستد و طرف دیگر آن را برمیدارد. ولی مدل ذهنی پشت هر کدام فرق دارد و همین تفاوت تعیین میکند کدام به کار شما میآید.
به نظرم بهترین راه فهمیدن این تفاوت، یک مثال واقعی است. پس یک فروشگاه اینترنتی کوچک را در نظر بگیرید.
سناریو: کاربر سفارش ثبت میکند
وقتی کاربری دکمهی «ثبت سفارش» را میزند، سرویس سفارش باید سریع جواب بدهد. ولی پشت این یک کلیک چند کار دیگر هم باید انجام شود:
- ایمیل و پیامک تأیید برای کاربر فرستاده شود.
- انبار موجودی را کم کند، تیم تحلیل داده سفارش را ثبت کند و سیستم پیشنهاد محصول از آن یاد بگیرد.
- فاکتور PDF ساخته شود و اگر کاربر تا یک ساعت پرداخت نکرد، یادآوری برایش برود.
هیچکدام از این کارها نباید کاربر را منتظر نگه دارد، پس همه باید ناهمگام انجام شوند. سؤال این است که هر کدام را با چه ابزاری بفرستیم. من در ادامه برای هر ابزار همان کاری را انتخاب کردهام که بیشتر از بقیه با طبیعتش جور است.
کافکا: دفتر ثبت رویدادها
کافکا در اصل صف نیست، یک لاگ است. پیامها به ترتیب به انتهای یک topic اضافه میشوند و بعد از خوانده شدن پاک نمیشوند. تا زمانی که مدت نگهداری (retention) تمام نشده، روی دیسک میمانند. هر مصرفکننده فقط یک عدد نگه میدارد به اسم offset، یعنی «تا کجا خواندهام».
همین طراحی کار دوم سناریو را ساده میکند. سرویس سفارش فقط اعلام میکند «سفارش ثبت شد» و اصلاً لازم نیست بداند چه کسی گوش میدهد:
import { Kafka } from "kafkajs";
const kafka = new Kafka({ clientId: "order-service", brokers: ["localhost:9092"] });
const producer = kafka.producer();
await producer.connect();
await producer.send({
topic: "orders",
messages: [
// کلید یکسان یعنی همهی رویدادهای یک سفارش به یک partition میروند و ترتیبشان حفظ میشود
{ key: String(order.id), value: JSON.stringify({ type: "OrderPlaced", ...order }) },
],
});
حالا انبار، تحلیل داده و سیستم پیشنهاد هر کدام با یک groupId جدا همین topic را میخوانند:
const consumer = kafka.consumer({ groupId: "analytics" });
await consumer.connect();
await consumer.subscribe({ topic: "orders", fromBeginning: true });
await consumer.run({
eachMessage: async ({ partition, message }) => {
const event = JSON.parse(message.value!.toString());
await saveToWarehouse(event);
},
});
چند نکته در این کد هست که با دو ابزار دیگر فرق دارد:
- هر گروه همهی پیامها را میگیرد. اگر فردا تیم کشف تقلب هم این رویدادها را بخواهد، فقط یک consumer با
groupIdتازه میسازد. سرویس سفارش دست نمیخورد. - میشود گذشته را دوباره خواند. اگر در کد تحلیل داده باگی بود، آن را درست میکنید و offset را به عقب برمیگردانید تا همهچیز از نو پردازش شود.
fromBeginning: trueهم یعنی گروهی که تازه اضافه شده، از قدیمیترین پیام موجود شروع کند. - ترتیب فقط داخل هر partition تضمین میشود. به همین دلیل آیدی سفارش را کلید گذاشتم. موازیسازی هم به تعداد partitionها محدود است: در یک گروه، بیشتر از تعداد partitionها consumer فعال نخواهید داشت.
در عوض، کافکا برای «این یک پیام را به یک worker بده و اگر شکست خورد، ده ثانیه بعد دوباره امتحان کن» ابزار خوبی نیست. offset برای کل partition جلو میرود، نه برای تکتک پیامها، پس retry و تأخیر روی یک پیام خاص را باید خودتان بسازید. راهاندازی و نگهداری کلاسترش هم از دو ابزار دیگر سنگینتر است.
RabbitMQ: ادارهی پست هوشمند
RabbitMQ یک message broker کلاسیک است. پیام به یک exchange فرستاده میشود، exchange با قانونهایی که تعریف کردهاید آن را به یک یا چند صف میفرستد، و هر پیام در صف فقط به یک مصرفکننده تحویل داده میشود. وقتی مصرفکننده تأیید (ack) فرستاد، پیام از صف حذف میشود.
کار اول سناریو، یعنی ایمیل و پیامک، دقیقاً همین شکلی است. هر پیام یک «کار» است که باید یکبار و فقط یکبار انجام شود و بعد دیگر به دردی نمیخورد. قدرت اصلی RabbitMQ هم در مسیریابی است. فرض کنید پیامک فقط برای کاربرانی برود که شمارهی تأییدشده دارند:
import amqp from "amqplib";
const conn = await amqp.connect("amqp://localhost");
const ch = await conn.createChannel();
await ch.assertExchange("notifications", "topic", { durable: true });
await ch.assertQueue("email", { durable: true });
await ch.assertQueue("sms", { durable: true });
// صف ایمیل همهی اعلانها را میگیرد، صف پیامک فقط آنهایی که کلیدشان با .sms تمام میشود
await ch.bindQueue("email", "notifications", "order.#");
await ch.bindQueue("sms", "notifications", "order.*.sms");
const routingKey = user.phoneVerified ? "order.placed.sms" : "order.placed";
ch.publish("notifications", routingKey, Buffer.from(JSON.stringify({ orderId: order.id })), {
persistent: true,
});
و سمت مصرفکننده:
await ch.prefetch(10); // بیشتر از ۱۰ پیام تأییدنشده به این worker نده
await ch.consume("sms", async (msg) => {
if (!msg) return;
try {
await sendSms(JSON.parse(msg.content.toString()));
ch.ack(msg);
} catch {
ch.nack(msg, false, false); // دوباره در صف نگذار؛ اگر dead-letter تعریف شده، به آنجا برود
}
});
اینجا ack برای هر پیام جداگانه است. اگر ارسال یک پیامک شکست خورد، فقط همان پیام به صف برمیگردد یا به صف پیامهای مرده (dead-letter) میرود و بقیه کارشان را میکنند. اگر صف شلوغ شد، چند worker دیگر بالا میآورید و RabbitMQ کارها را بینشان پخش میکند.
محدودیتش هم از همین طراحی میآید. پیامی که ack شد، دیگر نیست. اگر سه ماه بعد بخواهید همهی سفارشهای گذشته را دوباره پردازش کنید، RabbitMQ چیزی برای برگرداندن ندارد. البته RabbitMQ از نسخهی 3.9 قابلیتی به اسم Streams هم دارد که رفتاری شبیه لاگ کافکا دارد، ولی مدل اصلی و رایجش همان صف است.
صف روی Redis: ساده و دم دست
خیلی از پروژهها Redis را از قبل برای کش دارند. کتابخانههایی مثل BullMQ در Node.js یا RQ و Celery در پایتون روی همین Redis یک صف کار (job queue) کامل میسازند: تلاش دوباره، تأخیر، اولویت، کارهای زمانبندیشده و داشبورد. زیرساخت جدیدی هم لازم نیست.
کار سوم سناریو برای این ابزار ساخته شده. ساختن فاکتور یک کار پسزمینهی ساده است که ممکن است شکست بخورد، و یادآوری پرداخت یک کار با تأخیر یکساعته است:
import { Queue, Worker } from "bullmq";
const connection = { host: "localhost", port: 6379 };
const jobs = new Queue("order-jobs", { connection });
await jobs.add(
"invoice",
{ orderId: order.id },
{ attempts: 3, backoff: { type: "exponential", delay: 2000 } },
);
await jobs.add(
"payment-reminder",
{ orderId: order.id },
{ delay: 60 * 60 * 1000 }, // یک ساعت بعد اجرا شود
);
new Worker(
"order-jobs",
async (job) => {
if (job.name === "invoice") await generateInvoicePdf(job.data.orderId);
if (job.name === "payment-reminder") await remindIfUnpaid(job.data.orderId);
},
{ connection, concurrency: 5 },
);
همین کار با کافکا یعنی ساختن سازوکار تأخیر از صفر، و با RabbitMQ یعنی درگیر شدن با TTL و dead-letter یا نصب پلاگین. اینجا یک گزینهی delay است.
ولی باید حواستان به دو چیز باشد. اول اینکه Redis داده را در حافظه نگه میدارد و ماندگاری روی دیسک به تنظیم AOF یا RDB بستگی دارد. اگر این تنظیمات را جدی نگیرید، با ریاستارت سرور ممکن است کارهای داخل صف از دست بروند. BullMQ همچنین میخواهد maxmemory-policy روی noeviction باشد، وگرنه Redis وقتی حافظه پر شد، ممکن است کلیدهای صف را پاک کند. دوم اینکه این صفها معمولاً داخل یک اپلیکیشن یا یک تیم استفاده میشوند. برای ستون فقرات ارتباط دهها سرویس، یا برای نگه داشتن تاریخچهی رویدادها، ابزار مناسبی نیستند.
کنار هم
| کافکا | RabbitMQ | صف Redis (مثلاً BullMQ) | |
|---|---|---|---|
| مدل ذهنی | لاگ رویدادها | صف با مسیریابی | صف کار پسزمینه |
| بعد از مصرف | پیام میماند تا retention تمام شود | با ack حذف میشود | با اتمام کار حذف یا آرشیو میشود |
| چند مصرفکنندهی مستقل | بله، با consumer group جدا | با اتصال چند صف به یک exchange | نه بهطور طبیعی |
| خواندن دوبارهی گذشته | بله، با جابهجا کردن offset | خیر (مگر با Streams) | خیر |
| retry و تأخیر برای هر پیام | باید خودتان بسازید | با dead-letter و TTL | آماده، با یک گزینه |
| ترتیب | داخل هر partition | داخل هر صف، با یک مصرفکننده | داخل هر صف، با یک worker |
| هزینهی راهاندازی | بالا | متوسط | کم، اگر Redis دارید |
کدام را انتخاب کنم؟
من معمولاً با سه سؤال تصمیم میگیرم:
- آیا بعداً به این پیامها نیاز دارم، یا چند سرویس مستقل باید همهشان را ببینند؟ اگر جواب مثبت است، کافکا. رویداد «سفارش ثبت شد» یک واقعیت تاریخی است و سرویسهایی که هنوز ساخته نشدهاند هم ممکن است روزی بخواهند آن را بخوانند.
- آیا پیام یک کار است که باید یکبار انجام شود و منطق مسیریابی دارد؟ اگر بله، RabbitMQ. ایمیل و پیامکی که باید بر اساس نوع کاربر به صفهای مختلف برود، مثال خوبی است.
- آیا فقط میخواهم یک کار سنگین از مسیر درخواست بیرون برود، با retry و زمانبندی؟ اگر بله و Redis را از قبل دارید، صف Redis. ساختن فاکتور و یادآوری پرداخت دقیقاً همیناند.
در عمل هم لازم نیست فقط یکی را انتخاب کنید. در همین فروشگاه فرضی کاملاً منطقی است که رویداد سفارش در کافکا ثبت شود، یک consumer از آن بخواند و کار ارسال پیامک را در RabbitMQ بگذارد، و سرویس فاکتور هم کارهای داخلیاش را با BullMQ مدیریت کند. اشتباه رایج، برعکسِ این است: کافکا را فقط به این دلیل که «مقیاسپذیر است» برای یک صف سادهی ارسال ایمیل راه بیندازید و بعد retry را با دست بسازید، یا تاریخچهی رویدادهای کسبوکار را به صفی بسپارید که بعد از خواندن پاکشان میکند.