Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

RabbitMQ Architecture & Core Concepts

AMQP 0-9-1 from the ground up: the four exchange types and when each fits, queue properties, bindings and routing keys, prefetch and fairness, and publisher confirms.

Beginner6 min readUpdated
On this page

RabbitMQ's power is in routing. Where Kafka gives you a partitioned log and leaves distribution to the consumer, RabbitMQ lets the broker decide which queues a message reaches based on rules you declare — which is why it stays the better fit for task distribution and complex workflows.

Key Takeaways

  • Producers publish to an exchange, never to a queue. Bindings decide where messages land.
  • Four exchange types: direct, fanout, topic, headers. Topic covers most real routing.
  • Durability needs three things: durable queue, durable exchange, persistent message.
  • Prefetch is what makes work distribution fair — the default of unlimited is rarely right.
  • Publisher confirms are the only way to know the broker accepted a message.

Exchanges and bindings

One published message reaches every queue whose binding matches the routing key — and no others.
ExchangeRouting ruleUse for
DirectRouting key equals binding key exactlySimple task queues, severity routing
FanoutIgnores the key, copies to every bound queueBroadcast, cache invalidation
TopicWildcard match: * is one word, # is zero or moreMost real routing
HeadersMatches header values, x-match=all or anyWhen routing depends on structured metadata

Topic patterns are worth internalising because they cover almost every case. With a key of order.eu.created: order.*.created matches, order.eu.# matches, order.# matches, *.eu.created matches, and order.* does not* is exactly one word, and there are three.

Design the routing key as a hierarchy from general to specific: <entity>.<region>.<event>. Consumers then subscribe at whatever granularity they need without the producer knowing they exist, which is the whole point of using an exchange.

Queue properties

RabbitTopology.java
@Configuration
public class RabbitTopology {
 
    @Bean
    TopicExchange ordersExchange() {
        return ExchangeBuilder.topicExchange("orders").durable(true).build();
    }
 
    @Bean
    Queue orderCreatedQueue() {
        return QueueBuilder.durable("q.order.created")
                // Quorum queues use Raft. Classic mirrored queues are deprecated.
                .quorum()
                // Rejected or expired messages go here rather than vanishing.
                .deadLetterExchange("orders.dlx")
                .deadLetterRoutingKey("order.created.failed")
                // Redelivery limit: after this many attempts the message is
                // dead-lettered instead of looping forever.
                .deliveryLimit(5)
                .ttl(3_600_000)
                .maxLength(100_000)
                .build();
    }
 
    @Bean
    Binding orderCreatedBinding(Queue orderCreatedQueue, TopicExchange ordersExchange) {
        return BindingBuilder.bind(orderCreatedQueue).to(ordersExchange).with("order.*.created");
    }
 
    @Bean
    Queue deadLetterQueue() {
        return QueueBuilder.durable("q.order.dlq").quorum().build();
    }
}

deliveryLimit on a quorum queue is the fix for the classic poison-message loop. Without it, a message that always fails is requeued forever, consuming capacity and blocking the messages behind it.

The three parts of durability

This trips up nearly everyone, because each part looks like it should be sufficient on its own.

A durable queue survives a broker restart as a definition — but an empty one, unless the messages were also persistent.

A persistent message (delivery_mode=2) is written to disk — but only if it is in a durable queue. A persistent message in a transient queue dies with the queue.

A durable exchange survives restart. If it does not, publishes after the restart fail because the exchange no longer exists.

Spring AMQP defaults to persistent messages, and QueueBuilder.durable(...) handles the queue. The exchange is the one people forget.

Note the cost: persistence means an fsync per message. Where throughput matters more than durability — transient notifications, cache invalidation — non-persistent messages are dramatically faster, and that is a legitimate choice as long as it is deliberate.

Prefetch and fair dispatch

Without a prefetch limit the broker pushes everything to whichever consumer is ready first, and adding consumers does nothing.

Prefetch caps how many unacknowledged messages a consumer may hold. With no limit, the broker pushes messages as fast as the socket allows, one consumer takes the lot, and scaling out has no effect — a genuinely confusing failure because everything looks correctly configured.

application.yml
spring:
  rabbitmq:
    listener:
      simple:
        prefetch: 10
        concurrency: 3
        max-concurrency: 10
        acknowledge-mode: manual
        default-requeue-rejected: false

Choose the value from the work profile. Slow, variable tasks — image processing, report generation — want prefetch 1, so a consumer takes another message only when genuinely free. Fast uniform messages want 10 to 100, because the acknowledgement round trip otherwise dominates.

default-requeue-rejected: false is important: with true, a rejected message goes straight back to the head of the queue and is immediately redelivered, producing a hot loop.

Publisher confirms

Publishing to RabbitMQ is fire-and-forget by default. The broker may reject the message, or there may be no queue bound to match the routing key, and the producer never finds out.

application.yml
spring:
  rabbitmq:
    publisher-confirm-type: correlated
    publisher-returns: true
    template:
      mandatory: true
ConfirmingPublisher.java
@Component
public class OrderPublisher {
 
    private final RabbitTemplate template;
 
    public OrderPublisher(RabbitTemplate template) {
        this.template = template;
 
        // The broker accepted (or rejected) the message.
        template.setConfirmCallback((correlation, ack, reason) -> {
            if (!ack) log.error("broker rejected {}: {}", correlation, reason);
        });
 
        // Accepted by the exchange but matched no queue — a routing bug that
        // is otherwise completely invisible.
        template.setReturnsCallback(returned ->
            log.error("unroutable: exchange={} key={}",
                    returned.getExchange(), returned.getRoutedKey()));
    }
}

The returns callback catches a specific and nasty class of bug. A message published with a routing key nothing is bound to is silently discarded — the publish "succeeds", and the message simply disappears. With mandatory: true plus a returns callback, you get a log line instead of a mystery.

Connections and channels

A connection is a TCP socket and is expensive; a channel is a lightweight virtual connection multiplexed over it. The rule is one connection per application, one channel per thread — channels are not thread-safe, and sharing one across threads produces protocol errors that look like broker faults.

CachingConnectionFactory handles this for you. Give it a list of addresses so it can fail over between cluster nodes, and set a heartbeat below any proxy or firewall idle timeout on the path.

What to take away

Publish to exchanges, let bindings route, and design routing keys as a hierarchy. Get all three parts of durability right. Set a prefetch that matches your work profile, or scaling out will not work. Turn on publisher confirms and returns — silent message loss is otherwise invisible until someone notices missing data.

Frequently Asked Questions

Why do my messages disappear when RabbitMQ restarts?
Durability has three independent parts and you need all of them: the queue must be declared durable, the message must be published with persistent delivery mode, and the exchange must be durable. Miss any one and the message is gone on restart — this is the most common RabbitMQ surprise.
What prefetch value should I use?
Start at 1 for slow, uneven tasks so work is distributed fairly rather than pre-assigned to a busy consumer. Raise it to 10-100 for fast uniform messages where the round-trip per message dominates. Unlimited prefetch — the default — lets one consumer claim the entire queue while others sit idle.
Classic or quorum queues?
Quorum for anything you care about. They use Raft consensus, replicate properly, handle network partitions predictably and support delivery limits natively. Classic mirrored queues are deprecated. Classic non-replicated queues are still fine for transient work where losing messages on a node failure is acceptable.

Related tutorials