Backend / Message queues / RabbitMQ / 03_exchanges_queues_dlx.md

RabbitMQ Exchanges, Queues, DLX, and Quorum Queues

Updated 7 interview angles 6 min read source
On this page12
  1. Producers don’t send to queues directly
  2. Exchange types
  3. Queue types
  4. DLX (Dead-Letter Exchange)
  5. TTL on messages and queues
  6. Prefetch — the throughput dial
  7. Lazy queues vs default
  8. Publisher confirms (durability)
  9. Channel-level patterns
  10. Common pitfalls
  11. Comparison to Kafka
  12. Interview angle

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

text
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

python
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

python
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

python
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.

python
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.

python
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:

python
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

python
# 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

python
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.

python
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.

python
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 7

  • “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.