Database Caching Strategies

Reduce database load and latency by 100x with smart caching

From Database to Memory: 100x Speed Improvement

Database queries take milliseconds; in-memory cache lookups take microseconds. Caching is the single most effective performance optimization. This lesson expands on lesson 12's caching overview with production-ready patterns, cache invalidation strategies, and Redis/Memcached integration.

Real-World Impact:
  • Twitter: 95% of requests served from cache, database only sees 5% of traffic
  • Facebook: Memcached cluster with 28TB of RAM, serves 1 billion requests/second
  • Stack Overflow: Redis caching reduced page load from 200ms to 20ms

Why Caching Works

Databases optimize for durability (write to disk). Caches optimize for speed (keep in RAM). This fundamental trade-off explains the 100-1000x performance gap.

OperationLatencyThroughputUse Case
L1 CPU Cache1 ns1,000,000,000 ops/secCPU registers, compiler optimizations
RAM Access100 ns1,000,000,000 ops/secIn-process caches (e.g., dictionary/hashmap)
Redis/Memcached (localhost)50 - 100 μs100,000 - 500,000 ops/secApplication caching (this lesson)
SSD Read (Database)100 μs - 1 ms10,000 ops/secPersistent storage
Network Round-Trip1-10 ms100-1,000 ops/secRemote database queries
HDD Read10 ms100 ops/secLegacy storage
Key Insight: Redis over localhost (~100 μs) is 10-100x fasterthan PostgreSQL on SSD (1-10 ms with query parsing). For read-heavy workloads, caching delivers massive wins.
The Pareto Principle (80/20 Rule)
Cache Hit Patterns

In most applications, 80% of requests target 20% of data.

E-commerce Example:
Total products: 100,000
Popular products: 20,000

Requests:
  80% (8M/day) → Top 20K products
  20% (2M/day) → Remaining 80K

Cache 20K products = 80% hit rate
Reduces DB load by 80%!
Cache Size vs Hit Rate

Diminishing returns: doubling cache size doesn't double hit rate.

Cache Size | Hit Rate | DB Queries
-----------|----------|------------
     0 MB  |    0%    | 10,000 QPS
   100 MB  |   60%    |  4,000 QPS
   500 MB  |   85%    |  1,500 QPS
     1 GB  |   92%    |    800 QPS
     2 GB  |   95%    |    500 QPS

Sweet spot: 500MB-1GB
(92-95% hit rate)

Redis vs Memcached

Both are in-memory key-value stores, but Redis offers richer data structures and persistence. Most modern applications prefer Redis for its versatility.

FeatureRedisMemcached
Data StructuresStrings, Lists, Sets, Hashes, Sorted Sets, StreamsStrings only
PersistenceRDB snapshots + AOF logs (optional)None (pure cache)
ReplicationMaster-slave, Sentinel for HANone (client-side sharding)
TTL (Expiration)Per-key TTL with sub-second precisionPer-key TTL, second precision
Atomic OperationsINCR, DECR, List push/pop, Set operationsINCR, DECR only
Pub/SubYes (messaging, real-time features)No
Performance~100K ops/sec (single-threaded)~500K ops/sec (multi-threaded)
Best ForComplex caching, sessions, real-time, queuesSimple key-value caching at massive scale
Redis Setup and Basic Operations
# Installation
pip install redis

import redis
from datetime import timedelta

# Connect to Redis
cache = redis.Redis(
    host='localhost',
    port=6379,
    db=0,
    decode_responses=True  # Return strings instead of bytes
)

# Basic operations
cache.set('username', 'alice')
cache.set('user:1:email', 'alice@example.com')
cache.set('view_count', 0)

# Retrieve values
username = cache.get('username')  # 'alice'
email = cache.get('user:1:email')  # 'alice@example.com'

# Increment counter
cache.incr('view_count')  # Returns 1
cache.incr('view_count')  # Returns 2

# Result: Basic key-value operations with atomic increments
Result: Redis connected. Basic operations (GET, SET, INCR) execute in sub-millisecond time. decode_responses=True automatically converts bytes to strings.
Expiration (TTL)
# Set with expiration time (auto-delete after TTL)
cache.setex('session:abc123', timedelta(hours=1), 'user_data')
# Result: Key expires in 1 hour, automatically deleted

# Alternative syntax (set + expire)
cache.set('temp:token', 'xyz789')
cache.expire('temp:token', 300)  # Expire in 300 seconds (5 minutes)

# Check remaining TTL
ttl = cache.ttl('session:abc123')  # Returns seconds remaining
print(f"Session expires in {ttl} seconds")

# Result: 3599 (just under 1 hour)

# Persist (remove expiration)
cache.persist('session:abc123')  # Key will never expire now

# Result: Keys automatically clean themselves up, no manual deletion needed
Result: TTL ensures cache doesn't grow unbounded. Expired keys are automatically removed by Redis background process. Set aggressive TTLs (minutes to hours) for cache, longer TTLs (days) for session data.

Cache-Aside Pattern (Lazy Loading)

The most common caching pattern. Application checks cache first, loads from database on miss, then populates cache. Also called "lazy loading." (Note: read-through is a separate pattern where the cache itself loads from the database transparently.)

Flow Diagram
Cache Hit (Fast Path):
  Application → Cache → Return data (1ms)

Cache Miss (Slow Path):
  Application → Cache (miss)
              → Database (query)
              → Cache (populate)
              → Return data (10ms)

Subsequent requests: Fast path (cache hit)
Implementation
import redis
import psycopg2
import json

cache = redis.Redis(host='localhost', decode_responses=True)
db_conn = psycopg2.connect("dbname=myapp user=postgres")

def get_user(user_id):
    """Get user with cache-aside pattern."""
    cache_key = f"user:{user_id}"

    # Step 1: Try cache first
    cached_data = cache.get(cache_key)
    if cached_data:
        print("Cache HIT")
        return json.loads(cached_data)

    # Step 2: Cache miss - query database
    print("Cache MISS - querying database")
    cur = db_conn.cursor()
    cur.execute("SELECT id, username, email FROM users WHERE id = %s", (user_id,))
    row = cur.fetchone()

    if not row:
        return None

    user = {"id": row[0], "username": row[1], "email": row[2]}
    # Step 3: Populate cache with TTL
    cache.setex(cache_key, timedelta(hours=1), json.dumps(user))

    return user

# Usage
user1 = get_user(123)  # Cache MISS - querying database (10ms)
user2 = get_user(123)  # Cache HIT (0.5ms) - 20x faster!
user3 = get_user(123)  # Cache HIT (0.5ms)

# Result:
# First request: Slow (database query)
# Subsequent requests: Fast (cache hit)
Result: First request populates cache, subsequent requests are 10-100x faster. Cache TTL (1 hour) ensures stale data is eventually refreshed.
Handling Cache Stampede
# Problem: Cache expires at midnight, 1000 concurrent requests all miss cache
# All 1000 requests hit database simultaneously (stampede!)

import threading

def get_user_with_lock(user_id):
    """Prevent cache stampede with distributed lock."""
    cache_key = f"user:{user_id}"
    lock_key = f"lock:{cache_key}"

    # Try cache
    cached_data = cache.get(cache_key)
    if cached_data:
        return json.loads(cached_data)

    # Acquire lock (only one request rebuilds cache)
    lock_acquired = cache.set(lock_key, "1", nx=True, ex=5)  # 5-second lock

    if lock_acquired:
        try:
            # This request rebuilds cache
            user = query_database(user_id)
            cache.setex(cache_key, timedelta(hours=1), json.dumps(user))
            return user
        finally:
            cache.delete(lock_key)  # Release lock
    else:
        # Another request is rebuilding, wait and retry
        import time
        time.sleep(0.1)  # Wait 100ms
        return get_user_with_lock(user_id)  # Retry (should hit cache now)
Result: Only one request queries database, others wait for cache to be populated. Prevents database overload during cache expiration.

Write-Through vs Write-Back Caching

Cache-aside handles reads. What about writes? Three strategies exist, each with trade-offs for consistency, performance, and complexity.

Write-Around

Write to database only, invalidate cache.

1. Write → Database
2. Delete cache entry
3. Next read → Cache miss

Flow:
App → DB (write)
    → Cache (delete)

Pros:

  • Simple
  • No cache/DB inconsistency

Cons:

  • Next read is slow (cache miss)
Write-Through

Write to both cache and database synchronously.

1. Write → Database
2. Write → Cache
3. Return success

Flow:
App → DB (write)
    → Cache (update)

Both succeed or fail

Pros:

  • Cache always current
  • Next read is fast

Cons:

  • Slower writes (two ops)
Write-Back

Write to cache only, async flush to database.

1. Write → Cache
2. Return success (fast!)
3. Background job → DB

Flow:
App → Cache (write)
      ↓ (async)
      DB (batch write)

Pros:

  • Fastest writes
  • Batching possible

Cons:

  • Data loss if cache crashes
  • Complex to implement
Write-Around Implementation (Most Common)
def update_user(user_id, email):
    """Update user with write-around (invalidate cache)."""
    cache_key = f"user:{user_id}"

    # Step 1: Write to database
    cur = db_conn.cursor()
    cur.execute(
        "UPDATE users SET email = %s WHERE id = %s",
        (email, user_id)
    )
    db_conn.commit()

    # Step 2: Invalidate cache (delete stale entry)
    cache.delete(cache_key)

    # Result: Database is authoritative source
    # Next read will cache miss, fetch fresh data from DB

# Usage
update_user(123, 'newemail@example.com')
# Cache deleted, next get_user(123) will be slow but accurate
Result: Simple and safe. Cache deletion guarantees consistency, no stale data possible. Preferred for most applications.
Write-Through Implementation
def update_user_write_through(user_id, email):
    """Update user with write-through (update cache)."""
    cache_key = f"user:{user_id}"

    # Step 1: Write to database
    cur = db_conn.cursor()
    cur.execute(
        "UPDATE users SET email = %s, updated_at = NOW() WHERE id = %s RETURNING *",
        (email, user_id)
    )
    row = cur.fetchone()
    db_conn.commit()

    # Step 2: Update cache with fresh data
    user = {"id": row[0], "username": row[1], "email": row[2]}
    cache.setex(cache_key, timedelta(hours=1), json.dumps(user))

    # Result: Cache has latest data, next read is fast

# Usage
update_user_write_through(123, 'newemail@example.com')
# Cache updated, next get_user(123) is instant (cache hit)
Result: Cache stays warm, next read hits cache. Useful for frequently-read data that's occasionally updated (user profiles, product details).

Cache Invalidation Patterns

Phil Karlton famously said: "There are only two hard things in Computer Science: cache invalidation and naming things." When data changes, how do you ensure cache reflects reality?

Pattern 1: TTL-Based Expiration
# Simplest: Let cache expire naturally

cache.setex('product:123', timedelta(minutes=15), json.dumps(product_data))

# Result:
# - Stale data possible for up to 15 minutes
# - No invalidation logic needed
# - Works well for data that changes infrequently

# TTL Guidelines:
# - User sessions: 30 minutes - 24 hours
# - Product catalog: 5-15 minutes
# - User profiles: 1-6 hours
# - Static content: 24 hours - 7 days
# - Real-time data: Don't cache, or use 10-30 seconds
Result: Zero invalidation complexity. Accept eventual consistency, cache refreshes automatically after TTL. Most applications can tolerate 5-15 minute staleness.
Pattern 2: Event-Based Invalidation
# Invalidate cache when data changes

def update_product(product_id, new_price):
    """Update product and invalidate related cache entries."""

    # Update database and fetch category for cache invalidation
    cur = db_conn.cursor()
    cur.execute(
        "UPDATE products SET price = %s WHERE id = %s RETURNING category_id",
        (new_price, product_id)
    )
    row = cur.fetchone()
    db_conn.commit()

    category_id = row[0]

    # Invalidate all related cache keys
    cache.delete(f"product:{product_id}")                    # Individual product
    cache.delete(f"product:{product_id}:details")            # Product details page
    cache.delete(f"category:{category_id}:products")         # Category listing
    cache.delete("homepage:featured_products")               # Homepage cache

    # Result: All cached pages with this product are now stale
    # Next request will rebuild cache with fresh data
Result: Cache invalidated immediately when data changes. Requires tracking all cache keys affected by an update, can be complex for interconnected data.
Pattern 3: Tag-Based Invalidation
# Use Redis Sets to group related cache keys

def cache_with_tags(cache_key, data, tags, ttl=3600):
    """Cache data and associate with tags for bulk invalidation."""

    # Store data
    cache.setex(cache_key, ttl, json.dumps(data))

    # Add cache key to tag sets
    for tag in tags:
        cache.sadd(f"tag:{tag}", cache_key)
        cache.expire(f"tag:{tag}", ttl)  # Tag set expires with data

def invalidate_by_tag(tag):
    """Invalidate all cache entries with a specific tag."""
    tag_key = f"tag:{tag}"

    # Get all cache keys with this tag
    cache_keys = cache.smembers(tag_key)

    # Delete all tagged entries
    if cache_keys:
        cache.delete(*cache_keys)
        cache.delete(tag_key)  # Delete tag set itself

    print(f"Invalidated {len(cache_keys)} entries for tag '{tag}'")
# Usage
cache_with_tags(
    "product:123",
    product_data,
    tags=["product", "category:electronics", "brand:apple"],
    ttl=900
)

cache_with_tags(
    "product:456",
    product_data,
    tags=["product", "category:electronics", "brand:samsung"],
    ttl=900
)

# Invalidate all electronics category cache
invalidate_by_tag("category:electronics")
# Result: Invalidated 2 entries for tag 'category:electronics'
# Both products cleared from cache
Result: Bulk invalidation without tracking individual keys. Tag all cache entries by category, user, brand, etc. One tag deletion clears related caches.

Query Result Caching

Cache entire query results (not just single records) to avoid expensive joins and aggregations. Perfect for search results, leaderboards, and analytics.

Caching Search Results
import hashlib

def search_products(query, category=None, min_price=None, max_price=None):
    """Search products with query result caching."""

    # Generate cache key from search parameters (deterministic)
    cache_params = f"{query}:{category}:{min_price}:{max_price}"
    cache_key = f"search:{hashlib.md5(cache_params.encode()).hexdigest()}"

    # Try cache
    cached_results = cache.get(cache_key)
    if cached_results:
        print("Cache HIT - returning cached search results")
        return json.loads(cached_results)

    # Cache miss - run expensive query
    print("Cache MISS - executing database query")
    cur = db_conn.cursor()

    query_sql = """
        SELECT p.id, p.name, p.price, c.name AS category
        FROM products p
        JOIN categories c ON p.category_id = c.id
        WHERE p.name ILIKE %s
    """
    params = [f"%{query}%"]
    if category:
        query_sql += " AND c.name = %s"
        params.append(category)

    if min_price:
        query_sql += " AND p.price >= %s"
        params.append(min_price)

    if max_price:
        query_sql += " AND p.price <= %s"
        params.append(max_price)

    cur.execute(query_sql, params)
    results = [dict(zip(['id', 'name', 'price', 'category'], row))
               for row in cur.fetchall()]

    # Cache results for 5 minutes
    cache.setex(cache_key, 300, json.dumps(results))

    return results

# Usage
results1 = search_products("laptop", category="Electronics", min_price=500)
# Cache MISS - executing database query (50ms)

results2 = search_products("laptop", category="Electronics", min_price=500)
# Cache HIT - returning cached search results (1ms) - 50x faster!
Result: Identical searches hit cache. Hash-based cache key ensures same parameters = same cache key. 5-minute TTL keeps results fresh.
Caching Leaderboards
# Use Redis Sorted Sets for leaderboards (no database!)

def update_user_score(user_id, score):
    """Update user score in leaderboard."""
    cache.zadd('leaderboard:global', {f"user:{user_id}": score})
    # Result: Score stored in Redis sorted set, O(log N) operation

def get_top_users(limit=10):
    """Get top N users from leaderboard."""
    # Get top users with scores (descending order)
    top_users = cache.zrevrange('leaderboard:global', 0, limit - 1, withscores=True)

    # Format results
    leaderboard = [
        {"user_id": user.split(':')[1], "score": int(score)}
        for user, score in top_users
    ]
    return leaderboard

# Usage
update_user_score(123, 9500)
update_user_score(456, 12000)
update_user_score(789, 8000)

top_10 = get_top_users(10)
# Result: [{'user_id': '456', 'score': 12000},
#          {'user_id': '123', 'score': 9500},
#          {'user_id': '789', 'score': 8000}]
# Retrieved in <1ms, no database query!
Result: Redis Sorted Sets are perfect for leaderboards. O(log N) inserts, O(log N + M) range queries. Can handle millions of users with sub-millisecond queries.

Database Query Cache

MySQL and PostgreSQL have built-in query caches. Understanding how they work helps you optimize cache hit rates.

MySQL Query Cache (Deprecated in 8.0)
-- MySQL Query Cache (removed in MySQL 8.0)
-- Cached queries by exact SQL text match

SELECT * FROM users WHERE id = 1;  -- Cache MISS, query executed, result cached
SELECT * FROM users WHERE id = 1;  -- Cache HIT, instant result

-- Problem: Cache invalidated on ANY write to table
UPDATE users SET email = 'new@example.com' WHERE id = 999;
-- Result: Entire query cache for 'users' table CLEARED

-- Why it was removed:
-- - Too aggressive invalidation (any write clears all cached queries)
-- - Single mutex caused contention at high concurrency
-- - Application-level caching (Redis) is more flexible
Limitation: Database query caches are invalidated on any table write. For write-heavy workloads, hit rate is near zero. Application-level caching (Redis) offers better control.
PostgreSQL Shared Buffers (Cache)
-- PostgreSQL doesn't cache query results, but caches data pages in RAM

-- postgresql.conf
shared_buffers = 4GB          -- RAM for caching table/index pages
effective_cache_size = 12GB   -- OS + PG cache combined

-- How it works:
-- 1. First query reads from disk (slow)
-- 2. Pages loaded into shared_buffers (RAM)
-- 3. Subsequent queries hit RAM (fast)

-- Check cache hit rate
SELECT
    sum(heap_blks_read) AS disk_reads,
    sum(heap_blks_hit) AS cache_hits,
    sum(heap_blks_hit) / (sum(heap_blks_hit) + sum(heap_blks_read)) AS cache_hit_ratio
FROM pg_statio_user_tables;

-- Goal: >99% cache hit ratio
-- If <95%, increase shared_buffers
Result: PostgreSQL caches data pages (8KB blocks), not query results. Repeated queries benefit from cached pages. 99% hit rate means only 1% of reads touch disk.

Caching Best Practices

Do This
  • Set aggressive TTLs (5-15 min) to prevent stale data
  • Cache expensive queries (joins, aggregations, searches)
  • Use cache-aside for simplicity (most common pattern)
  • Monitor cache hit rate (target >80% for reads)
  • Use Redis over Memcached for data structures and persistence
  • Implement cache stampede protection with locks
  • Invalidate explicitly on writes (write-around pattern)
  • Use namespace prefixes (user:, product:, session:)
Avoid This
  • Don't cache forever (always set TTL, prevent unbounded growth)
  • Don't cache write-heavy data (hit rate will be <50%)
  • Don't cache user-specific data in shared cache (privacy issue)
  • Don't ignore cache failures (gracefully fall back to database)
  • Don't cache everything (only cache hot data, 80/20 rule)
  • Don't forget to serialize (use JSON, not pickle for Python)
  • Don't rely on MySQL query cache (removed in 8.0)
  • Don't cache large objects (>1MB, cache references/IDs instead)

Caching Strategy Decision Tree

1. What is your read/write ratio?
  • 90%+ reads → Cache aggressively with cache-aside
  • 50/50 or write-heavy → Cache selectively (expensive queries only)
2. How fresh must data be?
  • Real-time required → Don't cache, or use 10-30 sec TTL
  • 5-15 min staleness OK → Use cache-aside with TTL
  • Must be instantly consistent → Use write-through or write-around
3. What type of data are you caching?
  • Single records → Cache-aside with key-value (user:123)
  • Search results → Query result caching with hash keys
  • Leaderboards → Redis Sorted Sets (no database)
  • Counters → Redis INCR/DECR (atomic operations)
4. How do you invalidate cache?
  • Simple data → TTL-based expiration (set and forget)
  • Interconnected data → Tag-based invalidation (bulk delete)
  • Critical consistency → Event-based invalidation on every write
Recommended Stack: For most web applications, use Redis with cache-aside pattern, TTL expiration (5-15 min), and write-around invalidation. This covers 90% of use cases with minimal complexity.