Databases & SQL · پایگاه‌داده و SQL متوسطIntermediate ~42 دقیقه مطالعه~35 min read

Kafka، RabbitMQ، Outbox و SagaKafka, RabbitMQ, Outbox & Saga

از صفر یاد می‌گیری که هر بروکر چه چیزی را تضمین می‌کند — RabbitMQ پیام‌ها را تک‌به‌تک مسیریابی و ack می‌کند، Kafka یک لاگِ مرتبِ قابل‌بازپخش است — و چطور at-least-once را با idempotency، الگوی Transactional Outbox و جبران‌سازیِ Saga ترکیب کنی تا سیستمی بسازی که از crash جان سالم به‌در ببرد.Learn from scratch what each broker actually guarantees — RabbitMQ routes and acks messages one by one, Kafka is a replayable ordered log — and how to combine at-least-once delivery with idempotency, the Transactional Outbox, and Saga compensation to build systems that survive crashes.


بیشتر باگ‌های ترسناکِ سیستم‌های بزرگ آنجا زاده می‌شوند که یک پیام از یک سرویس خارج می‌شود و به سرویسِ دیگری می‌رود. این فصل درباره‌ی همان مرز است: چطور دو تکه از نرم‌افزار بدون اینکه هم‌زمان روشن باشند با هم حرف بزنند، و چطور کاری کنی که وقتی چیزی خراب شد — و حتماً خراب می‌شود — سیستم به‌جای گم‌کردنِ سفارش یا دوبار پول‌گرفتن از مشتری، درست بماند.

بیا اول با یک قول شروع کنیم: تا آخرِ این فصل، هر واژه‌ای که در مصاحبه‌ی سنیور می‌شنوی — broker، partition، offset، at-least-once، idempotent، Outbox، Saga، compensation — را نه‌فقط می‌شناسی، بلکه می‌توانی از صفر بسازی و توضیح بدهی.

نقشه‌ی راه

مسیرِ ما این است:

  1. مدلِ ذهنی بروکر — یک بروکر واقعاً چه می‌دهد و دو خانواده‌ی فکری (RabbitMQ در برابر Kafka).
  2. RabbitMQ — exchange و queue و binding، ack/nack، DLQ، prefetch، و دوامِ پیام.
  3. Kafka — partition و offset و consumer group، rebalancing، retention، و تله‌ی commit.
  4. معنای تحویل — at-most-once، at-least-once، exactly-once و چرا idempotency پاسخِ جهانی است.
  5. مسئله‌ی dual-write و راه‌حلش Transactional Outbox.
  6. Saga — تراکنشِ توزیع‌شده بدون 2PC، با جبران‌سازی.
  7. انتخابِ بروکر، دام‌های رایج، و ۱۶ سؤالِ مصاحبه با پاسخِ کامل.

بخش ۰ — واژه‌هایی که باید بلد باشی

قبل از هرچیز، چهار واژه را از ریشه باز کنیم تا بعداً سردرگم نشوی.

بروکر مثل اداره‌ی پست

تصور کن می‌خواهی به دوستت نامه بدهی اما نمی‌دانی الان خانه هست یا نه، و نمی‌خواهی تا خانه‌اش بروی. نامه را به اداره‌ی پست می‌سپاری. تو (فرستنده) و او (گیرنده) هیچ‌وقت لازم نیست هم‌زمان بیدار باشید؛ پست میانِ شما می‌ایستد. یک message broker دقیقاً همان اداره‌ی پست است برای برنامه‌ها.

  • producer (تولیدکننده): برنامه‌ای که پیام می‌فرستد (فرستنده‌ی نامه).
  • consumer (مصرف‌کننده): برنامه‌ای که پیام را می‌گیرد و کاری با آن می‌کند (گیرنده‌ی نامه).
  • idempotent (خنثی‌به‌تکرار): عملی که اگر دوبار انجامش بدهی، نتیجه با یک‌بار فرقی نکند. کلیدِ برق را «خاموش‌کن» idempotent است — هرچند بار بزنی، لامپ خاموش می‌ماند؛ اما «دکمه‌ی پرداختِ ۱۰۰ تومان» idempotent نیست، چون دوبار زدن یعنی ۲۰۰ تومان.
  • at-least-once (حداقل یک‌بار): تضمینِ اینکه پیام حتماً می‌رسد، اما شاید بیش از یک‌بار. این کلمه در کلِ فصل ستون است؛ همین‌جا نگهش دار.
چرا همین اول درد شروع می‌شود

لحظه‌ای که پیام از process تو خارج می‌شود، وارد دنیای سیستم‌های توزیع‌شده شده‌ای: خرابیِ جزئی (partial failure — بخشی کار می‌کند، بخشی نه)، retry (تلاشِ دوباره)، duplicate (تکرار)، و reordering (بازترتیب‌شدنِ پیام‌ها). این چهار شبح تا آخرِ فصل همراهت‌اند.

مدل ذهنی: یک بروکر واقعاً چه چیزی به تو می‌دهد

یک message broker زیرساختی است که producer و consumer را در زمان و مکان از هم جدا می‌کند. در مکان، چون producer نیازی نیست بداند چه کسی مصرف می‌کند؛ در زمان، چون consumer می‌تواند هنگام ارسالِ پیام down (خاموش) باشد. همین جداسازی کلِ ارزشِ کار است — و در عین حال کلِ منشأِ درد.

حالا مهم‌ترین جمله‌ی این فصل: دو مدلِ ذهنیِ غالب وجود دارد و اشتباه‌گرفتنشان ریشه‌ی بیشتر تصمیم‌های معماریِ بد است.

دو رستوران

یک رستوران را دو جور می‌شود اداره کرد. در رستورانِ اول، گارسون باهوش است: سفارش را می‌گیرد، به آشپزخانه‌ی درست می‌برد، و وقتی غذا خورده شد بشقاب را جمع می‌کند و می‌شوید — دیگر اثری از آن سفارش نمی‌ماند. در رستورانِ دوم، هرچه سفارش داده می‌شود در یک دفترِ ثبتِ ابدی نوشته می‌شود؛ گارسون کارِ خاصی نمی‌کند، فقط دفتر را نگه می‌دارد، و هر مشتری خودش با یک نشانگرِ صفحه دنبال می‌کند تا کجا خوانده. می‌توانی هفته‌ی بعد برگردی و از اول دفتر را دوباره بخوانی.

رستورانِ اول RabbitMQ است. رستورانِ دوم Kafka است.

  • RabbitMQ یعنی بروکرِ باهوش / consumerِ ساده. بروکر منطقِ مسیریابی (exchangeها و bindingها) را نگه می‌دارد، وضعیتِ هر پیام را دنبال می‌کند و پیام را پس از ack (تأیید) حذف می‌کند. این یک صف (queue) است: کار مصرف می‌شود و می‌رود.
  • Kafka یعنی بروکرِ ساده / consumerِ باهوش. بروکر یک commit log فقط‌افزودنی (append-only)، پارتیشن‌بندی‌شده و replicate‌شده (تکثیرشده روی چند ماشین برای دوام) است. فراتر از یک offset به‌ازای هر consumer group، دنبال نمی‌کند «چه کسی چه چیزی را خواند». پیام‌ها طبقِ سیاستِ زمان/حجم نگه‌داری می‌شوند، مستقل از مصرف، پس قابلِ بازپخش (replay) هستند. این یک لاگ (log) است، نه صف.
تمایزی که همه‌چیز از آن مشتق می‌شود

هرچیزِ دیگری — ترتیب، معنای تحویل، مقیاس‌پذیری، replay — از همین یک تفاوت بیرون می‌آید: RabbitMQ پیام را بعد از خواندن حذف می‌کند؛ Kafka نگهش می‌دارد. اگر این یک جمله را عمیق بفهمی، نیمی از راه را رفته‌ای.

(نکته‌ی جلوجلو: Kafka 4.0 با KIP-932 و «share group»ها معنای صف را هم به‌صورت opt-in اضافه می‌کند — بعداً می‌رسیم، ولی مرز دارد کمی محو می‌شود.)

RabbitMQ: exchangeها، queueها، bindingها

RabbitMQ پروتکلِ AMQP 0-9-1 را پیاده می‌کند. اولین چیزی که تازه‌کارها را غافلگیر می‌کند این است: producer هرگز مستقیم به یک queue منتشر نمی‌کند. به یک exchange با یک routing key منتشر می‌کند، و exchange تصمیم می‌گیرد کدام queueها کپی بگیرند.

exchange مثل بخشِ توزیعِ اداره‌ی پست

وقتی نامه‌ات را به پست می‌دهی، آن را روی صندوقِ خانه‌ی گیرنده نمی‌گذاری. به بخشِ توزیع (exchange) می‌دهی با یک آدرس (routing key) رویش. بخشِ توزیع طبقِ قوانینش (bindingها) تصمیم می‌گیرد نامه به کدام صندوق‌ها (queueها) برود — شاید یک صندوق، شاید ده صندوق، شاید هیچ‌کدام.

انواعِ exchange تعیین می‌کنند این «قوانین توزیع» چطور کار می‌کنند:

نوع exchange رفتار مسیریابی کاربرد رایج
direct تحویل به queueهایی که binding key == routing key نقطه‌به‌نقطه، work queue
topic تطبیق wildcard (* = یک کلمه، # = صفر یا بیشتر) روی routing keyهای نقطه‌دار pub/sub بر اساس دسته، مثل order.created.eu
fanout broadcast به همه‌ی queueهای bound، بی‌اعتنا به routing key fan-out رویداد به مصرف‌کننده‌های زیاد
headers تطبیق بر اساس header به‌جای routing key نادر؛ مسیریابی پیچیده

بیایید یک مثالِ topic را تصویری ببینیم. * یعنی «دقیقاً یک کلمه» و # یعنی «صفر یا چند کلمه» — پس order.created.* به order.created.eu می‌خورد ولی order.# به هر چیزی که با order. شروع شود:

                 binding: order.created.*
producer --publish("order.created.eu")--> [topic exchange] ---> [queue: eu-fulfilment]
                                                            \--> [queue: analytics]  (binding: order.#)

تأییدها: ack، nack، reject

اینجا محلِ زندگیِ بیشترِ باگ‌هاست، پس آرام برویم.

رسیدِ تحویل

پیکِ پست نامه را دمِ در به تو می‌دهد و می‌گوید «همین‌جا امضا کن». تا امضا نکنی، در سیستمِ پست نامه هنوز «تحویل‌نشده» است و اگر تو غیبت بزنی، دوباره می‌آورندش. آن امضا همان ack است.

وقتی consumer یک پیام می‌گیرد، RabbitMQ آن را unacked علامت می‌زند — تحویل‌شده اما تأییدنشده. consumer باید به بروکر بگوید چه شد:

  • basic.ack — با موفقیت پردازش شد؛ بروکر حذفش می‌کند.
  • basic.nack / basic.reject — پردازش شکست خورد. با requeue=true به صف برمی‌گردد؛ با requeue=false دور ریخته یا dead-letter می‌شود.

اگر channel/connectionِ مصرف‌کننده پیش از ack بمیرد، RabbitMQ به consumerِ دیگری redeliver می‌کند (دوباره تحویل می‌دهد). همین redelivery دقیقاً دلیلِ at-least-once بودن است — و همین‌جاست که باید idempotent باشی.

دامِ کلاسیک: autoAck

یک incidentِ همیشگیِ production: روشن‌کردنِ autoAck (تأییدِ خودکار). با auto-ack بروکر پیام را همان لحظه که به socket می‌رسد «تحویل‌شده» می‌داند، پیش از اینکه تو پردازش کنی. اگر وسطِ پردازش crash کنی، پیام از قبل رفته و بی‌صدا گم می‌شود. auto-ack عملاً صفِ durableِ تو را به fire-and-forget («بفرست و فراموش کن») تبدیل می‌کند. همیشه ack دستی، بعد از پردازشِ موفق.

// مصرف‌کننده‌ی Spring AMQP با ack دستی و DLQ برای شکست غیرقابل‌تکرار
@RabbitListener(queues = "orders.q", ackMode = "MANUAL")
public void onOrder(Message msg, Channel channel) throws IOException {
    long tag = msg.getMessageProperties().getDeliveryTag();
    try {
        OrderEvent e = deserialize(msg.getBody());
        handle(e);                             // باید idempotent باشد
        channel.basicAck(tag, false);          // false = فقط همین پیام، نه «چندتایی»
    } catch (TransientException ex) {
        // requeue=true -> بعداً retry. مراقب حلقه‌ی بی‌نهایتِ redelivery باش.
        channel.basicNack(tag, false, true);
    } catch (PoisonMessageException ex) {
        // requeue=false -> به DLX/DLQ برو؛ روی پیام خراب تا ابد حلقه نزن
        channel.basicNack(tag, false, false);
    }
}

به آن deliveryTag دقت کن — این شماره‌ی رسیدِ همان پیام است روی همان channel، و false دومی یعنی «فقط همین یکی را ack کن، نه همه‌ی قبلی‌ها».

Dead Letter Exchange (DLX) و DLQ

صندوقِ نامه‌های برگشتی

هر اداره‌ی پستی یک قفسه دارد برای نامه‌هایی که «قابلِ تحویل نیستند» — آدرس اشتباه، گیرنده رفته، بسته‌ی مشکوک. آن‌ها را دور نمی‌ریزند؛ می‌گذارند کنار تا یک انسان بررسی کند. آن قفسه DLQ است (Dead Letter Queue، صفِ نامه‌های مرده).

یک پیام «dead-lettered» می‌شود وقتی با requeue=false رد شود، از TTL بگذرد (Time To Live، عمرِ مجازِ پیام)، یا queue به x-max-length برسد. آن‌گاه RabbitMQ آن را به یک dead-letter exchange پیکربندی‌شده بازمنتشر می‌کند که به یک DLQ مسیریابی می‌شود. این تورِ نجاتِ تو برای poison message است (پیامِ زهرآگین: پیامی که هربار می‌خواهی پردازشش کنی خطا می‌دهد و اگر بی‌نهایت requeue شود، سیستم را فلج می‌کند).

یک توپولوژیِ retryِ رایج و باهوش: صفِ اصلی → روی شکست، dead-letter به یک صفِ تأخیر با TTL → پس از پایانِ TTL، دوباره dead-letter به صفِ اصلی. این بدونِ هیچ schedulerی به تو retryِ تأخیری می‌دهد. همیشه تعدادِ تلاش‌ها را سقف بگذار (شمارنده‌ی headerِ x-death را دنبال کن) تا پیامِ دائماً‌خراب در یک DLQِ نهایی برای بررسیِ انسانی بنشیند، نه در حلقه‌ی بی‌نهایت.

Prefetch (QoS): پیچِ throughput در برابر انصاف

پیشخدمتِ حریص

تصور کن یک پیشخدمت ۲۰ سفارش را یک‌جا از آشپزخانه برمی‌دارد و روی میزش تلنبار می‌کند، در حالی که سه پیشخدمتِ دیگر بیکار ایستاده‌اند. مشتری‌های میزهای آن سه نفر معطل می‌مانند، فقط چون این یکی حریص بود. prefetch همان قانونی است که می‌گوید «هر پیشخدمت حداکثر چند سفارشِ تأییدنشده می‌تواند هم‌زمان روی دستش داشته باشد».

به‌طور پیش‌فرض یک consumer حریصانه پیام‌های unackedِ زیادی buffer می‌کند، پس یک مصرف‌کننده‌ی کند کار را انبار می‌کند در حالی که بقیه بیکارند. basic.qos(prefetchCount) تعدادِ پیام‌های unackedی را که بروکر یک‌جا به یک channel می‌فرستد سقف می‌گذارد.

  • prefetch = 1 ← بیشترین انصاف، کمترین throughput (یک round-trip به‌ازای هر پیام). برای کارهای طولانی و ناهموار خوب است.
  • prefetch = بالا ← throughputِ بالا، انصافِ بد، حافظه‌ی بیشتر، و پنجره‌ی از‌دست‌رفتِ بزرگ‌تر روی crash.

آن را حدوداً روی ceil(round_trip_latency / processing_time) به‌ازای هر مصرف‌کننده تنظیم کن. اشتباه‌کردنِ همین، شماره‌یِ‌یکِ تیکت‌های «RabbitMQ کند است» است که در واقع اصلاً تقصیرِ RabbitMQ نیست.

دوام (durability) و quorum queue

برای اینکه پیام از restartِ بروکر جان سالم به‌در ببرد به سه چیز با هم نیاز داری — و جاافتادنِ هرکدام یعنی بخارشدنِ پیام‌ها هنگام restart:

  1. صفِ durable (خودِ صف روی دیسک ثبت شود، نه فقط در حافظه).
  2. پیامِ persistent (delivery mode 2 — یعنی خودِ پیام هم روی دیسک نوشته شود).
  3. publisher confirms (بروکر به producer تأیید بدهد که «گرفتم و روی دیسک نوشتم»).
تغییرِ مهم در RabbitMQ 4.0

از RabbitMQ 4.0 به بعد، صف‌های classicِ mirrored کاملاً حذف شده‌اند. برای high availability (دسترس‌پذیریِ بالا) حالا از quorum queue استفاده می‌کنی (replicationِ مبتنی بر Raft — یک الگوریتمِ اجماع که تضمین می‌کند اکثریتِ گره‌ها روی داده توافق دارند — با تمرکز بر data-safety) یا از stream (یک انتزاعِ لاگِ append-only شبیهِ Kafka). اگر HA در RabbitMQ را با سیاست‌های ha-mode یاد گرفته بودی، آن دانش منسوخ است؛ از quorum queue استفاده کن.

Kafka: پارتیشن‌ها، offsetها، consumer groupها

یک topic در Kafka به پارتیشن‌ها (partition) تقسیم می‌شود. هر پارتیشن یک دنباله‌ی مرتب، تغییرناپذیر و فقط‌افزودنی است؛ موقعیتِ هر پیام در آن دنباله، offset آن است (یک عددِ long اکیداً صعودی، مثل شماره‌ی خطِ یک دفتر).

دفترهای موازیِ یک انبار

یک topic را مثل «همه‌ی سفارش‌ها» ببین. اگر همه را در یک دفترِ واحد بنویسی، فقط یک نفر می‌تواند هم‌زمان بخواندش و کند می‌شود. پس آن را به چند دفتر (partition) تقسیم می‌کنی که موازی نوشته و خوانده شوند. داخلِ هر دفتر خط‌ها به‌ترتیب‌اند (خطِ ۵ همیشه بعد از خطِ ۴)، اما بینِ دو دفترِ مختلف هیچ ترتیبی نیست — نمی‌دانی خطِ ۳ِ دفترِ A زودتر نوشته شد یا خطِ ۷ِ دفترِ B.

سه حقیقتِ کلیدی که بقیه از آن‌ها مشتق می‌شوند:

  1. ترتیب فقط درونِ یک پارتیشن تضمین می‌شود، هرگز در سطحِ topic. اگر به ترتیبِ به‌ازای هر موجودیت نیاز داری (همه‌ی رویدادهای order-42 به‌ترتیب)، باید آن‌ها را به یک پارتیشن بفرستی — با استفاده از یک partition key. کافکا key را hash می‌کند و از رویش پارتیشن را انتخاب می‌کند، پس هر پیامی که همان key را داشته باشد به همان پارتیشن می‌رود.
  2. یک consumer group مجموعه‌ای از مصرف‌کننده‌ها با group.idِ مشترک است. Kafka هر پارتیشن را به دقیقاً یک consumer در گروه اختصاص می‌دهد. پس بیشینه‌ی موازی‌سازیِ مفیدِ یک گروه برابرِ تعدادِ پارتیشن‌هاست. ۲۰ پارتیشن ⇐ حداکثر ۲۰ مصرف‌کننده‌ی فعال؛ نفرِ بیست‌ویکم بیکار می‌نشیند.
  3. offsetها به‌ازای هر گروه commit می‌شوند، ذخیره در topicِ داخلیِ __consumer_offsets. این مکان‌نمای Kafka است — جایی که گروه از آن ادامه می‌دهد. دو گروهِ مختلف که همان topic را می‌خوانند کاملاً مستقل‌اند، هرکدام offsetِ خودش را دارد. اینطوری Kafka هم‌زمان هم pub/sub می‌کند و هم work-queue.
Topic "orders" (۳ پارتیشن)، Group "billing" (۲ مصرف‌کننده)

  P0: [o0 o1 o2 o3 ...]  --\
  P1: [o0 o1 o2 ...]      ---> consumer A  (مالک P0, P1)
  P2: [o0 o1 ...]         ---> consumer B  (مالک P2)
        ^offset commit‌شده به‌ازای (group, partition)
چرا «هر پارتیشن، یک مصرف‌کننده»

این قانون همان چیزی است که هم ترتیب را حفظ می‌کند (چون فقط یک نفر آن دفتر را می‌خواند، ترتیب به‌هم نمی‌ریزد) و هم سقفِ مقیاس‌پذیری را تعیین می‌کند. اگر این را بفهمی، سؤالِ مصاحبه‌ی «۲۰ مصرف‌کننده روی ۱۲ پارتیشن» برایت بدیهی می‌شود.

Rebalancing

وقتی یک consumer اضافه/حذف می‌شود یا می‌میرد، گروه rebalance می‌کند — پارتیشن‌ها بازتخصیص می‌شوند.

پخشِ دوباره‌ی میزها

سه پیشخدمت مسئولِ ۱۲ میزند. یکی مریض می‌شود و می‌رود. الان باید ۱۲ میز بینِ ۲ نفرِ باقی‌مانده دوباره تقسیم شود. سؤال این است: آیا کلِ رستوران را متوقف می‌کنی تا از نو همه‌چیز را تقسیم کنی (روشِ کند)، یا فقط میزهای آن یک نفر را بینِ دو نفرِ دیگر پخش می‌کنی و بقیه بی‌وقفه کار می‌کنند (روشِ باهوش)؟

پروتکلِ قدیمیِ «eager» متوقف‌کننده‌ی کلِ دنیا بود: همه، همه‌چیز را revoke می‌کردند، بعد بازتخصیص (یک جهشِ latency). تخصیصِ cooperative sticky (پیش‌فرض از نسخه‌ی 2.4) فقط پارتیشن‌هایی را جابه‌جا می‌کند که باید. Kafka 4.0 پروتکلِ KIP-848 را می‌آورد، یک پروتکلِ rebalanceِ broker-driven (بروکر هدایت می‌کند، نه خودِ مصرف‌کننده‌ها) که افزایشی و به‌مراتب سریع‌تر است؛ مصرف‌کننده‌ها با group.protocol=consumer opt-in می‌کنند.

نگه‌داری (retention): لاگ یک صف نیست

Kafka پیام‌ها را طبقِ retention.ms (پیش‌فرض ۷ روز) یا retention.bytes نگه می‌دارد، مستقل از اینکه کسی مصرفشان کرد یا نه. مصرف‌کردن حذف نمی‌کند.

قدرتِ پنهانِ replay

همین «مصرف حذف نمی‌کند» است که replay را ممکن می‌کند: offsetهای یک گروه را reset می‌کنی تا تاریخ را از اول دوباره پردازش کند. این برای backfill (پرکردنِ عقب‌افتادگیِ) یک consumerِ جدید یا recovery از یک deployِ خراب بی‌بدیل است. در RabbitMQ چون پیام بعد از ack پاک می‌شود، چنین چیزی اصلاً وجود ندارد.

log compaction (cleanup.policy=compact) یک حالتِ retentionِ متفاوت است که فقط آخرین مقدار به‌ازای هر key را نگه می‌دارد و topic را به یک changelogِ durable / فضای‌کلیدِ materialized تبدیل می‌کند (stateِ Kafka Streams و snapshotهای CDC اینطور کار می‌کنند).

زمان‌بندیِ commit و تله‌اش

Properties p = new Properties();
p.put("bootstrap.servers", "kafka:9092");
p.put("group.id", "billing");
p.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
p.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
p.put("enable.auto.commit", "false");         // دستی commit کن، بعد از پردازش
p.put("auto.offset.reset", "earliest");
p.put("isolation.level", "read_committed");   // رکوردهای transactionalِ abort‌شده را نادیده بگیر

try (KafkaConsumer<String,String> c = new KafkaConsumer<>(p)) {
    c.subscribe(List.of("orders"));
    while (true) {
        ConsumerRecords<String,String> records = c.poll(Duration.ofMillis(500));
        for (ConsumerRecord<String,String> r : records) {
            process(r);                        // idempotent!
        }
        c.commitSync();                        // at-least-once: اول پردازش، بعد commit
    }
}

ترتیبِ process در برابر commit دقیقاً معنای تحویلِ تو را تعریف می‌کند:

  • اول پردازش، بعد commit ← at-least-once (crash بعد از پردازش، پیش از commit ⇐ redelivery). این پیش‌فرضِ امن است.
  • اول commit، بعد پردازش ← at-most-once (crash بعد از commit، پیش از پردازش ⇐ پیامِ گم‌شده). تقریباً هرگز چیزی نیست که بخواهی.
تله‌ی enable.auto.commit=true

این تنظیم روی یک تایمر داخلِ poll() کامیت می‌کند. بی‌صدا به تو at-least-once با پنجره‌ی تکرارِ کنترل‌نشده می‌دهد و بدتر، اگر رکوردها را در طولِ چند poll نگه داری، می‌تواند offsetِ رکوردهایی را که هنوز پردازش‌شان تمام نشده commit کند. پایپ‌لاین‌های جدی آن را خاموش می‌کنند.

معنای تحویل و idempotency

حالا سه واژه‌ای که کلِ فصل حول آن‌ها می‌چرخد را کنار هم بگذاریم:

معنا مفهوم چطور به‌دستش می‌آوری هزینه
At-most-once ۰ یا ۱ تحویل، ممکن است گم شود commit/ack پیش از پردازش، autoAck، fire-and-forget از دست رفتن داده
At-least-once ≥۱ تحویل، ممکن است تکرار شود ack/commit بعد از پردازش + retry تکرار ← نیاز به idempotency
Exactly-once عملاً ۱ idempotency، یا Kafka transactions درون Kafka پیچیدگی/کاهش throughput
مسئله‌ی دو ژنرال

دو ژنرال روی دو تپه، دشمن در دره‌ی بین‌شان. باید هم‌زمان حمله کنند وگرنه شکست می‌خورند. ژنرال A پیک می‌فرستد «فردا سحر حمله». اما آیا پیک رسید؟ باید پیکِ تأیید برگردد. اما آیا آن تأیید رسید؟ باید تأییدِ تأیید... این زنجیره هرگز تمام نمی‌شود. هیچ‌کدام هرگز صددرصد مطمئن نمی‌شود. این دقیقاً چرا تحویلِ دقیقاً‌یک‌بار روی شبکه ناممکن است.

حقیقتِ سنیور: چون هرگز مطمئن نیستی ackت رسید، پس باید retry کنی، پس تکرار اجتناب‌ناپذیر است. آنچه هست پردازشِ دقیقاً‌یک‌بار (effectively-once) است که با بی‌ضررکردنِ تکرارها به‌دست می‌آید. دو مکانیزم:

۱. idempotency سمتِ مصرف‌کننده — پاسخِ جهانی. به هر پیام یک شناسه‌ی پایدارِ کسب‌وکاری/پیام بده و شناسه‌های پردازش‌شده را ثبت کن. پیش از عمل، به‌صورت atomic چک‌وست (check-and-set) کن.

// مصرف‌کننده‌ی idempotent با جدول dedup. قید unique روی message_id نگهبان است.
@Transactional
public void handle(PaymentEvent e) {
    try {
        jdbc.update("INSERT INTO processed_messages(message_id, at) VALUES (?, now())", e.id());
    } catch (DuplicateKeyException dup) {
        return;                    // قبلاً پردازش شده — no-opِ امن
    }
    applyPayment(e);               // همان tx: نشانه‌ی dedup + اثر با هم commit می‌شوند
}

نبوغِ این کد در قیدِ unique روی message_id است: دیتابیس خودش نگهبان می‌شود. اگر پیام تکراری بیاید، INSERT خطای DuplicateKeyException می‌دهد و ما امن برمی‌گردیم. چون هم درجِ نشانه و هم اثرِ واقعی در یک @Transactional هستند، یا هر دو با هم commit می‌شوند یا هیچ‌کدام.

۲. producerِ idempotent + transactions در Kafka — تکرارها را درونِ Kafka حل می‌کند.

پیش‌فرضِ مهم از Kafka 3.0

از Kafka 3.0 به بعد، enable.idempotence=true پیش‌فرض است: producer یک Producer ID (PID) و شماره‌ترتیب‌های per-partition می‌گیرد، پس یک retryِ سمت‌بروکر که قرار بود یک تکرار بنویسد توسط بروکر dedup می‌شود (بازترتیبی‌ای را هم که retryها می‌توانستند ایجاد کنند رفع می‌کند).

روی آن transactions بگذار (با ست‌کردنِ transactional.id) تا به‌صورت atomic روی چند پارتیشن/topic بنویسی و offsetهای مصرف‌کننده را در یک واحدِ atomic کامیت کنی — حلقه‌ی «consume-transform-produce». مصرف‌کننده‌ها باید isolation.level=read_committed را ست کنند تا رکوردهای abort‌شده را رد کنند.

نکته‌ی حیاتی: مرزِ exactly-once

این exactly-once فقط درونِ Kafka است. لحظه‌ای که اثرِ تو یک DBِ بیرونی یا یک فراخوانِ REST است، Kafka transactions نمی‌تواند پوششش بدهد — دوباره به idempotency برمی‌گردی. این دقیقاً همان شکافی است که الگوی Outbox پُر می‌کند.

مسئله‌ی dual-write ← Transactional Outbox

حالا به یکی از مهم‌ترین باگ‌های معماری می‌رسیم؛ باگی که تقریباً در هر سیستمِ «میکروسرویس یک رویداد منتشر می‌کند» پنهان است.

// خراب: دو سیستم، بدون transaction مشترک
@Transactional
public void placeOrder(Order o) {
    orderRepo.save(o);            // (۱) به Postgres commit می‌شود
    kafka.send("orders", event); // (۲) به Kafka فرستاده می‌شود
}
چرا این کد خراب است (dual-write)

هیچ اتمیک‌بودنی بین Postgres و Kafka نیست. چهار حالتِ خرابی:

  1. DB کامیت می‌شود اما sendِ بروکر شکست می‌خورد → رویداد گم، downstream هرگز نمی‌فهمد.
  2. sendِ بروکر موفق می‌شود اما txِ DB rollback می‌کند → رویدادِ شبح برای سفارشی که وجود ندارد.
  3. process بینِ (۱) و (۲) می‌میرد.
  4. kafka.send async است و محلی «موفق» می‌شود اما هرگز از buffer خارج نمی‌شود.

@Transactional اینجا هیچ حفاظتی نمی‌دهد — بروکر در transactionِ JDBC ثبت‌نام نشده.

راه‌حلِ کتابیِ 2PC (Two-Phase Commit، تراکنشِ دو-فازیِ XA) بینِ یک DB و Kafka است، اما Kafka از XA خوب پشتیبانی نمی‌کند و 2PC از نظرِ عملیاتی سمی است (coordinatorِ blocking، availabilityِ بد). نکن.

یک دفتر، یک امضا

مشکل این است که داری در دو دفترِ جدا می‌نویسی و ممکن است بینِ دو نوشتن بمیری. راه‌حلِ Outbox زیرکانه است: هر دو چیز را در همان یک دفتر بنویس. به‌جای «سفارش را در دیتابیس بنویس، رویداد را به Kafka بفرست»، می‌گویی «سفارش را در دیتابیس بنویس، و رویداد را هم در همان دیتابیس در جدولی به‌نامِ outbox بنویس». چون هر دو در یک دیتابیس‌اند، یک COMMIT هر دو را با هم قطعی می‌کند. بعداً یک کارگرِ جداگانه از رویِ جدولِ outbox، رویدادها را به Kafka می‌برد.

Transactional Outbox دو نوشتن را به یک نوشتن تبدیل می‌کند:

CREATE TABLE outbox (
    id           UUID PRIMARY KEY,
    aggregate_id TEXT NOT NULL,          -- partition keyِ Kafka می‌شود
    type         TEXT NOT NULL,          -- مثل 'OrderCreated'
    payload      JSONB NOT NULL,
    created_at   TIMESTAMPTZ DEFAULT now()
);
@Transactional  // یک local transaction — با ساختار، atomic
public void placeOrder(Order o) {
    orderRepo.save(o);
    outboxRepo.save(new OutboxRecord(
        UUID.randomUUID(), o.id().toString(), "OrderCreated", toJson(o)));
}

حالا یا هر دو ردیف commit می‌شوند یا هیچ‌کدام. سؤالِ باقی‌مانده این است: outbox چطور به Kafka می‌رسد؟ دو رویکرد:

  • Polling publisher (ناشرِ نظرسنجی‌کننده): یک jobِ پس‌زمینه مدام SELECT ... FROM outbox WHERE published=false ORDER BY created_at FOR UPDATE SKIP LOCKED می‌زند، هرکدام را منتشر می‌کند، بعد mark/delete می‌کند. ساده، بدونِ زیرساختِ اضافه؛ عیب‌هایش latencyِ ناشی از polling و بارِ اضافه است. آن SKIP LOCKED اجازه می‌دهد چند pollerِ موازی بی‌خطر اجرا کنی — هر کدام ردیف‌های قفل‌شده‌ی دیگری را رد می‌کند.
  • CDC (Change Data Capture، ثبتِ تغییرِ داده) با Debezium — انتخابِ سطحِ production. Debezium دنباله‌ی WAL پستگرس (Write-Ahead Log، لاگی که پستگرس قبل از هر تغییر می‌نویسد) را از راهِ logical replication می‌خواند، پس درج‌های commit‌شده‌ی outbox را بدونِ هیچ polling و با سربارِ نزدیک‌به‌صفر می‌بیند. Outbox Event Routerِ آن (یک SMT، تبدیلِ سبکِ پیام) هر ردیفِ outbox را به یک پیامِ درستِ Kafka باز می‌کند، بر اساسِ type مسیریابی و با aggregate_id کلیدگذاری می‌کند. این یک طراحیِ «log-follows-log» است: خودِ لاگِ durableِ دیتابیس منبعِ حقیقتِ رویدادها می‌شود.
دو صداقتِ لازم درباره‌ی Outbox

اول، outbox انتشارِ at-least-once می‌دهد (ناشر می‌تواند بعد از send و پیش از mark‌کردن crash کند) — پس مصرف‌کننده‌ها باز باید idempotent باشند. دوم، الگوی Inbox آینه‌ی سمتِ مصرف‌کننده است: شناسه‌های پیامِ پردازش‌شده را در همان transactionِ اثر ثبت کن تا effectively-once سرتاسری بگیری.

Saga: تراکنشِ توزیع‌شده بدونِ 2PC

وقتی یک عملیاتِ کسب‌وکاری چند سرویس را در بر می‌گیرد (order ← payment ← inventory ← shipping) که هرکدام دیتابیسِ خودش را دارد، نمی‌توانی از یک تراکنشِ ACIDِ واحد استفاده کنی. یک Saga آن را با دنباله‌ای از تراکنش‌های محلی جایگزین می‌کند، که هر گام یک رویداد منتشر می‌کند که گامِ بعدی را راه می‌اندازد.

رزروِ سفر

داری یک سفر می‌چینی: بلیتِ هواپیما، هتل، و ماشینِ کرایه‌ای. هیچ «دکمه‌ی جادوییِ واحد» نیست که هر سه را با هم قطعی کند. پس یکی‌یکی رزرو می‌کنی. اگر ماشین گیر نیامد، نمی‌توانی زمان را به عقب برگردانی — به‌جایش هتل را کنسل می‌کنی و بلیت را مسترد می‌کنی. آن کنسل‌کردن‌ها جبران‌سازی (compensation) هستند: کارِ جدیدی که اثرِ کارِ قبلی را خنثی می‌کند، نه یک «undo»ِ جادویی.

در Saga هیچ rollbackی در سطحِ سرویس‌ها نیست؛ در عوض هر گام یک عملِ جبران‌کننده (compensating action) دارد که آن را به‌طور معنایی خنثی می‌کند (بازپرداختِ یک شارژ، آزادسازیِ یک رزرو). Sagaها اتمیک‌بودن را با سازگاریِ نهایی (eventual consistency) معامله می‌کنند — سیستم از حالت‌های میانیِ قابل‌مشاهده عبور می‌کند.

Choreography در برابر Orchestration

رقص در برابر ارکستر

دو راه برای هماهنگیِ گروهی هست. در choreography (رقصِ گروهی)، رقصنده‌ای رهبر نیست؛ هر رقصنده حرکتِ نفرِ کناری را می‌بیند و حرکتِ خودش را می‌کند. در orchestration (رهبریِ ارکستر)، یک رهبر جلو ایستاده و به هر نوازنده می‌گوید کِی و چه بنوازد.

Choreography — بدونِ هماهنگ‌کننده‌ی مرکزی. هر سرویس به رویدادها واکنش می‌دهد و رویدادِ خودش را منتشر می‌کند. OrderCreated → Payment گوش می‌دهد، شارژ می‌کند، PaymentCompleted می‌فرستد → Inventory گوش می‌دهد، رزرو می‌کند، StockReserved می‌فرستد → Shipping گوش می‌دهد…

  • مزیت‌ها: کاملاً decoupled، بدونِ نقطه‌ی کنترلِ واحد، افزودنِ یک گام آسان است (فقط یک listenerِ جدید).
  • عیب‌ها: جریانِ کسب‌وکار ضمنی و پخش‌شده در بینِ سرویس‌هاست — هیچ جای واحدی کلِ saga را نشان نمی‌دهد. دیباگ یک نوع باستان‌شناسی است؛ وابستگی‌های رویدادیِ حلقوی کم‌کم پیدا می‌شوند. برای جریان‌های کوتاه (۲ تا ۴ گام) بهترین است.

Orchestration — یک orchestratorِ مرکزی (اغلب یک state machine، ماشینِ حالت) جریان را مالک است، commandهای صریح می‌فرستد و منتظرِ پاسخ می‌ماند. تصمیم می‌گیرد گامِ بعدی چیست و روی شکست، جبران‌ها را به ترتیبِ معکوس می‌راند.

  • مزیت‌ها: جریان در یک جای خوانا زندگی می‌کند؛ منطقِ شرطیِ پیچیده و timeoutها قابلِ مدیریت‌اند؛ ترتیبِ جبران آسان فهمیده می‌شود.
  • عیب‌ها: orchestrator یک مؤلفه‌ای است که باید بسازی/بگردانی؛ خطرِ god-service شدن اگر منطقِ کسب‌وکار را در آن تلنبار کنی.
ORCHESTRATION (مسیرِ خوش‌فرجام بعد جبران)
 Orchestrator --Charge--> Payment      (ok)
 Orchestrator --Reserve--> Inventory   (FAILS: out of stock)
 Orchestrator --Refund---> Payment     (جبرانِ گامِ ۱، ترتیبِ معکوس)
 Orchestrator: saga = FAILED
// طرحِ یک گامِ orchestrator که با جبرانِ معکوس، شکست را مدیریت می‌کند
void onInventoryReserved(SagaState s) { s.markReserved(); dispatch(new ArrangeShipping(s.orderId())); }

void onInventoryFailed(SagaState s) {
    // ترتیبِ معکوس: پرداختی را که قبلاً انجام دادیم خنثی کن
    dispatch(new RefundPayment(s.paymentId()));
    s.markFailed("out_of_stock");
}

جبران، rollback نیست

تفاوتِ بنیادین

یک جبران (compensation) یک تراکنشِ روبه‌جلوی جدید است که یک تراکنشِ قبلی را خنثی می‌کند؛ خودش هم می‌تواند شکست بخورد (و باید تا موفقیت retry شود — پس جبران‌ها را idempotent و قابلِ‌retry بساز). این با rollback فرق دارد: rollback تغییراتِ commit‌نشده را در یک تراکنش پاک می‌کند، اما جبران یک اثرِ قبلاً‌commit‌شده را با یک اثرِ تازه خنثی می‌کند.

بعضی اثرها اصلاً برگشت‌ناپذیرند (ایمیل فرستاده شد، موشک شلیک شد) — برای آن‌ها یک گامِ pivot/confirmation (لولا/تأیید) بگذار تا تا زمانِ commit شدنِ saga هیچ کارِ برگشت‌ناپذیری رخ ندهد. برای rollbackِ معنایی طراحی کن و انتظار داشته باش مسیرِ «بازیابیِ روبه‌عقب» به‌اندازه‌ی مسیرِ روبه‌جلو کد داشته باشد.

انتخاب یک بروکر

نیاز ترجیح
مسیریابیِ پیچیده، اولویتِ per-message، TTLِ per-message، request/reply مثل RPC RabbitMQ
صفِ کار با مصرف‌کننده‌های رقیب، «مصرف شد و رفت» RabbitMQ (یا share groupهای Kafka)
استریمینگِ رویدادیِ پرحجم، replay، نگه‌داریِ طولانی، event sourcing Kafka
ترتیبِ اکیدِ per-key در مقیاس Kafka (پارتیشن بر اساس key)
چند مصرف‌کننده‌ی مستقل از یک استریم (pub/sub + replay) Kafka
پردازشِ استریم / joinها / تجمیعِ stateful Kafka (+ Streams/Flink)
قاعده‌ی سرانگشتیِ طلایی

اگر پیام یک command است («این کار را بکن، بعد تمام») به RabbitMQ متمایل شو؛ اگر یک fact/event است («این رخ داد»، شاید چند علاقه‌مند، شاید بعداً بازپردازش) به Kafka متمایل شو.

توجه: مرز دارد محو می‌شود. share groupهای KIP-932 در Kafka 4.0 معنای صف را به‌صورت opt-in اضافه می‌کنند (چند مصرف‌کننده به‌ازای هر پارتیشن، ack/lockِ per-message) — اما early-access است، تا 4.0/4.1 آماده‌ی production نیست، پس هنوز رویش طراحی نکن.

دام‌ها و گیرهای رایج

  • @Transactional دورِ kafka.send — هیچ کاری نمی‌کند؛ بروکر در txِ DB نیست. از Outbox استفاده کن.
  • فرضِ اینکه Kafka یک topic را مرتب می‌کند — یک پارتیشن را مرتب می‌کند. تغییرِ تعدادِ پارتیشن، پایداریِ key→partition و در نتیجه ترتیب را می‌شکند.
  • مصرف‌کننده‌ها ≫ پارتیشن‌ها — مصرف‌کننده‌های اضافه‌ی Kafka بیکار می‌نشینند. با افزودنِ پارتیشن مقیاس بده (اما به‌سادگی نمی‌توانی کمشان کنی).
  • autoAck در RabbitMQ — از‌دست‌رفتنِ بی‌صدای پیام روی crash. از ack دستی استفاده کن.
  • بدونِ idempotency در downstream — هر سیستمِ at-least-once به تو تکرار تحویل می‌دهد؛ اگر پردازش idempotent نباشد، حتماً از کسی دوبار پول می‌گیری.
  • حلقه‌ی poison-message — requeue/redeliveryِ بی‌نهایت که بروکر را می‌کوبد. تلاش‌ها را سقف بگذار → DLQ.
  • پردازشِ طولانی > max.poll.interval.ms — Kafka فرض می‌کند consumer مرده و rebalance می‌کند، که پردازشِ تکراری می‌سازد. یا سریع‌تر پردازش کن، یا سقف را بالا ببر، یا کار را offload کن.
  • باورِ اینکه «exactly-once» همه‌چیز را حل می‌کند — EOSِ Kafka فقط داخلِ Kafka است. اثرهای جانبیِ بیرونی هنوز idempotency می‌خواهند.
  • prefetchِ بی‌کرانِ RabbitMQ — یک مصرف‌کننده‌ی حریص بقیه را گرسنه می‌گذارد و پنجره‌ی از‌دست‌رفتِ crash را بزرگ می‌کند.

سؤالات مصاحبه

هر سؤال را با صدای بلند تمرین کن؛ در مصاحبه‌ی سنیور نه‌فقط جوابِ درست، بلکه چراییِ آن ارزیابی می‌شود.

۱. Kafka در برابر RabbitMQ، هرکدام یک جمله، و کِی کدام را انتخاب می‌کنی؟

RabbitMQ یک بروکرِ باهوش است که پیام‌ها را تک‌به‌تک مسیریابی و حذف می‌کند — برای task queue، مسیریابیِ پیچیده و request/reply انتخابش کن. Kafka یک commit logِ توزیع‌شده، قابل‌بازپخش و پارتیشن‌بندی‌شده است — برای استریمینگِ پرحجم، ترتیبِ per-key در مقیاس، replay و چند مصرف‌کننده‌ی مستقل انتخابش کن. commandها → RabbitMQ؛ رویدادها/factها → Kafka.

۲. چرا تحویلِ دقیقاً‌یک‌بار ناممکن است و در عوض چه می‌سازیم؟

روی شبکه‌ی غیرقابل‌اعتماد نمی‌توانی «پیام گم شد» را از «ack گم شد» تشخیص دهی (Two Generals)، پس باید retry کنی، پس تکرار اجتناب‌ناپذیر است. در عوض پردازشِ دقیقاً‌یک‌بار (effectively-once) می‌سازیم با بی‌ضررکردنِ تحویلِ تکراری از راهِ idempotency، به‌علاوه‌ی اختیاریِ Kafka transactions برای بخشِ داخلِ Kafka.

۳. (گیر) سرویسِ تو داخل `@Transactional` اول `orderRepo.save()` بعد `kafka.send()` می‌کند. اشکال کجاست؟

transactionِ دیتابیس و sendِ Kafka atomic نیستند@Transactional بروکر را ثبت‌نام نمی‌کند. می‌توانی سفارش را commit کنی اما رویداد را گم کنی، یا رویدادِ شبح برای سفارشی که rollback شده منتشر کنی. این مسئله‌ی dual-write است؛ با Transactional Outbox رفعش کن.

۴. الگوی Outbox را توضیح بده و اینکه چطور از DB به بروکر می‌رسد.

تغییرِ دامنه و یک ردیفِ outbox را در یک local transactionِ DB بنویس (با ساختار atomic). یک process جداگانه ردیف‌های outbox را به بروکر می‌رساند از راهِ polling (FOR UPDATE SKIP LOCKED) یا ترجیحاً CDC با Debezium که WAL را دنبال می‌کند. انتشارِ at-least-once می‌دهد، پس مصرف‌کننده‌ها باز باید idempotent باشند.

۵. (سخت) Kafka ترتیب را تضمین می‌کند — درست یا غلط؟

همان‌طور که گفته شد غلط. Kafka ترتیب را فقط درونِ یک پارتیشن تضمین می‌کند. ترتیبِ بین‌پارتیشنی و بین‌topicی تعریف‌نشده است. برای ترتیبِ per-entity باید همه‌ی رویدادهای یک موجودیت را با partition key به یک پارتیشن ببری و هرگز تعدادِ پارتیشنِ آن فضای‌کلید را عوض نکنی.

۶. یک consumer group با ۲۰ مصرف‌کننده روی topicِ ۱۲‌پارتیشنی. چه می‌شود؟

۱۲ مصرف‌کننده هرکدام یک پارتیشن دارند؛ ۸ تا بیکار می‌نشینند (هر پارتیشن حداکثر مالکِ یک مصرف‌کننده در هر گروه). افزودنِ مصرف‌کننده فراتر از تعدادِ پارتیشن، throughput نمی‌خرد — باید پارتیشن اضافه کنی.

۷. تفاوتِ producerِ idempotent با Kafka transactions چیست؟

producerِ idempotent (پیش‌فرض از 3.0) retryهای producer به یک پارتیشن را با Producer ID + شماره‌ترتیب dedup می‌کند و از تکرار و بازترتیبیِ ناشی از retry جلوگیری می‌کند. transactions نوشتنِ atomic روی چند پارتیشن/topic و کامیتِ atomicِ offset را اضافه می‌کند (consume-transform-produce)، که مصرف‌کننده با isolation.level=read_committed می‌خواند. هر دو فقط داخلِ Kafka هستند.

۸. (گیر) با `enable.auto.commit=true` واقعاً چه معنای تحویلی داری و ریسکش چیست؟

at-least-once با پنجره‌ی تکرارِ کنترل‌نشده: offsetها روی تایمر داخلِ poll() کامیت می‌شوند، شاید پیش یا پس از اتمامِ پردازش. اگر رکوردهایی را که کامل پردازش نکرده‌ای کامیت کند و بعد crash کنی، از‌دست‌رفتنِ سبکِ at-most-once می‌گیری؛ اگر دیر کامیت کند، بازپخشِ تکراریِ بزرگ‌تر. مصرف‌کننده‌های جدی آن را false می‌گذارند و بعد از پردازش commitSync() می‌کنند.

۹. Choreography در برابر orchestration در Saga — trade-offها؟

Choreography: سرویس‌ها به رویدادهای هم واکنش می‌دهند، کاملاً decoupled، اما جریان ضمنی و پخش‌شده است و دیدن/دیباگش سخت — برای جریان‌های کوتاه خوب. Orchestration: یک هماهنگ‌کننده‌ی مرکزی command می‌دهد و جبران را می‌راند، جریان را در یک جای خوانا و منطقِ پیچیده را ممکن می‌کند، به بهای یک مؤلفه‌ی اضافه و ریسکِ god-service.

۱۰. transactionِ جبران‌کننده چیست و چه فرقی با rollback دارد؟

rollback تغییراتِ commit‌نشده را درونِ یک transactionِ ACID به‌طور atomic پاک می‌کند. یک جبران یک transactionِ روبه‌جلوی commit‌شده‌ی جدید است که یک گامِ قبلاً‌commit‌شده را به‌طور معنایی جبران می‌کند (بازپرداخت، آزادسازیِ رزرو). می‌تواند شکست بخورد و باید idempotent و retry شود؛ بعضی اثرها برگشت‌ناپذیرند، پس با گامِ pivot اعمالِ برگشت‌ناپذیر را تا commit‌شدنِ saga به‌تعویق بینداز.

۱۱. prefetchِ RabbitMQ برابر ۱ در برابر ۱۰۰۰ — پیامدها؟

prefetch=۱: بروکر هر بار یک پیامِ unacked می‌فرستد — بیشترین انصاف بین مصرف‌کننده‌ها، اما یک round-tripِ شبکه به‌ازای هر پیام throughput را سقف می‌گذارد؛ برای کارهای طولانی/ناهموار خوب. prefetch=۱۰۰۰: throughputِ بالا و سربارِ per-message کمتر، اما یک مصرف‌کننده‌ی حریص کار را انبار می‌کند، حافظه‌ی بیشتر می‌خورد و پنجره‌ی از‌دست‌رفتِ crash را پهن‌تر می‌کند.

۱۲. (گیر — باگ را پیدا کن) یک مصرف‌کننده‌ی RabbitMQ با `autoAck=true` پردازشِ کند می‌کند. گاهی پیام‌ها ناپدید می‌شوند. چرا؟

با auto-ack بروکر پیام را همان لحظه که به socketِ مصرف‌کننده نوشته می‌شود تأییدشده می‌داند، پیش از پردازش. اگر مصرف‌کننده در وسطِ پردازش crash کند، پیام از قبل رفته و هرگز redeliver نمی‌شود. رفع: ack دستی بعد از پردازشِ موفق.

۱۳. چطور از حلقه‌زدنِ ابدیِ poison message جلوگیری می‌کنی؟

یک شمارنده‌ی تحویل/تلاش دنبال کن (header x-death در RabbitMQ یا یک headerِ outbox/Kafka). بعد از N شکست، requeue را متوقف کن و به‌جای nack(requeue=true)ِ ابدی به یک DLQ برای بررسی مسیریابی کن. اختیاراً با صفِ تأخیرِ TTL بین retryها backoffِ نمایی اضافه کن.

۱۴. (سخت) پردازشِ مصرف‌کننده ۶ دقیقه طول می‌کشد؛ `max.poll.interval.ms` روی ۵ دقیقه است. چه می‌شکند؟

Kafka نتیجه می‌گیرد مصرف‌کننده مرده (به‌موقع poll() نکرده)، پارتیشن‌هایش را به دیگران rebalance می‌کند و همان رکوردها را دوباره پردازش می‌کند — که اگر مصرف‌کننده‌های زیادی کند باشند، تکرار و توفانِ rebalance می‌سازد. رفع با پردازشِ سریع‌تر، offloadِ کار به thread/poolِ دیگر همراهِ ادامه‌ی poll، یا بالابردنِ max.poll.interval.ms (و کوچک‌کردنِ max.poll.records).

۱۵. (سنیور) به ترتیبِ رویدادیِ per-order نیاز داری و هم fan-outِ بالا به چند تیمِ مستقل و هم replay برای یک مصرف‌کننده‌ی جدید ۳ ماه بعد. کدام بروکر و چطور؟

Kafka. بر اساس orderId پارتیشن بزن تا همه‌ی رویدادهای یک سفارش در یک پارتیشن بیفتند (ترتیب). هر تیم consumer groupِ خودش را استفاده کند تا مستقل مصرف کند (fan-out). retention/compaction اجازه می‌دهد یک گروهِ جدید offset را به earliest reset کند و تاریخ را replay کند. RabbitMQ پیام‌ها را روی ack حذف می‌کرد و هم fan-outِ با پیشرفتِ مستقل و هم replayِ ۳‌ماهه را شکست می‌داد.

۱۶. (گیر) این چه چاپ می‌کند؟
producer.send(new ProducerRecord<>("t", "k1", "a"));
producer.send(new ProducerRecord<>("t", "k2", "b"));
producer.send(new ProducerRecord<>("t", "k1", "c"));
// مصرف‌کننده مقادیر را به ترتیبی که از یک پارتیشنِ خودش می‌خواند چاپ می‌کند

نمی‌توانی a b c را فرض کنی. رکوردهای k1 (یعنی a, c) یک پارتیشن مشترک دارند و نسبت به هم مرتب‌اند → a پیش از c. اما b مربوط به k2 (احتمالاً) در پارتیشنِ دیگری است با هیچ رابطه‌ی ترتیبی نسبت به بقیه. در سطح topic، b می‌تواند پیش، بین، یا پس از a/c بیاید. فقط ac تضمین است.

جمع‌بندی
  • یک بروکر producer و consumer را در زمان و مکان جدا می‌کند؛ همین آزادی، مسائلِ سیستمِ توزیع‌شده را می‌آورد.
  • RabbitMQ = بروکرِ باهوش، صف، پیام بعد از ack حذف می‌شود: exchange/binding، ack دستی، DLQ، prefetch، و از 4.0 quorum queue برای HA.
  • Kafka = لاگِ append-only، پیام نگه داشته می‌شود و قابلِ replay است: partition/offset/consumer group، ترتیب فقط درونِ پارتیشن، KIP-848 برای rebalanceِ سریع‌تر، و تله‌ی enable.auto.commit.
  • تحویلِ exactly-once روی شبکه ناممکن است (Two Generals)؛ به‌جایش effectively-once با idempotency می‌سازی. Kafka idempotent producer + transactions فقط داخلِ Kafka کار می‌کنند.
  • dual-write (نوشتن هم‌زمان به DB و بروکر) atomic نیست؛ Transactional Outbox دو نوشتن را به یک تراکنشِ محلی تبدیل می‌کند، و polling یا Debezium/CDC آن را به بروکر می‌رساند.
  • Saga تراکنشِ توزیع‌شده را با دنباله‌ای از تراکنش‌های محلی + جبران‌سازی جایگزین می‌کند (choreography برای جریانِ کوتاه، orchestration برای پیچیده).
  • قاعده‌ی نهایی: command → RabbitMQ، event/fact → Kafka، و هر سیستمِ at-least-once را با idempotency ایمن کن.

Most of the scary bugs in large systems are born at one exact spot: the moment a message leaves one service and travels to another. This chapter is about that boundary — how two pieces of software talk to each other without being awake at the same time, and how you make sure that when something breaks (and it will) the system stays correct instead of losing an order or charging a customer twice.

Let me make you a promise: by the end of this chapter, every word you'll hear in a senior interview — broker, partition, offset, at-least-once, idempotent, Outbox, Saga, compensation — you won't just recognize it, you'll be able to build it from scratch and explain it.

Roadmap

Here's our path:

  1. The broker mental model — what a broker really gives you, and the two schools of thought (RabbitMQ vs Kafka).
  2. RabbitMQ — exchanges, queues, bindings, ack/nack, DLQ, prefetch, and message durability.
  3. Kafka — partitions, offsets, consumer groups, rebalancing, retention, and the commit trap.
  4. Delivery semantics — at-most-once, at-least-once, exactly-once, and why idempotency is the universal answer.
  5. The dual-write problem and its fix, the Transactional Outbox.
  6. Saga — distributed transactions without 2PC, using compensation.
  7. Choosing a broker, common pitfalls, and 16 interview questions with full answers.

Part 0 — words you must know

Before anything else, let's crack open four words at the root so you're never lost later.

A broker is a post office

Imagine you want to give your friend a letter, but you don't know if he's home right now, and you don't want to walk to his house. You hand the letter to the post office. You (sender) and he (recipient) never need to be awake at the same time; the post office sits between you. A message broker is exactly that post office, but for programs.

  • producer: a program that sends a message (the letter's sender).
  • consumer: a program that receives a message and does something with it (the recipient).
  • idempotent: an operation where doing it twice gives the same result as doing it once. "Turn the light off" is idempotent — flip it however many times, the lamp stays off; but "charge $100" is not idempotent, because doing it twice means $200.
  • at-least-once: the guarantee that a message will arrive, but maybe more than once. This word is a load-bearing pillar for the whole chapter; hold onto it.
Why the pain starts immediately

The instant a message leaves your process, you've entered the world of distributed systems: partial failure (part works, part doesn't), retry, duplicates, and reordering. These four ghosts haunt you for the rest of the chapter.

Mental model: what a broker actually gives you

A message broker is infrastructure that decouples a producer from a consumer in time and in space. In space, because the producer doesn't need to know who consumes; in time, because the consumer can be down when the message is sent. That decoupling is the whole value proposition — and also the whole source of pain.

Now the single most important sentence in this chapter: there are two dominant mental models, and confusing them is the root of most bad architecture decisions.

Two restaurants

You can run a restaurant two ways. In the first restaurant, the waiter is smart: he takes the order, carries it to the right kitchen, and when the food is eaten he clears the plate and washes it — no trace of that order remains. In the second restaurant, everything ordered is written into an eternal ledger; the waiter does little, just keeps the ledger, and each customer tracks their own page cursor for how far they've read. You can come back next week and re-read the ledger from the top.

The first restaurant is RabbitMQ. The second is Kafka.

  • RabbitMQ is a smart broker / dumb consumer. The broker holds routing logic (exchanges, bindings), tracks per-message state, and deletes a message once it's acknowledged (acked). It's a queue: work is consumed and gone.
  • Kafka is a dumb broker / smart consumer. The broker is an append-only, partitioned, replicated (copied across machines for durability) commit log. Beyond a per-consumer-group offset, it does not track "who read what." Messages are retained by a time/size policy regardless of consumption, so they can be replayed. It's a log, not a queue.
The distinction everything derives from

Everything downstream — ordering, delivery semantics, scaling, replay — falls out of this one difference: RabbitMQ deletes a message after it's read; Kafka keeps it. Understand that one sentence deeply and you're halfway there.

(A note for later: Kafka 4.0's KIP-932 "share groups" add opt-in queue semantics too — we'll get there — so the line is blurring a bit.)

RabbitMQ: exchanges, queues, bindings

RabbitMQ implements AMQP 0-9-1. The first thing that surprises newcomers: a producer never publishes to a queue directly. It publishes to an exchange with a routing key, and the exchange decides which queues get a copy.

The exchange is the post office sorting room

When you drop off your letter, you don't put it in the recipient's mailbox yourself. You give it to the sorting room (exchange) with an address (routing key) on it. The sorting room, according to its rules (bindings), decides which mailboxes (queues) the letter goes to — maybe one, maybe ten, maybe none.

Exchange types define how those "sorting rules" work:

Exchange type Routing behavior Typical use
direct Deliver to queues whose binding key == routing key Point-to-point, work queues
topic Wildcard match (* = one word, # = zero+ words) on dotted routing keys Pub/sub by category, e.g. order.created.eu
fanout Broadcast to all bound queues, ignore routing key Event fan-out to many consumers
headers Match on header attributes instead of routing key Rare; complex routing

Let's picture a topic example. * means "exactly one word" and # means "zero or more words" — so order.created.* matches order.created.eu while order.# matches anything starting with order.:

                 binding: order.created.*
producer --publish("order.created.eu")--> [topic exchange] ---> [queue: eu-fulfilment]
                                                            \--> [queue: analytics]  (binding: order.#)

Acknowledgements: ack, nack, reject

This is where most bugs live, so let's go slowly.

The delivery receipt

The courier hands you the letter at the door and says "sign here." Until you sign, the letter is still "undelivered" in the postal system, and if you vanish, they'll bring it again. That signature is the ack.

When a consumer gets a message, RabbitMQ marks it unacked — delivered but not confirmed. The consumer must tell the broker what happened:

  • basic.ack — processed successfully; broker deletes it.
  • basic.nack / basic.reject — processing failed. With requeue=true it goes back on the queue; with requeue=false it's dropped or dead-lettered.

If the consumer's channel/connection dies before acking, RabbitMQ redelivers to another consumer. That redelivery is exactly why you get at-least-once — and exactly where you must be idempotent.

The classic trap: autoAck

A perennial production incident: enabling autoAck (auto acknowledge). With auto-ack the broker considers the message "delivered" the instant it hits the socket, before you process it. If you crash mid-processing, the message is already gone and is silently lost. Auto-ack effectively turns your durable queue into fire-and-forget. Always manual ack, after successful processing.

// Spring AMQP consumer with manual ack, DLQ on non-retryable failure
@RabbitListener(queues = "orders.q", ackMode = "MANUAL")
public void onOrder(Message msg, Channel channel) throws IOException {
    long tag = msg.getMessageProperties().getDeliveryTag();
    try {
        OrderEvent e = deserialize(msg.getBody());
        handle(e);                             // must be idempotent
        channel.basicAck(tag, false);          // false = this message only, not "multiple"
    } catch (TransientException ex) {
        // requeue=true -> retry later. Beware infinite redelivery loops.
        channel.basicNack(tag, false, true);
    } catch (PoisonMessageException ex) {
        // requeue=false -> route to DLX/DLQ; don't loop forever on a bad message
        channel.basicNack(tag, false, false);
    }
}

Notice that deliveryTag — it's the receipt number for this message on this channel, and the second false means "ack just this one, not everything before it."

Dead Letter Exchanges (DLX) and DLQ

The returned-mail bin

Every post office has a shelf for letters that are "undeliverable" — wrong address, recipient moved, suspicious package. They don't throw them out; they set them aside for a human to inspect. That shelf is the DLQ (Dead Letter Queue).

A message becomes "dead-lettered" when it is rejected with requeue=false, when it exceeds a TTL (Time To Live, the message's allowed lifespan), or when the queue hits x-max-length. RabbitMQ then republishes it to a configured dead-letter exchange, which routes it to a DLQ. This is your safety net for poison messages (a message that throws an error every time you try to process it, and if requeued forever will cripple the system).

A common, clever retry topology: main queue → on failure dead-letter to a delay queue with a TTL → after the TTL expires, dead-letter back to the main queue. This gives you delayed retry with no scheduler at all. Always cap attempts (track an x-death count header) so a permanently-bad message ends in a terminal DLQ for human inspection, not an infinite loop.

Prefetch (QoS): the throughput vs fairness knob

The greedy waiter

Picture one waiter grabbing 20 orders from the kitchen at once and piling them on his tray, while three other waiters stand idle. Customers at those three waiters' tables wait, just because this one was greedy. prefetch is the rule that says "each waiter may hold at most N unacked orders at a time."

By default a consumer greedily buffers many unacked messages, so one slow consumer hoards work while others idle. basic.qos(prefetchCount) caps the number of unacked messages the broker pushes to a channel at once.

  • prefetch = 1 → maximum fairness, lowest throughput (a round-trip per message). Good for long, uneven tasks.
  • prefetch = high → high throughput, poor fairness, more memory, and a larger loss window on crash.

Tune it to roughly ceil(round_trip_latency / processing_time) per consumer. Getting this wrong is the #1 cause of "RabbitMQ is slow" tickets that aren't actually RabbitMQ's fault.

Durability and quorum queues

For a message to survive a broker restart you need three things together — and missing any one means messages evaporate on restart:

  1. A durable queue (the queue itself recorded on disk, not just in memory).
  2. A persistent message (delivery mode 2 — the message body written to disk too).
  3. Publisher confirms (the broker tells the producer "got it, and it's on disk").
Big change in RabbitMQ 4.0

Since RabbitMQ 4.0, classic mirrored queues have been removed entirely. For high availability you now use quorum queues (Raft-based replication — Raft is a consensus algorithm ensuring a majority of nodes agree on the data — focused on data-safety) or streams (an append-only, Kafka-like log abstraction). If you learned RabbitMQ HA via ha-mode policies, that knowledge is obsolete; use quorum queues.

Kafka: partitions, offsets, consumer groups

A Kafka topic is split into partitions. Each partition is an ordered, immutable, append-only sequence; a message's position in that sequence is its offset (a monotonically increasing long, like a ledger's line number).

Parallel ledgers in a warehouse

Think of a topic as "all orders." If you write them all into a single ledger, only one person can read it at a time and it's slow. So you split it into several ledgers (partitions) that can be written and read in parallel. Inside one ledger, lines are ordered (line 5 always after line 4), but between two different ledgers there is no ordering — you can't tell whether ledger A's line 3 was written before ledger B's line 7.

Three key facts everything else derives from:

  1. Ordering is guaranteed only within a partition, never across a topic. If you need per-entity ordering (all events for order-42 in order), you must send them to one partition — using a partition key. Kafka hashes the key to pick a partition, so any message with the same key lands in the same partition.
  2. A consumer group is a set of consumers sharing a group.id. Kafka assigns each partition to exactly one consumer in the group. So the max useful parallelism of a group equals the partition count. 20 partitions ⇒ at most 20 active consumers; a 21st sits idle.
  3. Offsets are committed per group, stored in the internal __consumer_offsets topic. This is Kafka's cursor — where a group resumes. Two different groups reading the same topic are fully independent, each with its own offsets. That's how Kafka does pub/sub and work-queue simultaneously.
Topic "orders" (3 partitions), Group "billing" (2 consumers)

  P0: [o0 o1 o2 o3 ...]  --\
  P1: [o0 o1 o2 ...]      ---> consumer A  (owns P0, P1)
  P2: [o0 o1 ...]         ---> consumer B  (owns P2)
        ^committed offset per (group, partition)
Why "one partition, one consumer"

This rule is what both preserves ordering (only one person reads a ledger, so order can't scramble) and sets the ceiling on scalability. Grasp this and the "20 consumers on 12 partitions" interview question becomes obvious.

Rebalancing

When a consumer joins, leaves, or dies, the group rebalances — partitions are reassigned.

Re-dividing the tables

Three waiters cover 12 tables. One gets sick and leaves. Now 12 tables must be re-split between the 2 remaining. The question is: do you halt the whole restaurant to reassign everything from scratch (the slow way), or do you just spread that one waiter's tables across the other two while they keep working uninterrupted (the smart way)?

The old "eager" protocol was stop-the-world: everyone revoked everything, then reassigned (a latency spike). Cooperative sticky assignment (default since 2.4) only moves the partitions that must move. Kafka 4.0 ships KIP-848, a broker-driven rebalance protocol (the broker coordinates, not the consumers themselves) that is incremental and far faster; consumers opt in with group.protocol=consumer.

Retention: the log is not a queue

Kafka keeps messages per retention.ms (default 7 days) or retention.bytes, independent of whether anyone consumed them. Consuming does not delete.

The hidden power of replay

It's precisely "consuming doesn't delete" that enables replay: you reset a group's offsets to reprocess history from the start. This is invaluable for backfilling a new consumer or recovering from a bad deploy. In RabbitMQ, since messages are erased on ack, nothing like this exists.

Log compaction (cleanup.policy=compact) is a different retention mode that keeps only the latest value per key, turning a topic into a durable changelog / materialized keyspace (this is how Kafka Streams state and CDC snapshots work).

Commit timing and its trap

Properties p = new Properties();
p.put("bootstrap.servers", "kafka:9092");
p.put("group.id", "billing");
p.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
p.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
p.put("enable.auto.commit", "false");         // commit manually, AFTER processing
p.put("auto.offset.reset", "earliest");
p.put("isolation.level", "read_committed");   // ignore aborted transactional records

try (KafkaConsumer<String,String> c = new KafkaConsumer<>(p)) {
    c.subscribe(List.of("orders"));
    while (true) {
        ConsumerRecords<String,String> records = c.poll(Duration.ofMillis(500));
        for (ConsumerRecord<String,String> r : records) {
            process(r);                        // idempotent!
        }
        c.commitSync();                        // at-least-once: process THEN commit
    }
}

The ordering of process vs commit defines your delivery semantics exactly:

  • Process then commit → at-least-once (crash after processing, before commit ⇒ redelivery). This is the safe default.
  • Commit then process → at-most-once (crash after commit, before processing ⇒ message lost). Almost never what you want.
The enable.auto.commit=true trap

This setting commits on a timer inside poll(). It silently gives you at-least-once with an uncontrolled duplication window and, worse, if you hold records across polls it can commit offsets for records you haven't finished processing. Serious pipelines disable it.

Delivery semantics & idempotency

Now let's put the three words the whole chapter orbits around side by side:

Semantic Meaning How you get it Cost
At-most-once 0 or 1 delivery, may lose commit/ack before processing, autoAck, fire-and-forget Data loss
At-least-once ≥1 delivery, may duplicate ack/commit after processing + retries Duplicates → need idempotency
Exactly-once effectively 1 idempotency, or Kafka transactions within Kafka Complexity/throughput
The Two Generals problem

Two generals on two hills, the enemy in the valley between. They must attack at the same time or both lose. General A sends a messenger: "attack at dawn." But did the messenger arrive? A confirmation must come back. But did that confirmation arrive? A confirmation of the confirmation... this chain never ends. Neither is ever 100% certain. This is exactly why exactly-once delivery over a network is impossible.

The senior truth: because you can never be sure your ack arrived, you must retry, so duplicates are inevitable. What exists is exactly-once processing (effectively-once), achieved by making duplicates harmless. Two mechanisms:

1. Consumer-side idempotency — the universal answer. Give every message a stable business/message ID and record processed IDs. Before acting, check-and-set atomically.

// Idempotent consumer using a dedup table. Unique constraint on message_id is the guard.
@Transactional
public void handle(PaymentEvent e) {
    try {
        jdbc.update("INSERT INTO processed_messages(message_id, at) VALUES (?, now())", e.id());
    } catch (DuplicateKeyException dup) {
        return;                    // already processed — safe no-op
    }
    applyPayment(e);               // same tx: dedup marker + effect commit together
}

The genius of this code is the unique constraint on message_id: the database itself becomes the guard. If a duplicate arrives, the INSERT throws DuplicateKeyException and we return safely. Because both the marker insert and the real effect are inside one @Transactional, either both commit or neither does.

2. Kafka idempotent producer + transactions — solves duplicates inside Kafka.

Important default since Kafka 3.0

Since Kafka 3.0, enable.idempotence=true is the default: the producer gets a Producer ID (PID) and per-partition sequence numbers, so a broker-side retry that would have written a duplicate is deduplicated by the broker (also fixing the reordering that retries could cause).

Layer transactions on top (set transactional.id) to atomically write to multiple partitions/topics and commit consumer offsets in one atomic unit — the "consume-transform-produce" loop. Consumers must set isolation.level=read_committed to skip aborted records.

Crucial caveat: the boundary of exactly-once

This exactly-once is only within Kafka. The moment your effect is an external DB or a REST call, Kafka transactions cannot cover it — you're back to idempotency. This is precisely the gap the Outbox pattern fills.

The dual-write problem → Transactional Outbox

Now we reach one of the most important architecture bugs — one that hides in nearly every "microservice publishes an event" system.

// BROKEN: two systems, no shared transaction
@Transactional
public void placeOrder(Order o) {
    orderRepo.save(o);            // (1) commits to Postgres
    kafka.send("orders", event); // (2) sends to Kafka
}
Why this code is broken (dual-write)

There is no atomicity across Postgres and Kafka. Four failure modes:

  1. DB commits but the broker send fails → event lost, downstream never learns.
  2. Broker send succeeds but the DB tx rolls back → a phantom event for an order that doesn't exist.
  3. The process dies between (1) and (2).
  4. kafka.send is async and "succeeds" locally but never leaves the buffer.

@Transactional gives you zero protection here — the broker is not enrolled in the JDBC transaction.

The textbook "fix" is 2PC (Two-Phase Commit, XA transactions) across a DB and Kafka, but Kafka doesn't support XA well and 2PC is operationally toxic (blocking coordinator, poor availability). Don't.

One ledger, one signature

The problem is you're writing in two separate ledgers and might die between the two writes. The Outbox fix is clever: write both things into the same ledger. Instead of "save the order to the database, send the event to Kafka," you say "save the order to the database, and save the event into that same database in a table called outbox." Since both are in one database, a single COMMIT makes both final. Later, a separate worker ships the events from the outbox table to Kafka.

Transactional Outbox turns two writes into one:

CREATE TABLE outbox (
    id           UUID PRIMARY KEY,
    aggregate_id TEXT NOT NULL,          -- becomes the Kafka partition key
    type         TEXT NOT NULL,          -- e.g. 'OrderCreated'
    payload      JSONB NOT NULL,
    created_at   TIMESTAMPTZ DEFAULT now()
);
@Transactional  // ONE local transaction — atomic by construction
public void placeOrder(Order o) {
    orderRepo.save(o);
    outboxRepo.save(new OutboxRecord(
        UUID.randomUUID(), o.id().toString(), "OrderCreated", toJson(o)));
}

Now either both rows commit or neither does. The remaining question: how does the outbox reach Kafka? Two approaches:

  • Polling publisher: a background job repeatedly runs SELECT ... FROM outbox WHERE published=false ORDER BY created_at FOR UPDATE SKIP LOCKED, publishes each, then marks/deletes. Simple, no extra infra; downsides are polling latency and load. That SKIP LOCKED lets you run multiple pollers safely — each one skips rows another has locked.
  • CDC (Change Data Capture) with Debezium — the production-grade choice. Debezium tails the Postgres WAL (Write-Ahead Log, the log Postgres writes before every change) via logical replication, so it sees committed outbox inserts with no polling and near-zero DB overhead. Its Outbox Event Router SMT (Single Message Transform, a lightweight message rewrite) unwraps each outbox row into a proper Kafka message, routing by type and keying by aggregate_id. This is a "log-follows-log" design: the DB's own durable log becomes the source of truth for events.
Two honest caveats about the Outbox

First, the outbox gives at-least-once publishing (the publisher can crash after sending, before marking published) — consumers still must be idempotent. Second, the Inbox pattern is the mirror image on the consumer side: record processed message IDs in the same transaction as the effect, giving effectively-once end-to-end.

Saga: distributed transactions without 2PC

Once a business operation spans multiple services (order → payment → inventory → shipping), each with its own database, you cannot use a single ACID transaction. A Saga replaces it with a sequence of local transactions, where each step publishes an event that triggers the next.

Booking a trip

You're arranging a trip: flight, hotel, and rental car. There's no "single magic button" that finalizes all three together. So you book them one at a time. If the car falls through, you can't rewind time — instead you cancel the hotel and refund the flight. Those cancellations are compensations: new work that undoes the effect of prior work, not a magical "undo."

In a Saga there is no rollback across services; instead, each step has a compensating action that semantically undoes it (refund a charge, release a reservation). Sagas trade atomicity for eventual consistency — the system passes through visible intermediate states.

Choreography vs Orchestration

Dance vs orchestra

Two ways to coordinate a group. In choreography (group dance), no dancer is the leader; each dancer watches the neighbor's move and does their own. In orchestration, a conductor stands in front and tells each musician when and what to play.

Choreography — no central coordinator. Each service reacts to events and emits its own. OrderCreated → Payment listens, charges, emits PaymentCompleted → Inventory listens, reserves, emits StockReserved → Shipping listens…

  • Pros: fully decoupled, no single point of control, easy to add a step (just a new listener).
  • Cons: the business flow is implicit and smeared across services — no one place shows the whole saga. Debugging is archaeology; cyclic event dependencies creep in. Best for short flows (2–4 steps).

Orchestration — a central orchestrator (often a state machine) owns the flow, sending explicit commands and awaiting replies. It decides the next step and, on failure, drives compensations in reverse.

  • Pros: the flow lives in one readable place; complex conditional logic and timeouts are manageable; compensation order is easy to reason about.
  • Cons: the orchestrator is a component to build/operate; risk of it becoming a god-service if you push business logic into it.
ORCHESTRATION (happy path then compensation)
 Orchestrator --Charge--> Payment      (ok)
 Orchestrator --Reserve--> Inventory   (FAILS: out of stock)
 Orchestrator --Refund---> Payment     (compensate step 1, reverse order)
 Orchestrator: saga = FAILED
// Sketch of an orchestrator step handling a failure by compensating in reverse
void onInventoryReserved(SagaState s) { s.markReserved(); dispatch(new ArrangeShipping(s.orderId())); }

void onInventoryFailed(SagaState s) {
    // reverse order: undo the payment we already made
    dispatch(new RefundPayment(s.paymentId()));
    s.markFailed("out_of_stock");
}

Compensation is not rollback

The fundamental difference

A compensation is a new forward transaction that offsets a prior one; it can itself fail (and must be retried until it succeeds — make compensations idempotent and retryable). This differs from rollback: a rollback erases uncommitted changes within one transaction, whereas a compensation offsets an already-committed effect with a fresh one.

Some effects can't be undone (an email was sent, a missile launched) — for those, insert a pivot/confirmation step so nothing irreversible happens until the saga is committed. Design for semantic rollback, and expect the "backward recovery" path to be as much code as the forward path.

Choosing a broker

Need Prefer
Complex routing, per-message priority, per-message TTL, RPC-style request/reply RabbitMQ
Task/work queue with competing consumers, "consume and it's gone" RabbitMQ (or Kafka share groups)
High-throughput event streaming, replay, long retention, event sourcing Kafka
Strict per-key ordering at scale Kafka (partition by key)
Multiple independent consumers of the same stream (pub/sub + replay) Kafka
Stream processing / joins / stateful aggregation Kafka (+ Streams/Flink)
The golden heuristic

If the message is a command ("do this task, then it's done") lean RabbitMQ; if it's a fact/event ("this happened," possibly many interested parties, possibly reprocessed later) lean Kafka.

Note the line is blurring: Kafka 4.0's KIP-932 share groups add opt-in queue semantics (multiple consumers per partition, per-message ack/lock) — but it's early-access, not production-ready as of 4.0/4.1, so don't design around it yet.

Common pitfalls & gotchas

  • @Transactional around kafka.send — does nothing; the broker isn't in the DB tx. Use Outbox.
  • Assuming Kafka orders a topic — it orders a partition. Repartitioning (changing partition count) breaks key→partition stability and thus ordering.
  • Consumers ≫ partitions — extra Kafka consumers sit idle. Scale by adding partitions (but you can't easily reduce them).
  • RabbitMQ autoAck — silent message loss on crash. Use manual ack.
  • No idempotency downstream — every at-least-once system will hand you duplicates; if handling isn't idempotent, you will double-charge someone.
  • Poison-message loops — infinite requeue/redelivery hammering the broker. Cap attempts → DLQ.
  • Long processing > max.poll.interval.ms — Kafka assumes the consumer died and rebalances, causing duplicate processing. Process faster, raise the limit, or offload work.
  • Believing "exactly-once" solves everything — Kafka EOS is Kafka-internal only. External side effects still need idempotency.
  • Unbounded RabbitMQ prefetch — one greedy consumer starves the rest and enlarges the crash-loss window.

Interview Questions

Practice each one out loud; a senior interview grades not just the right answer but the why behind it.

1. Kafka vs RabbitMQ in one sentence each, and when do you pick which?

RabbitMQ is a smart broker that routes and deletes individual messages — pick it for task queues, complex routing, and request/reply. Kafka is a distributed, replayable, partitioned commit log — pick it for high-throughput event streaming, per-key ordering at scale, replay, and multiple independent consumers. Commands → RabbitMQ; events/facts → Kafka.

2. Why is exactly-once delivery impossible, and what do we build instead?

Over an unreliable network you can't distinguish "message lost" from "ack lost" (Two Generals), so you must retry, so duplicates are unavoidable. We instead build exactly-once processing (effectively-once) by making duplicate delivery harmless via idempotency, plus optionally Kafka transactions for the Kafka-internal portion.

3. (Gotcha) Your service does `orderRepo.save()` then `kafka.send()` inside `@Transactional`. What's wrong?

The database transaction and the Kafka send are not atomic — @Transactional doesn't enroll the broker. You can commit the order but lose the event, or emit a phantom event for an order that rolled back. This is the dual-write problem; fix it with the Transactional Outbox.

4. Explain the Outbox pattern and how it gets from DB to broker.

Write the domain change and an outbox row in one local DB transaction (atomic by construction). A separate process relays outbox rows to the broker via polling (FOR UPDATE SKIP LOCKED) or, preferably, CDC with Debezium tailing the WAL. It provides at-least-once publishing, so consumers must still be idempotent.

5. (Hard) Kafka guarantees ordering — true or false?

False as stated. Kafka guarantees ordering only within a single partition. Cross-partition and cross-topic order is undefined. For per-entity ordering you must route all of an entity's events to one partition via a partition key, and never change the partition count for that key space.

6. A consumer group has 20 consumers on a 12-partition topic. What happens?

12 consumers each own one partition; 8 sit idle (a partition is owned by at most one consumer per group). Adding consumers beyond partition count buys no throughput — you must add partitions instead.

7. Difference between Kafka's idempotent producer and Kafka transactions?

The idempotent producer (default since 3.0) dedupes producer retries to a single partition using a Producer ID + sequence numbers, preventing duplicates and reordering from retries. Transactions add atomic writes across multiple partitions/topics and atomic offset commits (consume-transform-produce), read by consumers with isolation.level=read_committed. Both are Kafka-internal only.

8. (Gotcha) With `enable.auto.commit=true`, what delivery semantic do you actually have and what's the risk?

At-least-once with an uncontrolled duplication window: offsets commit on a timer inside poll(), potentially before or after you finish processing. If it commits records you haven't fully processed and you then crash, you get at-most-once-style loss; if it commits late, larger duplicate replay. Serious consumers set it false and commitSync() after processing.

9. Choreography vs orchestration Saga — trade-offs?

Choreography: services react to each other's events, fully decoupled, but the flow is implicit and smeared across services, making it hard to see/debug — good for short flows. Orchestration: a central coordinator issues commands and drives compensation, giving one readable place for the flow and complex logic, at the cost of an extra component and god-service risk.

10. What is a compensating transaction and how is it different from a rollback?

A rollback atomically erases uncommitted changes within one ACID transaction. A compensation is a new committed forward transaction that semantically offsets an already-committed step (refund, release reservation). It can fail and must be idempotent and retried; some effects are irreversible, so use a pivot step to defer irreversible actions until the saga can commit.

11. RabbitMQ prefetch is 1 vs 1000 — implications?

Prefetch 1: the broker sends one unacked message at a time — maximum fairness across consumers, but a network round-trip per message caps throughput; good for long/uneven tasks. Prefetch 1000: high throughput and lower per-message overhead, but one greedy consumer hoards work, uses more memory, and widens the window of messages lost on a crash.

12. (Gotcha — find the bug) A RabbitMQ consumer uses `autoAck=true` and does slow processing. Messages occasionally vanish. Why?

With auto-ack the broker considers a message acknowledged the instant it's written to the consumer's socket, before processing. If the consumer crashes mid-processing, the message is already gone and never redelivered. Fix: manual ack after successful processing.

13. How do you prevent poison messages from looping forever?

Track a delivery/attempt count (RabbitMQ x-death header or an outbox/Kafka header). After N failures, stop requeueing and route to a DLQ for inspection instead of nack(requeue=true) forever. Optionally add exponential backoff via a TTL delay queue between retries.

14. (Hard) Consumer processing takes 6 minutes; `max.poll.interval.ms` is 5 minutes. What breaks?

Kafka concludes the consumer is dead (it hasn't called poll() in time), rebalances its partitions to others, and reprocesses the same records — causing duplicates and a rebalance storm if many consumers are slow. Fix by processing faster, offloading work to another thread/pool while still polling, or raising max.poll.interval.ms (and shrinking max.poll.records).

15. (Senior) You need per-order event ordering AND high fan-out to many independent teams AND replay for a new consumer 3 months later. Which broker and how?

Kafka. Partition by orderId so all of an order's events land in one partition (ordering). Each team uses its own consumer group so they consume independently (fan-out). Retention/compaction lets a new group reset offsets to earliest and replay history. RabbitMQ would delete messages on ack, defeating both fan-out-with-independent-progress and 3-month replay.

16. (Gotcha) What does this print?
producer.send(new ProducerRecord<>("t", "k1", "a"));
producer.send(new ProducerRecord<>("t", "k2", "b"));
producer.send(new ProducerRecord<>("t", "k1", "c"));
// consumer prints values in the order it reads them from ONE partition it owns

You cannot assume a b c. k1 records (a, c) share a partition and are ordered relative to each other → a before c. But k2's b is (likely) in a different partition with no ordering relationship to the others. Across the topic, b may appear before, between, or after a/c. Only ac is guaranteed.

In a nutshell
  • A broker decouples producer and consumer in time and space; that same freedom brings distributed-systems problems.
  • RabbitMQ = smart broker, a queue, message deleted after ack: exchanges/bindings, manual ack, DLQ, prefetch, and since 4.0 quorum queues for HA.
  • Kafka = append-only log, message retained and replayable: partitions/offsets/consumer groups, ordering only within a partition, KIP-848 for faster rebalance, and the enable.auto.commit trap.
  • Exactly-once delivery over a network is impossible (Two Generals); instead build effectively-once with idempotency. Kafka's idempotent producer + transactions work Kafka-internally only.
  • dual-write (writing to DB and broker together) isn't atomic; the Transactional Outbox turns two writes into one local transaction, and polling or Debezium/CDC ships it to the broker.
  • Saga replaces the distributed transaction with a sequence of local transactions + compensation (choreography for short flows, orchestration for complex ones).
  • Final rule: command → RabbitMQ, event/fact → Kafka, and make every at-least-once system safe with idempotency.