When Your Cache Goes Down: Building a Resilient Layer with Redis and Fallbacks
Learn how to build a resilient caching layer in Python that gracefully handles Redis outages using local in-memory caches, circuit breakers, and a three-layer fallback chain to keep your application fast and reliable.
Advertisement
You know that sinking feeling when your application slows to a crawl because the cache server decided to take an unscheduled nap. It happens more often than we'd like to admit. Redis is fast, but it's not invincible. Network blips, memory pressure, or a simple restart can leave your app scrambling to serve data directly from the database. That's when users start seeing those dreaded loading spinners.
At PythonSkillset, we've seen teams build caching layers that work beautifully 99% of the time, but that 1% failure can bring everything crashing down. The trick isn't to make Redis invincible — it's to build a system that gracefully handles when Redis isn't available.
Why Simple Caching Fails
Most developers start with something like this:
import redis
cache = redis.Redis(host='localhost', port=6379)
def get_user(user_id):
key = f"user:{user_id}"
data = cache.get(key)
if data:
return data
# fetch from database
data = database.fetch_user(user_id)
cache.setex(key, 3600, data)
return data
Looks clean, right? But what happens when Redis is down? That cache.get() call throws an exception. Your user request fails. Your database might get hammered by retries. Your monitoring lights up red.
The problem isn't Redis itself — it's the assumption that it will always be there. Real systems have network partitions, memory limits, and maintenance windows. Building resilience means planning for these moments.
The Three-Layer Defense Strategy
A resilient caching layer doesn't just have one fallback — it has a chain of them. Think of it like a safety net with multiple levels.
Layer 1: In-Memory Cache (Local)
Before hitting Redis, check a local dictionary or functools.lru_cache. This is lightning fast and works even if Redis is completely gone. The tradeoff is memory usage and data freshness, but for frequently accessed items, it's a lifesaver.
Layer 2: Redis (Distributed Cache) This is your main cache. Fast, shared across instances, with TTL support. But it's a network call, so it can fail.
Layer 3: Database (The Ground Truth) Your database is the slowest but most reliable source. The fallback should always be here, but with protection against thundering herds.
Building the Fallback Chain
Let's build a practical example. We'll use a simple @cached decorator that tries local memory first, then Redis, then the database.
import redis
from functools import lru_cache
import time
# Local in-memory cache with TTL
class LocalCache:
def __init__(self, maxsize=128, ttl=60):
self.cache = {}
self.ttl = ttl
self.maxsize = maxsize
def get(self, key):
if key in self.cache:
value, expiry = self.cache[key]
if time.time() < expiry:
return value
else:
del self.cache[key]
return None
def set(self, key, value, ttl=None):
if len(self.cache) >= self.maxsize:
# Evict oldest entry
oldest = min(self.cache.keys(), key=lambda k: self.cache[k][1])
del self.cache[oldest]
expiry = time.time() + (ttl or 60)
self.cache[key] = (value, expiry)
local_cache = LocalCache(maxsize=256, ttl=30)
redis_client = redis.Redis(host='localhost', port=6379, socket_connect_timeout=1)
Notice the timeout on Redis connection. That's your first line of defense. Without it, a dead Redis server can hang your application for minutes.
The Fallback Chain in Action
Here's the pattern that works well in production at PythonSkillset:
def get_cached_or_fetch(key, fetch_func, ttl=300):
# Try local cache first (fastest)
result = local_cache.get(key)
if result is not None:
return result
# Try Redis
try:
result = redis_client.get(key)
if result:
# Store in local cache for next time
local_cache.set(key, result, ttl=30)
return result
except (redis.ConnectionError, redis.TimeoutError) as e:
# Log the failure but don't crash
logger.warning(f"Redis unavailable: {e}")
# Fallback to database
result = fetch_func()
# Try to populate Redis (but don't fail if it's still down)
try:
redis_client.setex(key, ttl, result)
except (redis.ConnectionError, redis.TimeoutError):
pass
# Always store in local cache
local_cache.set(key, result, ttl=30)
return result
This pattern is simple but powerful. The local cache acts as a shock absorber. When Redis goes down, your app still serves data from memory for a short time. When Redis comes back, it starts populating again naturally.
Handling the Thundering Herd Problem
Here's a scenario that's more common than you'd think: Redis goes down for 30 seconds. When it comes back, every single request tries to repopulate the cache simultaneously. Your database gets hammered with identical queries. This is the thundering herd problem.
The fix is a mutex lock around the cache population:
import threading
cache_locks = {}
def get_cached_with_lock(key, fetch_func, ttl=300):
# Check local cache first
result = local_cache.get(key)
if result:
return result
# Try Redis
try:
result = redis_client.get(key)
if result:
local_cache.set(key, result, ttl=30)
return result
except (redis.ConnectionError, redis.TimeoutError):
pass
# Use a lock to prevent thundering herd
lock = cache_locks.setdefault(key, threading.Lock())
with lock:
# Double-check after acquiring lock
result = local_cache.get(key)
if result:
return result
# Fetch from database
result = fetch_func()
# Populate caches
local_cache.set(key, result, ttl=30)
try:
redis_client.setex(key, ttl, result)
except (redis.ConnectionError, redis.TimeoutError):
pass
return result
This pattern ensures that only one request per key hits the database when the cache is cold. The rest wait for the lock and get the cached result.
Real-World Example: A Product Catalog
Let's say you're building a product catalog for an e-commerce site. Products don't change often, but they're accessed constantly. Here's how the fallback chain works in practice:
- Local cache holds the last 256 products for 30 seconds each. This covers rapid refreshes during a browsing session.
- Redis holds all products with a 5-minute TTL. This is your main cache.
- Database is the final fallback, but we add a circuit breaker to prevent overload.
from circuitbreaker import circuit
@circuit(failure_threshold=5, recovery_timeout=30)
def fetch_from_database(product_id):
# This will only be called if both caches miss
return database.query("SELECT * FROM products WHERE id = ?", product_id)
def get_product(product_id):
key = f"product:{product_id}"
# Local cache
result = local_cache.get(key)
if result:
return result
# Redis
try:
result = redis_client.get(key)
if result:
local_cache.set(key, result, ttl=30)
return result
except (redis.ConnectionError, redis.TimeoutError):
pass
# Database with circuit breaker
result = fetch_from_database(product_id)
# Populate caches (best effort)
local_cache.set(key, result, ttl=30)
try:
redis_client.setex(key, ttl, result)
except (redis.ConnectionError, redis.TimeoutError):
pass
return result
The circuit breaker on the database call is crucial. If your database is already struggling, you don't want to make it worse. After 5 failures in 30 seconds, it opens the circuit and returns a cached (possibly stale) result or an error.
Monitoring the Health of Your Cache
You can't fix what you don't measure. Add simple metrics to track cache hit rates and failure counts:
import time
class CacheMetrics:
def __init__(self):
self.hits = 0
self.misses = 0
self.redis_failures = 0
self.db_calls = 0
def record_hit(self):
self.hits += 1
def record_miss(self):
self.misses += 1
def record_redis_failure(self):
self.redis_failures += 1
def record_db_call(self):
self.db_calls += 1
def report(self):
total = self.hits + self.misses
if total > 0:
hit_rate = self.hits / total * 100
else:
hit_rate = 0
return {
"hit_rate": f"{hit_rate:.1f}%",
"redis_failures": self.redis_failures,
"db_calls": self.db_calls
}
metrics = CacheMetrics()
Now you can expose these metrics to your monitoring system. If Redis failures spike, you'll know before users start complaining.
When to Use Each Layer
Not every piece of data needs all three layers. Here's a practical guide:
- Session data: Use local cache only (short TTL, user-specific). Redis is optional.
- Product catalog: Use all three layers. Products change rarely, so stale data for 30 seconds is acceptable.
- User preferences: Local cache is fine. Redis for cross-instance consistency.
- Real-time counters: Skip local cache entirely. Use Redis directly with a fallback to database.
The Circuit Breaker Pattern
Your database is the last line of defense, but it needs protection too. A simple circuit breaker prevents cascading failures:
class CircuitBreaker:
def __init__(self, threshold=5, recovery_time=30):
self.failures = 0
self.threshold = threshold
self.recovery_time = recovery_time
self.last_failure_time = 0
self.state = "closed" # closed, open, half-open
def call(self, func, *args, **kwargs):
if self.state == "open":
if time.time() - self.last_failure_time > self.recovery_time:
self.state = "half-open"
else:
raise Exception("Circuit breaker is open")
try:
result = func(*args, **kwargs)
if self.state == "half-open":
self.state = "closed"
self.failures = 0
return result
except Exception as e:
self.failures += 1
self.last_failure_time = time.time()
if self.failures >= 5:
self.state = "open"
raise e
This circuit breaker prevents your database from being overwhelmed when Redis is down. After 5 failures, it stops trying for 30 seconds, giving your database a breather.
The Stale Data Tradeoff
Here's a truth that's hard to swallow: sometimes stale data is better than no data. If your product catalog updates once an hour, serving a 10-minute-old product name is fine. Serving an error page is not.
You can implement a "stale-while-revalidate" pattern:
def get_product_with_stale(product_id):
key = f"product:{product_id}"
# Try local cache
result = local_cache.get(key)
if result:
return result
# Try Redis
try:
result = redis_client.get(key)
if result:
local_cache.set(key, result, ttl=30)
return result
except (redis.ConnectionError, redis.TimeoutError):
pass
# Try database
try:
result = fetch_from_database(product_id)
# Populate caches
local_cache.set(key, result, ttl=30)
try:
redis_client.setex(key, ttl, result)
except (redis.ConnectionError, redis.TimeoutError):
pass
return result
except DatabaseError:
# Last resort: return stale data from local cache
stale = local_cache.get(key)
if stale:
return stale
raise
Notice the last resort: if the database also fails, we return stale data from the local cache. For many applications, a slightly outdated product name is better than a 500 error.
Testing Your Resilience
You can't just hope this works. You need to test it. Here's a simple way to simulate Redis failures in your development environment:
import random
class UnreliableRedis:
def __init__(self, failure_rate=0.1):
self.failure_rate = failure_rate
self.data = {}
def get(self, key):
if random.random() < self.failure_rate:
raise redis.ConnectionError("Simulated failure")
return self.data.get(key)
def setex(self, key, ttl, value):
if random.random() < self.failure_rate:
raise redis.ConnectionError("Simulated failure")
self.data[key] = value
Run your tests with this unreliable client. You'll quickly find edge cases where your fallback logic breaks.
The Cost of Resilience
There's no free lunch. Adding local caching means more memory usage. Adding circuit breakers means slightly more latency when they trip. But the tradeoff is worth it.
At PythonSkillset, we've seen applications that went from 5-second page loads during Redis outages to under 200ms — just by adding a local cache layer. The database queries dropped by 90% because the local cache absorbed most of the load.
A Simple Monitoring Dashboard
You don't need a complex monitoring system. A simple endpoint that shows cache health can be invaluable:
@app.route('/cache-health')
def cache_health():
stats = metrics.report()
return {
"status": "healthy" if stats["redis_failures"] < 10 else "degraded",
"hit_rate": stats["hit_rate"],
"redis_failures_last_hour": stats["redis_failures"],
"db_calls_last_hour": stats["db_calls"]
}
When you see Redis failures climbing, you know it's time to investigate. When the hit rate drops below 80%, your database is working harder than it should.
The Bottom Line
Building a resilient caching layer isn't about making Redis bulletproof. It's about designing your system to survive when Redis isn't there. A local cache, a circuit breaker, and a thoughtful fallback chain can turn a potential outage into a minor blip that users never notice.
Start simple. Add the local cache first. Then the circuit breaker. Then the monitoring. You'll sleep better knowing your app can handle a Redis hiccup without breaking a sweat.
Advertisement
Comments
Questions, corrections, and tips stay visible for everyone reading this page.
Join the discussion
No comments yet
Be the first to leave a note — it helps the next reader.