Why Redis for Caching?
Redis is the de facto standard for application caching. It stores data in memory, delivering sub-millisecond read latency — 100-1000x faster than a typical relational database. But raw speed isn't the only benefit: Redis supports rich data structures (strings, hashes, sorted sets, lists) that map naturally to caching problems.
The key decision isn't whether to cache, but which pattern to use. The wrong pattern leads to stale data, cache stampedes, or cache churn that negates the performance gain.
Pattern 1: Cache-Aside (Lazy Loading)
The most common pattern. The application manages the cache explicitly: check the cache, if miss fetch from DB, store in cache, return result.
import redis
import json
from typing import Optional
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
def get_user(user_id: int) -> dict:
cache_key = f"user:{user_id}"
# 1. Check cache
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# 2. Cache miss — fetch from database
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
# 3. Store in cache with TTL
r.setex(cache_key, 3600, json.dumps(user)) # TTL: 1 hour
return user
Pros: Only caches what is actually requested. Resilient to cache failures (app falls back to DB). Cache nodes can fail without taking down the app.
Cons: Cache miss on first request (cold start). Data can be stale for up to TTL seconds. Three round-trips on cache miss.
Pattern 2: Write-Through
Every write goes to the cache and the database simultaneously. Reads always hit the cache — no miss after first write.
def update_user(user_id: int, data: dict) -> dict:
# 1. Write to database
updated = db.execute(
"UPDATE users SET name=%s, email=%s WHERE id=%s",
data["name"], data["email"], user_id
)
# 2. Simultaneously update cache
cache_key = f"user:{user_id}"
r.setex(cache_key, 3600, json.dumps(updated))
return updated
Pros: Cache is always fresh. No stale reads. Low read latency guaranteed.
Cons: Write latency increases (two writes per operation). Cache bloat — you cache data that may never be read. Cache nodes becoming a bottleneck on write-heavy workloads.
Pattern 3: Write-Behind (Write-Back)
Writes go to the cache immediately and are asynchronously flushed to the database. Optimizes write performance at the cost of durability risk.
import asyncio
from collections import defaultdict
write_buffer: dict[str, dict] = {}
async def update_user_fast(user_id: int, data: dict) -> dict:
cache_key = f"user:{user_id}"
# Write to cache only — returns immediately
r.setex(cache_key, 3600, json.dumps(data))
write_buffer[cache_key] = data # Queue for async flush
return data
async def flush_writes():
"""Run as background task every 500ms"""
while True:
await asyncio.sleep(0.5)
if write_buffer:
batch = write_buffer.copy()
write_buffer.clear()
# Batch write to database
for key, data in batch.items():
user_id = key.split(":")[1]
await db.execute_async(
"UPDATE users SET name=%s WHERE id=%s",
data["name"], user_id
)
Warning: If Redis crashes before flush, data is lost. Use AOF persistence and set appendfsync always if durability matters.
Pattern 4: Read-Through
Similar to Cache-Aside but the cache itself fetches from the database on a miss. The application only ever talks to the cache.
class ReadThroughCache:
def __init__(self, redis_client, db_client):
self.redis = redis_client
self.db = db_client
def get(self, key: str, loader_fn, ttl: int = 3600):
value = self.redis.get(key)
if value:
return json.loads(value)
# Cache handles the miss internally
value = loader_fn()
self.redis.setex(key, ttl, json.dumps(value))
return value
# Usage — app only calls cache.get()
cache = ReadThroughCache(r, db)
user = cache.get(
f"user:42",
lambda: db.query("SELECT * FROM users WHERE id=42")
)
TTL Strategy
Choosing the right TTL is as important as choosing the right pattern:
- Session data: 15-30 minutes (match session timeout)
- User profiles: 1-24 hours (low update frequency)
- Product catalog: 1-6 hours (changes infrequently)
- Search results: 5-15 minutes (freshness matters)
- Rate limit counters: Match the rate limit window exactly
- Real-time data (prices, inventory): 30-60 seconds or event-driven invalidation
Add jitter to TTLs to prevent thundering herd when many keys expire simultaneously:
import random
BASE_TTL = 3600
jitter = random.randint(-300, 300) # +/- 5 minutes
r.setex(key, BASE_TTL + jitter, value)
Cache Stampede Prevention
When a popular cache key expires, many requests hit the database simultaneously — the "thundering herd" or cache stampede. Use probabilistic early expiration or a mutex lock:
import time
def get_with_lock(key: str, ttl: int, loader_fn) -> dict:
value = r.get(key)
if value:
return json.loads(value)
# Acquire a lock to prevent stampede
lock_key = f"lock:{key}"
lock_acquired = r.set(lock_key, "1", nx=True, ex=5) # 5s lock
if lock_acquired:
try:
result = loader_fn()
r.setex(key, ttl, json.dumps(result))
return result
finally:
r.delete(lock_key)
else:
# Wait for lock holder to populate cache
time.sleep(0.1)
return get_with_lock(key, ttl, loader_fn) # Retry
Eviction Policies
When Redis runs out of memory, it must evict keys. Choose your policy based on access patterns:
allkeys-lru— Evict least recently used keys (best for general caching)allkeys-lfu— Evict least frequently used (best for skewed access patterns)volatile-lru— Only evict keys with a TTL setnoeviction— Return errors when memory full (for session stores)
# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru
Cache Key Design
Good key design prevents collisions and simplifies debugging:
# Pattern: service:entity:id[:field]
"api:user:42"
"api:user:42:posts"
"api:search:q=python&page=1"
# Use hashes for related fields (saves memory vs multiple string keys)
r.hset("user:42", mapping={
"name": "Alice",
"email": "[email protected]",
"role": "admin"
})
r.expire("user:42", 3600)
Frequently Asked Questions
Cache-Aside vs Read-Through — which should I choose?
Cache-Aside gives you more control and is more resilient to cache failures. Read-Through is cleaner code when using a caching library that supports it. For most web applications, Cache-Aside is the pragmatic choice.
How do I handle cache invalidation?
Cache invalidation is famously hard. Prefer time-based TTL expiration for most cases. For data that must be fresh immediately (prices, inventory), use event-driven invalidation: publish a message to delete the cache key when the database row changes.
Should I cache API responses?
Yes — caching API responses at the edge (CDN) or in Redis reduces both latency and upstream load significantly. Use DevKits JSON Formatter to inspect and validate the JSON structures you are caching.