Model Work Queues with Multiple Consumers
Model work queues with multiple consumers — step 8 in the Messaging & queues track.
Focus: model work queues with multiple consumers
You've built a queue, published messages, and consumed them one at a time. But what happens when your backlog grows faster than a single worker can process? You could scale vertically, adding more CPU and memory to one machine, but that's expensive and has a ceiling. Or you could add more consumers — but then you need to ensure each message is processed exactly once, distribute the work fairly, and handle messages that fail. This lesson teaches you how to model work queues with multiple consumers, so you can scale your processing horizontally, keep your system responsive, and handle failures gracefully — all without losing or duplicating work.
The problem this lesson solves
When you have a single consumer pulling messages from a queue, you're limited by that consumer's throughput. If each message takes, say, 100 milliseconds to process, one consumer can handle at most 10 messages per second. If your producers push 100 messages per second, your queue grows indefinitely. You need to add more workers.
But adding more consumers introduces a new set of challenges:
- Message distribution: How do you ensure each message is consumed by exactly one worker? If two workers pull the same message, you risk duplicate processing.
- Fairness: If one worker is slow, do the faster workers pick up the slack? Or do messages get stuck behind a sluggish worker?
- Failure handling: What happens if a worker crashes mid-processing? Does the message get lost, or is it redelivered to another worker?
- Scaling: Can you dynamically add or remove workers without disrupting the queue?
Without a proper model, you end up with race conditions, duplicate work, and lost messages. This lesson gives you a mental model and a practical approach to solving these problems.
Core concept / mental model
Think of a work queue with multiple consumers like a team of workers pulling tasks off a shared to-do list. Each task is a message. The key is that each task is assigned to exactly one worker — no two workers should ever work on the same task simultaneously.
This is often called a competing consumers pattern. The queue acts as a buffer between producers and consumers. Producers push messages into the queue. Multiple consumers compete to pull messages from the queue. The queue's job is to ensure that each message is delivered to only one consumer at a time.
In real-world messaging systems, this is implemented using:
- Exclusive delivery: The broker marks a message as 'in flight' when it's delivered, so no other consumer receives it until it's acknowledged or times out.
- Acknowledgment: The consumer sends an ACK after processing a message. The broker removes it from the queue. If no ACK is received (e.g., consumer crashes), the broker redelivers it to another consumer.
This is different from a pub/sub model, where every consumer gets a copy of each message. In a work queue, each message is consumed by exactly one consumer.
Think of it like a print queue in an office: multiple printers share the queue of print jobs. Each job goes to exactly one printer. If a printer jams, the job is resent to another printer.
How it works step by step
Let's break down the lifecycle of a message in a multi-consumer work queue:
- Producer publishes a message to a queue. The broker stores it in an ordered list.
- A consumer sends a pull request to the broker, asking for a message.
- The broker selects the next available message (usually oldest first) and marks it as 'unacknowledged' (unacked). This prevents another consumer from receiving it.
- The broker delivers the message to the consumer.
- The consumer processes the message. This could take any amount of time.
- The consumer sends an ACK to the broker after successful processing. The broker deletes the message from the queue.
- If the consumer fails (crashes, network error, or doesn't ACK within a timeout), the broker puts the message back into the queue and makes it available for redelivery.
This process ensures that:
- Each message is processed exactly once (in the absence of redeliveries).
- If a consumer fails, the message is not lost — it's redelivered.
- Multiple consumers can pull messages concurrently, increasing throughput.
The key is the acknowledgment step. Without it, the broker can't know if a message was successfully processed. With it, the queue can guarantee at-least-once delivery.
Hands-on walkthrough
Let's implement a simple multi-consumer work queue using Python's concurrent.futures with a thread-safe queue, and then compare it to a real message broker like Redis or RabbitMQ.
Example 1: Simulating multiple consumers with a thread-safe queue
This example shows the core concept without external dependencies. We'll use queue.Queue and multiple worker threads to simulate competing consumers.
import queue
import threading
import time
import random
# Create a queue of tasks
work_queue = queue.Queue()
for i in range(10):
work_queue.put(f"Task-{i}")
# Worker function: each worker pulls tasks from the queue
def worker(name):
while True:
task = work_queue.get()
if task is None: # Sentinel to stop the worker
break
print(f"{name} processing {task}")
time.sleep(random.uniform(0.1, 0.5)) # Simulate work
work_queue.task_done() # Send ACK
print(f"{name} finished {task}")
# Create 3 consumers
consumers = []
for i in range(3):
t = threading.Thread(target=worker, args=(f"Consumer-{i}",))
t.start()
consumers.append(t)
# Wait for all tasks to complete
work_queue.join()
# Stop workers by sending sentinel
for _ in range(3):
work_queue.put(None)
for t in consumers:
t.join()
print("All tasks processed!")
Expected output (order will vary):
Consumer-0 processing Task-0
Consumer-1 processing Task-1
Consumer-2 processing Task-2
Consumer-0 finished Task-0
Consumer-0 processing Task-3
Consumer-2 finished Task-2
Consumer-2 processing Task-4
...
All tasks processed!
Here, each task is processed by exactly one consumer, thanks to the queue.Queue being thread-safe and task_done() acting as an ACK. This is a simplified model of a work queue.
Example 2: Using Redis as a broker with multiple consumers
In production, you'd use a dedicated broker. Redis can act as a simple work queue using BRPOPLPUSH or streams. Here's an example using Redis and two consumer processes.
import redis
import time
import os
# Connect to Redis
r = redis.Redis(host='localhost', port=6379, db=0)
# Producer: push tasks
def produce():
for i in range(5):
r.lpush('work_queue', f'task-{i}')
print(f"Produced task-{i}")
# Consumer: block until a task is available
def consume(consumer_id):
while True:
# BRPOPLPUSH atomically moves a message from work_queue to a processing list
task = r.brpoplpush('work_queue', 'processing', timeout=0)
if task:
print(f"Consumer {consumer_id} got {task.decode()}")
time.sleep(1) # Simulate work
# Remove from processing list (acknowledge)
r.lrem('processing', 1, task)
print(f"Consumer {consumer_id} finished {task.decode()}")
if __name__ == '__main__':
# Run two consumers (in separate processes in real life)
import multiprocessing
p1 = multiprocessing.Process(target=consume, args=(1,))
p2 = multiprocessing.Process(target=consume, args=(2,))
p1.start()
p2.start()
produce()
time.sleep(10)
In this example, brpoplpush atomically moves a message from the 'work_queue' to a 'processing' list, ensuring only one consumer gets it. After processing, we remove it from the processing list (ACK). If a consumer crashes, the message remains in the processing list — you'd need a recovery mechanism (like checking for stuck messages).
This example illustrates the competing consumers pattern with a real broker.
Compare options / when to choose what
When modeling a work queue with multiple consumers, you have several technology options. Here's a comparison:
| Option | Pros | Cons | Best for |
|---|---|---|---|
In-memory queue (queue.Queue) |
Simple, no setup, thread-safe | Single process, lost on crash, not persistent | Prototyping, single-process apps |
| Redis | Fast, supports atomic operations, easy to scale, can persist with RDB/AOF | No built-in retries/dead-letter, manual ACK handling, not a full message broker | Simple queuing, real-time analytics, lightweight workloads |
| RabbitMQ | Full-featured: ACK, redelivery, dead-letter queues, routing, persistence | Heavier, more complex to configure | Enterprise applications, complex routing, guaranteed delivery |
| Apache Kafka | High throughput, replayability, distributed | Not a queue (log), ordering per partition, consumer group model | Event streaming, data pipelines, log aggregation |
When to choose what:
- Prototyping or simple apps: Use an in-memory queue.
- Lightweight production: Redis is a good middle ground if you don't need full broker features.
- Enterprise reliability: RabbitMQ (or other AMQP brokers) offers built-in ACK, redelivery, and DLQ.
- High-throughput event streaming: Kafka's consumer groups provide a scalable model, but it's a log, not a queue.
Troubleshooting & edge cases
Even with a good model, you'll hit edge cases. Here are common pitfalls and fixes.
Problem: Messages are processed more than once
This happens when the broker redelivers a message because the consumer doesn't ACK in time, even though processing is ongoing. To fix:
- Increase the acknowledgment timeout in your broker.
- Make your consumers idempotent — so processing the same message twice has no side effects.
- For Redis, use a different approach (like Lua scripts) to ensure atomicity.
Problem: One slow consumer blocks others
If you use a simple round-robin or fair delivery, a slow consumer can accumulate a backlog. To fix:
- Set a prefetch limit (e.g., RabbitMQ's
basic_qos) so a consumer only fetches a limited number of messages at once. - Use a monitoring system to detect slow consumers and alert.
Problem: Messages get lost when a consumer crashes
If you forget to ACK, the broker redelivers. But if you lose the processing list in Redis (e.g., restart), messages may vanish. To fix:
- Use a broker that persists messages (RabbitMQ, Kafka).
- Implement a recovery mechanism for processing lists (e.g., scan for old entries and re-queue).
- Use dead-letter queues for messages that repeatedly fail.
Problem: Duplicate messages from producer retries
If your producer retries on failure, you might publish the same message twice. To fix:
- Use message IDs and deduplicate at the consumer.
- Configure broker-side deduplication if available.
What you learned & what's next
You've learned the core concept of modeling work queues with multiple consumers: the competing consumers pattern. You understand:
- How a queue ensures each message is delivered to exactly one consumer.
- The importance of acknowledgments for reliable processing.
- How to implement a simple multi-consumer system with Python's
queue.Queueand Redis. - How to choose between different messaging technologies based on your needs.
You also learned about common pitfalls like duplicate processing and slow consumers, and how to address them.
Next step: In the next lesson, you'll dive into idempotency — how to make your consumers safe to process the same message multiple times, which is essential when your queue uses at-least-once delivery. You'll learn strategies like idempotent operations, deduplication, and how to store message IDs to avoid side effects.
Keep this lesson's mental model in mind: a work queue is a team of consumers pulling tasks, and the broker is the dispatcher that ensures each task goes to exactly one worker at a time.
Practice recap
Practice by extending the Redis example: add a simulated consumer crash (kill a consumer halfway) and observe how messages get stuck in the processing list. Then add a recovery script that re-queues messages older than a threshold. This will solidify your understanding of ACK and redelivery.
Common mistakes
- Forgetting to acknowledge (ACK) messages after processing. This causes the broker to redeliver them, leading to duplicate processing.
- Using a pub/sub model instead of a queue when you only need one consumer per message. This sends copies to all consumers, breaking the work queue semantics.
- Ignoring consumer crash recovery. If a consumer dies mid-processing without ACK, the message may be stuck in a processing state unless you have a timeout/redelivery mechanism.
- Setting an ack timeout too low, causing messages to be redelivered while still being processed, leading to duplicate work.
Variations
- Use a dead-letter queue (DLQ) to handle messages that fail repeatedly, instead of endless redelivery.
- Implement consumer prefetch limits (e.g., RabbitMQ basic_qos) to prevent slow consumers from hogging messages.
- Use consumer groups in Kafka to scale consumption across a partition, which offers a different model than a traditional queue.
Real-world use cases
- Order processing in e-commerce: Multiple workers process incoming orders concurrently, ensuring each order is handled exactly once.
- Video transcoding pipelines: Workers pull video jobs from a queue and transcode them in different resolutions, scaling horizontally with demand.
- Email notification dispatch: A queue holds email messages, and multiple workers send them concurrently, with retries and failure handling.
Key takeaways
- A work queue with multiple consumers uses the competing consumers pattern: each message goes to exactly one consumer.
- Acknowledgment (ACK) is critical for reliable processing; without it, messages get redelivered and can be processed twice.
- Brokers like RabbitMQ handle ACK, redelivery, and dead-letter queues out of the box, while Redis requires manual implementation.
- Choose a broker based on requirements: in-memory queue for prototyping, Redis for lightweight production, RabbitMQ/Kafka for enterprise/high-throughput.
- Monitor slow consumers and use prefetch limits to avoid backlogs and ensure fair processing.
- Make consumers idempotent to safely handle at-least-once delivery semantics.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.