برنتی / می‌نویسم که فکر کنم

کافکا، RabbitMQ یا صف Redis؟ مقایسه با یک مثال ساده

1405.07.03·سیستم دیزاین

سؤال «کافکا بهتر است یا RabbitMQ؟» را زیاد می‌شنوم، و معمولاً جواب کوتاهش این است که سؤال درست پرسیده نشده. این دو ابزار، و صف‌هایی که روی Redis ساخته می‌شوند، ظاهر مشابهی دارند: یک طرف پیام می‌فرستد و طرف دیگر آن را برمی‌دارد. ولی مدل ذهنی پشت هر کدام فرق دارد و همین تفاوت تعیین می‌کند کدام به کار شما می‌آید.

به نظرم بهترین راه فهمیدن این تفاوت، یک مثال واقعی است. پس یک فروشگاه اینترنتی کوچک را در نظر بگیرید.

سناریو: کاربر سفارش ثبت می‌کند

وقتی کاربری دکمه‌ی «ثبت سفارش» را می‌زند، سرویس سفارش باید سریع جواب بدهد. ولی پشت این یک کلیک چند کار دیگر هم باید انجام شود:

  1. ایمیل و پیامک تأیید برای کاربر فرستاده شود.
  2. انبار موجودی را کم کند، تیم تحلیل داده سفارش را ثبت کند و سیستم پیشنهاد محصول از آن یاد بگیرد.
  3. فاکتور 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 دارید

کدام را انتخاب کنم؟

من معمولاً با سه سؤال تصمیم می‌گیرم:

  1. آیا بعداً به این پیام‌ها نیاز دارم، یا چند سرویس مستقل باید همه‌شان را ببینند؟ اگر جواب مثبت است، کافکا. رویداد «سفارش ثبت شد» یک واقعیت تاریخی است و سرویس‌هایی که هنوز ساخته نشده‌اند هم ممکن است روزی بخواهند آن را بخوانند.
  2. آیا پیام یک کار است که باید یک‌بار انجام شود و منطق مسیریابی دارد؟ اگر بله، RabbitMQ. ایمیل و پیامکی که باید بر اساس نوع کاربر به صف‌های مختلف برود، مثال خوبی است.
  3. آیا فقط می‌خواهم یک کار سنگین از مسیر درخواست بیرون برود، با retry و زمان‌بندی؟ اگر بله و Redis را از قبل دارید، صف Redis. ساختن فاکتور و یادآوری پرداخت دقیقاً همین‌اند.

در عمل هم لازم نیست فقط یکی را انتخاب کنید. در همین فروشگاه فرضی کاملاً منطقی است که رویداد سفارش در کافکا ثبت شود، یک consumer از آن بخواند و کار ارسال پیامک را در RabbitMQ بگذارد، و سرویس فاکتور هم کارهای داخلی‌اش را با BullMQ مدیریت کند. اشتباه رایج، برعکسِ این است: کافکا را فقط به این دلیل که «مقیاس‌پذیر است» برای یک صف ساده‌ی ارسال ایمیل راه بیندازید و بعد retry را با دست بسازید، یا تاریخچه‌ی رویدادهای کسب‌وکار را به صفی بسپارید که بعد از خواندن پاکشان می‌کند.

§ فهرست « سیستم دیزاین »
  1. 1405.07.03دیتابیس ستونی و OLAP: چرا گزارش سالانه را نباید از دیتابیس اپلیکیشن گرفتاز یک گزارش سالانه که دیتابیس فروشگاه را از کار می‌اندازد شروع می‌کنم و به تفاوت بار کاری OLTP و OLAP می‌رسم: دیتابیس ستونی مثل ClickHouse داده را چطور روی دیسک می‌چیند، چرا کوئری‌های تحلیلی روی آن چند ده برابر سریع‌تر است و چه چیزهایی را در ازایش از دست می‌دهید.
  2. 1405.07.03View در دیتابیس: وقتی سه تیم یک عدد را سه‌جور حساب می‌کننداز یک مسئله‌ی واقعی در طراحی سیستم شروع می‌کنم که در آن چند سرویس و تیم یک کوئری مشترک را هر کدام به شکل خودشان می‌نویسند و به view می‌رسم: view چیست، چه چیزی را حل می‌کند، materialized view کجا به کار می‌آید و در پروژه‌ی واقعی چطور باید ساخت و نگه‌داری‌اش کرد.
  3. 1405.07.03Bloom Filter: بررسی عضویت در مجموعه‌های بزرگ با حافظه‌ی کمBloom filter با چند بیت برای هر عضو جواب می‌دهد که یک مقدار «قطعاً در مجموعه نیست» یا «شاید هست». در این نوشته سازوکار، ریاضی انتخاب اندازه، یک پیاده‌سازی تست‌شده، محدودیت‌ها و جاهایی که در دیتابیس‌ها و CDNها به کار رفته را مرور می‌کنم و می‌گویم کجا در طراحی یک سیستم واقعی دردسر درست می‌کند.
  4. 1405.07.03ZooKeeper را هنوز باید بشناسیم؟ هماهنگی در سیستم‌های توزیع‌شدهکافکا دیگر به ZooKeeper نیاز ندارد، ولی مسئله‌ای که ZooKeeper حل می‌کرد سر جایش است. در این نوشته می‌گویم ZooKeeper چطور کار می‌کند، کجا هنوز به کار می‌آید و مهندس ارشد دقیقاً چه چیزی از آن باید بداند.
  5. 1405.07.03کافکا، RabbitMQ یا صف Redis؟ مقایسه با یک مثال سادههر سه ابزار پیام را از یک سرویس به سرویس دیگر می‌رسانند، ولی برای کارهای متفاوتی ساخته شده‌اند. در این نوشته با یک فروشگاه اینترنتی ساده نشان می‌دهم هر کدام کجا به کار می‌آید و کجا نه.
  6. 1405.07.03طراحی سرویس کوتاه‌کننده‌ی لینک: از یک سرور ساده تا مقیاس میلیاردیطراحی URL Shortener پرتکرارترین سؤال مصاحبه‌های طراحی سیستم است. در این نوشته قدم‌به‌قدم از ساده‌ترین نسخه با یک سرور و یک دیتابیس شروع می‌کنیم و تا معماری توزیع‌شده با کش، تولید شناسه‌ی توزیع‌شده، شاردینگ و آنالیتیکس جلو می‌رویم و مزایا و معایب هر مرحله را می‌سنجیم.