0%
RabbitMQ Deep Dive: AMQP, Queues, and Delivery Guarantees
The Broker Model
Delivery Guarantees
Flow and Scale
Patterns
Classic Queues, Process by Process
Almost everything surprising about a classic queue's performance follows from one implementation fact: a queue is a single Erlang process, and every operation that touches it is a message in that process's mailbox, handled one at a time in order.
The process, and what it owns
One gen_server, named rabbit_amqqueue_process, per queue.
When a queue is declared, the broker spawns a process on one node and registers it. That process owns the queue's entire state: the ordered sequence of messages, the set of consumers, the per-consumer unacknowledged deliveries, the per-consumer prefetch accounting, and the queue's arguments and effective policy. No other process reads or writes that state; everything arrives as an Erlang message and is handled in the order received.
The consequences form most of this lesson. One queue gets one core, because a single process is scheduled on a single scheduler at a time, so a queue that saturates one core cannot be made faster by adding CPUs to the node. Ordering is free, because serialising every operation through one process means there is no concurrent reordering to prevent. And the queue is a failure unit, because if that process dies, everything it held in memory is gone, and whether that matters depends entirely on what was persisted.
Why the single process is a design rather than an oversight
It buys simplicity that the alternatives have to pay for in locks.
A queue is a data structure with a dozen concurrent writers, each publishing, consuming, acknowledging, rejecting, cancelling and changing prefetch. Implemented with shared memory and locks, every one of those operations needs a correct locking discipline and every combination is a potential deadlock. Implemented as a process with a mailbox, the locking discipline is the mailbox and there is nothing to get wrong.
The price is that throughput per queue is bounded by what one process can do, which in practice is somewhere in the range of tens of thousands of messages per second for small transient messages and considerably less for persistent ones. That number is not a tuning target, it is a ceiling, and the only way through it is more queues.
This is the single most important architectural fact about RabbitMQ and it is the one most often discovered late. A system designed around one high-throughput queue has designed around one core. The same system with the work sharded across sixteen queues, by a consistent-hash exchange or by the application, has sixteen cores available and is otherwise identical.
The mailbox, which is where a broker actually dies
Unbounded by default, and that is the mechanism.
An Erlang process mailbox has no size limit. A queue process receiving publishes faster than it can handle them accumulates them in the mailbox, and the mailbox grows in the process's heap, which grows in the node's memory. There is no backpressure in the mailbox itself.
So the failure mode of an overloaded classic queue is not a rejected publish, it is a queue process whose mailbox contains a hundred thousand unprocessed operations, consuming memory, taking longer and longer to garbage collect, and eventually contributing to a node-wide memory alarm. From the outside this looks like a broker that has become slow for every queue, because the alarm blocks publishers across the node.
The protection is credit flow, which is covered below, and the memory alarm, which is covered in a later lesson. What matters here is the shape: a classic queue under extreme load degrades by accumulating work rather than by refusing it, and the symptom appears at the node level rather than at the queue.
Credit flow, the backpressure between a channel and a queue
A two-number credit scheme, and one of RabbitMQ's least-known settings.
Erlang's own credit_flow mechanism sits between the channel process handling a connection and the queue processes it publishes into. The channel starts with an initial credit per queue and spends one unit per message sent. When the credit runs out the channel blocks, and the queue grants more credit once it has made progress.
The setting is credit_flow_default_credit, a pair defaulting to {400, 200}: an initial credit of 400 messages and a top-up of 200. A channel publishing into a queue that cannot keep up therefore gets 400 messages ahead and then stops, which is what stops the mailbox growing without bound in the common case.
The important detail is what blocking a channel means. The channel process stops reading from its socket, which applies TCP backpressure to the publisher, which is why a slow queue presents to an application as a publish call that suddenly takes much longer. It is also why one slow queue can slow a publisher that is publishing to several queues on the same channel: the credit is per queue, but the block is on the channel.
What the serialisation actually costs, in numbers
Worth computing once, because the figure decides architectures.
A classic queue handling a small transient message does a short, bounded amount of work per operation: take it off the mailbox, append it to an in-memory list, notice a waiting consumer, write the delivery to the channel process. That is on the order of tens of microseconds, which is where the tens-of-thousands-per-second figure comes from.
Persistence changes the arithmetic rather than the structure. A persistent message is an index write, and above the embed threshold a message store write too, and if the publisher is using confirms the queue cannot confirm until those are durable. The per-message work grows by an order of magnitude or more, which is why the same queue shape that does 40,000 transient messages per second may do 4,000 persistent ones.
The number to carry away is not either of those, it is the ratio. The cheapest way to double a classic queue's throughput is almost always to establish that the durability requirement is weaker than the delivery_mode currently claims, and the second cheapest is to shard. Everything else moves the figure by percentages.
A single process is also a single scheduling unit
Which is why one busy queue can be invisible in node-level metrics.
The Erlang VM runs a scheduler per core, and a process is run on one of them at a time. A node with sixteen cores and one saturated queue shows roughly 6% CPU, which looks entirely healthy, while the queue it is running is at its absolute ceiling. Node-level CPU is therefore close to useless for diagnosing a per-queue limit, and this is one of the most common misreadings in RabbitMQ operations.
What does show it is per-scheduler utilisation, available through the management API's node statistics and through Erlang's own observability. One scheduler pinned while the rest idle is the signature. The complementary check is the queue's own message rates flattening at a value that does not move when more consumers are added, since a consumer-bound queue speeds up with more consumers and a process-bound one does not.
Where the process runs, and why that is a choice
One node holds the queue, and the exchange does not.
A classic queue's process lives on exactly one node in the cluster, decided when the queue is declared. Every other node routes to it over the cluster's internal links, so a publisher connected to node B publishing into a queue on node A is making a cross-node hop per message.
The queue_master_locator setting controls the placement, with three values. client-local, the historical default, puts the queue on the node the declaring client was connected to, which produces an unbalanced cluster whenever declarations come from an unbalanced set of clients. min-masters picks the node hosting the fewest queues, which is the setting to use for any estate with many queues. random is what it says.
Placement matters for a reason beyond balance: a node holding a disproportionate share of the busy queues is doing a disproportionate share of the work, and because each queue is one process, that work does not spread. A cluster whose nodes show very different CPU figures for similar connection counts is usually a placement problem rather than a load one, and rabbitmqctl list_queues name node messages is the one command that shows it.
A classic queue is one Erlang process, so one queue gets one core and no amount of CPU makes a single queue faster. That single fact generates per-queue ordering for free, makes the queue a failure unit, and means the only way past a per-queue throughput ceiling is more queues. A system designed around one high-throughput queue has designed around one core, and the same system sharded across sixteen queues has sixteen.