Implement the Cache-Aside Pattern with Redis
Use the cache-aside pattern to read and write data through Redis, handling cache misses, stale reads, and write-through invalidation
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Implement the Cache-Aside Pattern with Redis
Cache-aside (also called lazy loading) is the most common caching pattern. The application checks the cache first; on a miss, it loads from the database, writes the result to the cache, and returns it. On writes, the application updates the database and invalidates the cache entry. Here is a working implementation of cache-aside in Python with Redis, including thundering herd protection and write-through invalidation.
When to Use This
- Read-heavy workloads where the same data is accessed repeatedly
- Data that changes infrequently but is expensive to fetch
- You want the cache to be optional — the system works without it
Prerequisites
- Python 3.10+
redispackage (pip install redis)- A database client (SQLAlchemy, Psycopg, or similar)
Solution
1. Install Dependencies
pip install redis
2. Implement Cache-Aside Read
import json
import logging
from redis import Redis
logger = logging.getLogger(__name__)
class CacheAside:
def __init__(self, redis_client: Redis, default_ttl: int = 300):
self.redis = redis_client
self.default_ttl = default_ttl
def get_or_load(
self,
key: str,
loader: callable,
ttl: int | None = None,
) -> dict | None:
"""Read from cache, or load from source and cache the result.
Args:
key: Cache key.
loader: Function that loads data from the source on cache miss.
ttl: Override default TTL in seconds.
Returns:
Cached or freshly loaded data.
"""
try:
cached = self.redis.get(key)
if cached is not None:
return json.loads(cached)
except Exception as e:
logger.warning("Cache read failed for %s: %s", key, e)
result = loader()
if result is None:
return None
try:
self.redis.setex(key, ttl or self.default_ttl, json.dumps(result, default=str))
except Exception as e:
logger.warning("Cache write failed for %s: %s", key, e)
return result
def invalidate(self, key: str) -> None:
"""Remove a key from the cache."""
try:
self.redis.delete(key)
except Exception as e:
logger.warning("Cache invalidation failed for %s: %s", key, e)
def invalidate_pattern(self, pattern: str) -> int:
"""Delete all keys matching a pattern (e.g., 'user:*')."""
count = 0
try:
for key in self.redis.scan_iter(match=pattern, count=100):
self.redis.delete(key)
count += 1
except Exception as e:
logger.warning("Pattern invalidation failed for %s: %s", pattern, e)
return count
3. Implement Write-Through Invalidation
from myapp.database import db
cache = CacheAside(redis_client, default_ttl=300)
def get_user(user_id: str) -> dict | None:
return cache.get_or_load(
key=f"user:{user_id}",
loader=lambda: db.users.find_by_id(user_id),
ttl=120,
)
def update_user(user_id: str, data: dict) -> dict:
user = db.users.update(user_id, data)
cache.invalidate(f"user:{user_id}")
return user
def delete_user(user_id: str) -> None:
db.users.delete(user_id)
cache.invalidate(f"user:{user_id}")
cache.invalidate_pattern(f"user_posts:{user_id}:*")
4. Thundering Herd Protection
When a cache entry expires, multiple requests may simultaneously trigger the loader, causing a database spike. A lock prevents this:
import time
import uuid
class CacheAsideWithLock(CacheAside):
def get_or_load(
self,
key: str,
loader: callable,
ttl: int | None = None,
lock_timeout: int = 10,
) -> dict | None:
# Try cache first
try:
cached = self.redis.get(key)
if cached is not None:
return json.loads(cached)
except Exception:
pass
# Acquire lock to prevent thundering herd
lock_key = f"lock:{key}"
lock_token = str(uuid.uuid4())
acquired = self.redis.set(lock_key, lock_token, nx=True, ex=lock_timeout)
if acquired:
try:
result = loader()
if result is not None:
self.redis.setex(key, ttl or self.default_ttl, json.dumps(result, default=str))
return result
finally:
# Release lock only if we still own it
self._release_lock(lock_key, lock_token)
else:
# Wait and retry
for _ in range(5):
time.sleep(0.1)
cached = self.redis.get(key)
if cached is not None:
return json.loads(cached)
# Lock timed out — load anyway as fallback
return loader()
def _release_lock(self, lock_key: str, token: str) -> None:
"""Release lock using Lua script to prevent releasing someone else's lock."""
script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
self.redis.eval(script, 1, lock_key, token)
5. Cache Warming
Pre-populate the cache before traffic hits:
def warm_user_cache(user_ids: list[str]) -> None:
users = db.users.find_many({"id": {"in": user_ids}})
for user in users:
cache_key = f"user:{user['id']}"
redis_client.setex(cache_key, 300, json.dumps(user, default=str))
logger.info("Warmed cache for %d users", len(users))
How It Works
- Cache read —
get_or_loadchecks Redis first. On a hit, it returns the cached value without touching the database. - Cache miss — the
loaderfunction fetches from the database. The result is written to Redis with a TTL so it auto-expires. - Write-through invalidation — after any database write,
invalidatedeletes the cache entry so the next read loads fresh data. - Thundering herd lock — on a cache miss, only one request acquires the lock and loads from the database. Others wait and retry the cache read.
- Pattern invalidation —
invalidate_patternusesSCANto find and delete keys matching a glob pattern, useful for invalidating all entries for a user.
Variants
Read-Through with Refresh-Ahead
Refresh the cache before it expires:
def get_or_refresh(key: str, loader: callable, ttl: int = 300) -> dict | None:
cached = redis_client.get(key)
if cached:
result = json.loads(cached)
# Check if entry is near expiry (within 10% of TTL)
ttl_remaining = redis_client.ttl(key)
if ttl_remaining < ttl * 0.1:
# Refresh in background
threading.Thread(target=lambda: cache.get_or_load(key, loader, ttl), daemon=True).start()
return result
return cache.get_or_load(key, loader, ttl)
Write-Behind (Write-Back)
Write to the cache first, then asynchronously persist to the database:
def write_behind_update(user_id: str, data: dict) -> dict:
cache_key = f"user:{user_id}"
redis_client.setex(cache_key, 300, json.dumps(data, default=str))
redis_client.lpush("write_queue:user", json.dumps({"id": user_id, "data": data}))
return data
# Background worker processes the queue
def process_write_queue():
while True:
item = redis_client.brpop("write_queue:user", timeout=10)
if item:
payload = json.loads(item[1])
db.users.update(payload["id"], payload["data"])
Best Practices
-
For a deeper guide, see Complete Guide to Redis Caching Strategies.
-
Set a TTL on every cache entry — prevents stale data from persisting indefinitely
-
Invalidate on writes — update the database first, then delete the cache entry (not the other way around)
-
Handle Redis failures gracefully — the cache is an optimization; the loader should still work without it
-
Use short TTLs for mutable data — 30-120 seconds for user data, longer for reference data
Common Mistakes
- Updating cache before database — if the database write fails, the cache has stale data
- Not handling cache misses — if the loader returns
None, don’t cache it (or cache it with a short TTL to prevent repeated misses) - Using
KEYSinstead ofSCAN—KEYSblocks Redis;SCANis non-blocking and safe for production - Caching too much — cache only hot data; cold data wastes memory and increases invalidation overhead
FAQ
Q: Cache-aside vs. read-through — what is the difference? A: In cache-aside, the application manages cache reads and writes. In read-through, a cache library handles it transparently. Cache-aside gives more control; read-through is simpler.
Q: Should I update the cache or invalidate it on writes? A: Invalidate (delete). The next read will load fresh data. Updating the cache risks inconsistency if the database write fails.
Q: How do I handle cache stampede? A: Use the lock pattern shown above, or request coalescing — only one request loads data while others wait.
Q: What TTL should I use? A: Start with 300 seconds and adjust. Monitor cache hit rate — below 80% means the TTL is too short or the data changes too fast.
Is this solution production-ready?
Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.
What are the performance characteristics?
Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.
How do I debug issues with this approach?
Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.
Related Resources
Caching with Redis
How to implement application caching using Redis for performance and scalability.
RecipeCache Function Results with Redis and TTL in Python
Build a Python decorator that caches function return values in Redis with configurable TTL, key generation, and cache invalidation
PatternCache-Aside Pattern
Load data into the cache on demand from the backing store. A caching pattern that gives the application full control over what and when to cache.
RecipeCDN Cache Invalidation Strategies and Patterns
Implement CDN cache invalidation using purge APIs, surrogate keys, tag-based invalidation, and versioned URLs to keep content fresh
RecipeHTTP Cache-Control Headers for APIs and Static Assets
Set Cache-Control, ETag, and Last-Modified headers to control browser and CDN caching for API responses and static assets
RecipeMulti-Level Cache with In-Memory L1 and Redis L2
Implement a two-level cache combining in-memory L1 and Redis L2 for low-latency reads with cross-instance consistency
RecipeDistributed Locking with Redis and Redlock
Implement distributed locks with Redis for mutual exclusion across processes, using SET NX with TTL and the Redlock algorithm for reliability