Stage 4 · Provision
Cache Design Patterns
Cache-Aside Pattern
Read-through misses, write invalidation, TTLs, and stale reads with Redis and Memcached.
Cache-Aside Overview
Cache-aside (lazy loading) is the most common caching pattern. The application checks the cache first. On a miss, it fetches from the database and populates the cache. On a write, it invalidates the cache. The application controls all cache interactions.
Read Path
def get_product(product_id: int) -> dict:
cache_key = f"product:{product_id}"
# Step 1: Check cache
cached = redis.get(cache_key)
if cached:
return json.loads(cached)
# Step 2: Cache miss — fetch from database
product = db.query("SELECT * FROM products WHERE id = %s", product_id)
if product is None:
# Cache negative result to prevent cache stampede
redis.setex(cache_key, 60, json.dumps(None))
return None
# Step 3: Populate cache with TTL
redis.setex(cache_key, 300, json.dumps(product))
return productThe read path: check cache, on miss fetch from DB, populate cache. The TTL ensures stale data expires. Negative caching prevents repeated queries for non-existent keys.
Write Path
def update_product(product_id: int, updates: dict):
# Step 1: Update database
db.execute("UPDATE products SET ... WHERE id = %s", product_id)
# Step 2: Invalidate cache (delete, don't update)
cache_key = f"product:{product_id}"
redis.delete(cache_key)
# Next read will fetch fresh data from DB and populate cache
# This is called "lazy population" — the cache is populated on read, not writeOn write, invalidate (delete) the cache rather than updating it. This avoids race conditions where a concurrent read caches stale data. The next read will populate the cache with fresh data.
TTL Strategy
| TTL | Use Case | Tradeoff |
|---|---|---|
| Short (10-60s) | Frequently changing data | More cache misses, fresher data |
| Medium (5-30min) | Moderately changing data | Balanced |
| Long (1-24hr) | Rarely changing data | Fewer misses, risk of stale data |
| No TTL | Never changes | Must invalidate manually |
Handling Stale Reads
Stale reads occur when the cache serves data that is newer than what the user expects but older than the latest write. This is acceptable for most use cases. For read-after-write consistency, read from the master after a write.
class UserService:
def __init__(self):
self.written_keys = set() # Track recently written keys
def write_user(self, user_id, data):
db.write(user_id, data)
self.written_keys.add(user_id)
redis.delete(f"user:{user_id}")
def read_user(self, user_id):
if user_id in self.written_keys:
# Read from DB to guarantee consistency
self.written_keys.discard(user_id)
return db.read(user_id)
return self._get_from_cache(user_id)For read-after-write consistency, track recently written keys and read from the DB for those keys. This ensures the user always sees their own writes.
Production Implementation
Cache-aside is the simplest and most flexible caching pattern. Use it unless you have a specific reason to use write-through or write-behind. It gives you full control over cache population and invalidation.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.