Caching, Queues & Event-Driven Architecture¶
What this lesson gives you
Two patterns absorb most of the load in a large system: caches make reads cheap, and queues make work asynchronous. Together they unlock event-driven design.
Estimated time: 24 min read · Part: Designing Python Systems at Scale
Checkout freezes — and double charges
A checkout service sent payment requests directly in the web request thread. When the payment provider slowed to 8-second responses during a sale event, checkout pages froze, customers abandoned carts, and on-call engineers scrambled. The fix was to move payment calls onto a Celery task queue: checkout returned instantly with "Payment pending," and payments processed asynchronously. Conversion recovered. But two weeks later, customers reported double charges. Investigation revealed that Celery workers had crashed mid-task after calling the payment API but before writing the result — the broker re-delivered the message, a new worker charged the card again. The final fix was an idempotency key: generate a UUID for each payment attempt, store it in the DB before calling the API, and check it before processing a redelivered message. Two patterns introduced in sequence, two bugs discovered and closed. This is how production systems actually evolve.
Cache Patterns¶
Not all caches work the same way. The pattern determines who is responsible for cache population and invalidation, and what trade-offs you accept.
Cache-aside (lazy loading) — the most common pattern. The application code is responsible for both reading from cache and populating it on a miss. On a write, the application invalidates or updates the relevant cache key. Code controls the cache; misses do hit the DB; the cache only holds data that has actually been requested.
import json
import redis
from typing import Optional
r = redis.Redis(host="localhost", decode_responses=True)
def get_product(product_id: int) -> Optional[dict]:
"""Cache-aside: check cache → miss → read DB → populate → return."""
cache_key = f"product:{product_id}"
# 1. Check cache
cached = r.get(cache_key)
if cached is not None:
return json.loads(cached)
# 2. Cache miss — read from DB
product = db_get_product(product_id) # your DB call
if product is None:
return None
# 3. Populate cache with TTL (stagger to avoid thundering herd)
import random
ttl = 300 + random.randint(0, 60) # 5–6 min, jittered
r.setex(cache_key, ttl, json.dumps(product))
return product
def update_product(product_id: int, data: dict) -> dict:
"""On write: update DB, then invalidate the cache key."""
product = db_update_product(product_id, data)
r.delete(f"product:{product_id}") # invalidate
return product
Write-through — every write goes to the cache and the DB together, synchronously. Reads always hit cache (no miss after first write). Write latency doubles (two writes per operation). Cache stays warm and consistent. Best when reads are much more frequent than writes and you need strong read consistency.
Write-behind (write-back) — writes go to cache first; a background process asynchronously flushes dirty entries to the DB. Extremely high write throughput. Risk: if the cache node fails before flushing, writes are lost. Suitable for metrics aggregation, analytics counters, or any workload where occasional data loss is acceptable.
Read-through — the cache sits in front of the DB and handles miss logic automatically. On a miss, the cache fetches from DB, stores it, and returns it. Less code in the application, but less control over what gets cached and when. Common in managed caches (AWS ElastiCache with DAX for DynamoDB).
Cache warming — pre-populate the cache on startup (or after a deploy) to avoid a cold-start miss storm. Especially important for caches with high traffic from the moment of deployment. Implement with a startup task that reads the most-accessed keys from the DB and loads them into Redis before the app begins serving traffic.
The Thundering Herd Problem¶
When a popular cache key expires, hundreds or thousands of concurrent requests may all detect the miss simultaneously, all query the DB, all try to populate the same key. This thundering herd can overwhelm the DB and defeat the purpose of caching.
Three mitigations:
- Staggered TTLs (jitter) — add random seconds to the base TTL so popular keys don't all expire at the same clock tick.
- Mutex lock — only the first thread that detects a miss acquires a lock; others wait (or return stale data) while the lock-holder repopulates.
- Probabilistic early expiry (PER) — before a key expires, randomly decide to recompute it with probability proportional to how close it is to expiry. The XFetch algorithm implements this without locks.
Distributed Locking with Redis¶
A distributed lock allows multiple application instances to coordinate access to a shared resource. Redis provides atomic primitives for this.
import uuid
import time
import redis
from contextlib import contextmanager
r = redis.Redis(host="localhost", decode_responses=True)
# Atomic release: only delete if we own the lock (prevent releasing another owner's lock)
RELEASE_SCRIPT = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
"""
_release = r.register_script(RELEASE_SCRIPT)
@contextmanager
def redis_lock(key: str, timeout_seconds: int = 10, retry_delay: float = 0.1, max_retries: int = 50):
"""
Acquire a distributed lock using SET NX EX (atomic acquire + expiry).
Releases automatically on context exit.
"""
lock_id = str(uuid.uuid4()) # unique token — proves we own the lock
full_key = f"lock:{key}"
acquired = False
for attempt in range(max_retries):
# SET key lock_id NX EX timeout — atomic: set only if not exists, with expiry
result = r.set(full_key, lock_id, nx=True, ex=timeout_seconds)
if result:
acquired = True
break
time.sleep(retry_delay)
if not acquired:
raise TimeoutError(f"Could not acquire lock '{key}' after {max_retries} retries")
try:
yield lock_id
finally:
_release(keys=[full_key], args=[lock_id]) # safe release
# Usage — only one worker runs the job at a time across all instances
def run_nightly_report():
try:
with redis_lock("nightly-report", timeout_seconds=300):
print("Lock acquired. Running report...")
generate_report() # only one instance does this
except TimeoutError:
print("Another instance is running the report. Skipping.")
Redlock for higher safety
The single-node lock above fails if the Redis primary crashes after granting the lock but before replicating it — a failover replica won't know about the lock. The Redlock algorithm acquires the lock on N/2+1 independent Redis nodes; as long as a majority agree, the lock is valid. Use redis-py-lock or pottery for a production Redlock implementation. For most use cases, single-node locks with a reasonable expiry are sufficient.
Message Queues: At-Least-Once Delivery & Idempotency¶
Message brokers guarantee that a message is delivered at least once. If a worker crashes after receiving a message but before acknowledging it, the broker redelivers it. This means consumers must be idempotent: processing the same message twice must produce the same result as processing it once.
Idempotency implementation recipe:
- Every event carries a unique ID (UUID, Snowflake, or composite key).
- Before processing, the consumer checks whether the event ID has already been stored in a processed-events table (DB) or a Redis set (with TTL).
- If already processed: skip and acknowledge.
- If new: process, then atomically record the event ID and update state in the same DB transaction.
Dead Letter Queues (DLQ)¶
A message that fails processing repeatedly (after N retries with exponential backoff) would otherwise be retried forever or silently dropped. A dead letter queue receives these messages after max retries are exhausted. Operators can inspect the DLQ to understand failure patterns, fix the root cause, and replay messages without losing them.
from celery import Celery
from celery.utils.log import get_task_logger
app = Celery("myapp", broker="redis://localhost:6379/0", backend="redis://localhost:6379/1")
logger = get_task_logger(__name__)
# Configure DLQ: failed tasks routed to 'dead_letter' queue after max_retries
app.conf.update(
task_routes={
"tasks.process_payment": {"queue": "payments"},
"tasks.handle_dead_letter": {"queue": "dead_letter"},
},
task_annotations={
"tasks.process_payment": {
"max_retries": 5,
"default_retry_delay": 60,
}
},
)
@app.task(
bind=True,
max_retries=5,
acks_late=True, # only ack after task completes (prevents loss on crash)
reject_on_worker_lost=True, # re-queue if the worker dies
)
def process_payment(self, order_id: str, amount: float, idempotency_key: str):
"""Process a payment with exponential backoff retries and idempotency."""
from db import is_payment_processed, record_payment
import payment_gateway
# Idempotency check
if is_payment_processed(idempotency_key):
logger.info("Payment already processed", extra={"idempotency_key": idempotency_key})
return {"status": "already_processed"}
try:
result = payment_gateway.charge(order_id=order_id, amount=amount, key=idempotency_key)
record_payment(idempotency_key, result)
return {"status": "success", "transaction_id": result["id"]}
except payment_gateway.TransientError as exc:
# Exponential backoff: 2^retry seconds (2, 4, 8, 16, 32)
countdown = 2 ** self.request.retries
logger.warning(
"Payment transient failure, retrying",
extra={"retry": self.request.retries, "countdown": countdown},
)
raise self.retry(exc=exc, countdown=countdown)
except payment_gateway.PermanentError as exc:
# Don't retry permanent errors — route to DLQ
logger.error("Payment permanent failure", extra={"error": str(exc)})
handle_dead_letter.apply_async(
args=["process_payment", order_id, str(exc)],
queue="dead_letter",
)
raise # don't retry
@app.task(queue="dead_letter")
def handle_dead_letter(task_name: str, payload: str, error: str):
"""Log, alert, and store failed messages for manual inspection."""
logger.critical(
"Message in DLQ",
extra={"task": task_name, "payload": payload, "error": error},
)
# Alert on-call: pagerduty.trigger(f"DLQ: {task_name} failed: {error}")
# Store for replay: db.dlq_messages.insert(task_name, payload, error, now())
Celery Patterns: Chains, Groups & Chords¶
Celery's canvas API composes tasks into workflows without manual orchestration code:
from celery import chain, group, chord
from tasks import fetch_data, process_chunk, aggregate_results, send_report
# Chain: serial pipeline — each result feeds the next
pipeline = chain(
fetch_data.s(source="s3://bucket/data.csv"),
process_chunk.s(chunk_size=1000),
send_report.s(recipient="team@example.com"),
)
pipeline.delay()
# Group: parallel execution — all tasks run simultaneously
parallel = group(
process_chunk.s(chunk_id=i)
for i in range(10)
)
parallel.delay()
# Chord: run a group in parallel, then call a callback with all results
chord(
group(process_chunk.s(chunk_id=i) for i in range(10)),
aggregate_results.s(), # receives list of results from all chunks
).delay()
# ETA / countdown: schedule a task for later
process_payment.apply_async(
args=["order-99", 49.99, "idem-key-xyz"],
countdown=300, # run in 5 minutes
# eta=datetime(2026, 1, 1, 9, 0), # or at a specific time
)
# Celery Beat: periodic tasks (like cron)
from celery.schedules import crontab
app.conf.beat_schedule = {
"nightly-report": {
"task": "tasks.generate_nightly_report",
"schedule": crontab(hour=2, minute=0), # 02:00 daily
},
"health-check": {
"task": "tasks.ping_services",
"schedule": 60.0, # every 60 seconds
},
}
Kafka Deep Dive¶
Apache Kafka is a distributed event log — not just a message queue. Messages (events) are appended to topics, which are split into partitions for parallelism. Events are retained for a configurable period (days to forever), enabling replay.
Consumer groups allow multiple consumers to share the work of a topic. Each partition is assigned to exactly one consumer within a group at a time, so a group with 3 consumers and a topic with 6 partitions processes 2 partitions each. Different consumer groups each receive all events independently — enabling fan-out to multiple downstream services from a single topic.
| Concept | What it does |
|---|---|
| Topic | Named log of events; producers write here |
| Partition | Ordered, immutable sequence; unit of parallelism and replication |
| Offset | Position of a message within a partition; consumers track their position |
| Consumer group | Group of consumers sharing work; each partition to one consumer |
| Log compaction | Retain only the latest value per key; turns Kafka into a state store |
| Transactions | Produce to multiple topics atomically; enable exactly-once semantics |
from confluent_kafka import Producer, Consumer, KafkaError
import json
import uuid
# ── Producer ──────────────────────────────────────────────────────
producer = Producer({
"bootstrap.servers": "localhost:9092",
"acks": "all", # wait for all replicas to acknowledge (durability)
"enable.idempotence": True, # exactly-once producer semantics
})
def delivery_report(err, msg):
if err:
print(f"Delivery failed: {err}")
else:
print(f"Delivered to {msg.topic()} partition {msg.partition()} offset {msg.offset()}")
def publish_order_event(order: dict) -> None:
event = {
"event_id": str(uuid.uuid4()),
"event_type": "order.placed",
"data": order,
}
producer.produce(
topic="order-events",
key=str(order["user_id"]), # partition by user_id for ordering
value=json.dumps(event).encode(),
callback=delivery_report,
)
producer.poll(0) # trigger delivery callbacks
publish_order_event({"order_id": "ORD-1234", "user_id": 42, "total": 99.99})
producer.flush()
# ── Consumer ──────────────────────────────────────────────────────
consumer = Consumer({
"bootstrap.servers": "localhost:9092",
"group.id": "payment-service", # consumer group
"auto.offset.reset": "earliest",
"enable.auto.commit": False, # manual commit for at-least-once safety
})
consumer.subscribe(["order-events"])
try:
while True:
msg = consumer.poll(timeout=1.0)
if msg is None:
continue
if msg.error():
if msg.error().code() == KafkaError._PARTITION_EOF:
continue
raise KafkaError(msg.error())
event = json.loads(msg.value().decode())
try:
process_order_event(event) # your handler
consumer.commit(asynchronous=False) # commit after successful processing
except Exception as e:
print(f"Processing failed: {e}")
# Do NOT commit — broker will redeliver on restart
finally:
consumer.close()
The Outbox Pattern¶
A common bug in event-driven systems: write to the DB and publish an event in two separate operations. Either can succeed while the other fails, leaving state and event stream inconsistent. The outbox pattern solves the dual-write problem.
Write an "outbox" row to the same DB table in the same DB transaction as the business data. A separate relay process (called a transactional outbox publisher or change-data-capture relay) reads unprocessed outbox rows and publishes them to the broker, then marks them as published. If the relay crashes, it replays unprocessed rows on restart. Atomicity is guaranteed by the DB transaction; broker publishing is best-effort with at-least-once delivery.
from sqlalchemy.orm import Session
from datetime import datetime
import json
import uuid
# ── Model ──────────────────────────────────────────────────────────
class OutboxMessage(Base):
__tablename__ = "outbox"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
topic = Column(String, nullable=False)
payload = Column(Text, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
published_at = Column(DateTime, nullable=True) # None = not yet published
# ── Write: atomic business data + outbox row ───────────────────────
def place_order(session: Session, order_data: dict) -> Order:
order = Order(**order_data)
session.add(order)
# Write outbox row in the SAME transaction — guaranteed atomicity
outbox_msg = OutboxMessage(
topic="order-events",
payload=json.dumps({
"event_id": str(uuid.uuid4()),
"event_type": "order.placed",
"order_id": str(order.id),
"user_id": order.user_id,
"total": float(order.total),
}),
)
session.add(outbox_msg)
session.commit() # both order AND outbox row committed atomically
return order
# ── Relay: poll outbox, publish to Kafka, mark published ───────────
import time
def outbox_relay(session: Session, producer):
"""Run in a background thread or separate process."""
while True:
unpublished = (
session.query(OutboxMessage)
.filter(OutboxMessage.published_at.is_(None))
.order_by(OutboxMessage.created_at)
.limit(100)
.all()
)
for msg in unpublished:
payload = json.loads(msg.payload)
producer.produce(
topic=msg.topic,
key=payload.get("order_id", ""),
value=json.dumps(payload).encode(),
)
producer.flush()
msg.published_at = datetime.utcnow()
session.commit()
time.sleep(0.5) # poll every 500 ms; use CDC (Debezium) for lower latency
Change Data Capture as an outbox relay
Instead of polling, use Change Data Capture (CDC) tools like Debezium to stream PostgreSQL's write-ahead log (WAL) directly to Kafka. Every row inserted into the outbox table becomes a Kafka event in near-real-time, without polling latency. Debezium is the standard production approach for the outbox pattern at scale.
Event-Driven Architecture: Fan-Out & Fan-In¶
flowchart TD
Checkout[Checkout Service] -->|OrderPlaced event| Kafka[(Kafka<br/>order-events topic)]
Kafka --> PaySvc[Payment Service<br/>consumer group: payment]
Kafka --> InvSvc[Inventory Service<br/>consumer group: inventory]
Kafka --> NotifSvc[Notification Service<br/>consumer group: notification]
PaySvc -->|Payment failed| DLQ[(Dead Letter<br/>Queue)]
PaySvc -->|PaymentCompleted| Kafka2[(Kafka<br/>payment-events topic)]
Kafka2 --> Fulfillment[Fulfillment Service]
style DLQ fill:#e74c3c,color:#fff
style Kafka fill:#1a1a2e,color:#fff
style Kafka2 fill:#1a1a2e,color:#fff Message Queue Comparison¶
| System | Delivery guarantee | Ordering | Replay | Retention | Python client | Best for |
|---|---|---|---|---|---|---|
| RabbitMQ | At-least-once; exactly-once with quorum queues | Per-queue FIFO | No (consumed = deleted) | Configurable (memory/disk) | pika, kombu | Task queues, RPC, complex routing |
| AWS SQS | At-least-once (standard); FIFO queue for ordering | Best-effort (standard) / strict (FIFO) | No | Up to 14 days | boto3 | Serverless, AWS-native, simple queues |
| Apache Kafka | At-least-once; exactly-once with transactions | Per-partition | Yes — seek to any offset | Days to forever (log compaction) | confluent-kafka, aiokafka | Event streaming, audit log, fan-out |
| Celery + Redis | At-least-once (acks_late) | Priority queues | No (unless custom DLQ) | Until consumed | celery | Background tasks, scheduled jobs |
| Google Pub/Sub | At-least-once | Per-ordering-key | Yes (replay within window) | Up to 7 days | google-cloud-pubsub | GCP-native, serverless fan-out |
Consulting lens
When a client's system buckles under load or a slow third-party call ties up web workers, caching and queues are usually the prescription — and they are easy wins to articulate: "We cache the hot read path in Redis to cut database load by an order of magnitude, and move the slow email/PDF/payment step onto a queue so the user gets an instant response." Just be honest about the new cost: cache-invalidation discipline (every write must invalidate or update the right keys), idempotency engineering (queue consumers must handle redelivery), and the operational weight of a broker (monitoring, DLQ handling, consumer lag alerts). The pattern is well-understood, but the implementation details are where production systems go wrong.
Key takeaways
- Cache-aside is the most common pattern; write-through for consistency; write-behind for performance at risk of data loss.
- Thundering herd: jitter TTLs and use mutex locks to prevent stampedes on popular key expiry.
- Redis distributed locks use atomic SET NX EX; release via Lua script to avoid releasing another owner's lock.
- At-least-once delivery is the broker default; consumers must be idempotent using event IDs.
- Dead letter queues capture failed messages for inspection and replay rather than silently dropping them.
- Kafka enables replay, fan-out to multiple consumer groups, and use as an event log; not just a queue.
- The outbox pattern eliminates dual-write inconsistency by atomically writing business data and events to the same DB transaction.
- Celery canvas (chains, groups, chords) composes complex async workflows without manual orchestration code.
Knowledge check
InterviewBecause most message brokers guarantee at-least-once delivery, what property must queue consumers have to remain correct?
- Atomicity
- Durability
- Idempotency
- Linearizability
Answer
Idempotency
Idempotency means processing the same message twice has exactly the same effect as processing it once. Brokers may redeliver messages when a consumer crashes before acknowledging. Consumers must implement idempotency by keying operations on a unique event ID: check whether the ID has been processed, skip if yes, process and record if no. Without idempotency, redelivered messages cause duplicate charges, double inventory decrements, or duplicate emails.
Exercise
Exercise 14.2 · Cache-Aside with Mutex Lock Implement a get_with_lock(key, fetch_fn, ttl) function that uses a Redis mutex lock to prevent thundering herd. Only the first concurrent caller to detect a cache miss should invoke fetch_fn; other callers should wait (up to 2 seconds) and then re-check the cache. If the cache is populated during their wait, return it; if not (lock timeout), call fetch_fn anyway as a fallback.
Show solution
import json
import time
import uuid
import redis
r = redis.Redis(host="localhost", decode_responses=True)
RELEASE_SCRIPT = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
"""
_release = r.register_script(RELEASE_SCRIPT)
def get_with_lock(key: str, fetch_fn, ttl: int = 300, lock_timeout: float = 2.0):
"""Cache-aside with distributed mutex to prevent thundering herd."""
# Fast path: cache hit
cached = r.get(key)
if cached is not None:
return json.loads(cached)
# Cache miss: try to acquire a lock
lock_key = f"__lock:{key}"
lock_id = str(uuid.uuid4())
deadline = time.monotonic() + lock_timeout
while time.monotonic() < deadline:
acquired = r.set(lock_key, lock_id, nx=True, ex=int(lock_timeout) + 1)
if acquired:
try:
# We hold the lock — fetch and populate
value = fetch_fn()
r.setex(key, ttl, json.dumps(value))
return value
finally:
_release(keys=[lock_key], args=[lock_id])
# Another caller holds the lock — wait and re-check cache
time.sleep(0.05)
cached = r.get(key)
if cached is not None:
return json.loads(cached)
# Lock timeout: fall back to fetching directly (avoids hanging forever)
return fetch_fn()
# Test
import unittest.mock as mock
fetch_count = 0
def expensive_db_call():
global fetch_count
fetch_count += 1
time.sleep(0.1)
return {"data": "product", "fetch_count": fetch_count}
import threading
r.delete("product:42")
threads = [threading.Thread(target=lambda: get_with_lock("product:42", expensive_db_call)) for _ in range(10)]
[t.start() for t in threads]
[t.join() for t in threads]
print(f"fetch_fn called {fetch_count} time(s) — should be 1")