~learn.
?

Day 0145 minPreface+60 XP

Caching Patterns.

4 stages27 code

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

Preface · 1 min

Preface#

It's Monday morning. Your e-commerce site is crawling.

INCIDENT TIMELINE
  • 09:00 —
    Marketing launches flash sale
  • 09:01 —
    Traffic spikes 10x
  • 09:02 —
    Database CPU hits 100%
  • 09:03 —
    Response times: 200ms → 5,000ms
  • 09:05 —
    First timeout errors
  • 09:10 —
    Site effectively down
  • 09:15 —
    Revenue loss: $50,000 and counting
  • Post-incident analysis —
    Every product page = 5 database queries · 10,000 requests/sec × 5 queries = 50,000 queries/sec · Database capacity: 5,000 queries/sec · Result: 10x overload, cascade failure
  • The fix everyone suggests —
    "Just add Redis!"

But here's what they don't tell you:

  • Which caching pattern should you use?
  • When do you write to cache vs read from cache?
  • What happens when cache and database disagree?
  • How do you handle cache failures?

"Just add Redis" is not a strategy. It's a prayer.

This week, we turn caching from prayer into engineering.

Today, we start with the foundations: the four caching patterns that every system uses. By the end of today, you'll know exactly which pattern to use for any scenario.

ExplainerThe whole lesson: caching patterns

A flash sale sent 10,000 requests a second, 5 queries each, at a database that takes 5,000 queries a second; Redis answers in 0.5–2 ms and takes 100,000+ operations a second. Cache-aside loads on a miss, read-through lets the cache load, write-through writes cache and database synchronously, and write-behind returns early and batches writes, risking loss. Real stores mix them with TTLs by staleness, and handle Redis outages with a circuit breaker, failed invalidations with a TTL, and cold starts by warming the top 10,000 products.

Part I · Stage 1 of 4 · 14 min

Part I: Foundations#

Chapter 1: What Is Caching?#

1.1 The Simple Definition#

Caching is storing copies of data in a faster storage layer to reduce access time and load on the primary data store.

EVERYDAY ANALOGY: Your Kitchen

Database = Grocery store

  • Has everything
  • Takes time to get there
  • Always has fresh stock

Cache = Refrigerator

  • Limited space
  • Instant access
  • Food might be stale

You don't go to the grocery store every time you want milk. You check the fridge first. If it's there and not expired, you use it. If it's not there or expired, you go to the store. That's caching.

1.2 Why Cache?#

THE THREE REASONS TO CACHE
1. LATENCY
Layer Latency
Database query 10-100ms
Redis lookup 0.5-2ms
Local memory 0.0001ms

For user experience, milliseconds matter.

2. THROUGHPUT
Layer Throughput
Database 5,000 queries/sec (limited by disk, connections)
Redis 100,000+ ops/sec (limited by network)
Local memory Millions of ops/sec

Cache absorbs traffic that would overwhelm the database.

3. COST

Database queries cost: CPU time · Disk I/O · Network bandwidth · Connection pool slots

Serving from cache is dramatically cheaper.

WorksheetFlash-sale morning with a cache (assumptions labelled)
Value
Page requests at the 09:01 peak (from the incident) 10,000/s
Database queries per page (from the incident) 5 → 50,000 queries/s
Database capacity (from the incident) 5,000 queries/s
Hit ratio needed just to stay under capacity 1 − 5,000 ÷ 50,000 = 90%
Cache hit ratio (assumed) 95% → 2,500 queries/s, half of capacity
Cache hit ratio (assumed, well-tuned) 99% → 500 queries/s, a tenth of capacity
Products in the catalog (assumed) 1 million
Cached size per product (assumed) 5 KB → ~5 GB of Redis for the whole catalog

Conclusion: at this peak the cache isn't only a speed-up: below a 90% hit ratio the database falls over. A cold cache, a mass expiry or a dead Redis all push the hit ratio toward zero, which is why failure handling matters as much as the pattern you pick.

1.3 The Cache Hierarchy#

The cache hierarchy

MEMORY HIERARCHY (Fastest → Slowest)

Level Latency Size
CPU Registers < 1 ns Bytes
L1 Cache ~1 ns KB
L2 Cache ~4 ns KB
L3 Cache ~12 ns MB
RAM ~100 ns GB
SSD ~100 μs TB
HDD ~10 ms TB
Network ~1-100 ms Unlimited

APPLICATION CACHE HIERARCHY

Level Latency Capacity
In-Process Cache (HashMap, Guava) < 0.1 ms Limited by heap
Distributed Cache (Redis, Memcached) 0.5-2 ms Limited by cluster
Database (PostgreSQL, etc) 10-100 ms Limited by disk

1.4 Key Terminology#

Term Definition
Cache Hit Requested data found in cache
Cache Miss Requested data not in cache, must fetch from source
Hit Ratio Hits / (Hits + Misses) — higher is better
TTL Time-To-Live — how long before cache entry expires
Eviction Removing entries when cache is full
Invalidation Removing or updating stale entries
Cache Stampede Many requests hitting database after cache expires
Cold Cache Cache with no data (after restart or deploy)
Warm Cache Cache populated with frequently accessed data

Chapter 2: The Four Caching Patterns#

Your turn · checkpoint 1not answered

Who fetches on a miss?

Your product page reads through Redis in front of PostgreSQL. When a key is missing, should the application query the database itself, or should the cache do it for you? Predict what happens to product reads in each design if the whole Redis cluster dies.

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

One strong answer

  • Application fetches (cache-aside): the app checks Redis, queries the database on a miss and writes the result back. A dead Redis just looks like a miss, so pages get slower but stay up.
  • Cache fetches (read-through): application code gets simpler because it only talks to the cache, but the cache becomes a critical dependency: if it's down, reads have no path to the database.
  • So cache-aside is the default when you want resilience to cache failures.

2.1 Pattern 1: Cache-Aside (Lazy Loading)#

The most common pattern. Application manages both cache and database.

DiagramCache-aside flow (read path)
%% title: Cache-aside flow (read path)
sequenceDiagram
  participant A as Application
  participant C as Cache
  participant DB as Database
  A->>C: 1. Check cache
  alt 2a. HIT
    C-->>A: Return cached data
  else 2b. MISS
    C-->>A: miss
    A->>DB: 3. Fetch from database
    DB-->>A: data
    A->>C: 4. Write to cache
  end
  Note over A: 5. Return to caller

Write path: the application writes directly to the database. Then either:

  • a) Invalidate cache (delete key)
  • b) Update cache (write new value)

How it works:

  1. Application checks cache first
  2. If hit, return cached value
  3. If miss, query database
  4. Store result in cache for future requests
  5. Return result to caller

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.

pythonCacheAsideRepository · 72 lines
# Cache-Aside Implementation

class CacheAsideRepository:
    """
    Cache-aside pattern: Application manages cache explicitly.
    
    Also known as "lazy loading" because cache is only populated
    on read, not proactively.
    """
    
    def __init__(self, cache_client, db_client, default_ttl: int = 300):
        self.cache = cache_client
        self.db = db_client
        self.default_ttl = default_ttl
    
    async def get_product(self, product_id: str) -> dict:
        """
        Get product with cache-aside pattern.
        
        1. Check cache
        2. If miss, load from DB
        3. Populate cache
        4. Return
        """
        cache_key = f"product:{product_id}"
        
        # Step 1: Check cache
        cached = await self.cache.get(cache_key)
        if cached:
            return json.loads(cached)
        
        # Step 2: Cache miss - load from database
        product = await self.db.fetch_one(
            "SELECT * FROM products WHERE id = $1",
            product_id
        )
        
        if not product:
            return None
        
        # Step 3: Populate cache for next time
        await self.cache.setex(
            cache_key,
            self.default_ttl,
            json.dumps(dict(product))
        )
        
        # Step 4: Return
        return dict(product)
    
    async def update_product(self, product_id: str, data: dict) -> dict:
        """
        Update product - write to DB, invalidate cache.
        
        We invalidate rather than update to avoid race conditions.
        """
        # Write to database
        product = await self.db.fetch_one(
            """
            UPDATE products 
            SET name = $2, price = $3, updated_at = NOW()
            WHERE id = $1
            RETURNING *
            """,
            product_id, data['name'], data['price']
        )
        
        # Invalidate cache (not update!)
        cache_key = f"product:{product_id}"
        await self.cache.delete(cache_key)
        
        return dict(product)

Pros:

  • Simple to understand and implement
  • Only caches data that's actually used
  • Cache failure doesn't break the application (graceful degradation)
  • Works with any database

Cons:

  • First request for any data is always slow (cache miss)
  • Potential for stale data if invalidation fails
  • Application code coupled to caching logic

Use when:

  • Read-heavy workloads
  • Can tolerate slightly stale data
  • Want resilience to cache failures

2.2 Pattern 2: Read-Through#

Cache sits between application and database. Cache handles loading.

DiagramRead-through flow (read path)
%% title: Read-through flow (read path)
sequenceDiagram
  participant A as Application
  participant C as Cache
  participant DB as Database
  A->>C: 1. Request data
  alt 2a. HIT
    C-->>A: Return
  else 2b. MISS
    C->>DB: Load from DB (on miss)
    DB-->>C: data
    Note over C: Store in cache
    C-->>A: Return
  end

Application doesn't know about database on reads. Cache is responsible for loading data.

How it works:

  1. Application requests data from cache
  2. Cache checks if data exists
  3. If hit, cache returns data
  4. If miss, cache fetches from database, stores it, then returns
  5. Application only talks to cache, never directly to database for reads
pythonReadThroughCache · 62 lines
# Read-Through Implementation (Cache-side)

class ReadThroughCache:
    """
    Read-through cache: Cache is responsible for loading data.
    
    The application only interacts with the cache, not the database.
    Cache handles misses by loading from the database.
    """
    
    def __init__(self, db_client, default_ttl: int = 300):
        self.db = db_client
        self.default_ttl = default_ttl
        self._cache = {}  # In real impl, this is Redis with loader
    
    async def get(self, key: str, loader_query: str = None) -> any:
        """
        Get value, loading from database if not cached.
        
        In production, this is typically configured at the cache level,
        not per-request. Libraries like Caffeine (Java) or dogpile (Python)
        support this pattern natively.
        """
        # Check cache
        if key in self._cache:
            entry = self._cache[key]
            if not self._is_expired(entry):
                return entry['value']
        
        # Cache miss - load from database
        if loader_query:
            value = await self.db.fetch_one(loader_query)
        else:
            value = await self._default_loader(key)
        
        # Store in cache
        self._cache[key] = {
            'value': value,
            'expires_at': time.time() + self.default_ttl
        }
        
        return value
    
    async def _default_loader(self, key: str) -> any:
        """Default loader parses key to determine query."""
        # key format: "entity:id" e.g., "product:123"
        entity, id = key.split(':')
        query = f"SELECT * FROM {entity}s WHERE id = $1"
        return await self.db.fetch_one(query, id)
    
    def _is_expired(self, entry: dict) -> bool:
        return time.time() > entry['expires_at']


# Application code is simpler
class ProductService:
    def __init__(self, cache: ReadThroughCache):
        self.cache = cache
    
    async def get_product(self, product_id: str) -> dict:
        # Application doesn't know about database!
        return await self.cache.get(f"product:{product_id}")

Pros:

  • Simpler application code (just talks to cache)
  • Consistent data loading logic
  • Cache handles all the complexity

Cons:

  • Cache becomes a critical dependency
  • Harder to debug (abstraction hides database access)
  • Need cache that supports read-through (or custom implementation)

Use when:

  • Want to simplify application code
  • Using a cache that supports read-through natively
  • Database access patterns are uniform

2.3 Pattern 3: Write-Through#

Writes go to cache first, which synchronously writes to database.

DiagramWrite-through flow (write path)
%% title: Write-through flow (write path)
sequenceDiagram
  participant A as Application
  participant C as Cache
  participant DB as Database
  A->>C: 1. Write data
  C->>DB: 2. Write to database
  DB-->>C: Database write ok
  Note over C: 3. Store in cache
  C-->>A: 4. Return success

Both cache and database are updated synchronously. Write only succeeds when both succeed.

How it works:

  1. Application writes to cache
  2. Cache synchronously writes to database
  3. Cache stores the value locally
  4. Returns success only after both operations complete
pythonWriteThroughCache · 65 lines
# Write-Through Implementation

class WriteThroughCache:
    """
    Write-through cache: Writes go through cache to database.
    
    Keeps the cache in step with the database on every write
    (concurrent writers or a failed cache write can still leave it stale).
    Trade-off: Higher write latency.
    """
    
    def __init__(self, cache_client, db_client, default_ttl: int = 300):
        self.cache = cache_client
        self.db = db_client
        self.default_ttl = default_ttl
    
    async def set(self, key: str, value: any, db_query: str, db_params: tuple) -> bool:
        """
        Write-through: Update database, then cache.
        
        Both must succeed for the write to be considered successful.
        """
        try:
            # Step 1: Write to database FIRST
            await self.db.execute(db_query, *db_params)
            
            # Step 2: Write to cache
            await self.cache.setex(
                key,
                self.default_ttl,
                json.dumps(value)
            )
            
            return True
            
        except Exception as e:
            # If DB write fails, don't update cache
            # If cache write fails after DB write, we have inconsistency!
            # In production, may need to invalidate cache here
            logger.error(f"Write-through failed: {e}")
            raise
    
    async def get(self, key: str) -> any:
        """Read from cache (with read-through on miss)."""
        cached = await self.cache.get(key)
        if cached:
            return json.loads(cached)
        
        # Miss - need to load from database
        # (combine with read-through for complete solution)
        return None


# Usage
class ProductService:
    def __init__(self, write_through_cache: WriteThroughCache):
        self.cache = write_through_cache
    
    async def update_product(self, product_id: str, data: dict) -> bool:
        return await self.cache.set(
            key=f"product:{product_id}",
            value=data,
            db_query="UPDATE products SET name=$2, price=$3 WHERE id=$1",
            db_params=(product_id, data['name'], data['price'])
        )

Pros:

  • Cache is updated on every write, so it tracks the database closely
  • Stale reads are rare (concurrent writers or a failed cache write can still leave an old value)
  • Simpler mental model (cache ≈ database)

Cons:

  • Higher write latency (must write to both)
  • Write availability depends on both cache AND database
  • If cache write fails after DB write, inconsistency possible

Use when:

  • Consistency is critical (financial data, inventory counts)
  • Write frequency is low to moderate
  • Can tolerate slightly higher write latency

2.4 Pattern 4: Write-Behind (Write-Back)#

Writes go to cache immediately; database is updated asynchronously.

DiagramWrite-behind flow (write path)
%% title: Write-behind flow (write path)
sequenceDiagram
  participant A as Application
  participant C as Cache
  participant W as Background Writer
  participant DB as Database
  A->>C: 1. Write data
  Note over C: 2. Store in cache
  C-->>A: 3. Return immediately
  C-)W: 4. (async) Queue DB write
  W->>DB: 5. Batch writes to DB

Write returns immediately after cache update. Database is updated asynchronously, often in batches.

How it works:

  1. Application writes to cache
  2. Cache stores value and returns immediately
  3. Write is queued for database
  4. Background process batches and writes to database
  5. Database eventually consistent with cache
pythonPendingWrite · 131 lines
# Write-Behind Implementation

import asyncio
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Dict, List


@dataclass
class PendingWrite:
    key: str
    value: any
    db_query: str
    db_params: tuple
    queued_at: datetime


class WriteBehindCache:
    """
    Write-behind cache: Writes are cached immediately,
    database is updated asynchronously.
    
    WARNING: Risk of data loss if cache fails before DB write!
    """
    
    def __init__(
        self,
        cache_client,
        db_client,
        batch_size: int = 100,
        flush_interval: float = 1.0
    ):
        self.cache = cache_client
        self.db = db_client
        self.batch_size = batch_size
        self.flush_interval = flush_interval
        
        # Pending writes queue
        self._pending: Dict[str, PendingWrite] = {}
        self._running = False
    
    async def start(self):
        """Start the background writer."""
        self._running = True
        # keep a reference: the event loop only holds weak references to tasks
        self._flush_task = asyncio.create_task(self._flush_loop())
    
    async def stop(self):
        """Stop and flush remaining writes."""
        self._running = False
        await self._flush_to_database()
    
    async def set(self, key: str, value: any, db_query: str, db_params: tuple) -> bool:
        """
        Write-behind: Update cache immediately, queue DB write.
        
        Returns immediately after cache update - very fast!
        """
        # Step 1: Write to cache immediately
        await self.cache.set(key, json.dumps(value))
        
        # Step 2: Queue for database write
        # If same key written multiple times, only latest is kept
        self._pending[key] = PendingWrite(
            key=key,
            value=value,
            db_query=db_query,
            db_params=db_params,
            queued_at=datetime.now(timezone.utc)
        )
        
        # Step 3: Return immediately (don't wait for DB)
        return True
    
    async def _flush_loop(self):
        """Background loop that flushes writes to database."""
        while self._running:
            await asyncio.sleep(self.flush_interval)
            
            if self._pending:
                await self._flush_to_database()
    
    async def _flush_to_database(self):
        """Flush pending writes to database in batch."""
        if not self._pending:
            return
        
        # Take a snapshot of pending writes
        writes = list(self._pending.values())[:self.batch_size]
        keys_to_remove = [w.key for w in writes]
        
        try:
            # Batch write to database
            async with self.db.transaction():
                for write in writes:
                    await self.db.execute(write.db_query, *write.db_params)
            
            # Remove from pending queue
            for key in keys_to_remove:
                self._pending.pop(key, None)
            
            logger.info(f"Flushed {len(writes)} writes to database")
            
        except Exception as e:
            # DB write failed - writes stay in pending queue
            # They'll be retried on next flush
            logger.error(f"Database flush failed: {e}")
            # In production: alert, retry with backoff, eventual DLQ


# Usage - note how fast writes are
class AnalyticsService:
    """Write-behind is great for high-volume, loss-tolerant writes."""
    
    def __init__(self, write_behind_cache: WriteBehindCache):
        self.cache = write_behind_cache
    
    async def record_view_count(self, video_id: str, view_count: int):
        """
        Persist a video's latest view count (e.g. from a Redis INCR).
        Updated many times per second; we can tolerate some loss,
        but need low latency.
        """
        await self.cache.set(
            key=f"views:{video_id}",  # one key per video: repeated writes coalesce
            value={"video_id": video_id, "views": view_count},
            db_query="UPDATE videos SET view_count = $2 WHERE id = $1",
            db_params=(video_id, view_count)
        )
        # Returns immediately; only the latest count per video reaches the DB

Pros:

  • Extremely fast writes (just cache update)
  • Can batch database writes for efficiency
  • Reduces database write load significantly

Cons:

  • Data loss risk if cache fails before database write
  • Complex failure handling
  • Database may be behind cache (eventual consistency)

Use when:

  • Write performance is critical
  • Can tolerate some data loss
  • High write volume where batching helps
  • Examples: Analytics, logging, view counts, likes

Chapter 3: Pattern Comparison and Trade-offs#

3.1 Side-by-Side Comparison#

Aspect Cache-Aside Read-Through Write-Through Write-Behind
Read latency Fast (hit) / Slow (miss) Fast (hit) / Slow (miss) Fast for written keys (misses after eviction/TTL) Fast for written keys (misses after eviction/TTL)
Write latency DB latency DB latency Cache + DB Cache only
Consistency Eventual Eventual Fresh after writes (races possible) Eventual
Data loss risk Low Low Low High
Complexity Low Medium Medium High
Best for General use Uniform access Critical data High write volume
ExplainerCache-aside, write-through, write-behind

Cache-aside: GET /products/123 misses Redis, the app reads the database (~50 ms) and caches product:123 with a 300 s TTL, so the next read is a ~2 ms hit. Write-through writes the cache and, synchronously, the database before returning. Write-behind returns after the cache write and batches database writes later, losing queued writes if the cache dies first.

3.2 Decision Matrix#

DiagramChoosing a caching pattern
%% title: Choosing a caching pattern
flowchart TD
  Q{"What matters most?<br/>(start here)"}:::core
  Q --> S["Simplicity"]
  Q --> CN["Consistency"]
  Q --> P["Performance"]
  S --> CA["Cache-Aside<br/>Simple, works for most cases"]:::ok
  CN --> WT["Write-Through<br/>+ Read-Through"]:::ok
  P --> RH["Read-heavy"]
  P --> WH["Write-heavy"]
  P --> BL["Balanced, but<br/>loss-tolerant"]
  RH --> CR["Cache-Aside<br/>or Read-Through"]:::ok
  WH --> WB1["Write-Behind<br/>Fast writes, accept risk"]:::queue
  BL --> WB2["Write-Behind<br/>With careful failure handling"]:::queue
  class S,CN,P,RH,WH,BL service

3.3 Real-World Pattern Usage#

TYPICAL USES OF EACH PATTERN
  • CACHE-ASIDE — Most Common
    • Product catalogs
    • User profiles (Netflix's EVCache is used as a look-aside cache)
    • Session and config lookups
    • Most CRUD applications
  • READ-THROUGH
    • Hibernate/JPA second-level cache
    • CDN edge caches
    • Custom ORM implementations
  • WRITE-THROUGH
    • Banking — Account balances
    • E-commerce — Inventory counts
    • Gaming — Player state in competitive games
    • Any "never serve stale data" requirement
  • WRITE-BEHIND
    • Like and view counters
    • Analytics — Page views, clicks
    • Logging — Application events
    • Any high-volume, loss-tolerant writes
Your turn · checkpoint 2not answered

Inventory and view counts on one page

The same product page shows an inventory count and a view counter. Inventory changes with every purchase and overselling is expensive. The view counter is bumped thousands of times a second and nobody minds if it's slightly off. Which write pattern would you pick for each, and what does each choice cost?

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

One strong answer

  • Inventory: write-through. Every write goes to the database and the cache together, so the cache tracks the database closely. The cost is higher write latency, and a write fails if either store is down.
  • View count: write-behind. The write returns after the cache update and the database is updated later in batches, so writes are tiny and repeated updates coalesce. The cost is that a crash before the flush loses some counts.
  • The rule: pay for freshness only where stale data is expensive.

3.4 Combining Patterns#

Most production systems combine patterns:

HYBRID APPROACH: E-COMMERCE EXAMPLE
Data Pattern Reason
Product Catalog Cache-Aside Read-heavy, can tolerate minutes of staleness
Inventory Write-Through + Read-Through Must be accurate, overselling is expensive
Price Cache-Aside with event-driven invalidation Changes infrequently, but must update quickly when it does
Shopping Cart Write-Through User expects cart to persist across sessions
View Count Write-Behind High volume, approximate is fine
User Session Cache-Aside with sticky sessions Read-heavy, session-specific
Quick check3 questions · +5 XP each

The four caching patterns

  1. Q1/3 Your product service must keep serving pages even if the whole Redis cluster dies. Which pattern makes that easiest, and why?

    Answer & explanation

    Answer: B

    In cache-aside the application owns the database call, so a failed cache is just a miss and requests fall through (slower, but up). In read-through the cache does the loading, so it becomes a critical dependency: if it is down, reads have no path to the database.

  2. Q2/3 You need to record millions of "like" events per minute; a few lost likes during a crash are acceptable, but write latency must be tiny. Which pattern fits?

    Answer & explanation

    Answer: C

    Write-behind returns as soon as the cache is updated and flushes to the database in batches, which is ideal for high-volume, loss-tolerant counters. Write-through would pay cache + database latency on every single like, which is exactly what you are trying to avoid.

  3. Q3/3 In the cache-aside write path, the lesson deletes the cache key after updating the database instead of writing the new value into the cache. What is the main reason?

    Answer & explanation

    Answer: A

    Invalidate-on-write sidesteps races between concurrent writers: whoever reads next re-fetches the latest committed state from the database. The cost difference between DEL and SET is negligible; the point is correctness, not speed.

Part II · Stage 2 of 4 · 15 min

Part II: Implementation#

Chapter 4: Basic Cache-Aside Implementation#

4.1 The Simplest Version#

pythonSimpleCache · 55 lines
# Basic Cache-Aside Implementation
# WARNING: Not production-ready - for learning only

import json
from typing import Optional, Any


class SimpleCache:
    """
    Minimal cache-aside implementation.
    
    Good for understanding the pattern, not for production.
    """
    
    def __init__(self, redis_client, default_ttl: int = 300):
        self.redis = redis_client
        self.default_ttl = default_ttl
    
    async def get(self, key: str) -> Optional[Any]:
        """Get from cache, returns None on miss."""
        value = await self.redis.get(key)
        if value:
            return json.loads(value)
        return None
    
    async def set(self, key: str, value: Any, ttl: int = None) -> None:
        """Set in cache with TTL."""
        ttl = ttl or self.default_ttl
        await self.redis.setex(key, ttl, json.dumps(value))
    
    async def delete(self, key: str) -> None:
        """Delete from cache (invalidation)."""
        await self.redis.delete(key)


# Usage example
async def get_product(product_id: str, cache: SimpleCache, db) -> dict:
    cache_key = f"product:{product_id}"
    
    # Check cache
    product = await cache.get(cache_key)
    if product:
        return product  # Cache hit!
    
    # Cache miss - load from database
    product = await db.fetch_one(
        "SELECT * FROM products WHERE id = $1",
        product_id
    )
    
    if product:
        # Store in cache for next time
        await cache.set(cache_key, dict(product))
    
    return dict(product) if product else None

4.2 Understanding the Flow#

DiagramCache-aside read flow · GET /products/123
%% title: Cache-aside read flow · GET /products/123
sequenceDiagram
  participant Cl as Client
  participant A as Application
  participant R as Redis
  participant DB as Database
  Cl->>A: GET /products/123
  Note over A: Step 1: Build cache key<br/>cache_key = "product:123"
  A->>R: Step 2: redis.get("product:123")
  alt EXISTS (Hit)
    R-->>A: cached JSON
    A-->>Cl: Return cached JSON · total time ~2ms
  else NOT EXISTS (Miss)
    R-->>A: nil · continue to Step 3
    A->>DB: Step 3: SELECT * FROM products WHERE id = 123 (~50ms)
    DB-->>A: row
    A->>R: Step 4: redis.setex("product:123", 300, json_data) · TTL = 300 seconds
    A-->>Cl: Step 5: Return to client · total time (miss) ~55ms
  end
  Note over Cl,R: Next request (hit): ~2ms

Chapter 5: Production-Ready Implementation#

5.1 Requirements for Production#

Our production implementation needs:

  1. Serialization — Handle complex objects
  2. Error handling — Cache failures shouldn't break the app
  3. Metrics — Track hit/miss ratios
  4. Key management — Versioned keys for safe deploys
  5. Bulk operations — Efficient multi-get/set
  6. Circuit breaker — Protect against cache outages

5.2 Full Production Implementation#

pythonCacheConfig · 523 lines
# Production-Ready Cache-Aside Implementation

import asyncio
import json
import hashlib
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List, Callable, TypeVar, Generic
from enum import Enum
import time

logger = logging.getLogger(__name__)

T = TypeVar('T')


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

@dataclass
class CacheConfig:
    """Configuration for the cache layer."""
    default_ttl: int = 300  # 5 minutes
    max_ttl: int = 86400    # 24 hours
    key_prefix: str = "app"
    key_version: str = "v1"  # Bump to invalidate all keys on deploy
    
    # Circuit breaker settings
    failure_threshold: int = 5
    recovery_timeout: int = 30
    
    # Timeouts
    get_timeout: float = 0.1   # 100ms
    set_timeout: float = 0.2   # 200ms


class CacheStatus(Enum):
    HIT = "hit"
    MISS = "miss"
    ERROR = "error"
    SKIP = "skip"  # Circuit breaker open


@dataclass
class CacheResult(Generic[T]):
    """Result of a cache operation."""
    status: CacheStatus
    value: Optional[T] = None
    latency_ms: float = 0
    error: Optional[str] = None


# =============================================================================
# Serialization
# =============================================================================

class CacheSerializer:
    """Handles serialization of cache values."""
    
    @staticmethod
    def serialize(value: Any) -> str:
        """Serialize value to JSON string."""
        return json.dumps(value, default=str, ensure_ascii=False)
    
    @staticmethod
    def deserialize(data: str) -> Any:
        """Deserialize JSON string to value."""
        return json.loads(data)


# =============================================================================
# Circuit Breaker
# =============================================================================

class CircuitBreaker:
    """
    Circuit breaker for cache operations.
    
    Prevents cascading failures when cache is unhealthy.
    """
    
    def __init__(self, failure_threshold: int = 5, recovery_timeout: int = 30):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        
        self._failures = 0
        self._last_failure_time: Optional[float] = None
        self._state = "closed"  # closed, open, half-open
    
    def record_success(self):
        """Record a successful operation."""
        self._failures = 0
        self._state = "closed"
    
    def record_failure(self):
        """Record a failed operation."""
        self._failures += 1
        self._last_failure_time = time.time()
        
        if self._failures >= self.failure_threshold:
            self._state = "open"
            logger.warning(f"Circuit breaker opened after {self._failures} failures")
    
    def is_open(self) -> bool:
        """Check if circuit is open (should skip cache)."""
        if self._state == "closed":
            return False
        
        if self._state == "open":
            # Check if recovery timeout has passed
            if time.time() - self._last_failure_time > self.recovery_timeout:
                self._state = "half-open"
                return False
            return True
        
        return False  # half-open: let requests through as a trial (this sketch doesn't limit it to one probe)


# =============================================================================
# Metrics
# =============================================================================

@dataclass
class CacheMetrics:
    """Tracks cache performance metrics."""
    hits: int = 0
    misses: int = 0
    errors: int = 0
    skips: int = 0
    total_latency_ms: float = 0
    
    @property
    def total_requests(self) -> int:
        return self.hits + self.misses + self.errors + self.skips
    
    @property
    def hit_ratio(self) -> float:
        if self.total_requests == 0:
            return 0.0
        return self.hits / self.total_requests
    
    @property
    def avg_latency_ms(self) -> float:
        if self.total_requests == 0:
            return 0.0
        return self.total_latency_ms / self.total_requests
    
    def record(self, result: CacheResult):
        """Record a cache operation result."""
        self.total_latency_ms += result.latency_ms
        
        if result.status == CacheStatus.HIT:
            self.hits += 1
        elif result.status == CacheStatus.MISS:
            self.misses += 1
        elif result.status == CacheStatus.ERROR:
            self.errors += 1
        elif result.status == CacheStatus.SKIP:
            self.skips += 1


# =============================================================================
# Main Cache Implementation
# =============================================================================

class ProductionCache:
    """
    Production-ready cache-aside implementation.
    
    Features:
    - Automatic serialization
    - Circuit breaker for resilience
    - Metrics tracking
    - Versioned keys
    - Bulk operations
    - Graceful degradation
    """
    
    def __init__(
        self,
        redis_client,
        config: CacheConfig = None,
        metrics_client = None
    ):
        self.redis = redis_client
        self.config = config or CacheConfig()
        self.external_metrics = metrics_client
        
        self.serializer = CacheSerializer()
        self.circuit_breaker = CircuitBreaker(
            failure_threshold=self.config.failure_threshold,
            recovery_timeout=self.config.recovery_timeout
        )
        self.metrics = CacheMetrics()
    
    def _build_key(self, key: str) -> str:
        """Build full cache key with prefix and version."""
        return f"{self.config.key_prefix}:{self.config.key_version}:{key}"
    
    async def get(self, key: str) -> CacheResult[Any]:
        """
        Get value from cache.
        
        Returns CacheResult with status, value, and metrics.
        """
        start_time = time.time()
        full_key = self._build_key(key)
        
        # Check circuit breaker
        if self.circuit_breaker.is_open():
            result = CacheResult(status=CacheStatus.SKIP)
            self.metrics.record(result)
            return result
        
        try:
            # Get from Redis with timeout
            raw_value = await asyncio.wait_for(
                self.redis.get(full_key),
                timeout=self.config.get_timeout
            )
            
            latency_ms = (time.time() - start_time) * 1000
            
            if raw_value is None:
                result = CacheResult(
                    status=CacheStatus.MISS,
                    latency_ms=latency_ms
                )
            else:
                value = self.serializer.deserialize(raw_value)
                result = CacheResult(
                    status=CacheStatus.HIT,
                    value=value,
                    latency_ms=latency_ms
                )
            
            self.circuit_breaker.record_success()
            self.metrics.record(result)
            return result
            
        except asyncio.TimeoutError:
            logger.warning(f"Cache get timeout for key: {key}")
            self.circuit_breaker.record_failure()
            result = CacheResult(
                status=CacheStatus.ERROR,
                error="timeout",
                latency_ms=(time.time() - start_time) * 1000
            )
            self.metrics.record(result)
            return result
            
        except Exception as e:
            logger.error(f"Cache get error for key {key}: {e}")
            self.circuit_breaker.record_failure()
            result = CacheResult(
                status=CacheStatus.ERROR,
                error=str(e),
                latency_ms=(time.time() - start_time) * 1000
            )
            self.metrics.record(result)
            return result
    
    async def set(
        self,
        key: str,
        value: Any,
        ttl: int = None
    ) -> bool:
        """
        Set value in cache.
        
        Returns True on success, False on failure.
        Failures are logged but don't raise exceptions.
        """
        if self.circuit_breaker.is_open():
            return False
        
        full_key = self._build_key(key)
        ttl = min(ttl or self.config.default_ttl, self.config.max_ttl)
        
        try:
            serialized = self.serializer.serialize(value)
            
            await asyncio.wait_for(
                self.redis.setex(full_key, ttl, serialized),
                timeout=self.config.set_timeout
            )
            
            self.circuit_breaker.record_success()
            return True
            
        except asyncio.TimeoutError:
            logger.warning(f"Cache set timeout for key: {key}")
            self.circuit_breaker.record_failure()
            return False
            
        except Exception as e:
            logger.error(f"Cache set error for key {key}: {e}")
            self.circuit_breaker.record_failure()
            return False
    
    async def delete(self, key: str) -> bool:
        """Delete a key from cache (invalidation)."""
        if self.circuit_breaker.is_open():
            return False
        
        full_key = self._build_key(key)
        
        try:
            await self.redis.delete(full_key)
            self.circuit_breaker.record_success()
            return True
        except Exception as e:
            logger.error(f"Cache delete error for key {key}: {e}")
            self.circuit_breaker.record_failure()
            return False
    
    async def get_many(self, keys: List[str]) -> Dict[str, CacheResult]:
        """
        Get multiple values in a single round-trip.
        
        Much more efficient than multiple individual gets.
        """
        if self.circuit_breaker.is_open():
            return {key: CacheResult(status=CacheStatus.SKIP) for key in keys}
        
        start_time = time.time()
        full_keys = [self._build_key(k) for k in keys]
        
        try:
            # MGET for all keys at once
            raw_values = await asyncio.wait_for(
                self.redis.mget(full_keys),
                timeout=self.config.get_timeout * 2  # Allow more time for bulk
            )
            
            latency_ms = (time.time() - start_time) * 1000
            self.circuit_breaker.record_success()
            
            results = {}
            for key, raw_value in zip(keys, raw_values):
                if raw_value is None:
                    results[key] = CacheResult(
                        status=CacheStatus.MISS,
                        latency_ms=latency_ms / len(keys)
                    )
                else:
                    value = self.serializer.deserialize(raw_value)
                    results[key] = CacheResult(
                        status=CacheStatus.HIT,
                        value=value,
                        latency_ms=latency_ms / len(keys)
                    )
                self.metrics.record(results[key])
            
            return results
            
        except Exception as e:
            logger.error(f"Cache get_many error: {e}")
            self.circuit_breaker.record_failure()
            return {
                key: CacheResult(status=CacheStatus.ERROR, error=str(e))
                for key in keys
            }
    
    async def set_many(self, items: Dict[str, Any], ttl: int = None) -> bool:
        """Set multiple values in a single round-trip."""
        if self.circuit_breaker.is_open():
            return False
        
        ttl = min(ttl or self.config.default_ttl, self.config.max_ttl)
        
        try:
            pipe = self.redis.pipeline()
            
            for key, value in items.items():
                full_key = self._build_key(key)
                serialized = self.serializer.serialize(value)
                pipe.setex(full_key, ttl, serialized)
            
            await asyncio.wait_for(
                pipe.execute(),
                timeout=self.config.set_timeout * 2
            )
            
            self.circuit_breaker.record_success()
            return True
            
        except Exception as e:
            logger.error(f"Cache set_many error: {e}")
            self.circuit_breaker.record_failure()
            return False
    
    def get_stats(self) -> Dict[str, Any]:
        """Get cache statistics."""
        return {
            "hits": self.metrics.hits,
            "misses": self.metrics.misses,
            "errors": self.metrics.errors,
            "skips": self.metrics.skips,
            "hit_ratio": round(self.metrics.hit_ratio, 4),
            "avg_latency_ms": round(self.metrics.avg_latency_ms, 2),
            "circuit_breaker_state": self.circuit_breaker._state,
        }


# =============================================================================
# Repository Pattern with Cache
# =============================================================================

class CachedProductRepository:
    """
    Product repository with caching.
    
    Demonstrates the cache-aside pattern in a repository layer.
    """
    
    def __init__(
        self,
        db_pool,
        cache: ProductionCache,
        ttl_seconds: int = 300
    ):
        self.db = db_pool
        self.cache = cache
        self.ttl = ttl_seconds
    
    async def get_by_id(self, product_id: str) -> Optional[Dict]:
        """Get product by ID with caching."""
        cache_key = f"product:{product_id}"
        
        # Try cache first
        result = await self.cache.get(cache_key)
        
        if result.status == CacheStatus.HIT:
            logger.debug(f"Cache hit for product {product_id}")
            return result.value
        
        # Cache miss or error - load from database
        product = await self.db.fetch_one(
            "SELECT * FROM products WHERE id = $1",
            product_id
        )
        
        if product:
            product_dict = dict(product)
            # Populate cache (best effort)
            await self.cache.set(cache_key, product_dict, self.ttl)
            return product_dict
        
        return None
    
    async def get_by_ids(self, product_ids: List[str]) -> Dict[str, Dict]:
        """Get multiple products efficiently."""
        cache_keys = [f"product:{pid}" for pid in product_ids]
        
        # Try cache for all
        cache_results = await self.cache.get_many(cache_keys)
        
        # Separate hits and misses
        products = {}
        missing_ids = []
        
        for pid, key in zip(product_ids, cache_keys):
            result = cache_results[key]
            if result.status == CacheStatus.HIT:
                products[pid] = result.value
            else:
                missing_ids.append(pid)
        
        # Load missing from database
        if missing_ids:
            rows = await self.db.fetch(
                "SELECT * FROM products WHERE id = ANY($1)",
                missing_ids
            )
            
            to_cache = {}
            for row in rows:
                product = dict(row)
                pid = product['id']
                products[pid] = product
                to_cache[f"product:{pid}"] = product
            
            # Cache the missing ones (best effort)
            if to_cache:
                await self.cache.set_many(to_cache, self.ttl)
        
        return products
    
    async def update(self, product_id: str, data: Dict) -> Optional[Dict]:
        """Update product and invalidate cache."""
        # Update database
        product = await self.db.fetch_one(
            """
            UPDATE products 
            SET name = $2, price = $3, updated_at = NOW()
            WHERE id = $1
            RETURNING *
            """,
            product_id, data['name'], data['price']
        )
        
        if product:
            # Invalidate cache
            await self.cache.delete(f"product:{product_id}")
            return dict(product)
        
        return None
    
    async def delete(self, product_id: str) -> bool:
        """Delete product and invalidate cache."""
        result = await self.db.execute(
            "DELETE FROM products WHERE id = $1",
            product_id
        )
        
        # Always try to invalidate (even if delete found nothing)
        await self.cache.delete(f"product:{product_id}")
        
        return result == "DELETE 1"

Chapter 6: Edge Cases and Error Handling#

Your turn · checkpoint 3not answered

What breaks if Redis dies mid-request?

Your cache-aside code calls redis.get() with no try/except and no timeout. Redis becomes unreachable at peak traffic. What do users see, and what would you change so the site only gets slower instead of failing?

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

One strong answer

  • As written: the connection error propagates out of the request handler, so every product page returns a 500 even though the database is fine.
  • Catch and fall through: treat any cache error as a miss and go to the database.
  • Add a short timeout and a circuit breaker: after a few failures the breaker opens and requests skip Redis entirely, so they stop paying a timeout against a dead cache. After the recovery timeout it lets a trial request through.

6.1 Edge Case 1: Cache Failure on Read#

SCENARIO: Redis is down, read request comes in

Without proper handling

  1. Try to read from Redis
  2. Connection error
  3. Exception propagates
  4. User sees 500 error

With graceful degradation

  1. Try to read from Redis
  2. Repeated connection errors → Circuit breaker opens
  3. Subsequent reads skip cache entirely
  4. All requests go to database
  5. System slower but functional
pythonget_product_safe · 24 lines
# Graceful degradation on cache failure
async def get_product_safe(product_id: str) -> Optional[dict]:
    cache_key = f"product:{product_id}"
    
    # Cache.get() returns CacheResult, never throws
    result = await cache.get(cache_key)
    
    if result.status == CacheStatus.HIT:
        return result.value
    
    # Miss, error, or skip - all go to database
    # The circuit breaker ensures we don't keep hammering a dead cache
    product = await db.fetch_one(
        "SELECT * FROM products WHERE id = $1",
        product_id
    )
    
    if product:
        # Only try to cache if circuit is closed
        if result.status != CacheStatus.SKIP:
            await cache.set(cache_key, dict(product))
        return dict(product)
    
    return None

6.2 Edge Case 2: Race Condition on Write#

SCENARIO: Two requests update the same product simultaneously
Timeline
  • T0: Request A reads product (price: $100)
  • T1: Request B reads product (price: $100)
  • T2: Request A updates price to $90, invalidates cache
  • T3: Request B updates price to $110, invalidates cache
  • T4: Request C reads product - cache miss
  • T5: Request C loads from DB (price: $110), caches it

This is fine! Last write wins, cache reflects latest DB state.

BUT what if invalidation fails?
  • T0: Request A reads product (price: $100, cached)
  • T1: Request A updates price to $90 in DB
  • T2: Request A tries to invalidate cache - FAILS (Redis timeout)
  • T3: Request B reads product - gets $100 from cache (stale!)

SOLUTION: Use TTL as safety net. Even if invalidation fails, stale data expires eventually. Set TTL based on acceptable staleness window.

ExplainerBreak it: cache TTL vs hit rate

Sliders for the TTL and traffic in front of a cache-aside database (hit ~2 ms, miss ~55 ms). Model: 3,000 equally popular keys with random arrivals, so each key misses once per TTL and hit rate = λT ÷ (1 + λT). At the lesson's TTL of 300 s and 1,000 req/s that is about 99% hits and 10 req/s to the database; a TTL of 0 sends all 1,000 to the database, and a longer TTL lengthens the worst-case stale window when an invalidation fails.

Your turn · checkpoint 4not answered

The restart that empties the cache

A service handles 1,000 requests a second with a 99% cache hit ratio. It restarts and comes back with an empty cache. How many requests a second reach the database before and right after the restart? What could you do before the instance takes traffic?

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

One strong answer

  • Before: 1% of 1,000 = 10 requests/s reach the database.
  • Right after: the hit ratio is 0%, so all 1,000/s do: a 100x jump.
  • Before taking traffic: warm the cache with the most-viewed products, or start the instance on a small share of traffic and raise it as the cache fills. Request coalescing also stops many simultaneous misses on one key from each querying the database.

6.3 Edge Case 3: Thundering Herd on Cold Start#

SCENARIO: Service restarts, cache is empty
Requests Cache hit ratio Hit database
Normal state 1000 requests/sec 99% 10 requests/sec
After restart 1000 requests/sec 0% 1000 requests/sec — Database overwhelmed!
SOLUTIONS

1. CACHE WARMING (Pre-populate cache before serving traffic)

pythonwarm_cache · 9 lines
async def warm_cache():
    popular_products = await db.fetch(
        "SELECT * FROM products ORDER BY view_count DESC LIMIT 1000"
    )
    for product in popular_products:
        await cache.set(f"product:{product['id']}", dict(product))

# Call before accepting traffic
await warm_cache()

2. GRADUAL TRAFFIC SHIFT (If using load balancer) — New instance starts with 1% traffic · Gradually increase as cache warms naturally

3. REQUEST COALESCING (Covered in Day 3) — Multiple simultaneous requests for same key → Only one hits database → Others wait for result

6.4 Edge Case 4: Serialization Errors#

SCENARIO: Object can't be serialized to JSON
python7 lines
product = {
    "id": "123",
    "created_at": datetime(2024, 1, 15),  # datetime not JSON serializable!
    "data": bytes([1, 2, 3])               # bytes not JSON serializable!
}

await cache.set("product:123", product)  # Fails!
SOLUTIONS

1. CUSTOM SERIALIZER

pythonDateTimeEncoder · 7 lines
class DateTimeEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, datetime):
            return obj.isoformat()
        if isinstance(obj, bytes):
            return base64.b64encode(obj).decode()
        return super().default(obj)

2. EXPLICIT CONVERSION BEFORE CACHING

python5 lines
product_cacheable = {
    "id": product["id"],
    "created_at": product["created_at"].isoformat(),
    "data": base64.b64encode(product["data"]).decode()
}

3. USE PICKLE (But be careful with security/versioning)

python2 lines
# Not recommended for untrusted data
await cache.set(key, pickle.dumps(product))

6.5 Error Handling Matrix#

Error Impact Handling Prevention
Cache timeout Single request slower Skip cache, go to DB Set appropriate timeouts
Cache down All requests to DB Circuit breaker, degrade gracefully Redis Cluster, replicas
Serialization error Cache write fails Log, continue without cache Custom serializer
Invalidation fails Stale data served TTL as safety net Retry invalidation
Cache full Evictions happen An LRU-type maxmemory-policy evicts old data (Redis' default, noeviction, rejects writes instead) Monitor memory, scale
Key collision Wrong data served Versioned keys, proper naming Key design review
Quick check3 questions · +5 XP each

Production cache-aside and failure handling

  1. Q1/3 Redis becomes unhealthy and five get calls in a row time out. With the lesson's ProductionCache, what happens to the next requests for the following ~30 seconds?

    Answer & explanation

    Answer: D

    After failure_threshold failures the breaker opens and get returns SKIP without touching Redis, so you stop paying timeouts against a dead cache. After recovery_timeout it goes half-open and lets a trial request through; there is no queueing and no user-facing error.

  2. Q2/3 A price update commits to the database, but the follow-up cache delete times out. What stops the old price from being served indefinitely?

    Answer & explanation

    Answer: B

    TTL is the safety net: even when invalidation fails, the stale entry expires on its own, so you set TTL to the staleness you can tolerate. Key versions are bumped on schema changes or deploys, not on every row update, and cache-aside reads hit the cache first, so the database never "overrides" a live stale entry.

  3. Q3/3 A service handles 2,000 req/s with a 95% cache hit ratio. After a deploy, the cache comes up empty. Roughly how much does database traffic increase?

    Answer & explanation

    Answer: A

    Before: 5% of 2,000 = 100 req/s reach the database. Cold: all 2,000 do, a 20x jump. This is why the lesson recommends cache warming or a gradual traffic shift before a cold instance takes full load.

Part III · Stage 3 of 4 · 6 min

Part III: Real-World Application#

Chapter 7: How Big Tech Does It#

7.1 Case Study: Amazon-Scale Product Catalog Caching (illustrative)#

Amazon has not published its catalog caching stack; this is how a catalog at that scale is typically layered on AWS.

Scale:

  • Hundreds of millions of products
  • Millions of requests per second
  • Sub-100ms response time requirement
DiagramProduct cache architecture (illustrative)
%% title: Product cache architecture (illustrative)
flowchart TD
  U@{ icon: "sd:users", label: "User Request" }
  E@{ icon: "tech:cloudfront", label: "Edge Cache (CloudFront)<br/>TTL: 60s<br/>Static assets, some product data" }
  RC@{ icon: "sd:cache", label: "Regional Cache (ElastiCache)<br/>TTL: 5min<br/>Product details, prices" }
  PS["Product Service<br/>In-memory cache<br/>Frequently accessed products"]:::core
  DB@{ icon: "tech:dynamodb", label: "DynamoDB<br/>(Source of Truth)" }
  U --> E
  E -->|"Miss"| RC
  RC -->|"Miss"| PS
  PS -->|"Miss"| DB
  class U client
  class E edge
  class RC cache
  class DB store

Key decisions:

  1. TIERED CACHING — Different TTLs at different layers. Edge: static content, long TTL. Regional: dynamic content, shorter TTL.
  2. CACHE-ASIDE FOR FLEXIBILITY — Application controls cache logic. Different products have different caching rules.
  3. INVENTORY SEPARATE FROM CATALOG — Inventory changes frequently (seconds); catalog changes rarely (days). Different caching strategies for each.
  4. EVENTUAL CONSISTENCY ACCEPTED — User might see slightly stale price, but cart/checkout uses real-time data.

7.2 Case Study: Netflix — User Profile Caching#

Challenge:

  • 300+ million members
  • Each has personalized profile
  • Every request needs profile data
  • Profile rarely changes

Solution: EVCache (their custom distributed cache; the 24-hour TTL below is illustrative)

DiagramNetflix user profile caching
%% title: Netflix user profile caching
flowchart TD
  R@{ icon: "sd:user", label: "API Request<br/>(needs user context)" }
  API["API Service<br/>Check EVCache"]:::core
  EV@{ icon: "sd:cache", label: "EVCache" }
  OUT(["HIT: Return profile"]):::ok
  CS@{ icon: "tech:cassandra", label: "Cassandra" }
  R --> API
  API -->|"check"| EV
  EV -->|"HIT"| OUT
  API -->|"Miss: Load from DB"| CS
  CS -.->|"Cache for 24h"| EV
  class R client
  class EV cache
  class CS store

Key ideas (the TTL and invalidation details are illustrative, not Netflix's published settings):

  1. ZONE-AWARE CACHING — Cache replicas in multiple availability zones. Read from nearest, write to all. Survives zone failure.
  2. CACHE WARMING ON DEPLOY — Pre-populate cache before serving traffic. Avoids cold-start thundering herd.
  3. LONG TTL FOR STABLE DATA — A profile rarely changes, so a long TTL (24 hours here, illustrative) is acceptable, with event-driven invalidation when it does change.
  4. CACHE-ASIDE WITH FALLBACK — If EVCache fails, fall back to Cassandra. Higher latency but still works.
Your turn · checkpoint 5not answered

Where does a driver's location live?

Every driver's phone sends its position every 4 seconds, and riders need nearby drivers instantly. Would you put a database first and cache it with cache-aside? How would you stop riders seeing a driver whose phone lost signal?

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

One strong answer

  • Make the cache the primary store. Write each update to Redis synchronously and send history to the database asynchronously. Losing one update is harmless because the next one arrives 4 seconds later.
  • Use expiry as liveness. Give each driver a heartbeat key with a short TTL (about 10 seconds). A phone with no signal can't send a "disconnect" event, but its heartbeat expires, so the driver is filtered out.

7.3 Case Study: Ride-Hailing Driver Location Caching (illustrative)#

Uber's real system, as described in 2015, shards live locations across many processes by Google S2 cell; the Redis version below shows the same ideas with familiar tools.

Challenge:

  • Millions of active drivers
  • Location updates every 4 seconds
  • Rider needs nearby drivers instantly
  • Data is extremely time-sensitive

This is the OPPOSITE of typical caching!

DiagramDriver location cache (illustrative)
%% title: Driver location cache (illustrative)
flowchart TD
  D@{ icon: "sd:phone", label: "Driver App" }
  LS["Location Service"]:::core
  RG@{ icon: "tech:redis", label: "Redis Cluster (Geo)" }
  CS@{ icon: "tech:cassandra", label: "Cassandra (historical)" }
  RR@{ icon: "sd:user", label: "Rider Request:<br/>Show nearby drivers" }
  MS["Matching Service"]:::service
  D -->|"Location update every 4s"| LS
  LS ==>|"Synchronous write (Redis is primary)<br/>GEOADD drivers:sf &lt;lon&gt; &lt;lat&gt; driver123"| RG
  LS -.->|"Async to DB"| CS
  RR --> MS
  MS -->|"GEOSEARCH drivers:sf FROMLONLAT &lt;lon&gt; &lt;lat&gt; BYRADIUS 5 km"| RG
  RG -->|"Returns drivers within 5km"| MS
  class D,RR client
  class RG cache
  class CS store

Key decisions:

  1. CACHE IS THE PRIMARY STORE — For real-time location, cache IS the source of truth. Database is for history/analytics only.
  2. SHORT EXPIRY FOR LIVENESS — Redis can't expire individual members of a GEO set, so each driver also has a heartbeat key with a ~10 s TTL (or a last-seen timestamp). Drivers whose heartbeat has expired are filtered out and pruned.
  3. GEO-SHARDING — Separate Redis cluster per city. Reduces data size, improves locality.
  4. WRITE TO THE CACHE FIRST, HISTORY LATER — Every update goes to Redis synchronously; the history database is written asynchronously. Losing one update is harmless because the next arrives 4 seconds later.

7.4 Summary: Industry Patterns#

Company Use Case Pattern TTL Key Insight
Product catalog (Amazon-scale) Product catalog Cache-aside 5 min Tiered caching
Netflix User profiles Cache-aside 24 hr Zone-aware
Ride-hailing Driver location Cache as primary store ~10 sec heartbeat Expiry = liveness
Twitter Timelines Fan-out on write into Redis N/A Precompute on write
Social apps Like/view counts Write-behind N/A Approximate OK

Chapter 8: Common Mistakes to Avoid#

8.1 Mistake 1: Caching Everything#

Caching everything

WRONG: Cache all the things!

pythonget_anything · 9 lines
async def get_anything(key: str) -> any:
    cached = await cache.get(key)
    if cached:
        return cached

    # Load from DB
    value = await db.fetch(key)
    await cache.set(key, value, ttl=3600)  # Cache for 1 hour
    return value

Problems:

  • Rarely accessed data wastes cache memory
  • Low hit ratio
  • Cache evictions hurt actually hot data

CORRECT: Cache strategically

pythonget_with_strategy · 15 lines
# Only cache frequently accessed, expensive to compute data
CACHE_CONFIG = {
    "product_details": {"ttl": 300, "cache": True},   # Hot, expensive
    "user_profile": {"ttl": 600, "cache": True},      # Hot, stable
    "order_history": {"ttl": 0, "cache": False},      # Rarely re-read
    "audit_log": {"ttl": 0, "cache": False},          # Write-once, read-rarely
}

async def get_with_strategy(entity: str, id: str) -> any:
    config = CACHE_CONFIG.get(entity, {"cache": False})

    if not config["cache"]:
        return await db.fetch(entity, id)

    # ... caching logic for entities that benefit

8.2 Mistake 2: Not Handling Cache Failures#

Not handling cache failures

WRONG: Cache failure = Application failure

pythonget_product · 12 lines
async def get_product(product_id: str) -> dict:
    cache_key = f"product:{product_id}"

    # If Redis is down, this throws an exception!
    cached = await redis.get(cache_key)

    if cached:
        return json.loads(cached)

    product = await db.fetch_one("SELECT * FROM products WHERE id = $1", product_id)
    await redis.setex(cache_key, 300, json.dumps(product))  # Also throws!
    return product

CORRECT: Graceful degradation

pythonget_product · 25 lines
async def get_product(product_id: str) -> dict:
    cache_key = f"product:{product_id}"

    # Try cache, but don't fail if it's down
    try:
        cached = await asyncio.wait_for(
            redis.get(cache_key),
            timeout=0.1  # 100ms max
        )
        if cached:
            return json.loads(cached)
    except Exception as e:
        logger.warning(f"Cache read failed: {e}")
        # Continue to database

    # Load from database
    product = await db.fetch_one("SELECT * FROM products WHERE id = $1", product_id)

    # Try to cache, but don't fail if we can't
    try:
        await redis.setex(cache_key, 300, json.dumps(dict(product)))
    except Exception as e:
        logger.warning(f"Cache write failed: {e}")

    return dict(product)

8.3 Mistake 3: Ignoring Cache Metrics#

Ignoring cache metrics

WRONG: Deploy and forget

python8 lines
# Set up cache
cache = Redis(host='redis')

# Use cache
await cache.get(key)
await cache.set(key, value)

# Hope for the best!

CORRECT: Monitor and alert

pythonget_with_metrics · 16 lines
# Track every cache operation
async def get_with_metrics(key: str) -> any:
    start = time.time()

    result = await cache.get(key)

    latency = (time.time() - start) * 1000

    if result:
        metrics.increment('cache.hit', tags={'key_prefix': key.split(':')[0]})
    else:
        metrics.increment('cache.miss', tags={'key_prefix': key.split(':')[0]})

    metrics.timing('cache.latency', latency)

    return result

Alert on poor performance:

  • cache.hit_ratio < 0.8 — "Cache hit ratio dropped below 80%"
  • cache.latency.p99 > 50ms — "Cache latency elevated"
  • cache.errors > 10/min — "Cache errors spiking"

8.4 Mistake 4: Wrong Key Design#

Wrong key design

WRONG: Non-unique or collision-prone keys

python9 lines
# Key collision!
cache.set("user", user1)  # Overwritten by...
cache.set("user", user2)  # Different user, same key!

# Non-deterministic keys
cache.set(f"user:{user.email}", user)  # What if email changes?

# Keys without versioning
cache.set("product:123", product)  # Schema changes break deserialization

CORRECT: Properly designed keys

python11 lines
# Unique, stable identifier
cache.set(f"user:{user.id}", user)

# Versioned for schema changes
cache.set(f"v2:product:{product.id}", product)  # Bump version on schema change

# Namespaced for clarity
cache.set(f"myapp:prod:user:{user.id}", user)

# With prefix for bulk operations
# Redis has no delete-by-pattern command: SCAN with MATCH "myapp:prod:product:*", then DEL the matches in batches

8.5 Mistake Checklist#

Before deploying caching, verify:

Before deploying caching, verify0/8 ready
  • Right pattern chosen — Cache-aside vs write-through for your use case
  • Appropriate TTL — Based on acceptable staleness
  • Failure handling — App works (slower) when cache is down
  • Invalidation strategy — How/when cache is cleared
  • Key design — Unique, versioned, namespaced
  • Metrics in place — Hit ratio, latency, errors tracked
  • Memory limits set — Redis maxmemory with eviction policy
  • Serialization tested — All data types serialize correctly
Quick check3 questions · +5 XP each

Real-world caching and common mistakes

  1. Q1/3 In the driver-location design, a driver's phone loses signal and stops sending updates. Why don't riders keep seeing that driver as available?

    Answer & explanation

    Answer: C

    Here the cache is the source of truth for live location, and the short TTL doubles as liveness: no updates means the heartbeat expires and the driver is filtered out. A disconnected phone cannot send a "disconnect" event, so relying on explicit invalidation would leave ghost drivers.

  2. Q2/3 You start caching order history and audit logs with a 1-hour TTL alongside product pages. Soon the product-page hit ratio drops. What is the most likely cause?

    Answer & explanation

    Answer: D

    Caching data that is rarely re-read wastes memory; once the cache is full, eviction pushes out the hot data that was earning hits. The fix is to cache strategically: hot, expensive data only, and skip things like order history and audit logs.

  3. Q3/3 You change the shape of the cached product object. Right after the deploy, new code fails to deserialize entries written by the old code. Which key-design practice prevents this?

    Answer & explanation

    Answer: B

    A bumped version (e.g. v2:product:123) makes new code read and write a fresh keyspace, so it never sees old-format values; the old keys simply age out. A shorter TTL only narrows the window; requests in that window still hit incompatible entries.

Part IV · Stage 4 of 4 · 9 min

Part IV: Interview Preparation#

Chapter 9: Interview Tips and Phrases#

9.1 When to Bring Up Caching#

Bring up caching when:

  • System has read-heavy workload (>10:1 read/write ratio)
  • Database is a bottleneck or scaling concern
  • Latency requirements are strict (<100ms)
  • Same data is requested repeatedly
  • Computing the data is expensive

Don't default to caching when:

  • Data changes frequently (real-time stock prices)
  • Every request is unique (search queries)
  • Consistency is critical and invalidation is complex
  • Data is write-heavy
❯ Say this

9.2 Key Phrases to Use#

Key phrases
INTRODUCING CACHING

"For this read-heavy workload, I'd add a caching layer. With 100,000 reads per second and only 100 writes, the data rarely changes, so if the hot set fits in memory we can expect around a 99% hit ratio, reducing database load by two orders of magnitude."

EXPLAINING PATTERN CHOICE

"I'd use cache-aside here because it gives us the most control. The application can decide what to cache and for how long. Plus, if the cache fails, we gracefully fall back to the database—the system is slower but not broken."

DISCUSSING TRADE-OFFS

"The trade-off with caching is consistency versus latency. With a 5-minute TTL, users might see slightly stale product prices, but we reduce database load significantly. For checkout, we'd bypass the cache and hit the database directly to ensure accuracy."

ADDRESSING INVALIDATION

"For invalidation, I'd use a hybrid approach: event-driven invalidation for immediate consistency when data changes, plus a short TTL as a safety net in case an invalidation event is lost. This gives us near real-time updates with protection against stale data."

HANDLING FAILURE QUESTIONS

"If Redis goes down, the system degrades gracefully. The circuit breaker opens after a few failures, and all requests go directly to the database. Latency increases but functionality remains. Once Redis recovers, the circuit breaker allows test requests through, and normal operation resumes."

9.3 Questions to Ask Interviewer#

  • "What's the read/write ratio for this data?"
  • "How fresh does the data need to be? Can users tolerate 1-minute staleness?"
  • "What's the current database load? Is it a bottleneck?"
  • "Is there existing cache infrastructure we should use?"

9.4 Common Follow-up Questions#

Question Good Answer
"What if the cache goes down?" "Circuit breaker detects failures, subsequent requests go directly to database. Higher latency but still functional. We'd alert on this and investigate."
"How do you handle cache stampede?" "Request coalescing—multiple requests for the same key wait on a single database fetch. Also probabilistic early expiration to stagger cache misses."
"How do you decide TTL?" "Based on acceptable staleness. Product descriptions: 1 hour. Inventory counts: 30 seconds. Prices: 5 minutes with event-driven invalidation for flash sales."
"Which caching pattern would you use?" "Cache-aside for most cases—simple, resilient, flexible. Write-through only when consistency is critical. Write-behind only for high-volume, loss-tolerant writes like analytics."

Chapter 10: Practice Problems#

Practice · problem 1

Problem 1: E-commerce Product Page#

Setup: Design caching for an e-commerce product page. Page shows: product details, current price, inventory count, reviews, and related products.

Requirements:

  • 50,000 product views per second
  • Price changes during flash sales
  • Inventory updated with every purchase
  • Reviews added ~100 per minute

Questions:

  1. What caching pattern would you use for each data type?
  2. What TTLs would you set?
  3. How would you handle a flash sale where prices change instantly?
Click for hints
  • Different data has different freshness requirements
  • Inventory is most time-sensitive
  • Reviews rarely change and are expensive to compute
  • Consider event-driven invalidation for prices
Click for solution outline

Pattern by Data Type:

Data Pattern TTL Invalidation
Product details Cache-aside 1 hour On update
Price Cache-aside 5 min Event-driven for flash sales
Inventory Cache-aside 30 sec Or skip cache entirely
Reviews Cache-aside 15 min Event-driven on new review
Related products Cache-aside 1 hour Daily recompute

Flash Sale Handling:

pythonstart_flash_sale · 15 lines
async def start_flash_sale(product_ids: List[str], sale_prices: Dict):
    # Update database
    await db.execute("UPDATE products SET price = ... WHERE id IN ...")
    
    # Publish invalidation event
    await kafka.produce("product-updates", {
        "type": "flash_sale_started",
        "product_ids": product_ids
    })
    
    # Cache invalidation consumer
    async def handle_flash_sale(event):
        for product_id in event["product_ids"]:
            await cache.delete(f"product:{product_id}")
            await cache.delete(f"price:{product_id}")

For inventory, consider not caching at all during high-activity periods:

pythonget_inventory · 7 lines
async def get_inventory(product_id: str) -> int:
    if is_flash_sale_active(product_id):
        # During flash sale, always hit database for accuracy
        return await db.fetch_one("SELECT quantity FROM inventory WHERE product_id = $1", product_id)
    
    # Normal operation: use cache
    return await cached_inventory(product_id)

Practice · problem 2

Problem 2: User Session Store#

Setup: Design a session cache for a web application with 10 million concurrent users.

Requirements:

  • Session data: user ID, permissions, preferences (~2KB each)
  • Sessions expire after 30 minutes of inactivity
  • User can be logged in on multiple devices
  • Must handle datacenter failover

Questions:

  1. How would you structure the cache keys?
  2. How would you handle session updates (sliding expiration)?
  3. What happens during datacenter failover?
Click for hints
  • Sliding expiration means TTL resets on every access
  • Multiple devices = multiple sessions per user
  • Failover requires replication or persistence
Click for solution outline

Key Structure:

text2 lines
session:{session_id} → session data
user_sessions:{user_id} → set of session_ids (for logout-all)

Sliding Expiration:

pythonget_session · 12 lines
async def get_session(session_id: str) -> Optional[dict]:
    key = f"session:{session_id}"
    
    # Get and refresh TTL in one operation
    pipe = redis.pipeline()
    pipe.get(key)
    pipe.expire(key, 1800)  # Reset to 30 minutes
    results = await pipe.execute()
    
    if results[0]:
        return json.loads(results[0])
    return None

Failover Strategy:

Failover options
Option Properties
Option 1: Redis Cluster with replicas Automatic failover · Some sessions may be lost during failover
Option 2: Redis with AOF persistence Sessions survive restart · Slightly higher latency
Option 3: Multi-region replication Active-active or active-passive · Session available in either region

Logout All Devices:

pythonlogout_all · 11 lines
async def logout_all(user_id: str):
    # Get all sessions for user
    session_ids = await redis.smembers(f"user_sessions:{user_id}")
    
    # Delete all sessions
    if session_ids:
        pipe = redis.pipeline()
        for sid in session_ids:
            pipe.delete(f"session:{sid}")
        pipe.delete(f"user_sessions:{user_id}")
        await pipe.execute()

Practice · problem 3

Problem 3: API Rate Limiter#

Setup: Design a distributed rate limiter using Redis. Limit: 100 requests per minute per API key.

Requirements:

  • Must be accurate (can't significantly over-allow)
  • Must be fast (<5ms overhead)
  • Works across multiple API servers
  • Handle clock skew between servers

Questions:

  1. Which caching pattern applies here?
  2. How would you implement sliding window rate limiting?
  3. What happens if Redis is down?
Click for hints
  • This is write-heavy, not read-heavy
  • Consider Redis sorted sets for sliding window
  • Rate limiter failure mode: allow or deny?
Click for solution outline

Pattern: None of the four exactly—this is a specialized use case

  • Cache IS the authoritative store for rate limit state
  • Similar to write-through but cache-only (no database)

Sliding Window with Sorted Sets:

pythonis_rate_limited · 26 lines
async def is_rate_limited(api_key: str, limit: int = 100, window: int = 60) -> bool:
    now = time.time()
    key = f"ratelimit:{api_key}"
    
    pipe = redis.pipeline()
    
    # Remove old entries outside window
    pipe.zremrangebyscore(key, 0, now - window)
    
    # Count current entries
    pipe.zcard(key)
    
    # Add current request
    pipe.zadd(key, {str(now): now})
    
    # Set expiry on the key
    pipe.expire(key, window)
    
    results = await pipe.execute()
    current_count = results[1]
    
    return current_count >= limit

# Usage
if await is_rate_limited(api_key):
    return Response(status=429, body="Too Many Requests")

Redis Down Strategy:

pythoncheck_rate_limit · 13 lines
async def check_rate_limit(api_key: str) -> bool:
    try:
        return await is_rate_limited(api_key)
    except RedisError:
        # Option 1: Fail open (allow requests)
        logger.warning("Rate limiter unavailable, allowing request")
        return False
        
        # Option 2: Fail closed (deny requests)
        # return True
        
        # Option 3: Local fallback (less accurate)
        # return local_rate_limiter.check(api_key)

Chapter 11: Mock Interview Dialogue#

Scenario: Design Product Catalog Cache#

Transcript
Interviewer

"We have an e-commerce platform with 1 million products. Product pages get 50,000 views per second. How would you design the caching layer?"

You

"Great question. Let me start by understanding the data and access patterns.

For product pages, I assume we're showing product details like name, description, and images—which rarely change—plus price and inventory which change more frequently. What's the breakdown of read vs write operations?"

Interviewer

"It's heavily read-dominated. Products are updated maybe 1,000 times per day total, but viewed millions of times."

You

"Perfect, that's an ideal caching scenario. That's roughly 4 million reads per write (50,000 views/s × 86,400 s ÷ 1,000 updates/day), so even a 5-minute TTL would give us an excellent hit ratio.

I'd use the cache-aside pattern here. The application checks the cache first, and on a miss, loads from the database and populates the cache. This pattern gives us:

  1. Resilience — If Redis goes down, we fall back to the database. Slower, but functional.
  2. Simplicity — Easy to implement and reason about.
  3. Flexibility — We can cache different data with different TTLs.

For the cache key design, I'd use: product:{product_id}:v1 where v1 is the schema version. This lets us invalidate all cached products if we change the data structure.

Transcript
Interviewer

"What about inventory? It changes with every purchase."

You

"Good point. Inventory is tricky because it changes frequently and accuracy matters—we don't want to show 'In Stock' when we're actually sold out.

I'd handle inventory differently:

For most products, I'd cache inventory with a 30-second TTL. Users might see slightly stale counts, but it self-corrects quickly.

For high-demand items during sales, I'd skip the cache entirely and hit the database. Better to be slightly slower and accurate than fast and wrong.

For the add-to-cart and checkout flow, I'd always check real-time inventory—never rely on cache here. The product page can show an approximation, but the purchase flow needs truth.

Transcript
Interviewer

"What if we do a flash sale and prices change instantly? How do you invalidate?"

You

"For flash sales, TTL-based expiration isn't fast enough. I'd use event-driven invalidation.

When the sale starts:

  1. Update prices in the database
  2. Publish an event to Kafka: {type: 'flash_sale', product_ids: [...]}
  3. Cache invalidation service consumes the event
  4. Delete cache entries for affected products

Next request triggers a cache miss and loads the new price. This gives near-instant updates—typically under 1 second.

I'd keep a short TTL as a safety net too. If the invalidation event somehow fails, stale prices expire within 5 minutes anyway."

Interviewer

"How do you handle a cold start? What if you need to restart the cache cluster?"

You

"Cold start is a thundering herd waiting to happen. 50,000 requests per second, all hitting the database at once—that would be bad.

I'd use a few strategies:

  1. Cache warming — Before putting a new instance in rotation, run a job that pre-populates the cache with the top 10,000 most-viewed products. Covers 80% of traffic.

  2. Request coalescing — If multiple requests come in for the same product during a cache miss, only one actually queries the database. The others wait for that result. This is also called 'single-flight' pattern.

  3. Gradual traffic shift — If using a load balancer, start a new instance with 1% of traffic and slowly increase as the cache warms naturally.

For truly critical situations, I might also have a read replica database dedicated to cache rebuilding, so warming doesn't impact production traffic."

Interviewer

"Good. What metrics would you monitor?"

You

"For the cache layer specifically:

Hit ratio — Should be >95% for this use case. Drop below 90% means something's wrong.

Latency — p50 should be <2ms, p99 <10ms. If p99 climbs, we might have hot keys or network issues.

Memory usage — Track against maxmemory. Alert at 80% so we can scale before evictions spike.

Eviction rate — Some eviction is normal with LRU, but sudden spikes indicate we need more capacity.

Error rate — Connection errors, timeouts. Feed into circuit breaker.

I'd have alerts on hit ratio dropping and latency spiking. Those are the early warning signs before users notice problems."

Summary#

DAY 1 KEY TAKEAWAYS
CORE CONCEPT
  • Caching stores copies of data in faster storage
  • Reduces latency, increases throughput, lowers cost
  • But adds complexity: invalidation, consistency, failures
THE FOUR PATTERNS
Pattern How Trait Use for
Cache-Aside (Lazy Loading) App manages cache + database Most common, most flexible General purpose, read-heavy workloads
Read-Through Cache automatically loads from database Simpler app code Uniform data access patterns
Write-Through Writes go to both cache and database synchronously Fresh after writes (races possible) Critical data where stale reads are costly
Write-Behind Writes to cache, async to database Highest performance, highest risk Analytics, counters, loss-tolerant data
TRADE-OFFS
  • Consistency vs Performance
  • Complexity vs Control
  • Cache dependency vs Graceful degradation
DEFAULT CHOICE
  • Start with Cache-Aside
  • Add write-through for critical paths
  • Consider write-behind for analytics

Further Reading#

Official Documentation#

Engineering Blogs#

Books#

  • Book

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

  • Book

    "Redis in Action" by Josiah Carlson

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 01 · Caching Patterns
Finish Day 1Stages 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.

AmazonDocs · ~20 min

Database Caching Strategies Using Redis

2021
What
An AWS whitepaper on putting Redis in front of a relational database.
Key idea
It compares lazy loading (cache-aside) with write-through, and covers TTLs, eviction policies and what to cache.
Why here
The four caching patterns of this lesson, with the same trade-offs: stale reads with cache-aside, wasted writes with write-through.
taught here: Cache-aside
AmazonBlog post · ~15 min

Caching challenges and strategies

Jas Chhabra · 2019
What
Amazon's lessons on when caches help and when they create new failure modes.
Key idea
Caches introduce modal behaviour: a service tuned to a warm cache can fall over when it is cold, so plan for cold starts, coalescing and negative caching.
Why here
Adds the operational view to this lesson's patterns: in-process vs external caches, and why the cache must not become a hidden dependency.
taught here: Local vs external cache

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…