Hello! Welcome to the fourth lesson in our module on Asynchronous Messaging with Message Brokers.
Introduction
In our last lesson, we explored how to use direct and topic exchanges to route messages to specific subscribers based on routing keys. This gave us fine-grained control over message delivery, allowing different parts of a system to subscribe to precisely the information they need.
However, routing messages to the correct type of service is only half the battle. What happens when a service receives messages faster than it can process them? In any high-throughput system, like the FX trading or payment settlement platforms you've worked on, this is a critical bottleneck to solve. A single consumer processing a long queue of tasks leads to increased latency and can become a single point of failure.
Today, we will address this challenge by implementing one of the most fundamental scaling patterns in distributed systems.
This lesson will teach you how to:
Configure the competing consumers pattern in RabbitMQ to enable parallel processing of messages from a single queue.
We will configure multiple instances of a single consumer to share the workload from one queue, dramatically increasing processing throughput and system resilience.
1. The Bottleneck Problem and the Competing Consumers Pattern
In our previous examples, we had a one-to-one relationship between a queue and a consumer. This is simple, but it creates a bottleneck. If the publisher sends messages faster than the consumer can process them, the queue will grow indefinitely.
The solution is to scale out the consumers, not the queue. The Competing Consumers pattern involves launching multiple instances of the same consumer to process messages from a single, shared queue. RabbitMQ automatically distributes the messages from the queue among the connected consumers.

This pattern provides two main benefits:
- Scalability: If message volume increases, you can simply launch more consumer instances to handle the load.
- High Availability: If one consumer instance crashes, another can pick up the work, preventing a total service outage.
To implement this pattern correctly, we need to understand two crucial RabbitMQ mechanisms: Message Acknowledgment and Fair Dispatch.
2. Essential Mechanisms: Acknowledgments and Fair Dispatch
For RabbitMQ to safely distribute work, it needs feedback from the consumers.
Message Acknowledgment (Acks)
So far, we've used auto_ack=True. This tells RabbitMQ to consider a message "processed" the moment it's delivered to a consumer. This is risky: if your consumer crashes while processing the message, the message is lost forever.
For a reliable work queue, we must use manual acknowledgments.
- We set
auto_ack=Falsewhen subscribing. - The consumer, after successfully completing its task, explicitly sends an acknowledgment back to RabbitMQ using
channel.basic_ack().
If a consumer's connection drops before it sends an ack, RabbitMQ assumes the message was not processed and will re-queue it to be delivered to another available consumer.
Fair Dispatch
By default, RabbitMQ dispatches messages to consumers in a round-robin fashion. It sends message 1 to consumer A, message 2 to consumer B, message 3 to consumer A, and so on. This can be inefficient if tasks have variable processing times. A consumer might get a "long" task and be busy, while another consumer with "short" tasks becomes idle, but RabbitMQ will still blindly send the next message to the busy consumer's buffer.
Fair dispatch solves this. We can tell RabbitMQ not to send a new message to a consumer until it has processed and acknowledged its current one. This is configured using the Quality of Service (QoS) prefetch_count setting.
To understand this better, please read the following brief explanation.
RabbitMQ Work Queues Using python
The article 'RabbitMQ Work Queues Using python' by Nipun Thennakoon provides a very clear explanation of fair dispatch.
Please read the two paragraphs starting with 'We use basic_qos() channel method...'. Focus on the problem it describes (one worker being busy while another is idle) and the solution it presents.
Setting channel.basic_qos(prefetch_count=1) is the key. It tells RabbitMQ to only dispatch one message at a time to each consumer. The consumer must ack that message before RabbitMQ will send it another one. This ensures that idle consumers are put to work immediately.
3. Implementing Competing Consumers in Python
Now let's see how to put these concepts into practice. The following video provides a complete walkthrough of converting a single-consumer application into a scalable, multi-consumer work queue.
RabbitMQ- Tutorial 8a - Competing Consumers Python Implementation
This video, 'RabbitMQ- Tutorial 8a - Competing Consumers Python Implementation' from jumpstartCS, clearly demonstrates the necessary code changes and the resulting behavior.
Please watch the video from 01:24 to 11:19, paying close attention to these key segments: Consumer Modifications (01:24 - 04:44): Note the three critical changes: removing auto_ack=True, adding channel.basic_qos(prefetch_count=1), and calling ch.basic_ack() after the work is done (time.sleep). Producer Modifications (04:44 - 07:07): The producer is modified to send messages continuously to simulate a constant workload. Single Consumer Demo (07:07 - 08:39): Observe how the message queue starts to build up because one consumer cannot keep up with the producer. Multiple Consumers Demo (09:02 - 10:26): This is the core of the lesson. See how two consumer instances share the work and keep the queue empty. Notice how the fair dispatch (prefetch_count=1) ensures messages are distributed efficiently, not just in a strict round-robin sequence. Round-Robin Demo (10:26 - 11:19): Finally, see what happens when prefetch_count is removed. The dispatching reverts to a less efficient round-robin, where a busy worker might still be sent the next message.
This video clearly illustrates the power of this pattern. A simple configuration change allows the system to scale horizontally to meet processing demands.
4. Your Turn: Scaling a Trade Settlement System
It's time to apply this yourself. You'll simulate a trade settlement system where some settlements are quick (domestic) and others are slow (international). You will see firsthand how a single worker struggles and how adding a second worker solves the bottleneck.
Setup
- Create a new directory for this exercise.
- Ensure your RabbitMQ Docker container is running.
The Producer (settlement_producer.py)
This script will publish 10 settlement requests to a queue named settlement_queue. International trades are marked to take longer to process.
Create a file named settlement_producer.py with this code:
import pika
import json
import time
connection = pika.BlockingConnection(pika.ConnectionParameters(host='localhost'))
channel = connection.channel()
queue_name = 'settlement_queue'
channel.queue_declare(queue=queue_name, durable=True)
for i in range(1, 11):
# Every third trade is a slow, international one
is_international = (i % 3 == 0)
trade_type = "international" if is_international else "domestic"
message = {
'trade_id': f'trade_{i}',
'type': trade_type,
'amount': 1000 * i
}
channel.basic_publish(
exchange='',
routing_key=queue_name,
body=json.dumps(message),
properties=pika.BasicProperties(
delivery_mode=2, # make message persistent
))
print(f" [x] Sent settlement request for {message['trade_id']} ({message['type']})")
print("\n[+] All settlement requests have been published.")
connection.close()
The Consumer (settlement_worker.py)
This worker processes the settlement requests. It simulates work by sleeping for 5 seconds for international trades and 1 second for domestic ones.
Create a file named settlement_worker.py with this code:
import pika
import time
import json
import os
connection = pika.BlockingConnection(pika.ConnectionParameters(host='localhost'))
channel = connection.channel()
queue_name = 'settlement_queue'
channel.queue_declare(queue=queue_name, durable=True)
# Fair dispatch: Don't give me a new message until I have ack'd the previous one.
channel.basic_qos(prefetch_count=1)
def callback(ch, method, properties, body):
worker_id = os.getpid() # Use process ID to identify the worker
message = json.loads(body)
trade_id = message['trade_id']
trade_type = message['type']
print(f" [worker:{worker_id}] Received settlement for {trade_id} ({trade_type})...")
# Simulate work
if trade_type == 'international':
time.sleep(5)
else:
time.sleep(1)
print(f" [worker:{worker_id}] Finished settlement for {trade_id}.")
# Acknowledge the message
ch.basic_ack(delivery_tag=method.delivery_tag)
channel.basic_consume(queue=queue_name, on_message_callback=callback) # Note: auto_ack is False by default
print(' [*] Waiting for settlement requests. To exit press CTRL+C')
channel.start_consuming()
Running the Simulation
Part 1: The Bottleneck
- Open one terminal and start a single settlement worker:
python settlement_worker.py - Open a second terminal and run the producer:
python settlement_producer.py - Observe the worker terminal. You will see it processing messages one by one. It will take a long time, especially when it hits the "international" trades. The total processing time will be around (7 * 1s) + (3 * 5s) = 22 seconds.
Part 2: Scaling Out
- Stop the worker from Part 1 (with
CTRL+C). - Open two separate terminals. In each, start a settlement worker. You will now have two competing consumers.
# Terminal 1 python settlement_worker.py # Terminal 2 python settlement_worker.py - In a third terminal, run the producer again:
python settlement_producer.py - Observe the two worker terminals. You'll see them sharing the work. When one worker gets a slow "international" trade, the other worker will immediately pick up the next "domestic" trades from the queue. The total time to clear the queue will be much shorter.
This exercise demonstrates how the competing consumers pattern, enabled by manual acknowledgments and fair dispatch, allows a system to dynamically adapt to its workload and avoid bottlenecks.
Conclusion
In this lesson, you've implemented a crucial pattern for building scalable and resilient distributed systems. By moving from a single consumer to multiple competing consumers, you can parallelize message processing to handle high-throughput workloads.
Key Takeaways:
- The Competing Consumers pattern allows multiple consumer instances to share the load from a single queue.
- Manual message acknowledgment (
basic_ack) is essential for reliability. It ensures that if a consumer fails, its message is not lost and can be re-processed. - Fair dispatch (
basic_qos(prefetch_count=1)) prevents bottlenecks by ensuring messages are sent to consumers that are ready for work, rather than in a simple round-robin sequence. - This pattern provides a simple yet powerful mechanism for horizontally scaling the processing layer of your application.
Preview of the Next Lesson:
We've made our system more robust by ensuring messages are re-queued if a worker fails. But what if a message is "poisonous"? What if it contains malformed data that causes any worker to crash, leading to an endless cycle of redelivery and crashing? In the next lesson, we will learn how to handle such message processing failures gracefully by implementing a Dead-Letter Queue (DLQ).
Can't find a good explanation? Sign up and we'll make it for you
Sign up