17.5. Redis as a Message Broker
Table of Contents
Why Use Redis as a Message Broker?
In background processing, a message broker is the part that sits between the web application and the workers. It receives jobs, stores them, and delivers them to workers.
Redis is a very popular choice for this role because:
- It is extremely fast, in memory, and simple.
- It has data structures that map nicely to queues.
- It is widely supported by task queues like Celery and RQ.
- It is easy to install and run in Docker.
You already saw Redis used for caching and other use cases. Here we focus on using Redis specifically as the transport layer for background jobs.
Important rule: A message broker should be reliable. Your application must never assume that "fire and forget" is enough. Always design for:
- Jobs being retried.
- Messages possibly being delivered more than once.
- Workers possibly crashing while processing.
Basic Message Queue Concepts with Redis
A queue is a list of tasks that need to be processed. The main operations are:
- Producer puts messages into the queue.
- Worker takes messages out of the queue and processes them.
With Redis you can implement queues using list commands like LPUSH, RPUSH, LPOP, and blocking operations like BLPOP.
Simple Redis Queue with Lists
Imagine a queue called email_queue that stores tasks. Each task is a JSON string.
Adding a message (producer)
Producer (web app) enqueues a new task:
LPUSH email_queue '{"to": "alice@example.com", "subject": "Hello", "body": "Hi Alice"}'Or from Python:
import json
import redis
r = redis.Redis(host="localhost", port=6379, db=0)
task = {
"to": "alice@example.com",
"subject": "Hello",
"body": "Hi Alice",
}
r.lpush("email_queue", json.dumps(task))Here the left side of the list is considered the "front" of the queue.
Consuming a message (worker)
A worker can block and wait for new jobs:
BLPOP email_queue 0
The 0 means "wait forever until something is available".
In Python:
import json
import redis
r = redis.Redis(host="localhost", port=6379, db=0)
while True:
# BLPOP returns (queue_name, data)
_, raw_task = r.blpop("email_queue", timeout=0)
task = json.loads(raw_task)
print(f"Sending email to {task['to']}")
# ... actually send email here ...This is already a working message queue with:
- Producer using
LPUSH - Consumer using
BLPOP
But it has a major limitation: if the worker crashes after BLPOP but before finishing the job, the message is lost.
Reliable Message Delivery Patterns
To be a useful message broker, Redis needs to support reliable processing:
- Jobs should not be lost if a worker crashes.
- Jobs should not be processed twice unless you design for that case.
Redis on its own does not give you a full job system, but you can build patterns on top of it or use libraries that do it for you.
Using a Work Queue and a Processing Queue
A common pattern is:
- One list:
queue:pending - Another list:
queue:processing
The idea:
- Worker moves a job from
pendingtoprocessingin one atomic step usingRPOPLPUSH. - Worker processes the job.
- If processing succeeds, worker removes the job from
processing. - If worker crashes, the job remains in
processing. A recovery process can check for old jobs inprocessingand requeue them.
Step-by-step with Redis commands
1. Producer puts job in pending queue
LPUSH queue:pending '{"id": 1, "task": "send_email", "to": "alice@example.com"}'2. Worker atomically moves job to processing
RPOPLPUSH queue:pending queue:processing
This removes the rightmost element from queue:pending and pushes it to the left of queue:processing, atomically.
In Python:
raw_job = r.rpoplpush("queue:pending", "queue:processing")
job = json.loads(raw_job)3. Worker processes the job
try:
# Do the work
print(f"Sending email to {job['to']}")
# ... send email ...
except Exception:
# If failed, you may choose to move it back or log
raise
else:
# If successful, remove from processing
r.lrem("queue:processing", 1, raw_job)4. Recovery process
A separate script can scan queue:processing and decide if items are too old and should be requeued:
processing_jobs = r.lrange("queue:processing", 0, -1)
for raw_job in processing_jobs:
job = json.loads(raw_job)
# Suppose job has a "started_at" timestamp
# If too old, move back to pendingThis pattern is the foundation of many Redis-based queues.
Rule: Never just BLPOP from a single queue if you care about reliability. Use patterns that keep a separate processing state or use a library that implements it for you.
Using Redis Pub/Sub vs Queues
Redis also has Pub/Sub, which allows clients to subscribe to channels and receive messages that are published to them.
Example:
SUBSCRIBE log_channel
PUBLISH log_channel "Server restarted"In Python:
import redis
r = redis.Redis()
pubsub = r.pubsub()
pubsub.subscribe("log_channel")
for message in pubsub.listen():
print(message)Differences from queues:
| Feature | Queues (lists) | Pub/Sub |
|---|---|---|
| Message persistence | Stored in Redis until removed | Not stored, only delivered to subscribers |
| Late subscribers | Can read older messages still in the list | Miss messages sent before subscription |
| Typical use case | Background jobs, task queues | Real-time notifications, chat updates |
| Reliability after restart | Messages can remain if Redis persists data | Messages are lost on restart |
For background processing, you usually want queues with lists, not Pub/Sub, because:
- You need messages to be stored until processed.
- Workers may be restarted or offline temporarily.
Pub/Sub is more for real-time events where it is acceptable to miss some messages.
Redis as a Broker for Celery
Instead of manually implementing your queue logic, you can use Celery, a popular task queue library in Python. Celery supports Redis as a broker and handles:
- Enqueueing tasks.
- Distributing tasks to workers.
- Retries, acknowledgements, and results.
Here is a minimal setup.
Installing Celery with Redis
pip install celery[redis]Defining a Celery app using Redis as broker
celery_app.py:
from celery import Celery
celery_app = Celery(
"tasks",
broker="redis://localhost:6379/0", # Redis as broker
backend="redis://localhost:6379/1", # Optional, store results
)Declaring a task
tasks.py:
import time
from celery_app import celery_app
@celery_app.task
def add(x, y):
time.sleep(2) # simulate long work
return x + ySending a task from your web app
from tasks import add
# Enqueue a task
result = add.delay(4, 5) # returns immediately
print(result.id) # task IDCelery will push a message into Redis that looks something like:
- Queue name:
celery - Payload: JSON with task name
tasks.add, arguments[4, 5], etc.
Running a Celery worker
In a terminal:
celery -A tasks worker --loglevel=infoThe worker connects to Redis, listens to the queue, pulls messages, executes them, and updates the result.
Getting the result later
from tasks import add
result = add.delay(4, 5)
# Wait for the result (blocking)
value = result.get(timeout=10)
print(value) # 9Internally:
- The producer is your Python code calling
add.delay. - The broker is Redis storing the job message.
- The worker is the Celery process.
You do not need to manage Redis commands directly. Celery uses Redis lists and keys to implement a reliable queue.
Redis as a Broker with FastAPI
Let us combine FastAPI, Celery, and Redis. In a real project you would split files, but here is a simple example.
Project structure
.
ββ celery_app.py
ββ tasks.py
ββ main.py
celery_app.py:
from celery import Celery
celery_app = Celery(
"background_tasks",
broker="redis://localhost:6379/0",
backend="redis://localhost:6379/1",
)
tasks.py:
import time
from celery_app import celery_app
@celery_app.task
def send_email(to: str, subject: str, body: str) -> str:
time.sleep(5) # simulate long operation
# Here you would call an email provider
print(f"Sending email to {to} with subject '{subject}'")
return "ok"
main.py:
from fastapi import FastAPI
from pydantic import BaseModel
from tasks import send_email
app = FastAPI()
class EmailRequest(BaseModel):
to: str
subject: str
body: str
@app.post("/send-email")
def enqueue_email(email: EmailRequest):
task = send_email.delay(email.to, email.subject, email.body)
return {"task_id": task.id, "status": "queued"}Running everything
- Start Redis:
redis-server- Start Celery worker:
celery -A tasks worker --loglevel=info- Start FastAPI:
uvicorn main:app --reload- Send a request:
curl -X POST http://localhost:8000/send-email \
-H "Content-Type: application/json" \
-d '{"to": "alice@example.com", "subject": "Hi", "body": "Hello"}'Response example:
{
"task_id": "a203f18f-0f34-4abd-b9b2-53d43b96a0be",
"status": "queued"
}The HTTP response is immediate, while the actual email task is processed in the background by Celery workers, with Redis acting as the message broker.
Designing Queues and Routing with Redis
In real systems, you often need multiple queues for different job types and priorities.
Multiple queues
Example queues:
emailsreportsthumbnailshigh_prioritylow_priority
Producers push to different lists:
r.lpush("queue:emails", email_job_json)
r.lpush("queue:reports", report_job_json)Workers can:
- Listen to a specific queue, for example only
queue:emails. - Or use
BLPOPon multiple queues with a priority order:
# Prefer high_priority, fall back to low_priority
queue, raw_job = r.blpop("queue:high_priority", "queue:low_priority", timeout=0)With Celery, you can define named queues and route tasks to them using configuration, but Redis is still the underlying broker that stores these per-queue lists.
Priority queues
Basic priority pattern with multiple lists:
queue:highqueue:mediumqueue:low
Worker:
while True:
item = r.blpop("queue:high", "queue:medium", "queue:low", timeout=0)
queue_name, raw_job = item
# Process jobFor simple systems, this is enough. For more complex priority rules you might use sorted sets with a score field, but that is more advanced.
Handling Retries and Failures
Background jobs fail for many reasons:
- Temporary network problems.
- External services being down.
- Bugs in your code.
You should have a strategy for retries.
Manual retry strategy with Redis
A job may have fields like:
{
"id": 123,
"task": "send_email",
"payload": {
"to": "alice@example.com"
},
"retry_count": 0,
"max_retries": 5
}Simplified worker logic:
import json
import time
import redis
r = redis.Redis()
MAX_RETRIES = 5
while True:
_, raw_job = r.blpop("queue:pending", timeout=0)
job = json.loads(raw_job)
try:
# process job
print(f"Sending email to {job['payload']['to']}")
# ...
except Exception as e:
job["retry_count"] += 1
if job["retry_count"] <= job["max_retries"]:
# Requeue with some delay (very naive)
time.sleep(2 ** job["retry_count"]) # exponential backoff
r.lpush("queue:pending", json.dumps(job))
else:
# Move to dead-letter queue
r.lpush("queue:failed", json.dumps(job))This demonstrates:
- Retry count stored in job.
- Exponential backoff using
2 ** retry_count. - Dead-letter queue for jobs that failed too many times.
Rule: Always have a way to inspect failed jobs, usually a dead-letter queue or a dedicated set or list like queue:failed. Never silently drop failures.
Visibility Timeouts and Acknowledgements
More advanced message brokers, such as AWS SQS, have a visibility timeout: after a worker takes a message, the message becomes invisible for a time. If the worker does not acknowledge it before the timeout, the message becomes visible again and can be processed by another worker.
Redis does not have this built in, but some Redis-based queues implement a similar idea:
- Job is moved to a "processing" structure with a timestamp.
- A separate component checks if the job has been in "processing" too long and moves it back.
Systems like RQ, Celery, and BullMQ (Node.js) implement acknowledgement and timeouts on top of Redis, so you do not have to.
When you choose Redis as a broker through such libraries, you automatically get:
- Acknowledge on success.
- Retry on failure.
- Visibility timeout or similar mechanism.
Scaling Workers with Redis
One of the strengths of Redis as a broker is that you can add many workers, even on multiple machines, all pointing to the same Redis instance.
Horizontal scaling
Suppose you have heavy tasks:
- Image processing.
- Sending a lot of emails.
- Report generation.
You can run multiple worker processes or containers:
celery -A tasks worker --loglevel=info --concurrency=4
celery -A tasks worker --loglevel=info --concurrency=4With 2 worker processes and concurrency of 4 each, you can run up to 8 tasks in parallel, all reading from the same queue in Redis.
Because Redis operations are atomic, only one worker gets each message.
Load balancing
Redis does not "load balance" by itself through any special feature. Instead, all workers pull from the same queue. The number of workers and their concurrency levels determine how much total work can be processed.
If you see:
- Queue length growing fast.
- Jobs waiting a long time.
You can:
- Add more workers.
- Use separate queues for heavy tasks versus light tasks.
- Use more Redis instances if one becomes a bottleneck.
Redis Broker Configuration and Tuning Basics
For real systems, you need to think about:
- Persistence.
- Memory and eviction policy.
- Security and access.
Persistence and durability
Redis is in memory, but can persist to disk using:
- RDB snapshots.
- AOF (Append Only File).
For a message broker, losing messages on a restart can be very bad.
Basic configuration options in redis.conf to enable AOF:
appendonly yes
appendfsync everysecThis makes Redis slower but safer.
Eviction policies
If Redis runs out of memory, the eviction policy decides what to remove. For a broker, you usually do not want Redis to start evicting arbitrary keys that might be queue messages.
Example safe-ish choice in redis.conf for a broker:
maxmemory-policy noevictionWith this, Redis will refuse writes if it is out of memory, instead of deleting messages. Your application then needs to handle errors.
Security basics
For production:
- Run Redis in a private network, not accessible from the public internet.
- Use authentication:
In redis.conf:
requirepass your_strong_password_hereIn Python:
r = redis.Redis(host="redis-host", port=6379, password="your_strong_password_here")- Use TLS if Redis is accessed over untrusted networks.
Summary
Redis as a message broker gives you:
- Fast, simple queues using lists and commands like
LPUSH,BLPOP, andRPOPLPUSH. - The ability to build reliable patterns with pending and processing queues.
- Support for multiple queues and priority handling.
- Integration with libraries such as Celery, RQ, and others that implement robust task processing.
For backend development, Redis is often the easiest way to get from "I have a FastAPI app" to "I have background workers that process tasks reliably."
Views: 9
KAHIBARO