🔥 0
0
Lesson 4 of 6 18 min +225 XP

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
The TTL Dilemma

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
Why Stale-While-Revalidate?

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 itself
  • category:electronics - Category listing
  • search:laptop - Search results
  • cart:user:* - All carts containing this product
  • recommendation:* - 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's Approach

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.

🧠 Quick Quiz

Test your understanding of this lesson.

1

Why is cache invalidation called 'the hardest problem in computer science'?

2

What is the 'stale-while-revalidate' pattern?

3

In a distributed cache, why is 'delete then update database' safer than 'update database then delete'?

Eviction Policies