~learn.
?

Day 0243 minPreface+60 XP

Invalidation Strategies.

Yesterday, you learned the four caching patterns. You can now put data into a cache efficiently.

4 stages27 code

watch it first · newThe whole lesson in a 19-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 1 min

Preface#

Yesterday, you learned the four caching patterns. You can now put data into a cache efficiently.

Today, we tackle the harder problem: getting stale data out.

THE INCIDENT

Monday, 10:20 AM — Customer Support tickets spike

  • Ticket #4521: "Product shows $99, but I was charged $79"
  • Ticket #4522: "Price on page doesn't match cart"
  • Ticket #4523: "Flash sale price not showing"
  • Ticket #4524: "Wrong price displayed"
  • ... 143 more tickets
Investigation
  • 10:00 AM — Marketing starts flash sale
  • 10:00 AM — Database updated: price $99 → $79
  • 10:00 AM — Cache still has: price $99 (Cache TTL: 1 hour)
  • 10:00 - 11:00 AM — Users see $99, charged $79
  • 11:00 AM — Cache expires, new price $79 appears

Result: 147 confused customers · Support team overwhelmed · Trust damaged · "Why didn't the cache update?"

The cache did exactly what it was told. The problem: nobody told it the price changed.

This is the cache invalidation problem — and it's notoriously hard.

Phil Karlton famously said there are only two hard things in computer science: cache invalidation and naming things. Today, you'll understand why invalidation is hard — and learn the strategies that actually work.

ExplainerThe whole lesson: cache invalidation

A flash sale changed a price from $99 to $79 while the cache held $99 for an hour, and 147 customers were charged a price they weren't shown. TTLs bound staleness, event-driven invalidation deletes keys in about 100–500 ms, and versioned keys switch atomically. Deleting beats updating, because two concurrent updates can leave the slower, older value in the cache; production combines events with a short safety-net TTL, decomposes products by change rate, invalidates only after commit, and retries failed deletes.

Part I · Stage 1 of 4 · 18 min

Part I: Foundations#

Chapter 1: Why Invalidation Is Hard#

1.1 The Core Problem#

A cache is a copy of data. When the original changes, the copy becomes stale.

THE STALENESS PROBLEM
Time Source of Truth (Database) Cache
T0 product.price = $100 product.price = $100 (correct)
T1 product.price = $79 (updated!) product.price = $100 (STALE!)
T2 product.price = $79 product.price = $100 (STALE!)
T3 product.price = $79 product.price = $100 (STALE!)

The cache doesn't know the database changed. How do we tell it?

1.2 The Invalidation Trilemma#

You can optimize for two of three properties:

THE INVALIDATION TRILEMMA

You can optimize for two of three:

  • FRESHNESS: Data in cache matches database
  • SIMPLICITY: Easy to implement and maintain
  • PERFORMANCE: Low latency, high throughput
Trade-offs
Strategy Freshness Simplicity Performance
TTL-based no — Not always fresh (stale until TTL expires) ok — Simple ok — Good performance
Event-driven ok — Fresh (near real-time) no — Complex (need event infrastructure) ok — Good performance
Write-through ok — Fresh after each write (races possible) no — Complex (coupling) no — Slower writes (must update cache)

1.3 When Staleness Matters#

Not all data needs the same freshness:

Data Type Staleness Tolerance Why
Stock prices < 1 second Trading decisions
Inventory count < 30 seconds Overselling risk
Product price < 5 minutes Customer trust
Product description Hours Rarely changes
User profile Minutes to hours Low impact
Static content Days Never changes

1.4 Key Terminology#

Term Definition
Stale data Cached data that differs from source of truth
TTL (Time-To-Live) Duration before cache entry expires
Invalidation Removing or updating stale cache entries
Cache miss Data not in cache, must fetch from source
Write-invalidate Delete cache entry when data changes
Write-update Update cache entry when data changes
Tombstone Marker indicating deleted data

Chapter 2: Invalidation Strategies#

Your turn · checkpoint 1not answered

How should the cache have heard about $79?

In the incident, the database had $79 at 10:00 but the cache kept serving $99 until its 1-hour TTL ran out. Name two ways the cache could have dropped the old price sooner, and what each one costs you.

Think it through, or jot it down. Then compare.

One strong answer

  • A shorter TTL: entries expire sooner, but the price is still stale for up to the TTL, and every cut in TTL means more misses and more database load.
  • Event-driven invalidation: the price update publishes an event and a consumer deletes the key, so the next read is fresh within about a second. It needs event infrastructure, and an event can be lost.
  • In practice you combine them: events for speed, a TTL as the safety net.

2.1 Strategy 1: Time-To-Live (TTL)#

The simplest strategy: cache entries automatically expire after a set duration.

TTL-BASED INVALIDATION
Timeline
  • T0: Cache MISS, load from DB, cache with TTL=300s
  • T0-T300: Cache HIT (even if DB changes!)
  • T300: Cache entry expires
  • T301: Cache MISS, reload from DB
How it works

cache.setex("product:123", 300, product_data) — the 300 is the TTL in seconds.

After 300 seconds, key automatically deleted. Next request triggers cache miss and reload.

Implementation:

Note: the samples call fetch_one, as in the databases library (or a thin wrapper with that method). With asyncpg the equivalent is fetchrow, which returns a Record.

pythonTTLCache · 36 lines
# TTL-Based Invalidation

class TTLCache:
    """
    Simple TTL-based caching.
    
    Pros: Simple, self-healing
    Cons: Stale for up to TTL duration
    """
    
    def __init__(self, redis_client, default_ttl: int = 300):
        self.redis = redis_client
        self.default_ttl = default_ttl
    
    async def get_product(self, product_id: str) -> dict:
        cache_key = f"product:{product_id}"
        
        # Check cache
        cached = await self.redis.get(cache_key)
        if cached:
            return json.loads(cached)
        
        # Miss: load from database
        product = await self.db.fetch_one(
            "SELECT * FROM products WHERE id = $1",
            product_id
        )
        
        # Cache with TTL
        await self.redis.setex(
            cache_key,
            self.default_ttl,  # Expires in 300 seconds
            json.dumps(dict(product))
        )
        
        return dict(product)

Choosing TTL Values:

TTL SELECTION GUIDE

Question: "How stale is acceptable?"

Freshness need Examples TTL
Very fresh needed (seconds) Inventory counts during sales · Real-time dashboards · Session data (sliding expiration) 10-60 seconds
Moderate freshness (minutes) Product prices · User preferences · Search results 1-15 minutes
Low freshness OK (hours) Product descriptions · Category listings · User profiles (non-critical) 1-24 hours
Static content (days) Images, CSS, JS · Configuration that rarely changes · Historical data 1-30 days
FORMULA FOR TTL

TTL = (acceptable_staleness) × (safety_factor)

Example: "Users can tolerate 5 minutes of stale prices" → TTL = 5 minutes × 0.8 = 4 minutes. Safety factor accounts for clock skew, network delays.

Pros:

  • Extremely simple to implement
  • Self-healing (stale data eventually expires)
  • No external dependencies
  • Predictable behavior

Cons:

  • Data is stale for up to TTL duration
  • No control over when refresh happens
  • Short TTL = more cache misses = more DB load
  • Long TTL = longer staleness
WorksheetWhat a TTL alone costs (assumptions labelled)
Value
Hot products, each read at least once per TTL (assumed) 100,000
1-hour TTL (the incident's) 100,000 ÷ 3,600 s ≈ 28 reloads/s; a price can be wrong for up to 60 min
5-minute TTL ≈ 333 reloads/s; wrong for up to 5 min
30-second TTL ≈ 3,333 reloads/s; wrong for up to 30 s
1-second TTL (to keep up with a flash sale) 100,000 reloads/s: the whole hot set reloaded every second
Events + 5-minute safety TTL (next sections) ≈ 333 reloads/s plus one per change; wrong for ~100–500 ms normally, 5 min at worst

Conclusion: with a TTL alone, cutting staleness by some factor multiplies database reloads by the same factor, so no TTL gives both seconds of freshness and a quiet database. Event-driven invalidation breaks that link, which lets the TTL stay at minutes as a safety net.

2.2 Strategy 2: Event-Driven Invalidation#

When data changes, publish an event. Consumers invalidate relevant cache entries.

Write path:

  1. Update database
  2. Publish "product.updated" event
  3. Cache invalidator consumes event
  4. Delete/update cache entry

Timeline:

  • T0: DB update + publish event
  • T0.1: Event consumed
  • T0.2: Cache invalidated
  • T0.3: Next read gets fresh data

Latency: ~100-500ms (near real-time)

DiagramEvent-driven invalidation
%% title: Event-driven invalidation
flowchart TD
  U@{ icon: "sd:user", label: "Update" } --> PS["Product Service"]:::core
  PS --> DB@{ icon: "sd:database", label: "Database<br/>(write)" }
  PS --> K@{ icon: "tech:kafka", label: "Kafka<br/>(event)" }
  K -.-> CI["Cache Invalidator"]:::service
  CI --> R@{ icon: "tech:redis", label: "Redis<br/>(delete)" }
  class U client
  class DB store
  class K queue
  class R cache

Implementation:

pythonProductUpdatedEvent · 104 lines
# Event-Driven Invalidation

from dataclasses import dataclass
from typing import List, Optional
import json
from datetime import datetime, timezone


@dataclass
class ProductUpdatedEvent:
    """Event published when a product changes."""
    product_id: str
    changed_fields: List[str]
    timestamp: str
    source: str  # Which service made the change


class ProductService:
    """
    Product service that publishes events on changes.
    """
    
    def __init__(self, db, kafka_producer, cache):
        self.db = db
        self.kafka = kafka_producer
        self.cache = cache
    
    async def update_product(self, product_id: str, updates: dict) -> dict:
        """Update product and publish event for cache invalidation."""
        
        # Update database
        product = await self.db.fetch_one(
            """
            UPDATE products 
            SET name = COALESCE($2, name),
                price = COALESCE($3, price),
                updated_at = NOW()
            WHERE id = $1
            RETURNING *
            """,
            product_id, updates.get('name'), updates.get('price')
        )
        
        # Publish event for cache invalidation
        # (This is a dual write; use the Week 3 outbox for guaranteed delivery.)
        event = ProductUpdatedEvent(
            product_id=product_id,
            changed_fields=list(updates.keys()),
            timestamp=datetime.now(timezone.utc).isoformat(),
            source="product-service"
        )
        
        await self.kafka.send(
            "product-events",
            key=product_id.encode(),
            value=json.dumps(event.__dict__).encode()
        )
        
        return dict(product)


class CacheInvalidationConsumer:
    """
    Consumes product events and invalidates cache.
    
    Runs as a separate service/process.
    """
    
    def __init__(self, kafka_consumer, redis_client):
        self.kafka = kafka_consumer
        self.redis = redis_client
    
    async def run(self):
        """Main consumer loop."""
        async for message in self.kafka:
            try:
                event = json.loads(message.value.decode())
                await self.handle_event(event)
                await self.kafka.commit()
            except Exception as e:
                logger.error(f"Failed to process event: {e}")
                # Send to DLQ (Week 3, Day 4)
    
    async def handle_event(self, event: dict):
        """Handle a product event."""
        event_type = event.get('type', 'product.updated')
        product_id = event['product_id']
        
        if event_type in ('product.updated', 'product.deleted'):
            # Invalidate product cache
            await self.redis.delete(f"product:{product_id}")
            
            # Also invalidate related caches
            await self.redis.delete(f"product_page:{product_id}")
            await self.redis.delete(f"category_products:{event.get('category_id')}")
            
            logger.info(f"Invalidated cache for product {product_id}")
        
        elif event_type == 'product.price_changed':
            # Price change might affect more things
            await self.redis.delete(f"product:{product_id}")
            await self.redis.delete(f"deals_page")  # Might appear in deals
            # DEL takes literal key names, not patterns: use SCAN + DEL
            await delete_by_pattern("search_results:*")

Handling Related Caches:

pythoninvalidate_product · 41 lines
# Products appear in multiple caches - invalidate all of them

CACHE_DEPENDENCIES = {
    "product": [
        "product:{id}",           # Direct product cache
        "product_page:{id}",      # Rendered product page
        "category:{category_id}", # Category listing
        "search:*",               # Search results (pattern)
    ],
    "inventory": [
        "product:{id}",           # Product includes stock info
        "product_page:{id}",
        "availability:{id}",
    ],
    "price": [
        "product:{id}",
        "product_page:{id}",
        "deals_page",             # If product is on sale
        "cart:{user_id}",         # User's cart might show price
    ]
}


async def invalidate_product(product_id: str, change_type: str, metadata: dict):
    """Invalidate all caches affected by a product change."""
    
    patterns = CACHE_DEPENDENCIES.get(change_type, ["product:{id}"])
    
    for pattern in patterns:
        # Replace placeholders
        key = pattern.format(
            id=product_id,
            category_id=metadata.get('category_id', '*'),
            user_id='*'  # Wildcard for user-specific caches
        )
        
        if '*' in key:
            # Pattern delete (use with caution - expensive!)
            await delete_by_pattern(key)
        else:
            await redis.delete(key)

Pros:

  • Near real-time freshness
  • Precise control over what gets invalidated
  • Efficient (only invalidate what changed)
  • Scales well with proper event infrastructure

Cons:

  • Requires event infrastructure (Kafka, etc.)
  • More complex to implement
  • Event delivery isn't guaranteed (need DLQ)
  • Coupling between services

2.3 Strategy 3: Versioned Keys#

Include a version in the cache key. When data changes, increment the version.

VERSIONED KEYS
  • Instead of: cache.set("product:123", data)
  • Use: cache.set("product:123:v5", data)

When product changes:

  • Increment version: v5 → v6
  • New key: "product:123:v6"
  • Old key "product:123:v5" still exists but never accessed
  • Old key expires via TTL
Timeline
  • T0: Version = 5, cache key = "product:123:v5"
  • T1: Product updated, version = 6
  • T2: Read uses key "product:123:v6" - MISS (new key)
  • T3: Load from DB, cache as "product:123:v6"
  • T4: Old "product:123:v5" expires naturally

Implementation:

pythonVersionedCache · 92 lines
# Versioned Key Invalidation

class VersionedCache:
    """
    Cache with versioned keys.
    
    Version is stored separately and incremented on updates.
    Old versions expire naturally via TTL.
    """
    
    def __init__(self, redis_client, default_ttl: int = 3600):
        self.redis = redis_client
        self.default_ttl = default_ttl
    
    async def get_version(self, entity: str, entity_id: str) -> int:
        """Get current version for an entity."""
        version_key = f"version:{entity}:{entity_id}"
        version = await self.redis.get(version_key)
        # A missing key reads as 0: INCR creates it at 0 and returns 1, so the first
        # increment must move to a new version (a default of 1 would never change the key)
        return int(version) if version else 0
    
    async def increment_version(self, entity: str, entity_id: str) -> int:
        """Increment version (effectively invalidates cache)."""
        version_key = f"version:{entity}:{entity_id}"
        new_version = await self.redis.incr(version_key)
        return new_version
    
    def _build_key(self, entity: str, entity_id: str, version: int) -> str:
        """Build versioned cache key."""
        return f"{entity}:{entity_id}:v{version}"
    
    async def get(self, entity: str, entity_id: str) -> Optional[dict]:
        """Get cached value using current version."""
        version = await self.get_version(entity, entity_id)
        cache_key = self._build_key(entity, entity_id, version)
        
        cached = await self.redis.get(cache_key)
        if cached:
            return json.loads(cached)
        return None
    
    async def set(self, entity: str, entity_id: str, value: dict) -> None:
        """Set cached value with current version."""
        version = await self.get_version(entity, entity_id)
        cache_key = self._build_key(entity, entity_id, version)
        
        await self.redis.setex(
            cache_key,
            self.default_ttl,
            json.dumps(value)
        )
    
    async def invalidate(self, entity: str, entity_id: str) -> int:
        """Invalidate by incrementing version."""
        return await self.increment_version(entity, entity_id)


# Usage
class ProductRepository:
    def __init__(self, db, cache: VersionedCache):
        self.db = db
        self.cache = cache
    
    async def get_product(self, product_id: str) -> dict:
        # Try cache with current version
        cached = await self.cache.get("product", product_id)
        if cached:
            return cached
        
        # Load from database
        product = await self.db.fetch_one(
            "SELECT * FROM products WHERE id = $1",
            product_id
        )
        
        # Cache with current version
        await self.cache.set("product", product_id, dict(product))
        return dict(product)
    
    async def update_product(self, product_id: str, data: dict) -> dict:
        # Update database
        product = await self.db.fetch_one(
            "UPDATE products SET ... WHERE id = $1 RETURNING *",
            product_id
        )
        
        # Invalidate by incrementing version
        # Next read will miss (new version key doesn't exist)
        await self.cache.invalidate("product", product_id)
        
        return dict(product)

Global Version for Bulk Invalidation:

pythonGlobalVersionCache · 25 lines
# Invalidate ALL products at once (e.g., after bulk import)

class GlobalVersionCache:
    """Cache with global version for bulk invalidation."""
    
    async def get_global_version(self, entity: str) -> int:
        """Global version affects all entities of this type."""
        version = await self.redis.get(f"global_version:{entity}")
        # A missing key reads as 0: INCR creates it at 0 and returns 1, so the first
        # increment must move to a new version (a default of 1 would never change the key)
        return int(version) if version else 0
    
    async def increment_global_version(self, entity: str) -> int:
        """Invalidate ALL cached entities of this type."""
        return await self.redis.incr(f"global_version:{entity}")
    
    def _build_key(self, entity: str, entity_id: str, 
                   entity_version: int, global_version: int) -> str:
        """Key includes both entity and global version."""
        return f"{entity}:{entity_id}:v{entity_version}:g{global_version}"


# Usage: After bulk product import
await cache.increment_global_version("product")
# All product cache keys now miss (global version changed)

Pros:

  • No explicit deletion needed
  • Atomic invalidation (version increment is atomic)
  • Supports bulk invalidation via global version
  • Old data expires naturally

Cons:

  • More complex key management
  • Storage overhead (old versions remain until TTL)
  • Need to track version numbers
  • Extra Redis call to get version
Your turn · checkpoint 2not answered

Two admins, one price

Two admins change the same price a few milliseconds apart: A sets $90, B sets $80, and B's database write commits last. Each request writes its new value into the cache right after its database write. What can the cache end up holding? Would deleting the key instead of writing it fix every race?

Think it through, or jot it down. Then compare.

One strong answer

  • The cache can hold $90. The two cache writes race independently of the database commits, so A's slower cache write can land after B's and overwrite the correct $80.
  • Deleting fixes this write-write race: two deletes leave the key empty and the next read loads $80.
  • It doesn't fix every race. A reader that loaded the old row before the update can still write it back after the delete. The TTL bounds that window; leases or a delayed second delete close it.

2.4 Strategy 4: Write-Invalidate vs Write-Update#

When data changes, should you delete or update the cache?

Delete or update the cache?

WRITE-INVALIDATE (Delete)

On write:

  1. Update database
  2. DELETE cache entry

Next read:

  1. Cache miss
  2. Load from database
  3. Populate cache

WRITE-UPDATE (Update)

On write:

  1. Update database
  2. SET cache entry with new value

Next read:

  1. Cache hit (already updated)

Which is better?

The Race Condition Problem with Write-Update:

RACE CONDITION WITH WRITE-UPDATE

Two concurrent requests update the same product:

  • Request A (price = $90): T1 Read from DB (price = $100) · T2 Update DB to $90 · T4 Update cache to $90 ← Happens LATER
  • Request B (price = $80): T1.5 Read from DB (price = $100) · T3 Update DB to $80 · T3.5 Update cache to $80 ← Happens FIRST
Timeline
  • T1: A reads DB ($100)
  • T1.5: B reads DB ($100)
  • T2: A writes DB ($90)
  • T3: B writes DB ($80) ← DB now has $80 (correct)
  • T3.5: B updates cache ($80)
  • T4: A updates cache ($90) ← Cache now has $90 (WRONG!)

Result: Database: $80 (correct - last write wins) · Cache: $90 (STALE - from earlier update)

WITH WRITE-INVALIDATE
  • T3: B writes DB ($80)
  • T3.5: B deletes cache
  • T4: A deletes cache ← Idempotent, no problem

Result: Database: $80 · Cache: empty (next read will load fresh). No write-write race. A read-write race remains: a reader that loaded the old row before the update can still SET it after the DELETE. The TTL bounds that window; leases (Section 7.1) or a delayed second delete close it.

ExplainerWrite-update vs write-invalidate: the race

Requests A ($90) and B ($80) update the same product and B commits last, so the database holds $80. With write-update B sets the cache to $80, then A's slower SET leaves a stale $90. With write-invalidate both just delete the key, which is harmless twice, and the next read loads $80.

Implementation Comparison:

pythonupdate_product_invalidate · 34 lines
# Write-Invalidate (Recommended)

async def update_product_invalidate(product_id: str, data: dict):
    """Update database, then DELETE cache."""
    
    # 1. Update database
    product = await db.fetch_one(
        "UPDATE products SET price = $2 WHERE id = $1 RETURNING *",
        product_id, data['price']
    )
    
    # 2. Delete cache (invalidate)
    await cache.delete(f"product:{product_id}")
    
    # Next read will miss and load fresh data
    return dict(product)


# Write-Update (Use with caution)

async def update_product_update(product_id: str, data: dict):
    """Update database, then UPDATE cache."""
    
    # 1. Update database
    product = await db.fetch_one(
        "UPDATE products SET price = $2 WHERE id = $1 RETURNING *",
        product_id, data['price']
    )
    
    # 2. Update cache with new value
    # RISK: Race condition if concurrent updates!
    await cache.set(f"product:{product_id}", dict(product), ttl=300)
    
    return dict(product)

When Write-Update Is Safe:

pythonincrement_view_count · 17 lines
# Write-Update is safe when:
# 1. Single writer (no concurrent updates)
# 2. Using atomic operations
# 3. Data doesn't have complex dependencies

# Example: Incrementing a counter (atomic operation)
async def increment_view_count(product_id: str):
    """Atomic increment - safe for concurrent updates."""
    
    # Each increment is atomic on its own (the pair is not one transaction)
    await db.execute(
        "UPDATE products SET view_count = view_count + 1 WHERE id = $1",
        product_id
    )
    
    # INCR is atomic in Redis
    await cache.incr(f"product:{product_id}:views")

Recommendation:

Quick check3 questions · +5 XP each

Invalidation strategies

  1. Q1/3 Two requests update the same price concurrently with write-update: A sets $90, B sets $80. The database ends at $80 because B committed last. What can the cache end up holding?

    Answer & explanation

    Answer: D

    With write-update, the two cache SETs race independently of the database commits, so the slower A can overwrite B's correct $80 with a stale $90. With write-invalidate both requests just DELETE, which is idempotent, so the next read loads $80 from the database.

  2. Q2/3 After a bulk import of 2 million products, every cached product must be treated as stale right away. Which approach does the lesson recommend?

    Answer & explanation

    Answer: A

    One atomic INCR changes the key every reader builds, so all old entries become unreachable at once and expire on their own. Scanning or publishing millions of deletes is slow and heavy at scale, and a tiny TTL only affects entries written after the change.

  3. Q3/3 What is the main per-read cost of per-entity versioned keys?

    Answer & explanation

    Answer: B

    The reader has to know the current version to build the key, so it first GETs version:product:123, then GETs the data key: two calls instead of one. Old versions also linger in memory until their TTL, which is the storage-side cost.

Chapter 3: Hybrid Strategies#

Your turn · checkpoint 3not answered

Events plus a 5-minute TTL

Price events invalidate the cache in about 100–500 ms, and every price key also has a 5-minute TTL. Suppose 1 event in 100 is lost. How stale is a price in the usual case, and in the worst case? What would the worst case be without the TTL?

Think it through, or jot it down. Then compare.

One strong answer

  • Usually: 99 changes in 100 are visible within about half a second, once the consumer deletes the key.
  • Worst case: a lost event leaves the old price until the entry expires, so up to 5 minutes. The TTL is the cap on staleness.
  • Without the TTL: a lost event leaves the old price in the cache indefinitely.

3.1 TTL + Event-Driven (The Safety Net Pattern)#

Combine TTL with event-driven for best of both worlds.

SAFETY NET PATTERN
  • Primary: Event-driven invalidation (fast, precise)
  • Fallback: Short TTL (catches missed events)

Event published → Cache invalidated in ~100ms · Event lost → Cache expires in 5 minutes anyway

You get:

  • Near real-time freshness (99% of the time)
  • Guaranteed maximum staleness (TTL)
  • Self-healing (even if events fail)

Implementation:

pythonSafetyNetCache · 56 lines
# Safety Net Pattern: Event-driven + TTL
from datetime import datetime, timezone


class SafetyNetCache:
    """
    Event-driven invalidation with TTL safety net.
    
    - Events provide fast invalidation
    - TTL ensures stale data eventually expires
    - Best of both worlds
    """
    
    def __init__(self, redis_client, kafka_producer, safety_ttl: int = 300):
        self.redis = redis_client
        self.kafka = kafka_producer
        self.safety_ttl = safety_ttl  # Maximum staleness
    
    async def set(self, key: str, value: any) -> None:
        """Cache with safety TTL."""
        await self.redis.setex(
            key,
            self.safety_ttl,  # Even without invalidation, expires in 5 min
            json.dumps(value)
        )
    
    async def invalidate(self, key: str) -> None:
        """Explicit invalidation via event."""
        await self.redis.delete(key)
    
    async def update_product(self, product_id: str, data: dict):
        """Update with event-driven invalidation."""
        
        # Update database
        product = await self.db.fetch_one(
            "UPDATE products SET ... WHERE id = $1 RETURNING *",
            product_id
        )
        
        # Publish invalidation event
        await self.kafka.send("cache-invalidation", {
            "type": "invalidate",
            "keys": [f"product:{product_id}"],
            "timestamp": datetime.now(timezone.utc).isoformat()
        })
        
        # Even if event is lost, TTL ensures max 5 min staleness
        return dict(product)


# Invalidation consumer
async def handle_invalidation_event(event: dict):
    """Process invalidation events."""
    for key in event.get("keys", []):
        await redis.delete(key)
        logger.info(f"Invalidated {key}")

3.2 Different Strategies for Different Data#

MULTI-STRATEGY APPROACH — Product Catalog
  • product.name, product.description
    • TTL: 1 hour — rarely changes
  • product.price
    • Event-driven + TTL 5 min — changes occasionally, freshness matters
  • product.inventory
    • Event-driven + TTL 30 sec — changes frequently, accuracy critical
  • product.images
    • TTL: 24 hours — almost never changes

Implementation:

pythonInvalidationStrategy · 108 lines
# Multi-Strategy Cache Configuration

from dataclasses import dataclass
from enum import Enum
from typing import Optional, List


class InvalidationStrategy(Enum):
    TTL_ONLY = "ttl_only"
    EVENT_DRIVEN = "event_driven"
    VERSIONED = "versioned"
    WRITE_THROUGH = "write_through"


@dataclass
class CachePolicy:
    """Defines caching behavior for a data type."""
    strategy: InvalidationStrategy
    ttl: int
    event_topics: List[str] = None
    version_key: str = None


# Define policies for different data types
CACHE_POLICIES = {
    "product_details": CachePolicy(
        strategy=InvalidationStrategy.TTL_ONLY,
        ttl=3600  # 1 hour - rarely changes
    ),
    
    "product_price": CachePolicy(
        strategy=InvalidationStrategy.EVENT_DRIVEN,
        ttl=300,  # 5 min safety net
        event_topics=["price-changes", "flash-sales"]
    ),
    
    "product_inventory": CachePolicy(
        strategy=InvalidationStrategy.EVENT_DRIVEN,
        ttl=30,  # 30 sec safety net - changes frequently
        event_topics=["inventory-updates"]
    ),
    
    "user_session": CachePolicy(
        strategy=InvalidationStrategy.TTL_ONLY,
        ttl=1800  # 30 min sliding expiration
    ),
    
    "homepage_content": CachePolicy(
        strategy=InvalidationStrategy.VERSIONED,
        ttl=86400,  # 24 hours
        version_key="homepage_version"
    ),
}


class SmartCache:
    """
    Cache that applies different strategies based on data type.
    """
    
    def __init__(self, redis_client, policies: dict = CACHE_POLICIES):
        self.redis = redis_client
        self.policies = policies
    
    def get_policy(self, data_type: str) -> CachePolicy:
        """Get caching policy for data type."""
        return self.policies.get(data_type, CachePolicy(
            strategy=InvalidationStrategy.TTL_ONLY,
            ttl=300  # Default 5 min
        ))
    
    async def get(self, data_type: str, key: str) -> Optional[dict]:
        """Get from cache using appropriate strategy."""
        policy = self.get_policy(data_type)
        
        if policy.strategy == InvalidationStrategy.VERSIONED:
            return await self._get_versioned(data_type, key, policy)
        else:
            return await self._get_simple(data_type, key)
    
    async def set(self, data_type: str, key: str, value: dict) -> None:
        """Set in cache using appropriate strategy."""
        policy = self.get_policy(data_type)
        
        if policy.strategy == InvalidationStrategy.VERSIONED:
            await self._set_versioned(data_type, key, value, policy)
        else:
            await self._set_simple(data_type, key, value, policy.ttl)
    
    async def _get_simple(self, data_type: str, key: str) -> Optional[dict]:
        cache_key = f"{data_type}:{key}"
        cached = await self.redis.get(cache_key)
        return json.loads(cached) if cached else None
    
    async def _set_simple(self, data_type: str, key: str, value: dict, ttl: int):
        cache_key = f"{data_type}:{key}"
        await self.redis.setex(cache_key, ttl, json.dumps(value))
    
    async def _get_versioned(self, data_type: str, key: str, policy: CachePolicy):
        version = int(await self.redis.get(policy.version_key) or 0)
        cache_key = f"{data_type}:{key}:v{version}"
        cached = await self.redis.get(cache_key)
        return json.loads(cached) if cached else None
    
    async def _set_versioned(self, data_type: str, key: str, value: dict, policy: CachePolicy):
        version = int(await self.redis.get(policy.version_key) or 0)
        cache_key = f"{data_type}:{key}:v{version}"
        await self.redis.setex(cache_key, policy.ttl, json.dumps(value))

3.3 Strategy Comparison#

Strategy Freshness Complexity Use Case
TTL only Minutes Low Static content, tolerant reads
Event-driven Seconds High Prices, inventory, critical data
Versioned Seconds Medium Bulk updates, deployments
TTL + Events Seconds Medium-High Best balance for most systems
Write-through Immediate Medium Critical single-writer data
Quick check2 questions · +5 XP each

Hybrid strategies

  1. Q1/2 You use event-driven invalidation with a 5-minute safety TTL. A broker failure drops one price-change event. What is the worst case?

    Answer & explanation

    Answer: C

    Events give fast invalidation in the normal case; the TTL caps staleness when an event goes missing, so the system heals itself. Without the TTL, a lost event would leave the stale price in place indefinitely.

  2. Q2/2 A product's description changes monthly, its price changes a few times a day (including flash sales), and its inventory changes with every purchase. Which policy assignment follows the lesson's "match strategy to staleness tolerance" principle?

    Answer & explanation

    Answer: B

    Rarely changing, low-impact data can live on TTL alone; data where freshness affects trust or overselling gets events plus a TTL sized to its tolerance. Using the strictest policy everywhere adds event plumbing and misses for data that does not need it.

Part II · Stage 2 of 4 · 10 min

Part II: Implementation#

Chapter 4: Building an Invalidation System#

4.1 Requirements#

A production invalidation system needs:

  1. Multiple strategies — Different data needs different freshness
  2. Event handling — Process invalidation events reliably
  3. Bulk operations — Invalidate many keys efficiently
  4. Monitoring — Track invalidation success/failure
  5. Failure handling — Don't lose invalidations

4.2 Production Implementation#

pythonInvalidationConfig · 378 lines
# Production Cache Invalidation System

import asyncio
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Dict, List, Optional, Set
from enum import Enum

logger = logging.getLogger(__name__)


# =============================================================================
# Configuration
# =============================================================================

@dataclass
class InvalidationConfig:
    """Configuration for invalidation system."""
    
    # Event processing
    event_topic: str = "cache-invalidation"
    consumer_group: str = "cache-invalidator"
    batch_size: int = 100
    
    # Safety
    max_keys_per_invalidation: int = 1000
    enable_pattern_delete: bool = False  # Dangerous at scale
    
    # Monitoring
    enable_metrics: bool = True
    slow_invalidation_threshold_ms: float = 100


# =============================================================================
# Invalidation Events
# =============================================================================

class InvalidationType(Enum):
    DELETE = "delete"           # Delete specific keys
    PATTERN = "pattern"         # Delete keys matching pattern
    VERSION = "version"         # Increment version
    REFRESH = "refresh"         # Delete and pre-warm


@dataclass
class InvalidationEvent:
    """Event requesting cache invalidation."""
    type: InvalidationType
    keys: List[str] = field(default_factory=list)
    pattern: Optional[str] = None
    version_key: Optional[str] = None
    source: str = "unknown"
    timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
    correlation_id: Optional[str] = None
    
    def to_dict(self) -> dict:
        return {
            "type": self.type.value,
            "keys": self.keys,
            "pattern": self.pattern,
            "version_key": self.version_key,
            "source": self.source,
            "timestamp": self.timestamp.isoformat(),
            "correlation_id": self.correlation_id
        }
    
    @classmethod
    def from_dict(cls, data: dict) -> 'InvalidationEvent':
        return cls(
            type=InvalidationType(data["type"]),
            keys=data.get("keys", []),
            pattern=data.get("pattern"),
            version_key=data.get("version_key"),
            source=data.get("source", "unknown"),
            timestamp=datetime.fromisoformat(data["timestamp"]),
            correlation_id=data.get("correlation_id")
        )


# =============================================================================
# Invalidation Publisher (Use in application services)
# =============================================================================

class InvalidationPublisher:
    """
    Publishes cache invalidation events.
    
    Use this in your application services when data changes.
    """
    
    def __init__(self, kafka_producer, config: InvalidationConfig):
        self.kafka = kafka_producer
        self.config = config
    
    async def invalidate_keys(
        self,
        keys: List[str],
        source: str = "application",
        correlation_id: str = None
    ) -> None:
        """Invalidate specific cache keys."""
        if len(keys) > self.config.max_keys_per_invalidation:
            # Split into batches
            for i in range(0, len(keys), self.config.max_keys_per_invalidation):
                batch = keys[i:i + self.config.max_keys_per_invalidation]
                await self._publish(InvalidationEvent(
                    type=InvalidationType.DELETE,
                    keys=batch,
                    source=source,
                    correlation_id=correlation_id
                ))
        else:
            await self._publish(InvalidationEvent(
                type=InvalidationType.DELETE,
                keys=keys,
                source=source,
                correlation_id=correlation_id
            ))
    
    async def invalidate_pattern(
        self,
        pattern: str,
        source: str = "application"
    ) -> None:
        """Invalidate keys matching pattern (use sparingly!)."""
        if not self.config.enable_pattern_delete:
            raise ValueError("Pattern delete is disabled in config")
        
        await self._publish(InvalidationEvent(
            type=InvalidationType.PATTERN,
            pattern=pattern,
            source=source
        ))
    
    async def increment_version(
        self,
        version_key: str,
        source: str = "application"
    ) -> None:
        """Increment a version key for versioned invalidation."""
        await self._publish(InvalidationEvent(
            type=InvalidationType.VERSION,
            version_key=version_key,
            source=source
        ))
    
    async def _publish(self, event: InvalidationEvent) -> None:
        """Publish event to Kafka."""
        await self.kafka.send(
            self.config.event_topic,
            value=json.dumps(event.to_dict()).encode()
        )
        logger.debug(f"Published invalidation event: {event.type.value}")


# =============================================================================
# Invalidation Consumer (Background service)
# =============================================================================

class InvalidationConsumer:
    """
    Consumes and processes cache invalidation events.
    
    Run this as a separate service or background worker.
    """
    
    def __init__(
        self,
        kafka_consumer,
        redis_client,
        config: InvalidationConfig,
        metrics_client = None
    ):
        self.kafka = kafka_consumer
        self.redis = redis_client
        self.config = config
        self.metrics = metrics_client
        
        self._running = False
    
    async def start(self):
        """Start consuming invalidation events."""
        self._running = True
        logger.info("Invalidation consumer started")
        
        while self._running:
            # Wait at most 100ms for up to batch_size events, so a lone
            # invalidation is never held back waiting for the batch to fill
            records = await self.kafka.getmany(
                timeout_ms=100, max_records=self.config.batch_size
            )
            
            batch = []
            for messages in records.values():
                for message in messages:
                    try:
                        batch.append(InvalidationEvent.from_dict(
                            json.loads(message.value.decode())
                        ))
                    except Exception as e:
                        logger.error(f"Failed to parse event: {e}")
                        # Malformed event - log (or DLQ) and continue
            
            if batch:
                await self._process_batch(batch)
                await self.kafka.commit()
    
    async def stop(self):
        """Stop the consumer."""
        self._running = False
        logger.info("Invalidation consumer stopped")
    
    async def _process_batch(self, events: List[InvalidationEvent]) -> None:
        """Process a batch of invalidation events."""
        start_time = datetime.now(timezone.utc)
        
        # Group by type for efficient processing
        delete_keys: Set[str] = set()
        patterns: List[str] = []
        version_keys: List[str] = []
        
        for event in events:
            if event.type == InvalidationType.DELETE:
                delete_keys.update(event.keys)
            elif event.type == InvalidationType.PATTERN:
                patterns.append(event.pattern)
            elif event.type == InvalidationType.VERSION:
                version_keys.append(event.version_key)
        
        # Process deletions in bulk
        if delete_keys:
            await self._delete_keys(list(delete_keys))
        
        # Process patterns (one at a time - expensive!)
        for pattern in patterns:
            await self._delete_pattern(pattern)
        
        # Process version increments
        for version_key in version_keys:
            await self._increment_version(version_key)
        
        # Record metrics
        duration_ms = (datetime.now(timezone.utc) - start_time).total_seconds() * 1000
        
        if self.metrics:
            self.metrics.timing("invalidation.batch_duration_ms", duration_ms)
            self.metrics.increment("invalidation.keys_deleted", len(delete_keys))
            self.metrics.increment("invalidation.events_processed", len(events))
        
        if duration_ms > self.config.slow_invalidation_threshold_ms:
            logger.warning(f"Slow invalidation batch: {duration_ms:.2f}ms")
    
    async def _delete_keys(self, keys: List[str]) -> int:
        """Delete multiple keys efficiently."""
        if not keys:
            return 0
        
        # Use pipeline for bulk delete
        pipe = self.redis.pipeline()
        for key in keys:
            pipe.delete(key)
        results = await pipe.execute()
        
        deleted = sum(1 for r in results if r)
        logger.debug(f"Deleted {deleted}/{len(keys)} keys")
        return deleted
    
    async def _delete_pattern(self, pattern: str) -> int:
        """Delete keys matching pattern (use sparingly!)."""
        # SCAN to find matching keys (non-blocking)
        deleted = 0
        cursor = 0
        
        while True:
            cursor, keys = await self.redis.scan(
                cursor=cursor,
                match=pattern,
                count=100
            )
            
            if keys:
                await self.redis.delete(*keys)
                deleted += len(keys)
            
            if cursor == 0:
                break
        
        logger.info(f"Pattern delete '{pattern}' removed {deleted} keys")
        return deleted
    
    async def _increment_version(self, version_key: str) -> int:
        """Increment a version key."""
        new_version = await self.redis.incr(version_key)
        logger.debug(f"Incremented {version_key} to {new_version}")
        return new_version


# =============================================================================
# Helper: Invalidation Decorator
# =============================================================================

def invalidate_cache(*keys: str, publisher: InvalidationPublisher = None):
    """
    Decorator that invalidates cache after function execution.
    
    Usage:
        @invalidate_cache("product:{product_id}", "category:{category_id}")
        async def update_product(product_id: str, category_id: str, data: dict):
            ...
    """
    def decorator(func):
        async def wrapper(*args, **kwargs):
            result = await func(*args, **kwargs)
            
            # Format keys with function arguments
            formatted_keys = []
            for key_template in keys:
                try:
                    formatted_key = key_template.format(**kwargs)
                    formatted_keys.append(formatted_key)
                except KeyError:
                    logger.warning(f"Could not format key template: {key_template}")
            
            if formatted_keys and publisher:
                await publisher.invalidate_keys(
                    formatted_keys,
                    source=func.__name__
                )
            
            return result
        return wrapper
    return decorator


# =============================================================================
# Usage Example
# =============================================================================

# In your product service:
class ProductService:
    def __init__(self, db, cache, invalidation: InvalidationPublisher):
        self.db = db
        self.cache = cache
        self.invalidation = invalidation
    
    async def update_product(self, product_id: str, data: dict) -> dict:
        # Get old product for category info
        old_product = await self.db.fetch_one(
            "SELECT category_id FROM products WHERE id = $1",
            product_id
        )
        
        # Update database
        product = await self.db.fetch_one(
            "UPDATE products SET ... WHERE id = $1 RETURNING *",
            product_id
        )
        
        # Invalidate all related caches
        await self.invalidation.invalidate_keys([
            f"product:{product_id}",
            f"product_page:{product_id}",
            f"category:{old_product['category_id']}",
        ], source="ProductService.update_product")
        
        return dict(product)
    
    async def bulk_import(self, products: List[dict]) -> int:
        """Import many products - use version increment."""
        # Import to database
        count = await self._bulk_insert(products)
        
        # Invalidate all product caches via version
        await self.invalidation.increment_version("product_cache_version")
        
        return count

Chapter 5: Edge Cases and Error Handling#

5.1 Edge Case 1: Invalidation Event Lost#

SCENARIO: Kafka loses the invalidation event
Timeline
  • T0: Product price updated in DB ($100 → $79)
  • T1: Invalidation event published to Kafka
  • T2: Kafka broker fails, event lost!
  • T3: Cache still has old price ($100)
  • T4: Users see wrong price until TTL expires
SOLUTION: Safety net TTL

Even with event-driven invalidation, always set a TTL:

  • Events handle 99% of invalidations (fast)
  • TTL handles the 1% where events fail (safety)

Staleness ≈ event latency when the event arrives; if it is lost, staleness is bounded by the TTL (worst case = TTL).

5.2 Edge Case 2: Thundering Herd After Invalidation#

SCENARIO: Popular item cache invalidated during peak traffic
Timeline
  • T0: Flash sale starts, price updated
  • T1: Cache invalidated for product:123
  • T2: 10,000 concurrent requests for product:123
  • T3: All 10,000 hit database simultaneously!
  • T4: Database overwhelmed
SOLUTION: Request coalescing (covered in Day 3)

For now, awareness is key:

  • Invalidation can trigger thundering herd
  • Popular items need extra protection
  • Consider "soft" invalidation (mark stale, refresh async)
Your turn · checkpoint 4not answered

Delete inside the transaction

A developer deletes the cache key inside the database transaction, just before it commits. Another request reads the product in that gap. What does the cache hold after the commit, and how long does that last?

Think it through, or jot it down. Then compare.

One strong answer

  • Before commit, other readers still see the old row. The second request misses, reads the old price and puts it back in the cache.
  • After commit the database has the new price but the cache has the old one, and nothing will delete it again: it stays stale until the TTL expires.
  • Fix: commit first, then invalidate.

5.3 Edge Case 3: Cache and Database Inconsistency#

SCENARIO: Invalidation happens before DB transaction commits
Dangerous order
  • T0: Begin transaction
  • T1: Update product in DB (not committed)
  • T2: Invalidate cache
  • T3: Another request reads DB (sees OLD value)
  • T4: Populates cache with OLD value
  • T5: Transaction commits (DB now has NEW value)
  • T6: Cache has OLD value, DB has NEW value!
SOLUTION: Invalidate AFTER commit
pythonupdate_product · 12 lines
async def update_product(product_id: str, data: dict):
    async with db.transaction() as tx:
        product = await tx.fetch_one(
            "UPDATE products SET ... WHERE id = $1 RETURNING *",
            product_id
        )
    # Transaction committed!

    # NOW invalidate cache
    await cache.delete(f"product:{product_id}")

    return dict(product)

5.4 Edge Case 4: Partial Invalidation Failure#

SCENARIO: Need to invalidate 5 keys, 2 fail
Key to invalidate Result
product:123 ok — Success
product_page:123 ok — Success
category:456 no — Redis timeout
search:results no — Redis timeout
deals:page ok — Success
SOLUTION: Retry with idempotency

Invalidation is idempotent - safe to retry! DEL on a non-existent key = success

pythoninvalidate_with_retry · 24 lines
async def invalidate_with_retry(keys: List[str], max_retries: int = 3):
    remaining = set(keys)

    for attempt in range(max_retries):
        failed = []

        for key in remaining:
            try:
                await redis.delete(key)
            except Exception as e:
                failed.append(key)
                logger.warning(f"Invalidation failed for {key}: {e}")

        remaining = set(failed)

        if not remaining:
            return  # All succeeded

        # Exponential backoff
        await asyncio.sleep(0.1 * (2 ** attempt))

    # Some still failed - alert!
    logger.error(f"Failed to invalidate keys after {max_retries} attempts: {remaining}")
    # Consider: DLQ, manual intervention alert

5.5 Error Handling Matrix#

Error Impact Handling Prevention
Event lost Stale data until TTL TTL safety net Kafka replication
Redis timeout Invalidation delayed Retry with backoff Redis cluster
Pattern delete slow Blocks other invalidations Avoid patterns, batch instead Don't use patterns
Version key lost Wrong version used Rebuild from DB Persist versions
Consumer crash Events pile up Auto-restart, resume from offset Health checks
Quick check3 questions · +5 XP each

Building the invalidation system

  1. Q1/3 A developer calls cache.delete(...) inside the database transaction, before it commits. Another request reads the product in that gap. What happens?

    Answer & explanation

    Answer: C

    Before commit, other readers still see the old row, so the miss refills the cache with old data and the update is hidden until the TTL expires. The fix is ordering: commit first, then invalidate.

  2. Q2/3 An invalidation for 5 keys partly fails: 3 deletes succeed and 2 time out. What is the right response?

    Answer & explanation

    Answer: A

    Deleting a key that is already gone still succeeds, so retrying is harmless; alert or DLQ whatever still fails, and rely on the TTL as the last line. Undoing the database write or restoring stale entries makes things worse, not more consistent.

  3. Q3/3 Why does the production config ship with enable_pattern_delete = False?

    Answer & explanation

    Answer: D

    Even with non-blocking SCAN, matching a pattern means walking millions of keys, and the consumer processes patterns one at a time. Tracking dependent keys explicitly (e.g. a set per category) turns this into a few direct deletes.

Part III · Stage 3 of 4 · 4 min

Part III: Real-World Application#

Chapter 7: How Big Tech Does It#

7.1 Case Study: Facebook — Invalidation at Scale#

Scale:

  • Billions of cache operations per second
  • Distributed across multiple datacenters
  • Consistency across regions matters

System: McRouter + TAO

DiagramFacebook's cache invalidation
%% title: Facebook's cache invalidation
flowchart TD
  W["Write Request"]:::client
  TAO["Write to MySQL<br/>(via TAO / web tier)"]:::core
  MR["McRouter (Memcached Router)<br/>Broadcasts deletes to cache clusters"]:::cache
  W --> TAO
  TAO -->|"mcsqueal tails the commit log<br/>and emits deletes"| MR

Key innovation: LEASE-BASED INVALIDATION

Problem: Race between invalidation and cache fill

Solution: Leases

  1. On cache miss, get a "lease" (token)
  2. Load from database
  3. Set cache with lease token
  4. If lease was invalidated, set fails!

This prevents stale data from being cached after invalidation.

7.2 Case Study: Netflix — Event-Driven Invalidation (simplified)#

System: EVCache + Event-Driven (TTL values below are illustrative)

DiagramNetflix cache invalidation
%% title: Netflix cache invalidation
flowchart TD
  CU["Content Update"]:::client
  CS["Content Service<br/>Update DB<br/>Publish Event"]:::core
  CI["Cache Invalidator<br/>Subscribe to content events<br/>Invalidate EVCache"]:::service
  CU --> CS
  CS -.->|"Kafka Event"| CI

Key features:

  1. ZONE-AWARE INVALIDATION — Each availability zone has its own cache. Invalidation sent to ALL zones.
  2. TTL AS SAFETY NET — Entries carry a TTL (e.g. 24 hours). Events handle almost all invalidations; the TTL catches the rest.
  3. VERSIONED CONTENT — Content has version numbers. Cache key includes version. New version = automatic miss.
Your turn · checkpoint 5not answered

One entry per product, or several?

A product's inventory changes with every purchase, its price a few times a day, and its description about once a month. You cache the whole product as one entry. What happens to your hit ratio, and how would you lay out the cache instead?

Think it through, or jot it down. Then compare.

One strong answer

  • One entry: every purchase invalidates the whole product, so the description and reviews are reloaded constantly even though they didn't change, and the hit ratio drops.
  • Split it: product:{id}:details, :price, :inventory and :reviews, each with its own TTL and events (inventory 30 s plus events, details for hours). An inventory change then leaves the description alone.
  • Cost: the page reads several keys, fetched in parallel.

7.3 Case Study: Granular Product Invalidation (Amazon-style, illustrative)#

PRODUCT CACHE INVALIDATION
Challenge

Different parts of product change at different rates:

  • Inventory: every purchase
  • Price: hourly/daily
  • Description: weekly/monthly
Solution: DECOMPOSED CACHING

Instead of one cache entry per product:

Key TTL
product:123:details 24 hours
product:123:price 5 min + events
product:123:inventory 30 sec + events
product:123:reviews 1 hour

Benefits:

  • Inventory change doesn't invalidate description
  • Price change doesn't invalidate reviews
  • Each piece has appropriate freshness
Implementation
pythonget_product_page · 18 lines
async def get_product_page(product_id: str) -> dict:
    # Parallel fetch of decomposed cache
    details, price, inventory, reviews = await asyncio.gather(
        cache.get(f"product:{product_id}:details"),
        cache.get(f"product:{product_id}:price"),
        cache.get(f"product:{product_id}:inventory"),
        cache.get(f"product:{product_id}:reviews"),
    )

    # Load missing pieces from DB
    # ...

    return {
        "details": details,
        "price": price,
        "inventory": inventory,
        "reviews": reviews
    }

7.4 Summary: Industry Patterns#

Company Strategy Key Innovation
Facebook Lease-based Prevents stale fills
Netflix Event-driven + TTL Zone-aware
Amazon-style catalog Decomposed caching Granular invalidation
Twitter Fan-out on write Precomputed timelines
Ride-hailing Short TTL Real-time location

Chapter 8: Common Mistakes to Avoid#

Forgetting related caches

WRONG: Only invalidate direct cache

pythonupdate_product · 4 lines
async def update_product(product_id: str, data: dict):
    await db.update(product_id, data)
    await cache.delete(f"product:{product_id}")
    # FORGOT: category listing, search results, homepage!

CORRECT: Invalidate all related caches

pythonupdate_product · 16 lines
async def update_product(product_id: str, data: dict):
    # Get current product for related info
    old_product = await db.get(product_id)

    # Update database
    await db.update(product_id, data)

    # Invalidate ALL related caches
    await invalidation.invalidate_keys([
        f"product:{product_id}",
        f"product_page:{product_id}",
        f"category:{old_product['category_id']}",
        f"brand:{old_product['brand_id']}",
        # If price changed, also deals page
        *(["deals:page"] if data.get('price') else []),
    ])

8.2 Mistake 2: Invalidating Before Commit#

Invalidating before commit

WRONG: Invalidate inside transaction

pythonupdate_product · 6 lines
async def update_product(product_id: str, data: dict):
    async with db.transaction() as tx:
        await tx.update(product_id, data)
        await cache.delete(f"product:{product_id}")  # BAD!
        # Before commit, another request can re-cache the OLD row,
        # which then stays stale after the commit

CORRECT: Invalidate after commit

pythonupdate_product · 6 lines
async def update_product(product_id: str, data: dict):
    async with db.transaction() as tx:
        await tx.update(product_id, data)

    # Transaction committed - NOW invalidate
    await cache.delete(f"product:{product_id}")

8.3 Mistake 3: Using Pattern Delete at Scale#

Using pattern delete at scale

WRONG: Pattern delete in hot path

pythonupdate_category · 6 lines
async def update_category(category_id: str, data: dict):
    await db.update(category_id, data)

    # This scans ALL keys in Redis!
    await redis.delete_pattern(f"product:*:category:{category_id}")
    # At scale: millions of keys walked with SCAN, one pass per update, and the cache gets slow (KEYS would block it outright)

CORRECT: Track keys explicitly

pythoncache_product · 13 lines
# Maintain a set of keys per category
async def cache_product(product_id: str, category_id: str, data: dict):
    await cache.set(f"product:{product_id}", data)
    # Track this product in category's key set
    await redis.sadd(f"category_products:{category_id}", f"product:{product_id}")

async def invalidate_category(category_id: str):
    # Get tracked keys
    product_keys = await redis.smembers(f"category_products:{category_id}")
    # Delete specific keys (fast!)
    if product_keys:
        await redis.delete(*product_keys)
    await redis.delete(f"category_products:{category_id}")

8.4 Mistake 4: No TTL Safety Net#

No TTL safety net

WRONG: Event-driven only, no TTL

pythoncache_product · 5 lines
async def cache_product(product_id: str, data: dict):
    # No TTL! Relies entirely on events
    await redis.set(f"product:{product_id}", json.dumps(data))

# If event is lost, data is stale FOREVER

CORRECT: Always have TTL safety net

pythoncache_product · 7 lines
async def cache_product(product_id: str, data: dict):
    # TTL as safety net - even if event lost, max staleness is 5 min
    await redis.setex(
        f"product:{product_id}",
        300,  # 5 minute safety TTL
        json.dumps(data)
    )

8.5 Mistake Checklist#

Before deploying invalidation strategy:

Before deploying invalidation strategy0/7 ready
  • Related caches identified — All caches affected by each data change
  • Invalidation after commit — Never inside transaction
  • TTL safety net — Even with events, have TTL fallback
  • No pattern deletes — Use explicit key tracking instead
  • Retry logic — Invalidation should retry on failure
  • Monitoring — Track invalidation latency and failures
  • Documented dependencies — Map data → cache relationships
Quick check3 questions · +5 XP each

Invalidation in the real world

  1. Q1/3 A reader misses, reads the old row, and is about to fill the cache. Meanwhile a writer updates the row and invalidates the key. How do Facebook-style leases stop the reader from caching stale data?

    Answer & explanation

    Answer: B

    The lease ties the fill to the miss that issued it. If an invalidation happens in between, the lease is invalid and the stale fill fails. Locks would serialize readers and writers and hurt throughput; leases solve the race without blocking.

  2. Q2/3 Why split a product into details, price, inventory and reviews cache entries instead of one?

    Answer & explanation

    Answer: C

    Decomposed caching gives each piece its own freshness: inventory churns with short TTL plus events, while details sit for hours. With one combined entry, every purchase would invalidate the description and reviews too, which tanks the hit ratio.

  3. Q3/3 After a price change, the product page shows the new price but the category listing still shows the old one for minutes. What's the most likely bug?

    Answer & explanation

    Answer: D

    A product appears in many cached views (page, category, search, deals). Each one that embeds the price must be invalidated too, ideally from a documented dependency map. The product page is correct, so the product key's invalidation worked; the gap is in the related caches.

Part IV · Stage 4 of 4 · 10 min

Part IV: Interview Preparation#

Chapter 9: Interview Tips and Phrases#

9.1 When to Discuss Invalidation#

Bring up invalidation when:

  • Designing any caching layer
  • Data freshness is mentioned as a requirement
  • Interviewer asks "what if data changes?"
  • System involves prices, inventory, or real-time data
❯ Say this

9.2 Key Phrases to Use#

Key phrases
INTRODUCING INVALIDATION

"For caching, the key question is invalidation. We need to ensure users see fresh data while still benefiting from the cache. Let me walk through our options."

EXPLAINING TTL

"The simplest approach is TTL-based expiration. With a 5-minute TTL, we accept up to 5 minutes of staleness in exchange for simplicity. This works well for data like product descriptions that rarely change."

EXPLAINING EVENT-DRIVEN

"For price changes during a flash sale, TTL isn't fast enough. I'd use event-driven invalidation: when the price updates, we publish an event, and a consumer invalidates the cache within milliseconds. Combined with a short TTL as a safety net, we get near real-time freshness."

DISCUSSING TRADE-OFFS

"The trade-off is freshness versus complexity. TTL is simple but stale. Event-driven is fresh but requires infrastructure. For this system, I'd use event-driven for prices and inventory, but TTL-only for product descriptions."

HANDLING FAILURE QUESTIONS

"If the invalidation event is lost—maybe Kafka has an issue—the TTL safety net kicks in. Data might be stale for up to 5 minutes instead of milliseconds, but it won't be stale forever. That's why I always combine events with TTL."

9.3 Questions to Ask Interviewer#

  • "How fresh does this data need to be? Seconds, minutes, hours?"
  • "What's the acceptable user experience if they see slightly stale data?"
  • "How frequently does this data change?"
  • "Is there existing event infrastructure we can leverage?"

9.4 Common Follow-up Questions#

Question Good Answer
"What if you need to invalidate millions of keys?" "I'd use versioned keys with a global version. Incrementing one version key effectively invalidates everything. Old entries expire via TTL."
"How do you handle cache and DB getting out of sync?" "Always invalidate after transaction commits. Use TTL as safety net. If we detect inconsistency, we can force refresh by bumping the version."
"What about thundering herd after invalidation?" "That's a risk. For popular items, I'd use request coalescing—multiple requests for the same key share a single database fetch. Also consider 'soft' invalidation with async refresh."
"How do you test invalidation?" "Integration tests that verify cache state after updates. Load tests that simulate concurrent updates. Chaos testing that kills the invalidation consumer."

Chapter 10: Practice Problems#

Practice · problem 1

Problem 1: E-commerce Price Invalidation#

Setup: Design cache invalidation for an e-commerce site with flash sales. When a flash sale starts, 10,000 products have price changes that must be visible to users within 1 second.

Requirements:

  • 10,000 products in a flash sale
  • Price must update within 1 second
  • 100,000 users may be viewing products during sale start
  • Can't overwhelm the database

Questions:

  1. How would you invalidate 10,000 product caches quickly?
  2. How do you prevent thundering herd when everyone reloads?
  3. What's your fallback if invalidation fails?
Click for hints
  • Can you update cache instead of invalidate?
  • Consider bulk operations
  • Think about cache warming before the sale
Click for solution outline

Approach: Pre-compute + Write-Update + Fallback

pythonstart_flash_sale · 41 lines
async def start_flash_sale(sale_id: str, product_prices: Dict[str, Decimal]):
    """
    Start flash sale with immediate price visibility.
    """
    # BEFORE SALE: Pre-warm cache with new prices
    # Scheduled 1 second before sale start
    await pre_warm_flash_sale_prices(sale_id, product_prices)
    
    # AT SALE START: Atomic version flip
    await redis.set("active_flash_sale", sale_id)
    
    # Products check for active sale and use sale price


async def pre_warm_flash_sale_prices(sale_id: str, prices: Dict):
    """Pre-warm cache with flash sale prices."""
    pipe = redis.pipeline()
    
    for product_id, price in prices.items():
        # Store sale price separately
        pipe.setex(
            f"flash_price:{sale_id}:{product_id}",
            3600,  # 1 hour (sale duration)
            str(price)
        )
    
    await pipe.execute()  # Bulk write - fast!


async def get_product_price(product_id: str) -> Decimal:
    """Get price, checking for active flash sale."""
    # Check for active flash sale
    sale_id = await redis.get("active_flash_sale")
    
    if sale_id:
        sale_price = await redis.get(f"flash_price:{sale_id}:{product_id}")
        if sale_price:
            return Decimal(sale_price)
    
    # No sale or product not in sale - use regular price
    return await get_cached_price(product_id)

Thundering Herd Prevention:

  • Pre-warm cache BEFORE sale starts
  • No invalidation needed at sale start
  • Just flip a single flag

Fallback:

  • If pre-warm fails, fall back to database
  • Short TTL (30 sec) ensures fresh prices eventually
  • Can retry pre-warm in background

Practice · problem 2

Problem 2: Social Media Feed Invalidation#

Setup: Design invalidation for a social media feed cache. When a user posts, all their followers should see the post in their feeds. User has between 1 and 10 million followers.

Requirements:

  • Post visible in followers' feeds within 30 seconds
  • Can't invalidate 10 million cache entries synchronously
  • Feed should always load fast (<100ms)

Questions:

  1. How do you handle invalidation for users with millions of followers?
  2. What's different about invalidating a "celebrity" vs regular user?
  3. How do you balance freshness and performance?
Click for hints
  • Consider hybrid push/pull model
  • Not all followers are online
  • Think about which feeds actually need immediate updates
Click for solution outline

Approach: Hybrid Push/Pull with Activity-Based Invalidation

pythonon_user_post · 44 lines
# Follower tiers
CELEBRITY_THRESHOLD = 10_000  # Followers

async def on_user_post(user_id: str, post: dict):
    """Handle new post - different strategies by follower count."""
    
    follower_count = await get_follower_count(user_id)
    
    if follower_count < CELEBRITY_THRESHOLD:
        # Regular user: Push to all followers
        await push_to_follower_feeds(user_id, post)
    else:
        # Celebrity: Don't push, pull on read
        await mark_celebrity_post(user_id, post)


async def push_to_follower_feeds(user_id: str, post: dict):
    """Push post to all follower feed caches."""
    followers = await get_followers(user_id)
    
    # Only push to ACTIVE followers (online recently)
    active_followers = [f for f in followers if await is_active(f)]
    
    # Invalidate their feed caches
    keys = [f"feed:{f}" for f in active_followers]
    await invalidation.invalidate_keys(keys)
    
    # Inactive followers will get fresh feed on next login


async def get_feed(user_id: str) -> List[dict]:
    """Get feed, merging cached + celebrity posts."""
    
    # Get cached feed (pushed posts from regular users)
    cached_feed = await cache.get(f"feed:{user_id}")
    
    # Get celebrity posts (pulled on read)
    celebrity_posts = await get_followed_celebrity_posts(user_id)
    
    # Merge and sort
    all_posts = (cached_feed or []) + celebrity_posts
    all_posts.sort(key=lambda p: p['timestamp'], reverse=True)
    
    return all_posts[:50]  # Top 50 posts
  • Don't invalidate inactive users' caches
  • Celebrity posts pulled on read (no push amplification)
  • Regular users' posts pushed to active followers
  • Hybrid approach scales to any follower count

Practice · problem 3

Problem 3: Multi-Region Cache Invalidation#

Setup: Your application runs in 3 regions (US, EU, Asia). Each region has its own Redis cluster. When data changes, all regions must invalidate within 5 seconds.

Requirements:

  • Write happens in one region
  • All 3 regions must invalidate
  • Network between regions has ~100ms latency
  • If one region's Redis is down, others should still invalidate

Questions:

  1. How do you propagate invalidation across regions?
  2. How do you handle a region being temporarily unreachable?
  3. How do you ensure consistency across regions?
Click for hints
  • Consider async event propagation
  • Each region needs independent processing
  • Think about conflict resolution
Click for solution outline

Approach: Event Bus with Regional Consumers

pythoninvalidate_global · 50 lines
# Global event bus (Kafka with multi-region replication)
from datetime import datetime, timezone

from cachetools import TTLCache  # in-process cache with a size cap and per-entry TTL (not the Redis-backed class above)


async def invalidate_global(keys: List[str], source_region: str):
    """Publish invalidation to all regions."""
    
    event = {
        "type": "invalidate",
        "keys": keys,
        "source_region": source_region,
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "id": str(uuid.uuid4())
    }
    
    # Publish to global topic (replicated to all regions)
    await kafka.send("global-cache-invalidation", event)


# Each region runs its own consumer
class RegionalInvalidationConsumer:
    def __init__(self, region: str, redis_client):
        self.region = region
        self.redis = redis_client
        self.processed_ids = TTLCache(maxsize=100_000, ttl=300)  # Dedup
    
    async def process(self, event: dict):
        # Deduplication (event might arrive multiple times)
        if event["id"] in self.processed_ids:
            return
        self.processed_ids[event["id"]] = True
        
        # Invalidate in this region
        await self.redis.delete(*event["keys"])
        
        logger.info(f"Region {self.region} invalidated {len(event['keys'])} keys")


# Regional Redis failure handling
async def invalidate_with_fallback(redis_client, keys: List[str]):
    try:
        await redis_client.delete(*keys)
    except RedisError as e:
        logger.error(f"Regional Redis down: {e}")
        # Store invalidation for retry when Redis recovers
        await store_pending_invalidation(keys)
        # Alert ops team
        await alert("Regional cache invalidation failed")

Architecture:

DiagramCross-region invalidation
%% title: Cross-region invalidation
flowchart TD
  W@{ icon: "sd:user", label: "Write in US-East" }
  K@{ icon: "tech:kafka", label: "Global Kafka Topic<br/>(replicated to all regions)" }
  USC["US Consumer"]:::service
  EUC["EU Consumer"]:::service
  ASC["Asia Consumer"]:::service
  USR@{ icon: "tech:redis", label: "US Redis" }
  EUR@{ icon: "tech:redis", label: "EU Redis" }
  ASR@{ icon: "tech:redis", label: "Asia Redis" }
  W --> K
  K -.-> USC
  K -.-> EUC
  K -.-> ASC
  USC --> USR
  EUC --> EUR
  ASC --> ASR
  class W client
  class K queue
  class USR,EUR,ASR cache
  • Global event bus handles cross-region propagation
  • Each region processes independently
  • Deduplication prevents double-processing
  • Pending queue handles temporary Redis outage
  • 5 second target achieved with Kafka replication

Chapter 11: Mock Interview Dialogue#

Scenario: Design Invalidation for a News Site#

Transcript
Interviewer

"We're building a news site. Articles are written by journalists, edited, then published. Once published, they might be updated with corrections. How would you handle cache invalidation?"

You

"Good question. Let me understand the data flow first.

So an article goes through: draft → editing → published → possibly updated. Users only see published articles. How frequently are published articles updated?"

Interviewer

"Breaking news might get updates every few minutes. Regular articles maybe once a day if there's a correction."

You

"Got it. And what's the traffic pattern? I assume there's a power law—some articles get millions of views, most get very few?"

Interviewer

"Exactly. Top 100 articles might get 90% of traffic."

You

"Perfect. Here's my approach:

For invalidation strategy, I'd use event-driven invalidation with TTL safety net.

When an article is updated:

  1. Editor saves changes to database
  2. CMS publishes an 'article.updated' event to Kafka
  3. Cache invalidation consumer receives event
  4. Deletes the article cache entry

For the TTL, I'd use 5 minutes. Most articles don't change often, so a 5-minute stale window is acceptable. But the event-driven invalidation means most updates are visible within seconds.

Transcript
Interviewer

"What about the homepage that shows the latest articles?"

You

"The homepage is different. It's not one article—it's a computed view of 'top 10 articles right now.' I'd handle it differently:

For the homepage, I'd use short TTL with background refresh. TTL of 60 seconds, and a background job that refreshes the cache every 30 seconds. This means:

  • Homepage is always served from cache (fast)
  • Never more than 30 seconds stale
  • No thundering herd because background job refreshes it

For the 'breaking news' banner specifically, I might use even shorter TTL—10 seconds—or even skip caching entirely since it's a single small query.

Transcript
Interviewer

"What if an article is published with a typo and the editor fixes it immediately? The cache has the typo for 5 minutes?"

You

"That's where event-driven invalidation helps. The flow is:

  1. Article published with typo
  2. Cache populated with typo
  3. Editor notices, fixes typo
  4. 'article.updated' event published
  5. Cache invalidated within milliseconds
  6. Next reader gets corrected version

The 5-minute TTL is just a safety net for if the event is lost. In normal operation, the event invalidates the cache almost immediately.

To make it even faster, I could update the cache directly instead of invalidating:

pythonon_article_updated · 9 lines
async def on_article_updated(event):
    article_id = event['article_id']
    
    # Option A: Invalidate (requires DB hit on next read)
    await cache.delete(f"article:{article_id}")
    
    # Option B: Update (no DB hit, faster for readers)
    article = event['data']  # Include full article in event
    await cache.set(f"article:{article_id}", article, ttl=300)

I'd use Option B for breaking news articles where every second counts."

Interviewer

"How do you handle a situation where the CMS is down but you need to invalidate cache?"

You

"That's an important failure scenario. A few options:

  1. TTL as fallback — Even without CMS, cache expires in 5 minutes. Not ideal but bounded staleness.

  2. Manual invalidation tool — Build an admin endpoint that ops can call directly:

    text2 lines
    POST /admin/invalidate
    {"keys": ["article:12345"]}
  3. Version-based emergency invalidation — If we need to invalidate ALL articles (major bug, security issue):

    python3 lines
    # Increment global version
    await redis.incr("article_cache_version")
    # All article cache keys now miss

The version approach is like a nuclear option—use it rarely, but it's good to have."

Summary#

DAY 2 KEY TAKEAWAYS
CORE PROBLEM
  • Cache invalidation is hard because cache doesn't know when data changes
  • Balance freshness, simplicity, and performance
  • Different data needs different strategies
INVALIDATION STRATEGIES
Strategy Traits Use for
TTL-Based Simplest approach · Data stale for up to TTL duration Static content, low-change data
Event-Driven Near real-time freshness · Requires event infrastructure Prices, inventory, critical data
Versioned Keys Increment version to invalidate · Good for bulk invalidation Deployments, bulk imports
Hybrid (TTL + Events) Best of both worlds · Events for speed, TTL for safety Most production systems
KEY DECISIONS
  • Write-invalidate (delete) safer than write-update
  • Always invalidate AFTER transaction commits
  • Always have TTL safety net with event-driven
COMMON MISTAKES
  • Forgetting related caches
  • Invalidating inside transaction
  • Pattern delete at scale
  • No TTL safety net
DEFAULT CHOICE
  • Event-driven + 5 minute TTL safety net
  • Write-invalidate (delete on change)
  • Decomposed caching for different freshness needs

Further Reading#

Official Documentation#

Engineering Blogs#

  • Read

    Facebook TAO: "TAO: Facebook's Distributed Data Store for the Social Graph"

  • Read

    Netflix EVCache: "Caching for a Global Netflix"

  • Blog

    Uber Schemaless: "Designing Schemaless, Uber Engineering's Scalable Datastore Using MySQL"

Papers#

Books#

  • Book

    "Designing Data-Intensive Applications" by Martin Kleppmann — Chapter 5

this lesson in shorts · 3

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 02 · Invalidation Strategies
Finish Day 2Stages clear as you read them (0/4 so far). Skipping ahead doesn't count: marking complete unlocks after ~8 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

MetaBlog post · ~15 min

Cache made consistent

Lu Pan · 2022
What
How Meta measured and improved cache consistency in TAO and Memcache.
Key idea
Invalidation races leave stale entries; a monitoring service (Polaris) checks cache against the database, which took TAO from six nines to ten nines of consistency.
Why here
Shows why this lesson calls invalidation the hard part: you must measure staleness, not assume invalidations always arrive in order.
taught here: Invalidation races
UberBlog post · ~18 min

How Uber Serves Over 40 Million Reads Per Second from Online Storage Using an Integrated Cache

Eli Pozniansky, Piyush Patel, Preetham Narayanareddy · 2024
What
Uber's Redis cache built into its Docstore database layer.
Key idea
TTL alone was too stale, so Flux tails the MySQL binlog (change data capture) and invalidates or refreshes cached rows shortly after each commit.
Why here
The hybrid strategy this lesson recommends: TTL as a safety net plus CDC-driven invalidation for freshness.
taught here: Event-driven invalidation

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…