← Back to the section

When two services want to exchange data, the simplest solution is a direct call: one makes an HTTP request to the other. That works as long as the second service is always available, keeps up with every request, and doesn't fall over under load.

As soon as one of those conditions stops holding, a message broker enters the picture. The first service drops a message into the broker and moves on with its work; the second one picks it up when it's ready. But for the broker to know where to put each message and who to hand it to, you need a protocol — a set of routing rules.

AMQP 0.9.1 (Advanced Message Queuing Protocol) is one such protocol. It's used by RabbitMQ, Apache Qpid and ActiveMQ Classic. AMQP 0.9.1 is what people usually mean when they say "AMQP" without specifying a version.

How a message travels from sender to receiver

With regular mail you write the address on the envelope, and a sorting center decides which depot to ship it to. AMQP works in a similar way:

  • Producer — sends the message.
  • Exchange — the "sorting center". Receives the message and decides which queues to put it into.
  • Queue — the "warehouse". Stores messages until a consumer picks them up.
  • Consumer — takes messages out of the queue and processes them.
  • Binding — the rule that tells the exchange which queue to route a message to.
  • Routing key — the "address" on the envelope, a label string the producer attaches when sending.
Producer
   │
   │ publish(exchange, routingKey, body)
   ▼
Exchange  ──── bindings ────► Queue A
                         └──► Queue B
                                   │
                                   ▼
                               Consumer

The key idea: the producer does not choose the queue — it specifies an exchange and a routing key. Where the message actually ends up is decided by the bindings.

Four types of exchange

Direct exchange

A message lands in a queue if its binding key matches the routing key exactly.

publish(exchange="orders", routingKey="payment-failed")

Bindings:
  "payment-failed"  → queue "alerts"
  "payment-failed"  → queue "audit-log"
  "order-created"   → queue "fulfillment"

Result: the message goes to "alerts" and "audit-log".
        Not to "fulfillment".

A special case is the exchange named "" (empty string, the default direct exchange). If you set the routing key to a queue name, the message goes straight there. This is the simplest way to send something to a specific queue.

Topic exchange

Here the routing key is a string of words separated by dots: order.created.eu, metric.cpu.high. Bindings can use wildcards:

  • * — exactly one word
  • # — zero or more words
publish(exchange="events", routingKey="order.created.eu")

Bindings:
  "order.created.*"   → queue "fulfillment-orders"   ✓
  "order.#"           → queue "audit-all-orders"      ✓
  "*.created.eu"      → queue "eu-monitoring"         ✓
  "metric.cpu.*"      → queue "alerts"                ✗

A topic exchange fits when consumers want to subscribe not to everything, but to a specific subset of events.

Fanout exchange

The routing key is ignored entirely. The message goes to every queue bound to this exchange.

publish(exchange="broadcast", body=...)

Bindings:
  → queue "service-a-cache"
  → queue "service-b-cache"
  → queue "service-c-cache"

All three receive a copy of the message.

The classic task is cache invalidation or broadcasting a system event to all services at once.

Headers exchange

Routing is based on the message headers rather than the routing key. A binding specifies a set of key: value pairs and a match condition: x-match: all (all must match) or x-match: any (at least one).

publish(exchange="files", headers={format: "pdf", priority: "high"})

Bindings:
  {format: "pdf", priority: "high", x-match: all}  → queue "vip-pdf"      ✓
  {format: "pdf", x-match: any}                    → queue "pdf-all"      ✓
  {priority: "high", x-match: any}                 → queue "high-prio"    ✓

In practice the headers exchange is rarely used — the same task is more often solved with a topic exchange and a structured routing key.

What a queue is and how it lives

A queue stores messages until a consumer picks them up. When you declare it, you choose several parameters:

  • durable — whether the queue survives a broker restart. For business events, use true.
  • exclusive — available to a single connection only, deleted when that connection drops. Handy for temporary RPC replies.
  • auto-delete — deleted when the last consumer disconnects.

Each message can also be persistent (written to disk) or transient (in memory only). If the broker restarts, transient messages are lost.

The standard for important data: durable queue + persistent message.

Bindings and virtual hosts

A binding is created by the consumer at startup — it declares which queue to create and which exchange to bind it to:

channel.queue_declare("payment-failed-alerts", durable=True)
channel.queue_bind(
    queue="payment-failed-alerts",
    exchange="orders",
    routing_key="payment-failed"
)
channel.basic_consume(queue="payment-failed-alerts", on_message=handler)

A virtual host (vhost) is a namespace: its own exchanges, queues and access rights, isolated from other vhosts. One broker usually holds several vhosts: / (the default), /prod, /staging. It's a convenient way to separate environments without running separate servers.

Ack, nack, reject — how a consumer confirms processing

When the broker hands a message to a consumer, it isn't removed from the queue yet — it's only "reserved". It will be removed only after an acknowledgement.

The consumer has three options:

  • basic.ack — processed successfully, remove from the queue.
  • basic.nack(requeue=true) — not processed, put back in the queue (the consumer will try again).
  • basic.nack(requeue=false) or basic.reject(requeue=false) — not processed, discard (or send to a Dead Letter Exchange, if one is configured).

If the consumer crashes without sending an ack, the broker sees the dropped connection and automatically returns the message to the queue. This is the at-least-once guarantee: every message will be processed at least once, even if the consumer crashed mid-way.

Auto-acknowledgement

If you use basic.consume(autoAck=true), the broker considers the message delivered right away — without waiting for an ack. If the consumer crashes before finishing processing, the message is lost. This suits only metrics and logs, where loss is acceptable.

Prefetch — how many messages to hand out at once

By default the broker sends the consumer as many messages as it can. If the consumer is slow, all the messages pile up in its buffer while other consumers sit idle with nothing to do.

basic.qos limits the number of unacknowledged messages for a single consumer:

basic.qos(prefetch_count=10)

After that the broker won't send the 11th message until at least one of the first ten gets an ack.

Practical guidelines: 1–10 for slow handlers (seconds per message), 100–1000 for fast ones (milliseconds). Without explicit tuning, the behavior is unpredictable.

Publisher confirms — a guarantee on the sender side

By default basic.publish returns immediately, without waiting for the message to land in a queue. If the broker crashes at that moment, the message is lost and the producer never finds out.

Publisher confirms is an extension where the broker sends a basic.ack only after the message has been reliably stored:

channel.confirm_select()
channel.basic_publish(exchange="orders", routing_key="order.created", body=...)
# wait for the broker's ack before continuing

For important business events, publisher confirms should always be enabled. For metrics and logs — optional.

TTL and queue size limits

When declaring a queue you can set additional parameters:

  • x-message-ttl — how long a message lives in the queue. An expired message is deleted or sent to a Dead Letter Exchange.
  • x-expires — after how many milliseconds an unused queue deletes itself. Handy for temporary queues.
  • x-max-length / x-max-length-bytes — the maximum number of messages or bytes. When exceeded, old messages are evicted or rejected (depending on the x-overflow strategy).

Practical cases: keeping a queue from growing to gigabytes while the consumer is down; automatically discarding stale commands (for example, a "refresh cache" older than 30 seconds is already irrelevant).

What to choose for each case

TaskExchange type
One message — one handler from a pool"" (default direct), routing key = queue name
Event → several specific queuesdirect
Hierarchical events (order.*.eu, metric.cpu.#)topic
One signal — everyone hears itfanout
Routing by several attributesheaders (rarely)

In short

  • A producer publishes to an exchange, not directly to a queue. The exchange distributes it across queues according to the bindings rules.
  • Direct — exact routing key match. Topic* and # wildcards. Fanout — to everyone indiscriminately. Headers — by message headers.
  • durable + persistent — the standard for data you can't afford to lose.
  • Ack confirms successful processing. Without an ack, the message returns to the queue when the connection drops. That's at-least-once.
  • Prefetch limits how many unacknowledged messages a consumer holds. Without it, load is distributed unevenly.
  • Publisher confirms give a guarantee on the sender side: the broker confirms storage before returning control.
  • TTL and max-length protect against uncontrolled queue growth.
  • RabbitMQ in production — clustering, Quorum Queues, monitoring.
  • Spring AMQP — hands-on code in Java/Spring.
  • Messaging patterns with AMQP — work queue, RPC, pub/sub in practice.
  • AMQP vs Kafka — when queues, when the log model.