Cache Invalidation
> "There are only two hard things in Computer Science: cache invalidation and naming things."
> — Phil Karlton
Cache invalidation is ensuring your cache doesn't serve outdated data when the source of truth changes. Sounds simple. It's not.
Why Is It Hard?
Distributed Systems
Multiple cache servers, each with copies. How do you update all of them atomically?
Race Conditions
Two updates happen simultaneously. Which value should the cache hold?
Network Failures
What if the invalidation message is lost? Cache serves stale data forever.
Dependencies
Changing a product price might affect 50 cached pages. How do you find them all?
Strategy 1: TTL-Based Expiration
The simplest approach: let cached data expire automatically.
cache.set("user:123", user_data, ttl=300) # 5 minutes
Pros
- Zero coordination required
- Self-healing - stale data eventually goes away
- Simple to implement
Cons
- Stale data served until TTL expires
- Hard to choose the right TTL
- Short TTL = more DB load
Short TTL (1 min): Fresh data, but high DB load
Long TTL (1 hour): Low DB load, but users see stale data
There's no perfect answer - it depends on your consistency requirements.
Strategy 2: Explicit Invalidation
Delete or update the cache when data changes.
def update_user(user_id, new_data):
# Update database
db.execute("UPDATE users SET ... WHERE id = ?", user_id)
# Explicitly invalidate cache
cache.delete(f"user:{user_id}")
The Order Matters!
Wrong Order (Race Condition)
Thread A: Update DB (user.name = "Alice")
Thread B: Read cache (miss)
Thread B: Read DB (name = "Alice")
Thread A: Delete cache
Thread B: Write cache (name = "Alice")
# Cache now has "Alice" but next update won't know!
Safer: Delete First
def update_user(user_id, new_data):
# 1. Delete cache first
cache.delete(f"user:{user_id}")
# 2. Then update database
db.execute("UPDATE users SET ... WHERE id = ?", user_id)
# If step 2 fails, cache is empty - next read gets fresh data
# If step 1 fails, we haven't changed anything yet
Strategy 3: Pub/Sub Invalidation
Use a message queue to broadcast invalidation events.
[Service A] → Update DB → Publish "user:123 changed" → [Message Queue]
↓
[Cache Server 1] ← Subscribe ←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←
[Cache Server 2] ← Subscribe ←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←
[Cache Server 3] ← Subscribe ←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←
# Publisher (on data change)
def update_user(user_id, new_data):
db.update_user(user_id, new_data)
redis.publish("cache_invalidation", f"user:{user_id}")
# Subscriber (cache servers)
def listen_for_invalidations():
pubsub = redis.pubsub()
pubsub.subscribe("cache_invalidation")
for message in pubsub.listen():
cache_key = message['data']
local_cache.delete(cache_key)
Pros
- All cache servers get notified
- Near real-time invalidation
- Decoupled architecture
Cons
- Message queue adds complexity
- Messages can be lost
- Not atomic with DB update
Strategy 4: Write-Through with Versions
Tag cached data with a version number. Reject stale writes.
def update_user_with_version(user_id, new_data, expected_version):
# Optimistic locking
result = db.execute("""
UPDATE users
SET data = ?, version = version + 1
WHERE id = ? AND version = ?
""", new_data, user_id, expected_version)
if result.rows_affected == 0:
raise ConcurrentModificationError()
# Cache with new version
new_version = expected_version + 1
cache.set(f"user:{user_id}", {
"data": new_data,
"version": new_version
})
Strategy 5: Stale-While-Revalidate
Serve stale data immediately, refresh in the background.
def get_user_swr(user_id):
cache_key = f"user:{user_id}"
cached = cache.get(cache_key)
if cached:
if cached.is_fresh():
return cached.data
# Serve stale, refresh async
async_refresh(cache_key, user_id) # Background task
return cached.data # Return stale immediately
# Cache miss - fetch synchronously
user = db.get_user(user_id)
cache.set(cache_key, user, ttl=3600, stale_ttl=86400)
return user
Users get fast responses (cached data). Freshness happens in the background. Perfect for data where slight staleness is acceptable (profiles, catalogs, recommendations).
Cache-Aside Invalidation Pattern
The most common production pattern combines cache-aside with explicit invalidation:
class UserService:
def get_user(self, user_id):
cached = self.cache.get(f"user:{user_id}")
if cached:
return cached
user = self.db.get_user(user_id)
self.cache.set(f"user:{user_id}", user, ttl=3600)
return user
def update_user(self, user_id, data):
# Delete from cache first
self.cache.delete(f"user:{user_id}")
# Update database
self.db.update_user(user_id, data)
# DON'T repopulate cache here - let next read do it
# This avoids race conditions
Handling Cache Dependencies
When data has relationships, invalidation gets complex:
Example: E-commerce
When a product price changes, what needs invalidation?
product:123- The product itselfcategory:electronics- Category listingsearch:laptop- Search resultscart:user:*- All carts containing this productrecommendation:*- Affected recommendations
Solutions:
- Cache tags: Tag related entries, invalidate by tag
- Short TTLs: For dependent data
- Event sourcing: Track all changes, replay to rebuild
Real-World: Meta's Invalidation at Scale
Meta uses "McSqueal" - a system that tails MySQL's binlog and broadcasts invalidation events. When any row changes, the corresponding cache key is automatically invalidated across all data centers.
— Meta Engineering Blog
Key Takeaways
- TTL is the baseline - Always have a TTL as a safety net
- Delete first, update second - Safer ordering prevents stale data
- Don't repopulate immediately - Let the next read fill the cache
- Stale-while-revalidate - Best of both worlds for many use cases
Next up: Redis vs Memcached - Choosing the right caching solution.