backend / message queues / rabbitmq / 03_exchanges_queues_dlx.md

RabbitMQ Exchanges, Queues, DLX, and Quorum Queues

7 interview angles 6 min read source

RabbitMQ Exchanges, Queues, DLX, and Quorum Queues

The core RabbitMQ concepts that “what is rabbitmq” coverage usually skips. Production-relevant.

Producers don’t send to queues directly

producer → exchange → (binding routing) → queue(s) → consumer

The producer always publishes to an exchange, with a routing key. The exchange decides which queue(s) get the message based on bindings.

This indirection is why RabbitMQ is more flexible than Kafka topics. You can rewire who-listens-to-what without changing producer code.

Exchange types

Type Routing logic
direct route to queues bound with the exact routing key
topic route to queues bound with a matching pattern (order.*.placed, #.failed)
fanout route to ALL queues bound to the exchange (broadcast)
headers route based on message headers (rare)

Direct exchange — point-to-point

channel.exchange_declare(exchange="orders.direct", exchange_type="direct")
channel.queue_declare(queue="order_placed", durable=True)
channel.queue_bind(exchange="orders.direct", queue="order_placed", routing_key="placed")

# Producer
channel.basic_publish(exchange="orders.direct", routing_key="placed", body=payload)

Each routing key maps to specific queue(s). Useful for “this event goes to exactly these consumers.”

Topic exchange — pattern-based routing

channel.exchange_declare(exchange="orders.topic", exchange_type="topic")
channel.queue_bind(exchange="orders.topic", queue="all_orders", routing_key="orders.#")
channel.queue_bind(exchange="orders.topic", queue="failed_only", routing_key="orders.*.failed")

channel.basic_publish(exchange="orders.topic", routing_key="orders.us-east.placed", body=...)

* matches one word; # matches zero or more words. Powerful for event-driven setups where consumers subscribe to slices.

Fanout — broadcast

channel.exchange_declare(exchange="orders.broadcast", exchange_type="fanout")
channel.queue_bind(exchange="orders.broadcast", queue="audit")
channel.queue_bind(exchange="orders.broadcast", queue="analytics")
channel.queue_bind(exchange="orders.broadcast", queue="email")

Every bound queue gets a copy. The pub/sub pattern.

Queue types

Classic queues (legacy)

The original RabbitMQ queue. Mirrored for HA via “mirrored queues” — replicated to other nodes for failover.

Deprecated as of RabbitMQ 3.10+. Don’t use for new deployments.

Quorum queues (modern HA)

Replicated using Raft consensus. Strong durability guarantees; tolerates (N-1)/2 node failures.

channel.queue_declare(
    queue="orders",
    durable=True,
    arguments={
        "x-queue-type": "quorum",
        "x-quorum-initial-group-size": 3,
    },
)

Trade-offs vs classic mirrored:

  • Stronger consistency; no message loss on leader failover.
  • Faster failover.
  • Better tooling and observability.
  • Higher resource usage per message.
  • Slower for very small, in-memory workloads.

Default choice for new RabbitMQ deployments.

Streams (Kafka-like)

Append-only logs with offsets. Multiple consumers can read at different positions. Higher throughput than queues for fan-out.

channel.queue_declare(
    queue="events",
    durable=True,
    arguments={"x-queue-type": "stream"},
)

Use for event-streaming workloads. Most teams reach for Kafka here; streams are a RabbitMQ-only option.

DLX (Dead-Letter Exchange)

A queue can be configured to send dead-lettered messages to another exchange:

channel.exchange_declare(exchange="dlx", exchange_type="direct")
channel.queue_declare(queue="orders.dlq", durable=True)
channel.queue_bind(exchange="dlx", queue="orders.dlq", routing_key="orders")

channel.queue_declare(
    queue="orders",
    durable=True,
    arguments={
        "x-dead-letter-exchange": "dlx",
        "x-dead-letter-routing-key": "orders",
    },
)

A message is dead-lettered when:

  • Consumer basic.nack or basic.reject with requeue=False.
  • TTL expires (x-message-ttl on the queue, or per-message).
  • Queue length limit exceeded (x-max-length).

The dead-lettered message arrives in the DLQ with extra headers explaining why. Operators can inspect, replay, or alert.

DLQs are the right pattern for poison messages, retry-exhaustion, and “broken consumer” investigation.

TTL on messages and queues

# Per-queue TTL: messages expire after 60s in queue
channel.queue_declare(
    queue="ephemeral",
    arguments={"x-message-ttl": 60000},
)

# Per-message TTL
channel.basic_publish(
    exchange="...", routing_key="...", body=...,
    properties=pika.BasicProperties(expiration="30000"),
)

Expired messages are dropped (or dead-lettered if DLX configured). Useful for time-sensitive work where stale messages are pointless (cache invalidations, notifications).

Prefetch — the throughput dial

channel.basic_qos(prefetch_count=10)

How many unacked messages the broker delivers to a consumer at once. Default is no limit — the broker pushes everything it can, the consumer’s RAM fills up.

Tune:

  • Too low (1) — high broker round-trip overhead, low throughput.
  • Too high (1000) — one slow consumer hogs many messages; others starve.
  • Typical: 10-100 depending on message size and processing time.

For long-running tasks: prefetch=1 + manual ack ensures messages distribute fairly to free workers.

Lazy queues vs default

Default: messages cached in RAM, written to disk for durability. Memory-heavy under backlog.

channel.queue_declare(queue="bulk", arguments={"x-queue-mode": "lazy"})

Lazy queues prioritize disk over RAM. Slower per-message; survives huge backlogs without OOM. Good for large-payload, batch-style workloads.

Note: in modern RabbitMQ (3.12+), quorum queues largely supersede lazy queues for big workloads.

Publisher confirms (durability)

By default, basic_publish is fire-and-forget. The broker may not have actually persisted the message.

channel.confirm_delivery()

if channel.basic_publish(exchange=..., routing_key=..., body=..., mandatory=True):
    print("Confirmed durable")
else:
    print("Not confirmed; resend")

Confirms add latency but guarantee the broker has the message. Combined with quorum queues, gives strong durability.

mandatory=True returns the message if no queue is bound — useful for diagnosing routing key typos.

Channel-level patterns

  • One channel per thread. Channels aren’t thread-safe.
  • Long-lived connections + multiple channels is the standard pattern. Connection per app, channel per concurrency unit.
  • Open / close channels for transactions. Per-channel transactions aren’t free; prefer publisher confirms.

Common pitfalls

  • Classic mirrored queues in new deployments. Use quorum queues.
  • No prefetch limit. One consumer eats the whole queue, others idle.
  • No DLX configured. Poison messages bounce forever or vanish silently.
  • Publishing to default exchange with queue name as routing key. The default exchange routes by queue name — works, but you’ve coupled producer to queue topology. Use named exchanges.
  • Auto-delete queues that disappear on consumer reconnect. auto_delete=True removes the queue when last consumer disconnects; useful for RPC reply queues but accidentally enabled often.
  • durable=False on production queues. Queue lost on broker restart. Always durable=True for production work.

Comparison to Kafka

RabbitMQ Kafka
Model broker pushes to consumer consumer pulls from broker
Persistence durable per queue log per topic, append-only
Ordering per-queue per-partition
Throughput ~50k msg/s per node ~1M+ msg/s per cluster
Latency low (push) low
Routing flexible (exchanges) partitioned by key
Replay message acked = gone configurable retention, seek to offset
Use case work queues, RPC, complex routing event streaming, log integration, replay

Both are valid; pick based on use case. Many teams run both — RabbitMQ for tasks (Celery), Kafka for events.

Interview angle

  • “How does a producer get a message to a queue in RabbitMQ?” — through an exchange. Producer publishes to an exchange with a routing key; the exchange routes to bound queues based on its type (direct, topic, fanout, headers).
  • “Quorum queue vs classic mirrored queue?” — quorum is Raft-based, strong consistency, handles failover better. Classic mirrored is deprecated. Use quorum for new deployments.
  • “How does DLX work?” — set x-dead-letter-exchange on a queue; rejected/expired/over-limit messages route to that exchange. Bound DLQ holds them with reason headers. Operators triage and replay.
  • “What’s the prefetch count?” — how many unacked messages a consumer can have outstanding. Too low: throughput drops due to round trips. Too high: one slow consumer hogs messages. Typical: 10-100.
  • “Topic exchange vs direct exchange?” — direct: route by exact routing key. Topic: route by pattern (orders.#, *.failed). Use topic when consumers want to subscribe to slices of events.
  • “When does a message get dead-lettered?” — consumer nacks/rejects with requeue=False; message TTL expires; queue length limit exceeded. Configure x-dead-letter-exchange + a DLQ to capture them.
  • “How do you ensure messages aren’t lost?” — durable queues + persistent messages + publisher confirms + ack-after-processing. Quorum queues add replication for broker failover. Mind: still at-least-once delivery; the consumer must be idempotent.