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

RabbitMQ و AMQP (و مقایسه با Kafka، JMS و MQTT)RabbitMQ & AMQP (vs Kafka, JMS and MQTT)

از مدل AMQP 0-9-1 (exchange، binding، routing key و queue) تا durability و publisher confirms، ack و prefetch، DLX و retry با backoff، quorum queueها و عملیات کلاستر، Spring AMQP در عمل، و مقایسهٔ صریح RabbitMQ با Kafka، JMS/Artemis و MQTT.From the AMQP 0-9-1 model (exchanges, bindings, routing keys, queues) through durability and publisher confirms, acknowledgements and prefetch, dead-letter exchanges and proper backoff retries, quorum queues and cluster operations, Spring AMQP in practice, and a blunt comparison of RabbitMQ against Kafka, JMS/Artemis and MQTT.

پیش‌نیاز:Prerequisites: Kafka، RabbitMQ، Outbox و SagaKafka, RabbitMQ, Outbox & Saga


در فصل Kafka یاد گرفتی یک «log توزیع‌شده» چیست. حالا می‌رویم سراغ خانوادهٔ دیگری از broker‌ها که آگهی‌های شغلی دقیقاً به همان اندازه می‌خواهندش: broker‌های صف‌محور، و سردستهٔ آن‌ها RabbitMQ.

این‌ها دو ابزار برای یک کار نیستند. Kafka یک دفترچهٔ خاطرات است که هر کس می‌تواند از هر صفحه‌ای دوباره بخواند؛ RabbitMQ یک ادارهٔ پست است که نامه را به صندوق درست می‌رساند و بعد از تحویل نامه دیگر آنجا نیست. همهٔ تصمیم‌های معماری از همین جمله بیرون می‌آید.

از صفر شروع می‌کنیم و هیچ اصطلاحی را بدون تعریف رها نمی‌کنم — اما جایی تمام می‌شود که یک senior باید بایستد: «prefetch را چند بگذاریم»، «چرا retry شما production را زمین زد» و «چرا اینجا Kafka جواب نمی‌دهد».

نقشهٔ راه فصل

۱. چرا broker؟ و AMQP یعنی چه (پروتکل، نه کتابخانه). ۲. مدل AMQP 0-9-1 از صفر: producer، exchange، binding، routing key، queue، consumer، channel، vhost. ۳. چهار نوع exchange و مثال‌های واقعی routing. ۴. انواع صف: classic، quorum، stream — و اینکه mirroring مرده است. ۵. Durability: durable queue، persistent message، publisher confirms، fsync، transaction. ۶. Ack مصرف‌کننده: ack/nack/reject، requeue، prefetch/QoS، consumer timeout. ۷. مدیریت خطا: DLX، TTL، تأخیر، اولویت، retry با backoff، poison message. ۸. Idempotency و deduplication (با SQL). ۹. عملیات: cluster، alarm، flow control، Management API، متریک، sizing. ۱۰. Spring AMQP در عمل + تست با Testcontainers. ۱۱. مقایسهٔ صریح: RabbitMQ vs Kafka vs JMS/Artemis vs MQTT.


۱. چرا اصلاً یک broker؟

ادارهٔ پست

حالت اول: کارمند شرکت A سوار موتور می‌شود، می‌رود دم در شرکت B و منتظر می‌ماند تا کسی در را باز کند؛ اگر B تعطیل باشد، A بی‌کار پشت در می‌ماند. این فراخوانی HTTP همزمان است.

حالت دوم: A نامه را در صندوق پست می‌اندازد؛ ادارهٔ پست رسید می‌دهد و هر وقت B باز بود تحویلش می‌دهد. اگر B سه ساعت خواب باشد نامه‌ها جمع می‌شوند اما گم نمی‌شوند، و A فوراً به کارش برمی‌گردد. آن ادارهٔ پست، message broker است.

سه چیزی که broker می‌دهد و HTTP نمی‌دهد: decoupling، buffering (اگر گیرنده افتاد پیام‌ها می‌مانند نه اینکه ۵۰۰ برگردد) و back-pressure. و چیزی که نمی‌دهد: درستی منطق تو — پیام حداقل یک‌بار می‌رسد، پس consumer باید idempotent باشد.

AMQP مخفف Advanced Message Queuing Protocol است و اینجا تلهٔ بزرگی هست:

  • AMQP 0-9-1: پروتکلی که RabbitMQ حول آن ساخته شده، با مدل exchange/binding/queue. وقتی در دنیای RabbitMQ می‌گویند «AMQP» منظور همین است.
  • AMQP 1.0: یک پروتکل کاملاً متفاوت با نام مشابه — استاندارد OASIS و ISO/IEC 19464، بدون مدل exchange، فقط node و link و flow. Azure Service Bus و ActiveMQ Artemis از این استفاده می‌کنند.

این دو، دو نسخه از یک چیز نیستند؛ دو پروتکل مستقل با نام‌های شبیه‌اند.

RabbitMQ از ۴.۰ به بعد پشتیبانی بومی از AMQP 1.0 دارد، نه از راه plugin ترجمه‌کننده: روی همان پورت 5672 هم کلاینت 0-9-1 و هم 1.0 وصل می‌شوند. سری ۴.x تغییرات بزرگ دیگری هم آورد: در ۴.۰ آینه‌کاری صف‌های classic کاملاً حذف شد؛ در ۴.۲ انبارهٔ متادیتای جدید Khepri (مبتنی بر Raft) پیش‌فرض شد؛ و در ۴.۳ (آوریل ۲۰۲۶) Khepri تنها گزینه شد، quorum queueها اولویت تا ۳۲ سطح و delayed retry بومی گرفتند، و حداقل Erlang/OTP به ۲۷ رسید.


۲. مدل AMQP 0-9-1 از صفر

هر واژه را جدا بسازیم. این بخش را حفظ نکن — تصویرش را بساز.

Producer: برنامه‌ای که پیام می‌فرستد. نکتهٔ کلیدی و ضدشهودی: producer هرگز مستقیماً به queue نمی‌فرستد؛ همیشه به یک exchange می‌فرستد.

Exchange: جعبهٔ مسیریابی — پیام را می‌گیرد، به قاعده‌هایش نگاه می‌کند و تصمیم می‌گیرد در کدام صف(ها) بیندازد؛ اگر هیچ صفی مطابقت نکند پیام بی‌سروصدا حذف می‌شود. Queue: بافر نام‌دار FIFO؛ صف است که پیام را نگه می‌دارد، نه exchange. Binding: قاعده‌ای که exchange را به queue وصل می‌کند («هر پیامی که با این الگو خواند بریز در این صف») و می‌تواند یک binding key داشته باشد. Routing key: رشته‌ای که producer روی پیام می‌گذارد — آدرس روی پاکت، که exchange آن را با binding keyها مقایسه می‌کند.

Consumer: برنامه‌ای که از صف پیام می‌گیرد و پس از پردازش ack می‌دهد. Connection: یک اتصال TCP به broker؛ گران است (handshake، TLS، احراز هویت). Channel: اتصال مجازی سبکی که روی همان TCP مالتی‌پلکس می‌شود و تمام دستورهای AMQP روی آن اجرا می‌شوند. Virtual host (vhost): فضای‌نام منطقی داخل broker که exchangeها، صف‌ها، bindingها و مجوزها داخل آن‌اند (پیش‌فرض /) — مثل یک schema در پایگاه‌داده، برای جدا کردن محیط‌ها یا تیم‌ها.

کل مدل در یک نگاه | The whole AMQP 0-9-1 model at a glance:

flowchart LR
  P[Producer] -->|"basic.publish + routing key"| X{{Exchange}}
  X -->|"binding key: order.created"| Q1[[Queue: billing]]
  X -->|"binding key: order.*"| Q2[[Queue: audit]]
  X -->|no binding matched| D[Dropped or returned]
  Q1 -->|basic.deliver| C1[Consumer A]
  Q2 -->|basic.deliver| C2[Consumer B]
  C1 -.->|basic.ack| Q1
connection گران است، channel ارزان — و churn قاتل

یک connection TCP بساز و در آن ده‌ها channel باز کن، نه برعکس. قاعده: یک connection به ازای هر پروسه، یک channel به ازای هر thread یا هر consumer — هر connection در سمت broker چند پروسهٔ Erlang و بافر و socket می‌گیرد.

دو اشتباهی که مرتب production را زمین می‌زند: اشتراک یک channel بین چند thread (channel در کلاینت رسمی جاوا برای publish همزمان thread-safe نیست؛ نتیجه UNEXPECTED_FRAME و بسته‌شدن ناگهانی channel)؛ و churn، یعنی باز کردن اتصال برای هر پیام، که CPU و حافظهٔ broker را می‌بلعد. راه‌حل: CachingConnectionFactory یا یک pool با طول عمر برابر طول عمر برنامه.

هر پیام AMQP سه بخش دارد: method frame (دستور، مثلاً basic.publishcontent header (خصوصیت‌ها) و یک یا چند body frame. خصوصیت‌هایی که باید بشناسی: delivery_mode (۱ گذرا، ۲ ماندگار روی دیسک)، content_type، headers (جدول دلخواه که routing برای headers exchange روی آن انجام می‌شود)، correlation_id و reply_to برای request/reply، expiration یعنی TTL همان یک پیام برحسب میلی‌ثانیه به‌صورت رشته، و message_id که شناسهٔ یکتا و پایهٔ deduplication است.

فرق exchange و queue چیست و چرا producer مستقیم به صف نمی‌فرستد؟

پاسخ: queue فقط یک بافر است و هیچ منطقی ندارد؛ exchange موتور مسیریابی است. producer به exchange می‌فرستد تا از توپولوژی مصرف‌کننده‌ها بی‌خبر بماند: امروز یک صف bind شده، فردا پنج صف، و کد producer عوض نمی‌شود. همین است که RabbitMQ را «smart broker / dumb consumer» می‌کند در برابر Kafka که «dumb broker / smart consumer» است.

نکتهٔ تکمیلی که امتیاز می‌گیرد: یک default exchange با نام خالی "" از نوع direct وجود دارد و هر صف خودکار با binding key برابر نام خودش به آن bind می‌شود؛ پس «مستقیم فرستادن به صف» در واقع فرستادن به default exchange با routing key برابر نام صف است — استثنا نیست، همان قاعده است.


۳. چهار نوع exchange

پشت باجهٔ پست چهار کارمند نشسته‌اند: direct اگر آدرس دقیقاً با برچسب یک صندوق یکی بود نامه را می‌اندازد داخلش؛ fanout اصلاً به آدرس نگاه نمی‌کند و از هر نامه یک کپی در همهٔ صندوق‌ها می‌اندازد؛ topic آدرس را با نقطه تکه‌تکه می‌کند و با الگو تطبیق می‌دهد؛ و headers پاکت را باز می‌کند و به برچسب‌های داخلی نگاه می‌کند نه به آدرس.

Direct تطبیق دقیق و بایت‌به‌بایت بین routing key و binding key است:

# rabbitmqadmin v2 — باینری Rust مستقل، نسل دوم ابزار HTTP API
rabbitmqadmin declare exchange --name payments --type direct --durable true
rabbitmqadmin declare queue --name payments.captured --type quorum --durable true
rabbitmqadmin declare binding --source payments --destination payments.captured \
  --destination-type queue --routing-key captured

پیامی با routing key برابر captured می‌رسد؛ capture یا Captured نمی‌رسد.

Fanout routing key را نادیده می‌گیرد و پیام را در همهٔ صف‌های bind‌شده کپی می‌کند. سریع‌ترین نوع است، اما اگر ۵۰ صف bind کرده‌ای عملاً throughput نوشتن را در ۵۰ ضرب کرده‌ای.

Topic پرکاربردترین است: routing key با نقطه به بخش‌ها شکسته می‌شود و در binding key دو wildcard داری — * دقیقاً یک بخش، # صفر یا چند بخش.

چهار binding را روی exchange amq.topic تصور کن: order.*.created، order.eu.#، # و order.eu.created. حالا ببین هر پیام کجا می‌رود:

routing key پیام order.*.created order.eu.# # order.eu.created
order.eu.created
order.us.created.retry ❌ (چهار بخش)
order.eu ✅ (# صفر بخش را می‌گیرد)
invoice.eu.created

دقت کن order.eu.# عبارت order.eu را هم می‌گیرد، چون # می‌تواند صفر بخش باشد — همین را در مصاحبه می‌پرسند.

routing key را از عام به خاص بچین، چون wildcardها از چپ معنا پیدا می‌کنند: order.eu.created بهتر از created.eu.order است چون اجازه می‌دهد order.# را bind کنی. شناسهٔ متغیر با کاردینالیتی بالا (order.1234567.created) هرگز در routing key نگذار؛ جایش payload یا header است. سقف routing key ۲۵۵ بایت است.

Headers routing key را نادیده می‌گیرد و روی جدول headers تطبیق می‌دهد. در binding آرگومان x-match می‌گذاری: all یعنی همهٔ هدرهای مشخص‌شده باید مطابقت کنند (AND)، any یعنی یکی کافی است (OR)؛ نسخه‌های اخیر all-with-x و any-with-x را هم دارند که هدرهای شروع‌شده با x- را در تطبیق دخالت می‌دهند. مثلاً یک binding با x-match=all، format=pdf و locale=fa-IR فقط پیام‌هایی را می‌گیرد که هر دو هدر را داشته باشند.

headers از topic کندتر است (تطبیق روی map به‌جای تطبیق رشته‌ای بهینه‌شده)؛ فقط وقتی استفاده کن که واقعاً تطبیق چندبُعدی می‌خواهی. هر vhost این exchangeها را از پیش دارد: "" (default، direct)، amq.direct، amq.fanout، amq.topic، amq.headers/amq.match و amq.rabbitmq.trace.

پیام‌های بی‌مقصد بی‌سروصدا حذف می‌شوند

اگر پیامی به exchange برسد و هیچ binding‌ای مطابقت نکند، RabbitMQ آن را دور می‌اندازد: بدون خطا، بدون لاگ، بدون استثنا. publisher confirm هم می‌گیری، چون از دید broker پیام «با موفقیت پردازش شد» — یعنی در هیچ صفی جا نگرفت. این کلاسیک‌ترین باگ «پیام‌ها گم می‌شوند» است و معمولاً از یک typo در routing key می‌آید.

دو محافظ لازم است و باید هر دو را داشته باشی: پرچم mandatory هنگام publish که broker پیام بی‌مقصد را با basic.return برمی‌گرداند (در Spring: template.mandatory=true + یک ReturnsCallback)؛ و alternate-exchange روی exchange اصلی که به یک صف «unroutable» می‌رود — شبکهٔ ایمنی سمت server. Prometheus شمارندهٔ پیام‌های بی‌مقصد دارد؛ روی آن alert بگذار.

چطور «یک پیام به همهٔ سرویس‌ها» و «یک پیام فقط به یکی از N نمونهٔ یک سرویس» را هم‌زمان پیاده می‌کنی؟

پاسخ: با تفکیک نقش exchange و queue. یک صف به ازای هر سرویس، نه به ازای هر نمونه: همهٔ podهای سرویس billing از صف billing.orders مصرف می‌کنند و RabbitMQ round-robin پخش می‌کند، پس هر پیام فقط به یک نمونه می‌رسد؛ این «competing consumers» است و مقیاس افقی را رایگان می‌دهد. برای انتشار به چند سرویس، همان exchange را به صف‌های چند سرویس bind کن: billing.orders، shipping.orders و analytics.orders هر کدام نسخهٔ خودشان را می‌گیرند.

تفاوتی که مصاحبه‌گر منتظرش است: در Kafka موازی‌سازی مصرف به تعداد partition محدود است (۱۰ partition یعنی حداکثر ۱۰ consumer فعال در یک گروه)؛ در RabbitMQ چنین سقفی نیست. در عوض Kafka ترتیب per-partition و replay می‌دهد که صف‌های معمولی نمی‌دهند.


۴. انواع صف: classic، quorum، stream

ویژگی Classic Quorum Stream
مدل تک‌نسخه، غیرتکراری Raft، چند replica لاگ append-only تکرارشونده
بقا در سقوط node ✅ با اکثریت ✅ با اکثریت
پیام پس از ack حذف می‌شود حذف می‌شود تا پایان retention می‌ماند
Replay از ابتدا ✅ (offset-based)
Priority ✅ (x-max-priority) ✅ از ۴.۳ (تا ۳۲ سطح)
TTL هر پیام فقط retention کل stream
Poison message دستی delivery-limit داخلی مصرف‌کننده مسئول است
بهترین برای صف موقت، RPC، کار دورریختنی هر چیزی که از دست دادنش گران است رویداد، replay، چند مصرف‌کنندهٔ مستقل
Classic queue mirroring دیگر وجود ندارد

اگر مقاله‌ای دیدی که می‌گوید policy با ha-mode: all بگذار تا صف‌ها HA شوند، آن مقاله برای RabbitMQ 3.x است و در ۴.x بی‌اثر است: آینه‌کاری صف‌های classic در ۴.۰ کاملاً حذف شد و آن بخش‌های policy نادیده گرفته می‌شوند. صف‌ها کار می‌کنند اما با یک replica؛ اگر آن node بیفتد صف در دسترس نیست و اگر دیسک از دست برود پیام‌ها رفته‌اند.

مسیر درست: صف‌های مهم را با x-queue-type: quorum اعلام کن. و توجه: نوع صف پس از ساخت قابل تغییر نیست — باید صف جدید بسازی و ترافیک را جابه‌جا کنی (با shovel یا با تغییر binding و drain کردن صف قدیمی).

Map<String, Object> args = Map.of(
        "x-queue-type", "quorum",
        "x-dead-letter-exchange", "orders.dlx",
        "x-delivery-limit", 5,             // پیش‌فرض در 4.x برابر 20 است
        "x-max-length", 100_000,
        "x-overflow", "reject-publish");   // برای dead-lettering at-least-once لازم است

channel.queueDeclare("orders.work", /* durable */ true,
                     /* exclusive */ false, /* autoDelete */ false, args);

سه نکتهٔ عملیاتی: تعداد replica فرد باشد (۳ یا ۵؛ عدد زوج فقط هزینه اضافه می‌کند)؛ صف باید کوتاه بماند؛ و صف‌های exclusive یا auto-delete نمی‌توانند quorum باشند — برای صف پاسخ RPC همچنان classic exclusive بزن.

Stream از ۳.۹ اضافه شد: یک لاگ append-only تکرارشونده که مصرف از آن پیام را حذف نمی‌کند؛ مصرف‌کننده یک offset دارد و می‌تواند از ابتدا، از offset مشخص یا از timestamp شروع کند. Super stream همان stream پارتیشن‌شده روی چند stream فیزیکی است — معادل topic در Kafka، و هر stream معادل یک partition. پروتکل اختصاصی آن روی پورت 5552 است، ولی با AMQP 0-9-1 هم می‌توان مصرف کرد (x-stream-offset).

کِی از quorum queue استفاده می‌کنی و کِی نه؟

پاسخ: فرمول ساده: اگر از دست رفتن یک پیام یعنی از دست رفتن پول، داده یا اعتماد، quorum؛ وگرنه classic. quorum بر پایهٔ Raft است و هر پیام قبل از confirm باید در اکثریت replicaها نوشته شود — یعنی هزینهٔ نوشتن بیشتر، latency کمی بالاتر و ترافیک درون‌کلاستری.

جاهایی که نباید quorum بزنی: صف‌های پاسخ RPC که exclusive‌اند (اصلاً پشتیبانی نمی‌شود)؛ صف‌های با نرخ خیلی بالا و ارزش خیلی پایین مثل telemetry دورریختنی؛ و وقتی صف‌های خیلی زیاد و کوتاه‌عمر می‌سازی، چون هر quorum queue یک خوشهٔ Raft است.

جملهٔ پایانی که senior بودن را نشان می‌دهد: «quorum queue دوامِ صف را تضمین می‌کند، نه دوامِ پردازش را. اگر publisher confirm نگرفته باشد یا consumer قبل از ack خطا بخورد، همچنان مسئلهٔ من است.»


۵. Durability و persistence — سه‌گانه‌ای که همه اشتباه می‌گیرند

سه چیز مستقل وجود دارد و هر سه باید درست باشند وگرنه پیام از دست می‌رود:

۱. Durable queue: خودِ تعریف صف در متادیتای broker ذخیره می‌شود و با restart بازساخته می‌شود. صف non-durable با restart ناپدید می‌شود، با تمام محتوایش. ۲. Persistent message: پیام با delivery_mode = 2 منتشر می‌شود و broker آن را روی دیسک می‌نویسد. delivery_mode = 1 یعنی فقط در حافظه. ۳. Publisher confirm: publisher منتظر تأیید broker می‌ماند که پیام واقعاً پذیرفته و ذخیره شده است.

ترکیب‌هایی که در production می‌سوزانند
  • صف durable + پیام transient: صف بعد از restart برمی‌گردد اما خالی. پرتکرارترین اشتباه، چون تیم فکر می‌کند «صف را durable کردیم پس امن است».
  • پیام persistent + صف non-durable: پیام روی دیسک نوشته می‌شود ولی صف بعد از restart وجود ندارد.
  • همه چیز درست + بدون confirm: بین basic.publish تو و fsync شدن پیام یک بازه هست؛ اگر در آن بازه broker بمیرد پیام رفته و برنامهٔ تو هرگز نمی‌فهمد، چون basic.publish هیچ پاسخی ندارد و ذاتاً fire-and-forget است.

پس: durable queue و persistent message و publisher confirm. هر سه.

وقتی broker پیامی را «روی دیسک می‌نویسد» اول فقط به page cache سیستم‌عامل می‌نویسد؛ اگر برق برود آن داده هنوز روی دیسک فیزیکی نیست و fsync است که سیستم‌عامل را مجبور می‌کند بافر را واقعاً بنویسد. RabbitMQ برای صف‌های classic نوشتن‌ها را دسته می‌کند و دوره‌ای fsync می‌زند، پس پنجرهٔ کوچکی از دست‌رفتن داده هست — و publisher confirm دقیقاً همین را حل می‌کند. برای quorum queue قاعده قوی‌تر است: confirm وقتی می‌آید که ورودی در اکثریت replicaها نوشته شده باشد، یعنی تحمل از دست رفتن یک ماشین کامل نه فقط یک reboot تمیز.

جریان publisher confirm و آنچه تضمین می‌کند | The publisher confirm flow and its guarantee:

sequenceDiagram
  participant P as Publisher
  participant B as Broker
  participant Q as Durable / quorum queue
  P->>B: confirm.select
  B-->>P: confirm.select-ok
  P->>B: basic.publish (delivery_mode=2, tag 1)
  B->>Q: route + persist / replicate to majority
  Q-->>B: durable
  B-->>P: basic.ack (tag 1)
  P->>B: basic.publish (bad routing key, tag 2)
  B-->>P: basic.return (mandatory)
  B-->>P: basic.ack (tag 2)
  Note over P,B: ack does NOT mean routed. Watch returns too.
channel.confirmSelect();   // فعال کردن حالت confirm روی همین channel

channel.addConfirmListener(
    (tag, multiple) -> outstanding.headMap(tag, true).clear(),          // ack
    (tag, multiple) -> log.error("broker nacked delivery tag {}", tag)); // nack

channel.addReturnListener(ret ->
    log.error("unroutable: exchange={} rk={}", ret.getExchange(), ret.getRoutingKey()));

AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
        .deliveryMode(2)                                  // persistent
        .messageId(UUID.randomUUID().toString())
        .contentType("application/json")
        .build();

channel.basicPublish("orders", "order.eu.created", /* mandatory */ true, props, body);

سه سبک confirm وجود دارد: تک‌تک همزمان (throughput فاجعه، تقریباً هیچ‌وقت)، دسته‌ای با waitForConfirmsOrDie برای pipelineهای batch، و ناهمزمان با ConfirmListener که برای سرویس‌های آنلاین بهترین است.

AMQP 0-9-1 تراکنش هم دارد (tx.select / tx.commit) با تضمین مشابه اما ۱۰ تا ۱۰۰ برابر کندتر، چون هر commit یک fsync همزمان است؛ ضمناً یک channel نمی‌تواند هم‌زمان transactional و در حالت confirm باشد. در عمل: همیشه confirm، هرگز transaction.

confirm یعنی «broker گرفت»، نه «کار انجام شد»

publisher confirm نمی‌گوید پیام به صفی مسیریابی شد (برای آن mandatory + return listener لازم است)، نمی‌گوید consumer‌ای آن را دید، و نمی‌گوید کار انجام شد. مهم‌تر: مشکل اتمیک نبودن پایگاه‌داده و broker را حل نمی‌کند — اگر تراکنش دیتابیس commit شود و بعد publish شکست بخورد، وضعیت ناسازگار داری.

راه‌حل استاندارد transactional outbox است: در همان تراکنش دیتابیس یک ردیف در جدول outbox بنویس و یک پروسهٔ جدا آن‌ها را بخواند، بفرستد و بعد از confirm علامت بزند. publisher confirm جایگزین outbox نیست بلکه مکمل آن است.


۶. Acknowledgement مصرف‌کننده و prefetch

Auto-ack (autoAck = true): به‌محض اینکه broker پیام را روی سوکت نوشت آن را حذف‌شده حساب می‌کند؛ اگر consumer قبل از پردازش بمیرد پیام رفته، و ضمناً هیچ محدودیتی روی جریان ندارد. Manual ack: تو صریحاً می‌گویی چه شد.

دستور معنا requeue؟ چند پیام؟
basic.ack(tag, multiple) موفق با multiple=true همه تا آن tag
basic.nack(tag, multiple, requeue) ناموفق قابل انتخاب با multiple=true دسته‌ای
basic.reject(tag, requeue) ناموفق قابل انتخاب فقط یکی

basic.reject استاندارد AMQP است و فقط تک‌پیامی؛ basic.nack افزودهٔ RabbitMQ با پرچم multiple است. requeue = false یعنی dead-letter شدن (یا حذف، اگر DLX نباشد)؛ requeue = true یعنی برگشت به صف، معمولاً نزدیک سرِ آن. توجه: multiple=true یعنی «همهٔ پیام‌های قبلی هم موفق بودند» — اگر خارج از ترتیب پردازش می‌کنی، پیام‌های ناتمام را بی‌سروصدا ack می‌کند.

prefetch سینی پیشخدمت است: اگر هر بار یک بشقاب ببرد مدام راه می‌رود، و اگر بیست بشقاب روی سینی بگذارد بشقاب بیستم سرد می‌شود و یک زمین‌خوردن همه را می‌ریزد. یعنی چند پیامِ ack‌نشده broker حاضر است هم‌زمان به یک consumer بدهد.

// prefetchSize باید صفر باشد؛ RabbitMQ آن را پیاده نکرده است
// global=false یعنی «به ازای هر consumer»، global=true یعنی «به ازای کل channel»
channel.basicQos(0, 50, false);
prefetch نامحدود یا auto-ack، سریع‌ترین راه برای OOM شدن سرویس

اگر basicQos را صدا نزنی prefetch نامحدود است: broker با تمام سرعت می‌فرستد و کلاینت جاوا آن‌ها را در حافظه بافر می‌کند. سناریوی واقعی: صفی با ۲ میلیون پیام انباشته، consumer را restart می‌کنی و در چند ثانیه با OutOfMemoryError می‌افتد، دوباره restart، دوباره OOM — حلقهٔ مرگی که فقط با prefetch شکسته می‌شود.

عوارض کمتر دیده‌شدهٔ prefetch زیاد: توزیع ناعادلانه (یک consumer کند ۵۰۰ پیام را قفل می‌کند و بقیه بی‌کارند)؛ موج requeue هنگام deploy؛ و latency دم توزیع، چون پیام‌ها پشت کارهای کند در صفِ محلیِ یک consumer گیر می‌کنند.

اندازهٔ درست prefetch — و مهلت ۳۰ دقیقه‌ای که غافلگیرت می‌کند

پردازش کند (چند ثانیه، I/O سنگین، سرویس بیرونی): prefetch = 1 تا 5، چون انصاف در توزیع از throughput مهم‌تر است. پردازش سریع (زیر چند میلی‌ثانیه): prefetch = 100 تا 300. فرمول تقریبی: prefetch ≈ (RTT شبکه ÷ زمان پردازش هر پیام) × تعداد thread — اگر پردازش ۵۰ms و RTT ۱ms است، حتی prefetch=۱۰ هم زیاد است. در Spring، prefetch باید حداقل به اندازهٔ concurrency باشد وگرنه بعضی threadها همیشه بی‌کار می‌مانند.

و نکته‌ای که خیلی‌ها به سختی یاد می‌گیرند: consumer_timeout با پیش‌فرض ۱٬۸۰۰٬۰۰۰ میلی‌ثانیه (۳۰ دقیقه). اگر consumer پیامی را بیشتر از آن ack نکند، broker channel را با PRECONDITION_FAILED می‌بندد و پیام requeue می‌شود. می‌توانی مقدار سراسری را در rabbitmq.conf یا policy consumer-timeout عوض کنی — اما جواب بهتر این است: کار طولانی را به چند پیام کوچک‌تر بشکن، نه به یک ack طولانی.

چرخهٔ عمر پیام از دید broker | The message lifecycle as the broker sees it:

stateDiagram-v2
  [*] --> Ready: published and routed
  Ready --> Unacked: basic.deliver to a consumer
  Unacked --> [*]: basic.ack
  Unacked --> Ready: basic.nack requeue=true
  Unacked --> Ready: channel closed or connection lost
  Unacked --> DeadLettered: basic.nack requeue=false
  Unacked --> DeadLettered: delivery-limit exceeded
  Ready --> DeadLettered: TTL expired or max-length reached
  DeadLettered --> [*]: consumed from the DLQ
`nack` با `requeue=true` یعنی حلقهٔ داغ بی‌نهایت

این کد را زیاد می‌بینی و همیشه غلط است:

try {
    process(msg);
    channel.basicAck(tag, false);
} catch (Exception e) {
    channel.basicNack(tag, false, true);  // ← بمب ساعتی
}

اگر پیام ذاتاً خراب باشد (JSON نامعتبر، فیلد اجباری خالی، شناسه‌ای که در دیتابیس نیست) این کد آن را بی‌نهایت بار پردازش و رد می‌کند: ۱۰۰٪ CPU، سیل لاگ و مسدود شدن پیام‌های سالم. به این «poison message» می‌گویند.

قاعدهٔ درست دو شاخه است: خطای دائمی (داده خراب است) → requeue = false و رفتن به DLQ، هرگز تلاش دوباره؛ خطای گذرا (دیتابیس در دسترس نیست، تایم‌اوت HTTP) → مکانیزم retry با تأخیر، نه requeue فوری. و به‌عنوان تور ایمنی روی quorum queue حتماً delivery-limit بگذار؛ در ۴.x پیش‌فرض آن ۲۰ است و مقدار -1 محدودیت را برمی‌دارد (کاری که معمولاً نباید بکنی).

چطور مطمئن می‌شوی هیچ پیامی گم نمی‌شود، از publish تا پردازش؟

پاسخ: یک زنجیره که هر حلقه‌اش باید بسته باشد. سمت publisher: exchange و صف durable، پیام با delivery_mode=2، confirm.select روشن، mandatory=true با return listener، و برای اتمیک بودن با دیتابیس الگوی outbox. سمت broker: صف quorum با ۳ replica روی سه node و ترجیحاً سه availability zone، cluster_partition_handling = pause_minority، و alert روی alarmهای حافظه و دیسک. سمت consumer: autoAck=false، ack بعد از commit شدن کار، prefetch محدود، DLX تنظیم‌شده و delivery-limit برای poison message. سمت منطق: consumer باید idempotent باشد چون این معماری at-least-once است؛ اگر بین commit دیتابیس و ack پروسه بمیرد پیام دوباره می‌آید.

جملهٔ بستن بحث: «exactly-once در سطح انتقال وجود ندارد؛ چیزی که وجود دارد at-least-once به‌علاوهٔ اثرِ idempotent است.»


۷. DLX، TTL، تأخیر، اولویت — و retry درست

یک dead-letter exchange (DLX) فقط یک exchange عادی است که صف به آن اشاره می‌کند. چهار حالت پیام را به آنجا می‌فرستد و هر کدام مقدار reason خودش را دارد: rejected (consumer با requeue=false رد کرد)، expired (TTL پیام یا TTL صف تمام شد)، maxlen (صف به x-max-length یا x-max-length-bytes رسید) و delivery_limit (تعداد تحویل‌ها از حد گذشت — مخصوص quorum queue).

RabbitMQ روی پیام dead-letter شده هدر x-death می‌گذارد: آرایه‌ای از رکوردها با count، reason، queue، routing-keys و original-expiration — برای دیباگ و شمردن تلاش‌ها گنج است.

ساخت DLX و DLQ همان سه دستور همیشگی است: declare exchange --name orders.dlx --type fanout، declare queue --name orders.dlq --type quorum، و یک declare binding بین آن دو.

برای quorum queue نکتهٔ ظریفی هست: dead-lettering پیش‌فرض at-most-once است و بین صف مبدأ و مقصد ممکن است پیام گم شود. اگر پیام‌های dead-letter شده ارزشمندند:

rabbitmqctl set_policy dlq-reliable "^orders\.work$" \
  '{"dead-letter-strategy":"at-least-once","overflow":"reject-publish","max-length":200000}' \
  --priority 10 --apply-to queues

دقت کن: at-least-once فقط با overflow: reject-publish کار می‌کند؛ با drop-head (پیش‌فرض) سیستم بی‌سروصدا به at-most-once برمی‌گردد. و چون صف مبدأ باید dead-letterها را تا تأیید نگه دارد، max-length هم لازم است.

TTL سه جا می‌نشیند: هر پیام با expiration؛ هر صف با x-message-ttl؛ و خود صف با x-expires که صف بی‌استفاده را حذف می‌کند (برای صف‌های موقت RPC عالی است).

TTL هر پیام و مسدودشدن سرِ صف (head-of-line blocking)

یکی از خطرناک‌ترین تله‌های RabbitMQ که در معماری‌های retry مدام قربانی می‌گیرد: RabbitMQ فقط سرِ صف را برای انقضا بررسی می‌کند و پیام‌ها فقط وقتی به سر صف برسند dead-letter می‌شوند.

پس اگر در یک صف retry اول پیامی با TTL برابر ۳۰ ثانیه بگذاری و بلافاصله پیامی با TTL برابر ۱ ثانیه، پیام دوم ۳۰ ثانیه منتظر می‌ماند نه یک ثانیه — چون تا پیام اول منقضی نشود دومی به سر صف نمی‌رسد.

نتیجهٔ عملی: در یک صف retry فقط یک TTL ثابت بگذار. برای backoff پلکانی چند صف مجزا بساز: retry.5s، retry.30s، retry.5m. این «نردبان retry» پایدارترین الگوی شناخته‌شده است. جایگزین‌های بدون این مشکل: plugin تأخیر، یا delayed retry بومی quorum queue در ۴.۳.

برای پیام تأخیری سه راه داری. راه ۱ — plugin رسمی rabbitmq_delayed_message_exchange: یک نوع exchange به نام x-delayed-message اضافه می‌کند؛ پیام را با هدر x-delay (میلی‌ثانیه) می‌فرستی و broker نگهش می‌دارد.

// پس از rabbitmq-plugins enable rabbitmq_delayed_message_exchange
channel.exchangeDeclare("orders.delayed", "x-delayed-message", true, false,
                        Map.of("x-delayed-type", "topic"));

AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
        .deliveryMode(2)
        .headers(Map.of("x-delay", 30_000))   // ۳۰ ثانیه تأخیر
        .build();
channel.basicPublish("orders.delayed", "order.eu.retry", true, props, body);

این plugin برای تأخیر ثانیه تا چند ساعت طراحی شده نه زمان‌بندی چندروزه، نسخه‌های اخیرش به RabbitMQ 4.x با Khepri نیاز دارند، و چون خارج از هستهٔ broker است در حد quorum queue پایدار نیست.

راه ۲ — نردبان صف‌های TTL + DLX (بدون plugin، همه‌جا کار می‌کند)، نمودارش پایین‌تر است. راه ۳ — delayed retry بومی quorum queue در RabbitMQ 4.3: تمیزترین گزینه اگر روی ۴.۳ هستی، چون صف پیام برگشتی را داخلی کنار می‌گذارد و فقط پس از سپری‌شدن تأخیر دوباره در دسترس می‌گذارد، بدون بازنویسی پیام و بدون ریسک تکثیر یا گم‌شدن. با آرگومان x-delayed-retry-type (مقادیر disabled پیش‌فرض، all، returned، failed) و x-delayed-retry-min یا معادل‌های policy تنظیم می‌شود؛ back-off خطی است.

برای اولویت، x-max-priority را روی صف بگذار و سطح‌ها را کم بگیر (۵ تا ۱۰؛ هر سطح یک ساختار جدا در broker است). priority فقط بین پیام‌های در صف کار می‌کند و پیامی که prefetch شده دیگر دست broker نیست، پس prefetch بالا عملاً priority را بی‌اثر می‌کند. قبل از ۴.۳ فقط classic queue آن را داشت؛ از ۴.۳ quorum queue هم تا ۳۲ سطح دارد.

نردبان retry — بدون بلاک‌کردن هیچ consumer‌ای | The retry ladder, blocking no consumer:

flowchart LR
  APP{{orders exchange}} --> W[[orders.work]]
  W -->|"reject requeue=false"| RX{{orders.retry.dlx}}
  RX -->|attempt 1| R1[["retry.5s (ttl 5000)"]]
  RX -->|attempt 2| R2[["retry.30s (ttl 30000)"]]
  RX -->|attempt 3| R3[["retry.5m (ttl 300000)"]]
  R1 -.->|dead-letter back| APP
  R2 -.->|dead-letter back| APP
  R3 -.->|dead-letter back| APP
  W -->|"attempts exhausted"| PARK[[orders.parking-lot]]
rabbitmqadmin declare queue --name retry.30s --type quorum --durable true \
  --arguments '{"x-message-ttl":30000,"x-dead-letter-exchange":"orders","x-dead-letter-routing-key":"order.eu.created"}'

هیچ consumer‌ای روی صف‌های retry.* نمی‌نشیند؛ صف فقط پیام را نگه می‌دارد تا TTL تمام شود و خودکار برگردد.

چرا نردبان retry، و نه `Thread.sleep` در consumer

Thread.sleep(30_000) داخل consumer سه فاجعهٔ هم‌زمان است: آن thread ۳۰ ثانیه بی‌کار می‌ماند؛ پیام همچنان unacked است و یک واحد از سهم prefetch را اشغال کرده؛ و با بالا رفتن تلاش‌ها به consumer_timeout می‌خوری، channel بسته می‌شود و پیام‌های در پرواز requeue می‌شوند. با نردبان retry، consumer بلافاصله nack می‌کند و می‌رود سراغ بعدی؛ انتظار را broker می‌کشد، رایگان.

سه انتخاب تکمیلی که senior را از mid جدا می‌کند: jitter اضافه کن (اگر ۱۰٬۰۰۰ پیام همه دقیقاً ۳۰ ثانیه بعد برگردند، سرویس پایین‌دستی را دوباره می‌کشی)؛ parking lot بساز نه فقط DLQ، چون بدون ابزار «ارسال دوبارهٔ دستی» صف نهایی فقط یک گورستان است؛ و بین «تلاش دوباره ارزش دارد» و «ندارد» تفکیک کن — خطای ۴۰۰ هرگز با retry درست نمی‌شود ولی ۵۰۳ اغلب می‌شود.

یک consumer مدام روی یک پیام خطا می‌دهد. مرحله‌به‌مرحله چه می‌کنی؟

پاسخ: ۱) مهارش کن: مطمئن شو requeue=false استفاده می‌شود و صف delivery-limit دارد تا پیام خودبه‌خود به DLQ برود. ۲) پیدایش کن: پیام را از DLQ بخوان و به x-death نگاه کن — count تعداد تلاش‌ها، reason علت و queue مبدأ را می‌دهد. ۳) دسته‌بندی کن: خرابی داده است یا خرابی وابستگی؟ اگر داده خراب است اصلاً نباید در مسیر retry می‌رفت، پس طبقه‌بندی خطا در کد ایراد دارد. ۴) اصلاح ساختاری: استثناهای دائمی را به یک نوع مشخص نگاشت کن (AmqpRejectAndDontRequeueException) و بقیه را به نردبان retry بفرست. ۵) روی عمق DLQ alert بگذار و ابزار replay از parking lot را از قبل آماده کن، نه ساعت سه بامداد.

آیا RabbitMQ ترتیب پیام‌ها را تضمین می‌کند؟

پاسخ: تضمین محدود و مشروط است: در یک صف، با یک consumer، بدون requeue و بدون priority ترتیب FIFO حفظ می‌شود. هر شرطی که بشکند ترتیب می‌شکند: چند consumer روی یک صف یعنی پردازش موازی (ترتیب تحویل حفظ است، ترتیب اتمام نه)؛ requeue پیام را نزدیک سر صف برمی‌گرداند؛ صف priority ذاتاً ترتیب انتشار را می‌شکند؛ و نردبان retry پیام تأخیری را حتماً بعد از پیام‌های جدیدتر می‌رساند.

اگر واقعاً به ترتیب نیاز داری سه گزینه داری: x-single-active-consumer که تضمین می‌کند در هر لحظه فقط یک consumer از صف بخواند (با failover خودکار)؛ پارتیشن‌بندی دستی یعنی یک صف به ازای هر کلید موجودیت؛ یا پذیرفتن بی‌ترتیبی و طراحی consumer به‌صورت commutative یا با شماره‌گذاری نسخه. مقایسه‌ای که امتیاز می‌آورد: Kafka ترتیب per-partition را ذاتاً می‌دهد؛ اگر ترتیب اکید per-key نیاز اصلی توست، این نشانهٔ قوی به سمت Kafka است.


۸. Consumer‌های idempotent و deduplication

چون تحویل at-least-once است، هر consumer باید یک پیام تکراری را بی‌خطر ببلعد. سه سطح راه‌حل: عملیات ذاتاً idempotent (UPDATE account SET status = 'ACTIVE' WHERE id = ? را می‌توان هزار بار اجرا کرد)؛ کلید یکتا در پایگاه‌داده؛ و جدول inbox که شناسهٔ پیام را در همان تراکنشِ کارِ اصلی ثبت می‌کند:

CREATE TABLE processed_message (
    message_id   text        PRIMARY KEY,
    consumer     text        NOT NULL,
    processed_at timestamptz NOT NULL DEFAULT now()
);

-- در همان تراکنشی که کار اصلی انجام می‌شود
INSERT INTO processed_message (message_id, consumer)
VALUES ('7f3c1e8a-...', 'order-projector')
ON CONFLICT (message_id) DO NOTHING;

اگر درجی انجام نشد یعنی پیام قبلاً پردازش شده — کار را رد کن و فقط ack بده. سمت publisher همان جدول outbox را داری؛ خواندن دسته‌ای امن با چند worker موازی:

SELECT id, aggregate_id, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 100;

سه قضاوت senior: جدول dedup را با یک job پاک‌سازی هرس کن — پنجرهٔ نگه‌داری را از روی حداکثر عمر ممکن پیام‌ها انتخاب کن وگرنه جدول خودش تبدیل به حادثه می‌شود؛ message_id را publisher تولید کند، چون اگر consumer از روی محتوا هش بسازد دو رویداد متفاوت با payload یکسان اشتباهی یکی حساب می‌شوند؛ و رکورد dedup را در همان تراکنش کار اصلی نگه دار، چون dedup در Redis با یک flush یا evict از بین می‌رود. جزئیات outbox، inbox و saga در فصل ms-data آمده.


۹. عملیات: کلاستر، alarm، flow control و متریک

nodeهای RabbitMQ با Erlang distribution حرف می‌زنند: پورت 4369 برای epmd (کشف نام) و 25672 برای خود توزیع؛ همهٔ nodeها باید Erlang cookie یکسان داشته باشند.

کلاینت به هر node می‌تواند وصل شود؛ اگر آن node رهبرِ صف نباشد درخواست را داخل کلاستر ارجاع می‌دهد — یک hop اضافه اما شفاف. هر quorum queue یک خوشهٔ Raft مستقل با یک leader و چند follower است.

متادیتای broker (تعریف صف‌ها، exchangeها، bindingها، کاربران) سال‌ها در Mnesia ذخیره می‌شد که در برابر network partition ضعیف بود. Khepri جایگزین مبتنی بر Raft است — همان الگوریتم اجماعی quorum queue — که از ۴.۲ پیش‌فرض شد و در ۴.۳ تنها گزینه است. اثر عملی: رفتار کلاستر در partition قابل پیش‌بینی‌تر است، اما در سمت اقلیت عملیات‌های تغییردهندهٔ schema (ساخت صف یا exchange) شکست می‌خورند تا اکثریت برگردد؛ اگر برنامه‌ات صف‌ها را در زمان اجرا اعلام می‌کند، این را در طراحی خطاها لحاظ کن.

RabbitMQ دو resource alarm دارد و هر دو یک کار می‌کنند: بلاک کردن publisherها. Memory alarm وقتی مصرف حافظهٔ node از vm_memory_high_watermark بگذرد (پیش‌فرض 0.6 یعنی ۶۰٪ RAM قابل تشخیص)، و disk alarm وقتی فضای آزاد زیر disk_free_limit برود (پیش‌فرض ۵۰ مگابایت، که فقط برای آموزش مناسب است).

# rabbitmq.conf
vm_memory_high_watermark.relative = 0.6
disk_free_limit.relative = 2.0             # دو برابر RAM — توصیهٔ production
consumer_timeout = 1800000
cluster_partition_handling = pause_minority
وقتی alarm فعال می‌شود، انتشار متوقف می‌شود — و برنامهٔ تو هنگ می‌کند

وقتی alarm فعال است، broker به connectionهای publish‌کننده فریم connection.blocked می‌فرستد و دیگر از آن‌ها نمی‌خواند؛ consumerها همچنان کار می‌کنند (عمدی است، چون تنها راه خروج از alarm خالی شدن صف‌هاست). در سمت تو basicPublish بلاک می‌شود، و اگر روی thread درخواست HTTP publish می‌کنی کل استخر threadها می‌خوابد و سرویس تو، نه broker، از دسترس خارج می‌شود.

سه دفاع: BlockedListener ثبت کن (Connection#addBlockedListener) و به health check وصلش کن؛ هرگز روی thread درخواست کاربر و بدون تایم‌اوت publish نکن؛ و علت را حل کن نه علامت را — alarm حافظه تقریباً همیشه یعنی consumer عقب مانده.

جدا از alarmها، RabbitMQ یک flow control مبتنی بر credit بین پروسه‌های داخلی Erlang دارد؛ connection در وضعیت flow یعنی publisher سریع‌تر از توان broker می‌فرستد — خطا نیست، هشدار زودهنگام است.

rabbitmq-plugins enable rabbitmq_management   # پورت 15672
rabbitmq-plugins enable rabbitmq_prometheus   # پورت 15692، مسیر /metrics

# HTTP API — عمق صف و تعداد consumer (%2F همان vhost پیش‌فرض «/» است)
curl -s -u guest:guest 'http://localhost:15672/api/queues/%2F/orders.work' \
  | jq '{messages, messages_ready, messages_unacknowledged, consumers}'

# health checkهای آماده برای probe کوبرنتیز
curl -s -u guest:guest http://localhost:15672/api/health/checks/alarms
curl -s -u guest:guest http://localhost:15672/api/health/checks/node-is-quorum-critical
متریک چه چیزی را می‌گوید چه زمانی alert
rabbitmq_queue_messages_ready عمق صف (منتظر تحویل) روند صعودی پایدار = consumer عقب افتاده
rabbitmq_queue_messages_unacked تحویل‌شده ولی ack نشده عدد بزرگ = consumer کند یا prefetch زیاد
نرخ publish / deliver / ack تعادل ورودی و خروجی publish > ack مداوم = فاجعه
rabbitmq_queue_consumers تعداد consumerها افت به صفر = deploy خراب یا crash loop
عمق DLQ خرابی منطقی هر رشد پایدار

هرگز کلاستر دو‌نُدی نساز: با دو node اکثریت ممکن نیست و اگر یکی بیفتد دیگری هم نمی‌تواند تصمیم بگیرد — برای quorum queue و Khepri عدد درست ۳ (یا ۵) است. و صف‌ها را کوتاه نگه دار: صف بلند فقط «کند» نیست، حافظه را بالا می‌برد، صفحه‌بندی روی دیسک را شروع می‌کند، latency را می‌پراند و در نهایت alarm‌ی را فعال می‌کند که همهٔ publisherها را بلاک می‌کند.

کار فرمان
وضعیت node rabbitmq-diagnostics status
بحرانی بودن quorum این node rabbitmq-queues check_if_node_is_quorum_critical
دروازهٔ امن rolling restart rabbitmq-upgrade await_online_quorum_plus_one
alarmهای محلی rabbitmq-diagnostics check_local_alarms
فهرست صف با عمق rabbitmqctl list_queues name type messages_ready messages_unacknowledged
channelها با prefetch rabbitmqctl list_channels name prefetch_count messages_unacknowledged
ساخت policy rabbitmqctl set_policy <name> <pattern> <json> --apply-to queues
وضعیت Raft یک صف rabbitmq-queues quorum_status orders.work
افزودن/حذف replica rabbitmq-queues grow <node> all / shrink <node>
خروج امن برای نگه‌داری rabbitmq-upgrade drain سپس rabbitmq-upgrade revive

پورت‌های پیش‌فرض: 5672 و 5671 (AMQP بدون/با TLS)، 15672 (management)، 15692 (Prometheus)، 25672 (توزیع Erlang)، 4369 (epmd)، 5552 (stream)، 1883/8883 (MQTT)، 61613/61614 (STOMP).

چطور یک کلاستر RabbitMQ را برای production اندازه‌گذاری و سخت‌سازی می‌کنی؟

پاسخ: پنج محور. توپولوژی: حداقل ۳ node در ۳ availability zone، صف‌های مهم quorum با ۳ replica، و pause_minority. منابع: حافظه را بر اساس عمق صف در بدترین حالت بزن نه نرخ عادی، چون سناریوی سخت وقتی است که consumer یک ساعت پایین باشد؛ disk_free_limit.relative = 2.0 و سقف file descriptor بالا. جریان: prefetch محدود، max-length + overflow روی همهٔ صف‌ها تا یک صف مهاجم کل کلاستر را نبلعد، و alternate-exchange برای پیام‌های بی‌مقصد. امنیت: vhost جدا برای هر محیط، حذف یا محدود کردن کاربر guest، TLS روی 5671، و مجوزهای دقیق configure/write/read به‌جای .* (فصل ms-security). مشاهده‌پذیری: plugin Prometheus، alert روی alarmها و عمق صف و DLQ، و اتصال health probeها به /api/health/checks/....

جملهٔ آخر که وزن دارد: «مهم‌ترین کار من در sizing این است که صف‌ها کوتاه بمانند؛ بقیهٔ اعداد از این یکی مشتق می‌شوند.»


۱۰. Spring AMQP در عمل

spring-boot-starter-amqp کلاینت رسمی جاوا را در بر می‌گیرد و دو ابزار اصلی می‌سازد: RabbitTemplate برای انتشار و @RabbitListener برای مصرف.

spring:
  rabbitmq:
    host: rabbit.internal
    virtual-host: /orders
    username: ${RABBIT_USER}
    password: ${RABBIT_PASSWORD}
    publisher-confirm-type: correlated   # confirm ناهمزمان با correlation data
    publisher-returns: true
    template:
      mandatory: true                    # بدون این، publisher-returns بی‌اثر است
    listener:
      type: simple
      simple:
        acknowledge-mode: auto           # container خودش ack می‌زند مگر استثنا بخورد
        prefetch: 20
        concurrency: 4
        max-concurrency: 16
        default-requeue-rejected: false  # ← بسیار مهم
        retry:
          enabled: false                 # retry را به broker بسپار، نه به حافظه
دو پیش‌فرض Spring که تیم‌ها را زمین می‌زند

۱. default-requeue-rejected پیش‌فرض true است — یعنی هر استثنایی که از listener بیرون بیاید nack با requeue=true می‌دهد: همان حلقهٔ داغ بی‌نهایت، این بار پنهان در تنظیمات پیش‌فرض. آن را false کن و مسیر خطا را با DLX بساز.

۲. SimpleMessageConverter پیش‌فرض از Java serialization استفاده می‌کند — هم شکننده و هم خطر امنیتی. همیشه یک converter JSON صریح تعریف کن؛ و اگر ناچار به سریال‌سازی جاوا هستی، allow-list کلاس‌ها را با DefaultClassMapper تنظیم کن.

نکتهٔ نسخه: در Spring AMQP نسخهٔ ۴.x (همراه Spring Boot 4 و Jackson 3) کلاس Jackson2JsonMessageConverter منسوخ شده و جایگزینش JacksonJsonMessageConverter است.

@Bean
RabbitTemplate rabbitTemplate(ConnectionFactory cf) {
    RabbitTemplate template = new RabbitTemplate(cf);
    // در Spring AMQP 4.x نام کلاس JacksonJsonMessageConverter است
    template.setMessageConverter(new Jackson2JsonMessageConverter());
    template.setConfirmCallback((correlation, acked, cause) -> {
        if (!acked) log.error("publish nacked id={} cause={}", correlation, cause);
    });
    template.setReturnsCallback(r ->
        log.error("unroutable exchange={} rk={}", r.getExchange(), r.getRoutingKey()));
    return template;
}

void publish(OrderCreated event) {
    rabbitTemplate.convertAndSend("orders", "order.eu.created", event,
            message -> {
                MessageProperties p = message.getMessageProperties();
                p.setDeliveryMode(MessageDeliveryMode.PERSISTENT);
                p.setMessageId(event.id().toString());
                return message;
            },
            new CorrelationData(event.id().toString()));
}
@RabbitListener(queues = "orders.work", concurrency = "4-16")
public void onOrderCreated(@Payload OrderCreated event,
                           @Header(name = "x-death", required = false)
                           List<Map<String, Object>> deaths) {

    long attempt = (deaths == null || deaths.isEmpty())
            ? 0L : (Long) deaths.get(0).get("count");

    try {
        orderService.handle(event);          // باید idempotent باشد
    } catch (InvalidPayloadException e) {
        // خطای دائمی → مستقیم به DLQ، هرگز retry
        throw new AmqpRejectAndDontRequeueException("bad payload", e);
    } catch (DownstreamUnavailableException e) {
        if (attempt >= 5) {
            throw new AmqpRejectAndDontRequeueException("attempts exhausted", e);
        }
        throw e;                             // → به نردبان retry می‌رود
    }
}

نکته‌های ظریفی که در بازبینی کد باید بگیری:

  • دو نوع container: SimpleMessageListenerContainer (پیش‌فرض؛ تعداد consumer قابل تغییر در زمان اجرا) و DirectMessageListenerContainer (listener.type=direct؛ هر consumer thread خودش را دارد و سربار کمتری دارد). برای بار زیاد و پایدار معمولاً direct بهتر است.
  • retry.enabled سمت listener، retry را در حافظه انجام می‌دهد و thread را همان مدت نگه می‌دارد؛ برای چندصد میلی‌ثانیه قابل قبول است، برای backoff چنددقیقه‌ای فاجعه. اگر واقعاً retry سمت برنامه می‌خواهی، RepublishMessageRecoverer را با یک RetryTemplate ترکیب کن تا پیام همراه با stack trace به یک صف خطا منتشر شود، نه اینکه فقط رد شود.
  • ack دستی وقتی لازم است که ack را به یک رویداد ناهمزمان گره بزنی: acknowledge-mode: manual و گرفتن پارامتر Channel در امضای متد.
`AmqpRejectAndDontRequeueException` چه می‌کند و چرا مهم است؟

پاسخ: container بر اساس استثنایی که از متد listener بیرون می‌آید تصمیم می‌گیرد چه ack‌ای بفرستد؛ رفتار پیش‌فرض تابع defaultRequeueRejected است (پیش‌فرض true یعنی requeue). این استثنا آن تصمیم را override می‌کند: پیام با requeue=false رد می‌شود و مستقیم به DLX می‌رود. قرینه‌اش ImmediateRequeueAmqpException است که همیشه requeue می‌کند.

چرا مهم است: ابزار اصلی طبقه‌بندی خطا است؛ بدون آن هر باگ NullPointer در listener تبدیل به حلقهٔ بی‌نهایت می‌شود. الگوی خوب: در لایهٔ سرویس استثناهای دامنه بینداز و در listener آن‌ها را به یکی از این دو نگاشت کن. نکتهٔ تکمیلی: خطاهای تبدیل پیام (JSON نامعتبر) قبل از رسیدن به متد تو رخ می‌دهند و به MessageConversionException تبدیل می‌شوند؛ container این‌ها را قابل تلاش دوباره نمی‌داند و بدون requeue رد می‌کند — که درست است.

@SpringBootTest
@Testcontainers
class OrderListenerIT {

    @Container
    @ServiceConnection     // Spring Boot خودش host/port/credentials را تزریق می‌کند
    static RabbitMQContainer rabbit =
            new RabbitMQContainer(DockerImageName.parse("rabbitmq:4.1-management"));

    @Autowired RabbitTemplate rabbitTemplate;
    @Autowired RabbitAdmin rabbitAdmin;

    @Test
    void routesToDlqOnPermanentFailure() {
        rabbitTemplate.convertAndSend("orders", "order.eu.created", brokenEvent());

        Awaitility.await().atMost(Duration.ofSeconds(10)).untilAsserted(() ->
            assertThat(rabbitAdmin.getQueueInfo("orders.dlq").getMessageCount())
                .isEqualTo(1));
    }
}

وابستگی در Testcontainers 1.x برابر org.testcontainers:rabbitmq است (کلاس در org.testcontainers.containers)؛ در 2.x نام artifact به org.testcontainers:testcontainers-rabbitmq و پکیج به org.testcontainers.rabbitmq.RabbitMQContainer تغییر کرده و کلاس قدیمی منسوخ شده است.

سه چیزی که ارزش تست یکپارچه دارد و با mock به دست نمی‌آید: درست بودن binding (آیا routing key واقعاً به آن صف می‌رسد؟)، مسیر DLQ (آیا خطای دائمی واقعاً پارک می‌شود؟) و idempotency (اگر همان پیام دو بار بیاید، حالت نهایی یکی است؟). مبانی Testcontainers در فصل testing آمده.


۱۱. RabbitMQ در برابر Kafka، JMS و MQTT

تفاوت بنیادی در یک جمله: RabbitMQ صف است، Kafka لاگ است. در RabbitMQ پیام پس از ack ناپدید می‌شود؛ در Kafka تا پایان retention سر جایش می‌ماند و offset فقط یک نشانگر است.

بُعد RabbitMQ (صف) Kafka (لاگ)
مدل ذخیره صف؛ پیام پس از ack حذف می‌شود لاگ append-only با retention زمانی/حجمی
مسیریابی exchange + binding + wildcard، سمت broker topic + partition؛ منطق سمت کلاینت
موازی‌سازی مصرف نامحدود روی یک صف محدود به تعداد partition در هر گروه
ترتیب فقط با یک consumer یا single-active-consumer ذاتاً per-partition
Replay ندارد (مگر با stream) نقطهٔ قوت اصلی
ack هر پیام بله، انتخابی و غیرترتیبی نه؛ فقط commit کردن offset
TTL / priority / DLQ هر پیام بومی و کامل ندارد؛ باید سمت برنامه بسازی
throughput معمول ده‌ها هزار پیام در ثانیه به‌ازای صف صدها هزار تا میلیون‌ها
مصرف‌کنندهٔ کند صف بلند می‌شود، alarm فعال می‌شود فقط lag بالا می‌رود
Push یا Pull push به consumer (با prefetch) pull توسط consumer
عملیات ساده‌تر، کلاستر کوچک‌تر سنگین‌تر؛ KRaft، partition، rebalance
چه زمانی کدام — قضاوت واقعی

RabbitMQ را انتخاب کن وقتی به مسیریابی هوشمند per-message نیاز داری؛ وقتی TTL، اولویت، تأخیر یا DLQ در سطح تک‌پیام لازم است (اجرای این‌ها روی Kafka یعنی نوشتن نصف یک broker)؛ وقتی الگویت صف کار یا request/reply است؛ و وقتی هزینهٔ عملیاتی مهم است.

Kafka را انتخاب کن وقتی به replay نیاز داری؛ وقتی ترتیب اکید per-key با throughput بالا می‌خواهی؛ وقتی چند مصرف‌کنندهٔ مستقل باید همان جریان را با سرعت‌های متفاوت بخوانند؛ یا وقتی حفظ داده به‌عنوان منبع حقیقت بخشی از طراحی است (event sourcing، log compaction).

و صادق باش: خطوط دارند محو می‌شوند. RabbitMQ با stream و super stream حالا replay و پارتیشن‌بندی دارد، و Kafka با share groups (KIP-932) در حال گرفتن معنای صف است — در ۴.۰ early access، در ۴.۱ preview، و ۴.۲ هدف GA. پاسخ خوب در مصاحبه کوبیدن یکی نیست؛ این است که بگویی «مدل مصرف کدام است و کدام ابزار آن را بومی پیاده می‌کند».

سرویسی با ۵۰ هزار رویداد در ثانیه و سه مصرف‌کننده داریم که یکی از آن‌ها گاهی باید دادهٔ دیروز را دوباره پردازش کند. RabbitMQ یا Kafka؟

پاسخ: «پردازش دوبارهٔ دادهٔ دیروز» تقریباً به‌تنهایی تصمیم را می‌گیرد. صف‌های معمولی RabbitMQ بعد از ack هیچ چیزی نگه نمی‌دارند، پس replay ممکن نیست مگر اینکه یک انبار موازی بسازی — یعنی نوشتن دوبارهٔ Kafka با دست. پس Kafka، یا RabbitMQ Streams اگر می‌خواهی از اکوسیستم RabbitMQ خارج نشوی.

بین این دو با سه سؤال انتخاب کن: برای retention چندهفته‌ای با حجم ترابایتی و tiered storage، Kafka؛ اگر همان جریان به مسیریابی پیچیدهٔ per-message هم نیاز دارد، RabbitMQ Streams کنار exchangeها زندگی راحت‌تری می‌دهد؛ و اگر اکوسیستم stream processing و connector لازم است، باز Kafka.

و اگر بقیهٔ سیستم دستورمحور است، معقول این است که RabbitMQ برای دستورها بماند و فقط این جریان رویداد به Kafka برود — نه اینکه همه چیز مهاجرت کند.

JMS و Jakarta Messaging چیست؟

بدفهمی رایجی که در مصاحبه سریع لو می‌رود: JMS یک پروتکل نیست، یک API است — مجموعه‌ای از interfaceهای جاوا. آنچه روی سیم می‌گذرد به broker مربوط است و استاندارد نیست؛ به همین دلیل کلاینت JMS یک vendor معمولاً به broker vendor دیگر وصل نمی‌شود. AMQP برعکس است: یک پروتکل سیم مستقل از زبان.

JMS پس از انتقال به بنیاد Eclipse به Jakarta Messaging تغییر نام داد و پکیج‌ها از javax.jms به jakarta.jms رفتند؛ نسخهٔ جاری 3.1 است. Apache ActiveMQ Artemis (۲.۴۴.x در زمان نگارش) برجسته‌ترین broker متن‌بازی است که آن را پیاده می‌کند و چندپروتکلی است: AMQP 1.0، MQTT، STOMP، OpenWire و پروتکل بومی خودش.

Artemis هنوز در سه موقعیت انتخاب درستی است: الزام Jakarta EE و MDBها (@MessageDriven) روی application server (فصل ms-runtime-legacy)؛ نیاز واقعی به تراکنش XA که RabbitMQ ندارد؛ و message selector، یعنی فیلتر سمت مصرف‌کننده روی خصوصیت‌ها (JMSType = 'urgent' AND region = 'EU') که در RabbitMQ باید به توپولوژی routing تبدیل شود. برای یک سرویس تازه و چندزبانه، یک پروتکل سیم تقریباً همیشه بهتر از یک API مخصوص جاوا است.

نگاشت مفاهیم ساده است: Queue همان صف با چند consumer رقیب؛ Topic معادل fanout exchange به‌علاوهٔ یک صف به ازای هر مشترک؛ durable subscription معادل صف durable؛ و ack modeها معادل auto-ack و manual ack.

MQTT — جایی که RabbitMQ به دنیای دستگاه‌ها می‌رسد

MQTT پروتکلی است برای شبکه‌های کم‌پهنا و دستگاه‌های کم‌توان؛ هدر ثابتش می‌تواند فقط ۲ بایت باشد، در برابر ده‌ها بایت در HTTP — برای سنسوری با باتری روی شبکهٔ سلولی یعنی ماه‌ها عمر بیشتر.

مفاهیم اصلی: Topic با / سطح‌بندی می‌شود (sensors/floor2/room7/temp) با wildcardهای + (یک سطح) و # (چند سطح، فقط در انتها). QoS 0 حداکثر یک‌بار، QoS 1 حداقل یک‌بار، QoS 2 دقیقاً یک‌بار. Retained message: آخرین پیام هر topic نگه داشته می‌شود تا مشترک جدید «آخرین وضعیت» را بگیرد. LWT: پیامی که دستگاه هنگام اتصال ثبت می‌کند و broker در صورت قطع غیرعادی به‌جای او منتشر می‌کند — مکانیزم «دستگاه آفلاین شد». Clean session / session expiry: ماندن یا نماندن اشتراک‌ها پس از قطع.

با rabbitmq-plugins enable rabbitmq_mqtt (پورت‌های 1883 و 8883) و rabbitmq_web_mqtt برای WebSocket، طرز کار ظریف و زیباست: پیام MQTT به exchange amq.topic منتشر می‌شود با تبدیل topic به routing key — / به . و + به *. یعنی دستگاه MQTT منتشر می‌کند و سرویس backend با AMQP 0-9-1 مصرف می‌کند، بدون هیچ پلی در وسط. برای هر مشترک MQTT یک صف ساخته می‌شود (classic، quorum، یا نوع ویژهٔ rabbit_mqtt_qos0_queue). Native MQTT از ۳.۱۲ مصرف حافظهٔ هر اتصال را چند برابر کم کرد و مقیاس میلیونی اتصال را ممکن ساخت؛ پشتیبانی از MQTT 5.0 از ۳.۱۳ اضافه شد.

محدودیت‌های MQTT در RabbitMQ که باید بدانی
  • QoS 2 پشتیبانی نمی‌شود. RabbitMQ هم انتشار و هم اشتراک QoS 2 را به QoS 1 تنزل می‌دهد و در CONNACK مقدار Maximum QoS = 1 را اعلام می‌کند. اگر طراحی‌ات به exactly-once سطح پروتکل تکیه دارد اینجا نمی‌گیرد — idempotency را در برنامه بساز.
  • Retained messageها پیش‌فرض در انبارهٔ محلی هر node‌اند و بین nodeها تکرار نمی‌شوند؛ اگر دستگاه به node دیگری وصل شود ممکن است آن‌ها را نبیند.
  • انفجار تعداد صف: هر مشترک MQTT حداقل یک صف می‌سازد. صد هزار دستگاه یعنی صد هزار صف — برای quorum queue (هر کدام یک خوشهٔ Raft) اصلاً مناسب نیست؛ برای QoS 0 از نوع صف ویژه استفاده کن.
  • دستگاه‌های بی‌ثبات: با clean session = false صف‌ها باقی می‌مانند و انباشته می‌شوند؛ session expiry و TTL صف تنظیم کن وگرنه هزاران صف یتیم می‌ماند.

درخت تصمیم | Decision tree: which broker family fits:

flowchart TD
  S{"Need replay of past events?"} -->|yes| K["Kafka or RabbitMQ Streams"]
  S -->|no| R{"Per-message routing, TTL, priority or DLQ?"}
  R -->|yes| RM["RabbitMQ queues (AMQP 0-9-1)"]
  R -->|no| T{"Many constrained or intermittent devices?"}
  T -->|yes| M["MQTT (or RabbitMQ MQTT plugin)"]
  T -->|no| J{"Jakarta EE app server or XA required?"}
  J -->|yes| A["JMS broker: ActiveMQ Artemis"]
  J -->|no| RM
فرق AMQP و MQTT را برای یک backend دستگاه‌های IoT توضیح بده.

پاسخ: دو پروتکل برای دو محیط. MQTT برای «لبه» است: پهنای باند کم، اتصال ناپایدار، دستگاه کم‌حافظه؛ هدر بسیار کوچک و ویژگی‌های مخصوص دستگاه مثل LWT، retained message و session persistence. AMQP 0-9-1 برای «مرکز» است: مسیریابی پیچیده، تضمین‌های تحویل، DLQ و اولویت — سنگین‌تر اما معنایی‌تر.

معماری معمول و درست: دستگاه‌ها با MQTT وصل می‌شوند، همان broker (یا یک پل) پیام‌ها را به دامنهٔ AMQP می‌آورد، و سرویس‌های backend با AMQP مصرف می‌کنند؛ RabbitMQ این را خودش انجام می‌دهد چون MQTT را روی amq.topic نگاشت می‌کند.

نکته‌ای که مصاحبه‌گر خوشش می‌آید: مقیاس‌بندی دو سمت متفاوت است — سمت MQTT مسئلهٔ تعداد اتصال است، سمت AMQP مسئلهٔ throughput و عمق صف، و این دو گاهی به کلاسترهای جدا نیاز دارند.


جمع‌بندی فصل
  • AMQP 0-9-1 یک پروتکل سیم با مدل producer → exchange → binding → queue → consumer است؛ producer هرگز به صف نمی‌فرستد. AMQP 1.0 پروتکلی متفاوت است که RabbitMQ از ۴.۰ بومی پشتیبانی می‌کند.
  • چهار نوع exchange: direct، fanout، topic (* یک بخش، # صفر یا چند بخش) و headers. پیام بی‌مقصد بی‌سروصدا حذف می‌شود — با mandatory و alternate-exchange محافظت کن.
  • دوام سه‌گانه است: صف durable + پیام persistent + publisher confirm؛ هیچ‌کدام به‌تنهایی کافی نیست، و تراکنش AMQP ۱۰ تا ۱۰۰ برابر کندتر از confirm است.
  • آینه‌کاری classic در ۴.۰ حذف شد. برای هر چیزی که از دست دادنش گران است، x-queue-type: quorum با ۳ replica.
  • ack و prefetch را جدی بگیر: auto-ack یعنی از دست دادن پیام، prefetch نامحدود یعنی OOM، و consumer_timeout پیش‌فرض ۳۰ دقیقه است.
  • retry را broker انجام دهد، نه consumer: نردبان TTL + DLX، plugin تأخیر، یا delayed retry بومی quorum queue در ۴.۳. هرگز Thread.sleep و هرگز requeue=true بی‌شرط؛ delivery-limit (پیش‌فرض ۲۰) تور ایمنی poison message است و TTL فقط در سرِ صف بررسی می‌شود.
  • تحویل at-least-once است، پس consumer باید idempotent باشد: جدول inbox با کلید یکتا در همان تراکنش کار اصلی.
  • در عملیات: ۳ node نه ۲ تا، pause_minority، alert روی alarmها، و بالاتر از همه صف‌ها را کوتاه نگه دار — عمق صف یک متریک سلامت است نه یک بافر ظرفیت.
  • در Spring AMQP: default-requeue-rejected را false کن، converter JSON صریح بگذار، خطاها را با AmqpRejectAndDontRequeueException طبقه‌بندی کن، و مسیر DLQ را با Testcontainers تست کن.
  • انتخاب ابزار: RabbitMQ برای مسیریابی و صف کار؛ Kafka برای replay، ترتیب per-key و throughput عظیم؛ JMS/Artemis برای Jakarta EE و XA؛ MQTT برای دستگاه‌های کم‌توان در لبه — و داشتن بیش از یکی در یک معماری کاملاً معقول است.

In the Kafka chapter you learned what a distributed log is. Now we go to the other broker family that job ads demand just as loudly: the queue-oriented brokers, headed by RabbitMQ.

These are not two tools for one job. Kafka is a diary anyone can re-read from any page; RabbitMQ is a post office that delivers the letter to the right mailbox, and once delivered it is no longer there. Every architectural decision falls out of that one sentence.

We start from zero with no term left undefined — but finish where a senior has to stand: "what do we set prefetch to", "why did your retry take production down", "why Kafka is wrong here".

Chapter roadmap
  1. Why a broker, and what AMQP actually is (a protocol, not a library).
  2. The AMQP 0-9-1 model from zero: producer, exchange, binding, routing key, queue, consumer, channel, vhost.
  3. The four exchange types with worked routing examples.
  4. Queue types: classic, quorum, stream — and the fact that mirroring is dead.
  5. Durability: durable queues, persistent messages, publisher confirms, fsync, transactions.
  6. Acknowledgements: ack/nack/reject, requeue, prefetch/QoS, consumer timeout.
  7. Failure handling: DLX, TTL, delays, priority, backoff retries, poison messages.
  8. Idempotency and deduplication (with SQL).
  9. Operations: clustering, alarms, flow control, metrics, sizing.
  10. Spring AMQP in practice + testing with Testcontainers.
  11. The comparison: RabbitMQ vs Kafka vs JMS/Artemis vs MQTT.

1. Why a broker at all?

The post office

Option one: an employee of company A rides to company B's door and waits for someone to open it; if B is closed, A stands there doing nothing. That is a synchronous HTTP call.

Option two: A drops the letter in a postbox; the post office gives a receipt and delivers it whenever B is open. If B sleeps for three hours the letters pile up but are not lost, and A goes back to work. That is a message broker.

Three things a broker gives you that HTTP does not: decoupling, buffering (if the receiver dies, messages wait instead of turning into 500s), and back-pressure. And one thing it does not: correctness of your logic — a message arrives at least once, so consumers must be idempotent.

AMQP stands for Advanced Message Queuing Protocol, and here lies a big trap:

  • AMQP 0-9-1: the protocol RabbitMQ was built around, with the exchange/binding/queue model. When people in the RabbitMQ world say "AMQP", this is it.
  • AMQP 1.0: a completely different protocol with a similar name — an OASIS and ISO/IEC 19464 standard, with no exchange model, only nodes, links and flow. Azure Service Bus and ActiveMQ Artemis speak this one. They are not two versions of one thing but two independent protocols with confusingly similar names.

Since 4.0 RabbitMQ speaks AMQP 1.0 natively, not through a translating plugin: both 0-9-1 and 1.0 clients connect on port 5672. The 4.x line also removed classic queue mirroring in 4.0, made the Raft-based metadata store Khepri the default in 4.2, and in 4.3 (April 2026) made Khepri the only option, gave quorum queues up to 32 priority levels and native delayed retries, and raised the minimum Erlang/OTP to 27.


2. The AMQP 0-9-1 model from zero

Producer: the application that sends a message. The key, counter-intuitive point: a producer never publishes directly to a queue; it always publishes to an exchange.

Exchange: the routing box — it takes the message, applies its rules and decides which queue(s) it lands in; if none matches, the message is silently discarded. Queue: a named FIFO buffer; the queue holds the message, not the exchange. Binding: the rule connecting an exchange to a queue ("anything matching this pattern goes here"), optionally with a binding key. Routing key: the string the producer stamps on the message — the address on the envelope, compared against binding keys.

Consumer: the application that receives from a queue and acks after processing. Connection: a TCP connection to the broker; expensive (handshake, TLS, authentication). Channel: a lightweight virtual connection multiplexed over that TCP, on which every AMQP command runs. Virtual host (vhost): a logical namespace holding exchanges, queues, bindings and permissions (default /) — like a database schema, for separating environments or teams.

The whole model at a glance | کل مدل در یک نگاه:

flowchart LR
  P[Producer] -->|"basic.publish + routing key"| X{{Exchange}}
  X -->|"binding key: order.created"| Q1[[Queue: billing]]
  X -->|"binding key: order.*"| Q2[[Queue: audit]]
  X -->|no binding matched| D[Dropped or returned]
  Q1 -->|basic.deliver| C1[Consumer A]
  Q2 -->|basic.deliver| C2[Consumer B]
  C1 -.->|basic.ack| Q1
Connections are expensive, channels are cheap — and churn kills

Open one TCP connection and dozens of channels inside it, not the other way round: one connection per process, one channel per thread or per consumer — every connection costs the broker Erlang processes, buffers and a socket.

Two mistakes that flatten production regularly: sharing one channel across threads (a channel in the official Java client is not thread-safe for concurrent publishing; you get UNEXPECTED_FRAME and an abruptly closed channel); and churn, opening a connection per message, which eats broker CPU and memory. The fix: a CachingConnectionFactory or a pool living as long as the application.

Every AMQP message has three parts: a method frame (the command, e.g. basic.publish), a content header (the properties) and one or more body frames. The properties worth knowing: delivery_mode (1 transient, 2 persisted), content_type, headers (the table a headers exchange routes on), correlation_id/reply_to for request/reply, expiration (a per-message TTL in milliseconds, as a string), and message_id, the unique id deduplication is built on.

What is the difference between an exchange and a queue, and why doesn't a producer publish straight to a queue?

Answer: a queue is only a buffer with no logic; the exchange is the routing engine. The producer publishes to an exchange so it can stay ignorant of consumer topology: one queue bound today, five tomorrow, and producer code does not change. That is what makes RabbitMQ a "smart broker / dumb consumer" against Kafka's "dumb broker / smart consumer".

The follow-up that scores points: a default exchange with the empty name "" of type direct exists, and every queue is automatically bound to it with a binding key equal to its own name. So "publishing straight to a queue" is really publishing to that default exchange with the queue name as routing key — not an exception, the same rule.


3. The four exchange types

Picture four clerks: direct drops the letter in a box only if the address matches its label exactly; fanout ignores the address and drops a copy into every box; topic splits the address on dots and matches patterns; headers opens the envelope and reads the internal labels.

Direct is an exact, byte-for-byte match between routing key and binding key:

# rabbitmqadmin v2 — a standalone Rust binary, the second generation HTTP API tool
rabbitmqadmin declare exchange --name payments --type direct --durable true
rabbitmqadmin declare queue --name payments.captured --type quorum --durable true
rabbitmqadmin declare binding --source payments --destination payments.captured \
  --destination-type queue --routing-key captured

A message with routing key captured arrives; Captured does not.

Fanout ignores the routing key and copies the message into every bound queue. It is the fastest type since it matches nothing, but 50 bound queues multiply write throughput by 50.

Topic is the workhorse: the routing key is split on dots and the binding key gets two wildcards — * matches exactly one word, # matches zero or more.

Picture four bindings on the amq.topic exchange: order.*.created, order.eu.#, # and order.eu.created. Now see where each message lands:

message routing key order.*.created order.eu.# # order.eu.created
order.eu.created
order.us.created.retry ❌ (four words)
order.eu ✅ (# matches zero)
invoice.eu.created

Note that order.eu.# also catches order.eu, because # can match zero words — exactly the question interviewers ask.

Order routing keys general to specific, because wildcards gain meaning from the left: order.eu.created beats created.eu.order because it lets you bind order.#. Never put a high-cardinality id in a routing key (order.1234567.created) — ids belong in the payload or headers. The limit is 255 bytes.

Headers ignores the routing key and matches on the headers table. The binding carries an x-match argument: all means every listed header must match (AND), any means one is enough (OR); recent versions add all-with-x and any-with-x, which include headers starting with x-. A binding with x-match=all, format=pdf and locale=fa-IR only catches messages carrying both.

Headers is slower than topic (map matching instead of optimised string matching); use it only for genuinely multi-dimensional matching. Every vhost ships with "" (default, direct), amq.direct, amq.fanout, amq.topic, amq.headers/amq.match and amq.rabbitmq.trace.

Unroutable messages are dropped silently

If a message reaches an exchange and no binding matches, RabbitMQ throws it away: no error, no log, no exception. You even get a publisher confirm, because from the broker's point of view it was "handled successfully" — it just landed in zero queues. This is the classic "messages are disappearing" bug, usually one typo in a routing key.

You need both guards: the mandatory flag at publish time, which makes the broker hand the message back with basic.return (in Spring: template.mandatory=true plus a ReturnsCallback); and an alternate-exchange pointing at an "unroutable" queue — a server-side net that works even with a careless publisher. Prometheus counts unroutable messages; alert on that.

How do you implement "one message to every service" and "one message to only one of N instances of a service" at the same time?

Answer: by separating the roles of exchange and queue. One queue per service, not per instance: every pod of billing consumes from billing.orders and RabbitMQ round-robins, so each message reaches exactly one instance; this is "competing consumers" and gives horizontal scaling for free. To broadcast, bind the same exchange to several services' queues: billing.orders, shipping.orders and analytics.orders each get a copy.

The difference an interviewer waits for: in Kafka consumption parallelism is capped by partition count (10 partitions, at most 10 active consumers per group); RabbitMQ has no such ceiling. In exchange, Kafka gives per-partition ordering and replay.

The difference an interviewer waits for: in Kafka consumption parallelism is capped by partition count (10 partitions means at most 10 active consumers per group); RabbitMQ has no such ceiling. In exchange, Kafka gives per-partition ordering and replay, which ordinary queues do not.


4. Queue types: classic, quorum, stream

Property Classic Quorum Stream
Model single copy, not replicated Raft, several replicas replicated append-only log
Message after ack removed removed stays until retention ends
Replay from the start ✅ (offset based)
Priority ✅ (x-max-priority) ✅ since 4.3 (up to 32)
Poison messages manual ✅ built-in delivery-limit consumer's job
Best for temporary queues, RPC, disposable work anything expensive to lose events, replay, independent readers
Classic queue mirroring no longer exists

If you find an article telling you to set ha-mode: all to make queues HA, it is about RabbitMQ 3.x and has no effect on 4.x: classic queue mirroring was removed outright in 4.0 and those policy keys are ignored. Queues still work, but with one replica; if that node goes down the queue is unavailable, and if the disk is lost the messages are gone.

The right path: declare important queues with x-queue-type: quorum. And note that a queue's type cannot be changed after creation — create a new queue and move traffic across (with a shovel, or by rebinding and draining the old one).

Map<String, Object> args = Map.of(
        "x-queue-type", "quorum",
        "x-dead-letter-exchange", "orders.dlx",
        "x-delivery-limit", 5,             // the 4.x default is 20
        "x-max-length", 100_000,
        "x-overflow", "reject-publish");   // required for at-least-once dead-lettering

channel.queueDeclare("orders.work", /* durable */ true,
                     /* exclusive */ false, /* autoDelete */ false, args);

Three operational notes: use an odd replica count (3 or 5; even numbers only add cost); the queue must stay short; and exclusive or auto-delete queues cannot be quorum — for an RPC reply queue keep a classic exclusive one.

Streams arrived in 3.9: a replicated append-only log where consuming does not remove the message; the consumer holds an offset and can start from the beginning, a given offset or a timestamp. A super stream is that stream partitioned across several physical streams — a Kafka topic, each stream a partition. Its protocol lives on port 5552, but you can also consume over AMQP 0-9-1 (x-stream-offset).

When do you use a quorum queue, and when do you not?

Answer: the simple formula: if losing one message means losing money, data or trust, quorum; otherwise classic. Quorum queues are Raft-based and every message must be written to a majority of replicas before being confirmed — higher write cost, slightly higher latency, intra-cluster traffic.

Where not to reach for quorum: exclusive RPC reply queues (unsupported); very high-rate, very low-value queues such as disposable telemetry; and workloads creating huge numbers of short-lived queues, since each quorum queue is its own Raft cluster.

The closing line that shows seniority: "a quorum queue guarantees the durability of the queue, not the durability of the processing. If the publisher never got a confirm, or the consumer fails before acking, that is still my problem."


5. Durability and persistence — the triple everyone gets wrong

Three independent things exist, and all three must be right or the message is lost:

  1. Durable queue: the queue definition is stored in broker metadata and recreated on restart. A non-durable queue vanishes on restart with all of its contents.
  2. Persistent message: published with delivery_mode = 2, so the broker writes it to disk; delivery_mode = 1 means memory only.
  3. Publisher confirm: the publisher waits for the broker's acknowledgement that the message really was accepted and stored.
Combinations that burn you in production
  • Durable queue + transient messages: the queue comes back after a restart but empty. The commonest mistake of all, because the team thinks "we made the queue durable, so we are safe".
  • Persistent messages + non-durable queue: the message is written to disk but the queue does not exist after the restart.
  • Everything right + no confirms: there is a window between your basic.publish and the fsync; if the broker dies inside it the message is gone and your application never finds out, since basic.publish has no reply.

So: durable queue and persistent message and publisher confirm. All three.

When the broker "writes a message to disk" it first writes only to the OS page cache; if the power goes, that data is not yet on physical media, and fsync forces the flush. For classic queues RabbitMQ batches writes and fsyncs periodically, so there is a small loss window — and publisher confirms are exactly what closes it. For quorum queues the rule is stronger: the confirm arrives once the entry is written to a majority of replicas, tolerating the loss of a whole machine.

The publisher confirm flow and its guarantee | جریان publisher confirm و آنچه تضمین می‌کند:

sequenceDiagram
  participant P as Publisher
  participant B as Broker
  participant Q as Durable / quorum queue
  P->>B: confirm.select
  B-->>P: confirm.select-ok
  P->>B: basic.publish (delivery_mode=2, tag 1)
  B->>Q: route + persist / replicate to majority
  Q-->>B: durable
  B-->>P: basic.ack (tag 1)
  P->>B: basic.publish (bad routing key, tag 2)
  B-->>P: basic.return (mandatory)
  B-->>P: basic.ack (tag 2)
  Note over P,B: ack does NOT mean routed. Watch returns too.
channel.confirmSelect();   // put this channel into confirm mode

channel.addConfirmListener(
    (tag, multiple) -> outstanding.headMap(tag, true).clear(),          // ack
    (tag, multiple) -> log.error("broker nacked delivery tag {}", tag)); // nack

channel.addReturnListener(ret ->
    log.error("unroutable: exchange={} rk={}", ret.getExchange(), ret.getRoutingKey()));

AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
        .deliveryMode(2)                                  // persistent
        .messageId(UUID.randomUUID().toString())
        .contentType("application/json")
        .build();

channel.basicPublish("orders", "order.eu.created", /* mandatory */ true, props, body);

Three confirm styles exist: one-at-a-time synchronous (throughput disaster), batched with waitForConfirmsOrDie for batch pipelines, and asynchronous with a ConfirmListener — the right choice for online services.

AMQP 0-9-1 also has transactions (tx.select / tx.commit) with a similar guarantee but 10 to 100 times slower, since every commit is a synchronous fsync; a channel also cannot be transactional and in confirm mode at once. In practice: always confirms, never transactions.

A confirm means "the broker took it", not "the work is done"

A publisher confirm does not say the message was routed to any queue (for that you need mandatory plus a return listener), does not say a consumer saw it, and does not say the work happened. More importantly it does not solve the non-atomicity of your database and the broker — if the DB transaction commits and the publish then fails, you have an inconsistent state.

The standard answer is the transactional outbox: inside the same DB transaction write a row into an outbox table, and let a separate process read, publish and mark those rows after the confirm. A confirm does not replace the outbox; it complements it.


6. Consumer acknowledgements and prefetch

Auto-ack (autoAck = true): as soon as the broker writes the message to the socket it considers it removed; if the consumer dies before processing it is gone — and it applies no flow limit whatsoever. Manual ack: you say explicitly what happened.

Command Meaning requeue? How many?
basic.ack(tag, multiple) success with multiple=true, everything up to that tag
basic.nack(tag, multiple, requeue) failure your choice batched with multiple=true
basic.reject(tag, requeue) failure your choice one only

basic.reject is standard AMQP and single-message only; basic.nack is RabbitMQ's extension carrying the multiple flag. requeue = false means dead-lettering (or dropping, with no DLX); requeue = true puts the message back near the head. Careful: multiple=true asserts "all earlier messages succeeded too" — processing out of order, it silently acks unfinished messages.

prefetch is a waiter's tray: taking one plate at a time means constant walking, stacking twenty means plate twenty arrives cold and a trip spills all of them. Concretely it is how many unacked messages the broker will hand one consumer at once.

// prefetchSize must be zero; RabbitMQ does not implement it
// global=false means "per consumer", global=true means "per channel"
channel.basicQos(0, 50, false);
Unbounded prefetch, or auto-ack, is the fastest route to an OOM

If you never call basicQos, prefetch is unlimited: the broker pushes at full speed and the Java client buffers it all in memory. Real scenario: a queue with 2 million backed-up messages, you restart the consumer and it dies with OutOfMemoryError within seconds, restarts, OOMs again — a death loop only a prefetch setting breaks.

Less obvious effects of a large prefetch: unfair distribution (one slow consumer locks 500 messages while others idle); a requeue wave on every deploy; and tail latency, because messages sit behind slow work in one consumer's local queue.

Getting prefetch right — and the 30-minute deadline that ambushes you

Slow processing (seconds, heavy I/O, an external service): prefetch = 1 to 5, because fair distribution beats throughput. Fast processing (sub-millisecond): prefetch = 100 to 300. Rough formula: prefetch ≈ (network RTT ÷ per-message processing time) × threads — with 50ms processing and 1ms RTT even prefetch=10 is too much. In Spring, prefetch must be at least the container's concurrency or some threads idle forever.

And the detail people learn the hard way: consumer_timeout, default 1,800,000 ms (30 minutes). If a consumer does not ack within it, the broker closes the channel with PRECONDITION_FAILED and requeues the message. You can raise the global value in rabbitmq.conf or the consumer-timeout policy — but the better answer is: split long work into several smaller messages, not one long ack.

The message lifecycle as the broker sees it | چرخهٔ عمر پیام از دید broker:

stateDiagram-v2
  [*] --> Ready: published and routed
  Ready --> Unacked: basic.deliver to a consumer
  Unacked --> [*]: basic.ack
  Unacked --> Ready: basic.nack requeue=true
  Unacked --> DeadLettered: basic.nack requeue=false
  Unacked --> DeadLettered: delivery-limit exceeded
  Ready --> DeadLettered: TTL expired or max-length
  DeadLettered --> [*]: consumed from the DLQ
`nack` with `requeue=true` is an infinite hot loop

You see this code often and it is always wrong:

try {
    process(msg);
    channel.basicAck(tag, false);
} catch (Exception e) {
    channel.basicNack(tag, false, true);  // ← a time bomb
}

If the message is intrinsically broken (invalid JSON, a missing mandatory field, an id that isn't in the database) this code processes and rejects it forever: 100% CPU, a flood of logs, healthy messages blocked behind it. That is a "poison message".

The correct rule has two branches: a permanent error (broken data) → requeue = false and off to the DLQ, never retried; a transient error (database unreachable, HTTP timeout) → a delayed retry, not an immediate requeue. As a safety net always set delivery-limit on quorum queues; the 4.x default is 20, and -1 disables the limit (which you usually should not do).

How do you guarantee no message is ever lost, from publish to processing?

Answer: a chain where every link must be closed. Publisher: durable exchange and queue, delivery_mode=2, confirm.select on, mandatory=true with a return listener, and the outbox pattern for atomicity with the database. Broker: a quorum queue with 3 replicas across three nodes, ideally three availability zones, pause_minority, alerts on memory and disk alarms. Consumer: autoAck=false, ack after the work commits, bounded prefetch, a configured DLX, delivery-limit. Logic: the consumer must be idempotent, because this is at-least-once.

The line that closes the discussion: "exactly-once does not exist at the transport level; what exists is at-least-once plus an idempotent effect."


7. DLX, TTL, delays, priority — and retries done properly

A dead-letter exchange (DLX) is just an ordinary exchange that a queue points at. Four situations send a message there, each with its own reason: rejected (rejected with requeue=false), expired (message or queue TTL elapsed), maxlen (the queue hit x-max-length/x-max-length-bytes) and delivery_limit (quorum queues only).

RabbitMQ stamps an x-death header on the message: records with count, reason, queue, routing-keys and original-expiration — a treasure for debugging and counting attempts. Creating the DLX and DLQ is three commands: declare exchange --name orders.dlx --type fanout, declare queue --name orders.dlq --type quorum, and a declare binding between them.

A subtlety for quorum queues: dead-lettering is at-most-once by default, so a message can be lost between source and target. If your dead-lettered messages are valuable:

rabbitmqctl set_policy dlq-reliable "^orders\.work$" \
  '{"dead-letter-strategy":"at-least-once","overflow":"reject-publish","max-length":200000}' \
  --priority 10 --apply-to queues

Careful: at-least-once only works with overflow: reject-publish; with drop-head (the default) it silently falls back to at-most-once. And since the source queue retains dead-lettered messages until confirmed, max-length is required too.

TTL lives in three places: per message via expiration; per queue via x-message-ttl; and on the queue itself via x-expires, which deletes an unused queue (perfect for temporary RPC queues).

Per-message TTL and head-of-line blocking

One of RabbitMQ's most dangerous traps, and it claims victims in retry architectures constantly: RabbitMQ only checks the head of the queue for expiry, so messages are dead-lettered only once they reach the head.

Put a message with a 30-second TTL into a retry queue and immediately after it one with a 1-second TTL, and the second waits 30 seconds, not one — until the first expires the second never reaches the head.

The practical consequence: use exactly one fixed TTL per retry queue. For staged backoff, build several separate queues: retry.5s, retry.30s, retry.5m. This "retry ladder" is the most robust known pattern. Alternatives without the problem: the delayed-message plugin, or quorum queues' native delayed retries in 4.3.

For delayed messages you have three routes. Route 1 — the official rabbitmq_delayed_message_exchange plugin: it adds an exchange type called x-delayed-message; you publish with an x-delay header (milliseconds) and the broker holds the message back.

// after rabbitmq-plugins enable rabbitmq_delayed_message_exchange
channel.exchangeDeclare("orders.delayed", "x-delayed-message", true, false,
                        Map.of("x-delayed-type", "topic"));

AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
        .deliveryMode(2)
        .headers(Map.of("x-delay", 30_000))   // 30-second delay
        .build();
channel.basicPublish("orders.delayed", "order.eu.retry", true, props, body);

The plugin targets delays of seconds to a few hours, not multi-day scheduling; recent releases require RabbitMQ 4.x with Khepri; and being outside the broker core it is less durable than a quorum queue.

Route 2 — the TTL + DLX queue ladder (no plugin, works everywhere), diagrammed below. Route 3 — quorum queues' native delayed retry in RabbitMQ 4.3: the cleanest option on 4.3, because the queue sets the returned message aside internally and only makes it available again after the delay, without rewriting it and with no risk of duplication or loss. Configure with x-delayed-retry-type (disabled by default, all, returned, failed) and x-delayed-retry-min, or the policy keys; the back-off is linear.

For priority, set x-max-priority and keep levels low (5 to 10; each is a separate structure inside the broker). Priority only orders messages still in the queue, and a prefetched message is no longer the broker's to reorder, so a large prefetch neutralises priority. Before 4.3 only classic queues supported it; since 4.3 quorum queues offer up to 32 strict levels.

The retry ladder, blocking no consumer | نردبان retry — بدون بلاک‌کردن هیچ consumer‌ای:

flowchart LR
  APP{{orders exchange}} --> W[[orders.work]]
  W -->|"reject requeue=false"| RX{{orders.retry.dlx}}
  RX -->|attempt 1| R1[["retry.5s (ttl 5000)"]]
  RX -->|attempt 2| R2[["retry.30s (ttl 30000)"]]
  RX -->|attempt 3| R3[["retry.5m (ttl 300000)"]]
  R1 -.->|dead-letter back| APP
  R2 -.->|dead-letter back| APP
  R3 -.->|dead-letter back| APP
  W -->|"attempts exhausted"| PARK[[orders.parking-lot]]
rabbitmqadmin declare queue --name retry.30s --type quorum --durable true \
  --arguments '{"x-message-ttl":30000,"x-dead-letter-exchange":"orders","x-dead-letter-routing-key":"order.eu.created"}'

No consumer ever sits on the retry.* queues: the queue simply holds the message until the TTL elapses.

Why a retry ladder, and never `Thread.sleep` in the consumer

Thread.sleep(30_000) inside a consumer is three disasters at once: the thread idles for 30 seconds; the message is still unacked, occupying a prefetch slot; and as attempts pile up you hit consumer_timeout, the channel closes and in-flight messages are requeued. With a retry ladder the consumer nacks immediately and moves on; the broker does the waiting, for free.

Three choices that separate senior from mid: add jitter (if 10,000 messages all return exactly 30 seconds later you kill the downstream service again); build a parking lot, not just a DLQ, because without a manual replay tool the final queue is only a graveyard; and distinguish "worth retrying" from "not" — a 400 never fixes itself, a 503 usually does.

A consumer keeps failing on one message. Walk me through what you do.

Answer: 1) Contain it: ensure requeue=false is used and the queue has a delivery-limit, so the message reaches the DLQ on its own. 2) Find it: read it from the DLQ and inspect x-deathcount gives attempts, reason the cause, queue the origin. 3) Classify it: broken data or broken dependency? Broken data should never have entered the retry path, so the error classification is wrong. 4) Fix it structurally: map permanent exceptions to AmqpRejectAndDontRequeueException and send the rest to the retry ladder. 5) Alert on DLQ depth and build the replay-from-parking-lot tool in advance, not at three in the morning.

Does RabbitMQ guarantee message ordering?

Answer: the guarantee is narrow and conditional: within one queue, with one consumer, no requeues and no priority, FIFO holds. Break any condition and ordering breaks: several consumers means parallel processing (delivery order preserved, completion order not); a requeue puts the message back near the head; a priority queue breaks publication order by design; a retry ladder guarantees the delayed message arrives after newer ones.

If you genuinely need ordering you have three options: x-single-active-consumer, guaranteeing only one consumer reads the queue at any moment (with automatic failover); manual partitioning, one queue per entity key; or accepting disorder and making the consumer commutative or version-numbered. Kafka gives per-partition ordering intrinsically; if strict per-key ordering is your core requirement, that is a strong signal towards Kafka.


8. Idempotent consumers and deduplication

Because delivery is at-least-once, every consumer must swallow duplicates harmlessly. Three levels: naturally idempotent operations (UPDATE account SET status = 'ACTIVE' WHERE id = ? can run a thousand times); a unique key in the database; and an inbox table recording the message id in the very transaction that does the work:

CREATE TABLE processed_message (
    message_id   text        PRIMARY KEY,
    consumer     text        NOT NULL,
    processed_at timestamptz NOT NULL DEFAULT now()
);

-- inside the same transaction that performs the real work
INSERT INTO processed_message (message_id, consumer)
VALUES ('7f3c1e8a-...', 'order-projector')
ON CONFLICT (message_id) DO NOTHING;

If nothing was inserted the message was already processed — skip the work and just ack. On the publisher side sits the outbox table; safe batched reads with parallel workers:

SELECT id, aggregate_id, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 100;

Three pieces of senior judgement: prune the dedup table with a cleanup job sized from the maximum possible message age, or it becomes an incident of its own; let the publisher generate message_id, because a consumer hashing content collapses two genuinely different events with identical payloads into one; and keep the dedup record in the same transaction as the work — dedup in Redis evaporates on a flush or eviction. The outbox, inbox and saga patterns are covered in the ms-data chapter.


9. Operations: clustering, alarms, flow control and metrics

RabbitMQ nodes talk over Erlang distribution: port 4369 for epmd (name discovery) and 25672 for distribution itself, and all nodes share one Erlang cookie. A client can connect to any node; if it is not the queue's leader the request is forwarded inside the cluster — one extra hop, but transparent. Each quorum queue is an independent Raft cluster with a leader and followers.

Broker metadata (queues, exchanges, bindings, users) lived in Mnesia for years, which handled network partitions poorly. Khepri is the Raft-based replacement — the same algorithm quorum queues use — default since 4.2 and the only option in 4.3. The practical effect: partition behaviour is more predictable, but on the minority side schema changes (declaring a queue or exchange) fail until the majority returns.

RabbitMQ has two resource alarms and both do one thing: block publishers. A memory alarm fires when node memory exceeds vm_memory_high_watermark (today's default 0.6, i.e. 60% of detected RAM), and a disk alarm when free space falls below disk_free_limit (default 50 MB, which is only suitable for tutorials).

# rabbitmq.conf
vm_memory_high_watermark.relative = 0.6
disk_free_limit.relative = 2.0             # twice RAM — the practical production advice
consumer_timeout = 1800000
cluster_partition_handling = pause_minority
When an alarm fires, publishing stops — and your application hangs

While an alarm is active the broker sends connection.blocked to publishing connections and stops reading from them; consumers keep working (deliberately — draining the queues is the only way out). On your side basicPublish blocks, and if you publish on an HTTP request thread the pool sleeps and your service, not the broker, goes down.

Three defences: register a BlockedListener (Connection#addBlockedListener) and wire it into a health check; never publish on a user request thread without a timeout; and fix the cause, not the symptom — a memory alarm almost always means a consumer has fallen behind.

Separately, RabbitMQ has credit-based flow control between its Erlang processes; a connection in flow means the publisher is outrunning the broker — not an error, an early warning.

rabbitmq-plugins enable rabbitmq_management   # port 15672
rabbitmq-plugins enable rabbitmq_prometheus   # port 15692, path /metrics

# HTTP API — queue depth and consumers (%2F is the default "/" vhost)
curl -s -u guest:guest 'http://localhost:15672/api/queues/%2F/orders.work' \
  | jq '{messages_ready, messages_unacknowledged, consumers}'

# ready-made health checks for probes
curl -s -u guest:guest http://localhost:15672/api/health/checks/alarms
curl -s -u guest:guest http://localhost:15672/api/health/checks/node-is-quorum-critical
Metric What it tells you Alert when
rabbitmq_queue_messages_ready queue depth (waiting for delivery) a sustained upward trend = consumers behind
rabbitmq_queue_messages_unacked delivered but not acked a large number = slow consumers or too much prefetch
publish / deliver / ack rates in vs out balance publish > ack for long = disaster inbound

Never build a two-node cluster: with two nodes a majority is impossible, so if one dies the other cannot decide anything either — for both quorum queues and Khepri the right number is 3 (or 5). And keep queues short: a long queue is not merely "slow", it drives memory up, starts paging to disk, spikes latency, and eventually trips the alarm that blocks every publisher.

Task Command
Node status rabbitmq-diagnostics status
Is this node quorum-critical rabbitmq-queues check_if_node_is_quorum_critical
Safe rolling restart gate rabbitmq-upgrade await_online_quorum_plus_one
Queues with depth rabbitmqctl list_queues name type messages_ready messages_unacknowledged
Safe drain for maintenance rabbitmq-upgrade drain then rabbitmq-upgrade revive

Default ports: 5672/5671 (AMQP without/with TLS), 15672 (management), 15692 (Prometheus), 25672 (Erlang distribution), 4369 (epmd), 5552 (stream), 1883/8883 (MQTT), 61613/61614 (STOMP).

How do you size and harden a RabbitMQ cluster for production?

Answer: five axes. Topology: at least 3 nodes across 3 availability zones, important queues quorum with 3 replicas, pause_minority. Resources: size memory from worst-case queue depth, not the normal rate — the hard case is a consumer down for an hour; disk_free_limit.relative = 2.0 and a high file-descriptor ceiling. Flow: bounded prefetch, max-length + overflow everywhere so one runaway queue cannot swallow the cluster, alternate-exchange for unroutable messages. Security: a vhost per environment, guest removed or restricted, TLS on 5671, precise permissions instead of .* (see ms-security). Observability: the Prometheus plugin, alerts on alarms and queue/DLQ depth, probes on /api/health/checks/....

The closing line that carries weight: "the most important thing I do when sizing is keep queues short; every other number derives from that."

The closing line that carries weight: "the most important thing I do when sizing is keep queues short; every other number derives from that."


10. Spring AMQP in practice

spring-boot-starter-amqp wraps the official Java client with two main tools: RabbitTemplate for publishing and @RabbitListener for consuming.

spring:
  rabbitmq:
    virtual-host: /orders
    publisher-confirm-type: correlated   # async confirms with correlation data
    publisher-returns: true
    template:
      mandatory: true                    # without this, publisher-returns does nothing
    listener:
      simple:
        acknowledge-mode: auto           # the container acks unless an exception escapes
        prefetch: 20
        concurrency: 4
        max-concurrency: 16
        default-requeue-rejected: false  # ← critically important
        retry:
          enabled: false                 # leave retries to the broker, not to memory
Two Spring defaults that flatten teams

1. default-requeue-rejected defaults to true — any exception escaping a listener produces a nack with requeue=true: the same infinite hot loop, hidden in a default. Set it to false and build the error path with a DLX.

2. The default SimpleMessageConverter uses Java serialization — brittle and a security risk. Always declare an explicit JSON converter; if forced into Java serialization, configure a class allow-list with DefaultClassMapper.

Version note: in Spring AMQP 4.x (Spring Boot 4, Jackson 3) Jackson2JsonMessageConverter is deprecated in favour of JacksonJsonMessageConverter.

@Bean
RabbitTemplate rabbitTemplate(ConnectionFactory cf) {
    RabbitTemplate template = new RabbitTemplate(cf);
    // in Spring AMQP 4.x the class is named JacksonJsonMessageConverter
    template.setMessageConverter(new Jackson2JsonMessageConverter());
    template.setConfirmCallback((correlation, acked, cause) -> {
        if (!acked) log.error("publish nacked id={} cause={}", correlation, cause);
    });
    template.setReturnsCallback(r ->
        log.error("unroutable exchange={} rk={}", r.getExchange(), r.getRoutingKey()));
    return template;
}

void publish(OrderCreated event) {
    rabbitTemplate.convertAndSend("orders", "order.eu.created", event,
            message -> {
                MessageProperties p = message.getMessageProperties();
                p.setDeliveryMode(MessageDeliveryMode.PERSISTENT);
                p.setMessageId(event.id().toString());
                return message;
            },
            new CorrelationData(event.id().toString()));
}
@RabbitListener(queues = "orders.work", concurrency = "4-16")
public void onOrderCreated(@Payload OrderCreated event,
                           @Header(name = "x-death", required = false)
                           List<Map<String, Object>> deaths) {

    long attempt = (deaths == null || deaths.isEmpty())
            ? 0L : (Long) deaths.get(0).get("count");

    try {
        orderService.handle(event);          // must be idempotent
    } catch (InvalidPayloadException e) {
        // permanent error → straight to the DLQ, never retried
        throw new AmqpRejectAndDontRequeueException("bad payload", e);
    } catch (DownstreamUnavailableException e) {
        if (attempt >= 5) {
            throw new AmqpRejectAndDontRequeueException("attempts exhausted", e);
        }
        throw e;                             // → goes to the retry ladder
    }
}

Subtleties worth catching in code review:

  • Two container types: SimpleMessageListenerContainer (default; consumer count adjustable at runtime) and DirectMessageListenerContainer (listener.type=direct; a thread per consumer, lower overhead). For high, steady load direct is usually better.
  • Listener-side retry.enabled retries in memory and holds the thread: fine for a few hundred milliseconds, a disaster for multi-minute backoff. For application-side retries combine RepublishMessageRecoverer with a RetryTemplate, so the message is finally published to an error queue with its stack trace instead of merely rejected.
  • Manual acks tie the ack to an asynchronous event: acknowledge-mode: manual plus a Channel parameter.
What does `AmqpRejectAndDontRequeueException` do and why does it matter?

Answer: the container decides which ack to send from the exception escaping the listener; the default follows defaultRequeueRejected (true, i.e. requeue). This exception overrides that: the message is rejected with requeue=false and goes straight to the DLX. Its opposite is ImmediateRequeueAmqpException, which always requeues.

Why it matters: it is the primary tool for error classification; without it every NullPointer bug becomes an infinite loop. Throw domain exceptions in the service layer and map them to one of these two in the listener. A follow-up: conversion failures (invalid JSON) happen before your method is reached and become MessageConversionException; the container does not treat those as retryable and rejects without requeue — which is correct.

@SpringBootTest
@Testcontainers
class OrderListenerIT {

    @Container
    @ServiceConnection     // Spring Boot injects host/port/credentials itself
    static RabbitMQContainer rabbit =
            new RabbitMQContainer(DockerImageName.parse("rabbitmq:4.1-management"));

    @Autowired RabbitTemplate rabbitTemplate;
    @Autowired RabbitAdmin rabbitAdmin;

    @Test
    void routesToDlqOnPermanentFailure() {
        rabbitTemplate.convertAndSend("orders", "order.eu.created", brokenEvent());

        Awaitility.await().atMost(Duration.ofSeconds(10)).untilAsserted(() ->
            assertThat(rabbitAdmin.getQueueInfo("orders.dlq").getMessageCount())
                .isEqualTo(1));
    }
}

On Testcontainers 1.x the dependency is org.testcontainers:rabbitmq (class in org.testcontainers.containers); in 2.x it is org.testcontainers:testcontainers-rabbitmq with the package org.testcontainers.rabbitmq.

Three things worth an integration test that mocks never give you: binding correctness (does that routing key really reach that queue?), the DLQ path, and idempotency (same message twice, identical final state?). Testcontainers fundamentals are in the testing chapter.


11. RabbitMQ vs Kafka, JMS and MQTT

The fundamental difference in one sentence: RabbitMQ is a queue, Kafka is a log. In RabbitMQ the message disappears after the ack; in Kafka it stays until retention ends and the offset is a pointer.

Dimension RabbitMQ (queue) Kafka (log)
Storage model queue; removed after ack append-only log with time/size retention
Routing exchange + binding + wildcards, broker-side topic + partition, client-side
Consumption parallelism unlimited on one queue capped by partition count
Ordering one consumer or single-active-consumer only intrinsically per-partition
Replay none (except streams) the headline strength
Per-message TTL / priority / DLQ native absent; build it in the application
Which one, when — the real judgement

Choose RabbitMQ for smart per-message routing; when TTL, priority, delay or DLQ are needed per message (building those on Kafka means writing half a broker); when the pattern is a work queue or request/reply; and when operational cost matters.

Choose Kafka for replay; for strict per-key ordering at high throughput; when independent consumers must read the same stream at different speeds; or when data as the source of truth is part of the design (event sourcing, log compaction).

And be honest: the lines blur. RabbitMQ has replay and partitioning through streams and super streams, and Kafka is acquiring queue semantics through share groups (KIP-932) — early access in 4.0, preview in 4.1, 4.2 the GA target. A good answer is not to bash one; it is to ask "which consumption model is this, and which tool implements it natively".

A service handles 50k events/s with three consumers, and one of them occasionally has to reprocess yesterday's data. RabbitMQ or Kafka?

Answer: "reprocess yesterday's data" almost decides it alone. Ordinary RabbitMQ queues keep nothing after the ack, so replay is impossible unless you build a parallel store — hand-writing Kafka again. So: Kafka, or RabbitMQ Streams to stay in the RabbitMQ ecosystem.

Choose between them with three questions: for multi-week retention at terabyte volume with tiered storage, Kafka; if that same stream also needs per-message routing, RabbitMQ Streams next to your exchanges is easier; and if you need the stream-processing and connector ecosystem, Kafka again. If the rest of the system is command-driven, keep RabbitMQ for commands and move only this event stream — not everything.

What are JMS and Jakarta Messaging?

A misunderstanding that gets exposed fast in interviews: JMS is not a protocol, it is an API — a set of Java interfaces. What goes over the wire is up to the broker and is not standardised, which is why one vendor's JMS client generally cannot talk to another vendor's broker. AMQP is the opposite: a wire protocol, independent of language.

After moving to the Eclipse Foundation, JMS became Jakarta Messaging and the packages moved from javax.jms to jakarta.jms; the current version is 3.1. Apache ActiveMQ Artemis (2.44.x at the time of writing) is the most prominent open-source implementation and is multi-protocol: AMQP 1.0, MQTT, STOMP, OpenWire and its own core protocol.

Artemis is still right in three situations: a Jakarta EE mandate with MDBs (@MessageDriven) on an application server (see ms-runtime-legacy); a genuine need for XA transactions, which RabbitMQ lacks; and message selectors, consumer-side filtering on properties (JMSType = 'urgent' AND region = 'EU'), which in RabbitMQ must become routing topology. For a new polyglot service a wire protocol almost always beats a Java-only API.

MQTT — where RabbitMQ reaches the device world

MQTT targets low-bandwidth networks and constrained devices; its fixed header can be just 2 bytes against dozens in HTTP — for a battery-powered sensor on a cellular network, months of extra life.

Core concepts: a topic is levelled with / (sensors/floor2/room7/temp) with wildcards + (one level) and # (multi-level, only at the end). QoS 0 is at most once, QoS 1 at least once, QoS 2 exactly once. Retained message: the last message per topic is kept so a new subscriber immediately gets the "current state". LWT: a message the device registers on connect that the broker publishes for it on an abnormal disconnect. Clean session: whether subscriptions survive a disconnect.

With rabbitmq-plugins enable rabbitmq_mqtt (ports 1883 and 8883) plus rabbitmq_web_mqtt for WebSocket the mechanism is elegant: the MQTT message is published to amq.topic with the topic translated into a routing key — / becomes . and + becomes *. So the device publishes MQTT and the backend consumes AMQP 0-9-1, with no bridge in between. One queue is created per subscriber (classic, quorum, or rabbit_mqtt_qos0_queue); Native MQTT since 3.12 cut per-connection memory sharply, and MQTT 5.0 arrived in 3.13.

RabbitMQ's MQTT limitations you must know
  • QoS 2 is not supported. RabbitMQ downgrades QoS 2 publishes and subscriptions to QoS 1 and advertises Maximum QoS = 1 in CONNACK. If your design leans on protocol-level exactly-once it will not hold — build idempotency in the application.
  • Retained messages live in a per-node local store and are not replicated; a device reconnecting elsewhere may not see them.
  • Queue explosion: every subscriber creates at least one queue. A hundred thousand devices means a hundred thousand queues — unsuitable for quorum queues; use the special queue type for QoS 0.
  • Flaky devices: with clean session = false queues persist and accumulate; set session expiry and queue TTL or orphaned queues pile up.

Decision tree: which broker family fits | درخت تصمیم:

flowchart TD
  S{"Need replay of past events?"} -->|yes| K["Kafka or RabbitMQ Streams"]
  S -->|no| R{"Per-message routing, TTL, priority or DLQ?"}
  R -->|yes| RM["RabbitMQ queues (AMQP 0-9-1)"]
  R -->|no| T{"Many constrained or intermittent devices?"}
  T -->|yes| M["MQTT (or RabbitMQ MQTT plugin)"]
  T -->|no| J{"Jakarta EE app server or XA required?"}
  J -->|yes| A["JMS broker: ActiveMQ Artemis"]
  J -->|no| RM
Explain the difference between AMQP and MQTT for an IoT device backend.

Answer: two protocols for two environments. MQTT is for the edge: low bandwidth, unstable connections, constrained devices; a tiny header plus device features such as LWT, retained messages and session persistence. AMQP 0-9-1 is for the centre: complex routing, delivery guarantees, DLQs and priority — heavier but richer semantically.

The usual architecture: devices connect over MQTT, the same broker (or a bridge) brings messages into the AMQP domain, and backend services consume over AMQP; RabbitMQ does this itself by mapping MQTT onto amq.topic.

The point interviewers like: the two sides scale differently — MQTT is a connection count problem, AMQP a throughput and queue depth problem, sometimes needing separate clusters.


Chapter wrap-up
  • AMQP 0-9-1 is a wire protocol: producer → exchange → binding → queue → consumer; a producer never publishes to a queue. AMQP 1.0 is a different protocol, native since 4.0.
  • Four exchange types: direct, fanout, topic (* one word, # zero or more), headers. Unroutable messages are dropped silently — guard with mandatory and alternate-exchange.
  • Durability is a triple: durable queue + persistent message + publisher confirm; none suffices alone, and AMQP transactions are 10–100× slower than confirms.
  • Classic mirroring was removed in 4.0. For anything expensive to lose, x-queue-type: quorum with 3 replicas.
  • Acks and prefetch matter: auto-ack loses messages, unbounded prefetch means an OOM, consumer_timeout defaults to 30 minutes.
  • Let the broker retry: a TTL + DLX ladder, the delay plugin, or native delayed retries in 4.3. Never Thread.sleep, never an unconditional requeue=true; delivery-limit (default 20) is the poison-message net, and TTL is evaluated only at the head of the queue.
  • Delivery is at-least-once, so consumers must be idempotent: an inbox table with a unique key, in the same transaction as the work.
  • In operations: 3 nodes not 2, pause_minority, alerts on alarms, and above all keep queues short — queue depth is a health metric, not a capacity buffer.
  • In Spring AMQP: default-requeue-rejected to false, an explicit JSON converter, errors classified with AmqpRejectAndDontRequeueException, the DLQ path tested with Testcontainers.
  • Tool choice: RabbitMQ for routing and work queues; Kafka for replay, per-key ordering and huge throughput; JMS/Artemis for Jakarta EE and XA; MQTT for constrained devices.