Skip to main content

Caching Patterns

Effective caching strategies are critical for performance and data consistency.

Why Caching Patterns Matter

  • Performance: Cache hit = microsecond latency vs millisecond for database
  • Consistency: Stale cache = incorrect data
  • Scalability: Reduce database load, handle more traffic

Real-world impact:

  • Good caching: 90%+ cache hit rate, 10x fewer database queries
  • Bad caching: Cache stampede (thundering herd) brings down database
  • Inconsistent cache: Users see old data, lost updates

Cache Aside (Lazy Loading)

How It Works

Implementation

# Read
cache_value = GET cache:user:123
if cache_value == NULL:
# Cache miss, query database
user = db.query("SELECT * FROM users WHERE id = 123")
# Write to cache (with TTL)
SETEX cache:user:123 3600 user.to_json()
return user
else:
# Cache hit
return parse_json(cache_value)

# Write (update database, invalidate cache)
db.execute("UPDATE users SET name = 'Alice' WHERE id = 123")
DEL cache:user:123 # Invalidate cache

Pros and Cons

ProsCons
Simple implementationCache stampede on cache miss
Cache only requested dataStale cache (until TTL)
Low memory usage (only cached data)Three round trips on miss

Cache Stampede Prevention

Problem: Multiple requests miss cache, all query database simultaneously

Solution: Lock or early expiration

# Use SETNX (set if not exists) for lock
lock_key = "lock:user:123"
if SETNX lock_key 1 == 1:
# Acquired lock, query database
EXPIRE lock_key 10 # Prevent stale lock
user = db.query("SELECT * FROM users WHERE id = 123")
SETEX cache:user:123 3600 user.to_json()
DEL lock_key
else:
# Wait and retry
sleep(0.1)
return get_user_cached(123) # Retry

# Or use probabilistic early expiration (refresh cache before TTL)
value = GET cache:user:123
if value != NULL and ttl < 60: # TTL < 60 seconds
# Refresh cache asynchronously
async_refresh_cache(123)

Write Through

How It Works

Implementation

# Write
user = {"id": 123, "name": "Alice"}
# Write to cache (synchronous)
SETEX cache:user:123 3600 user.to_json()
# Write to database (synchronous)
db.execute("UPDATE users SET name = 'Alice' WHERE id = 123")

# Read (simpler, no database query on hit)
cache_value = GET cache:user:123
if cache_value != NULL:
return parse_json(cache_value)
else:
# Fallback to database
user = db.query("SELECT * FROM users WHERE id = 123")
SETEX cache:user:123 3600 user.to_json()
return user

Pros and Cons

ProsCons
Cache always consistent with databaseSlower writes (write to cache + DB)
No stale cacheCache stores all writes (even uncached data)
Simple readsWrite failures leave cache inconsistent

Write Behind (Async)

How It Works

Implementation

# Write (fast, returns immediately)
user = {"id": 123, "name": "Alice"}
SETEX cache:user:123 3600 user.to_json()
queue.push({"type": "update", "table": "users", "id": 123, "data": user})
return success # Return immediately

# Background worker (process queue)
while true:
task = queue.pop()
if task.type == "update":
db.execute("UPDATE users SET ... WHERE id = ?", task.id)

Pros and Cons

ProsCons
Fast writes (no database I/O)Data loss if cache fails before persisting
Batch database writes (efficient)Complex implementation
Reduced database loadEventual consistency (lag before DB write)

Cache Invalidation Strategies

TTL (Time to Live)

Automatic expiration: Cache entries expire after fixed time

SETEX cache:user:123 3600 user  # Expire after 1 hour
EXPIRE cache:user:123 300 # Set expiration to 5 minutes

Trade-off:

  • Short TTL: Frequent cache misses (more database load)
  • Long TTL: Stale cache (inconsistent data)

Best practices:

  • Cache expiration before data changes (proactive refresh)
  • Use different TTL for different data types
  • Monitor cache hit rate to tune TTL

Write Invalidation

Invalidate on write: Delete or update cache when data changes

# Update user
db.execute("UPDATE users SET name = 'Alice' WHERE id = 123")
DEL cache:user:123 # Invalidate cache

# Or update cache (write-through)
user = db.query("SELECT * FROM users WHERE id = 123")
SETEX cache:user:123 3600 user.to_json()

Challenge: Multiple database writes, need to track all mutations

Solution: Use ORM hooks, database triggers, or application events

Database Trigger Invalidation

Trigger invalidates cache on database change:

-- MySQL trigger
CREATE TRIGGER user_update AFTER UPDATE ON users
FOR EACH ROW
BEGIN
-- Call Redis (via UDF or external script)
DO redis_del(concat('cache:user:', NEW.id));
END;

Pros: Database-driven, application-agnostic Cons: Complexity, latency (trigger execution)

Message Queue Invalidation

Publish invalidation events:

# On database update
db.execute("UPDATE users SET name = 'Alice' WHERE id = 123")
# Publish invalidation event
PUBLISH cache:invalidate:user:123 ""

# Cache subscribers (multiple cache instances)
SUBSCRIBE cache:invalidate:user:*
# On message: DEL cache:user:123

Pros: Decoupled, supports distributed cache Cons: Eventual consistency (lag between publish and subscribe)

Common Caching Pitfalls

1. Cache Penetration

Problem: Attacker requests non-existent data, bypassing cache (always miss)

# Attacker requests: GET /users/99999 (does not exist)
# Cache miss, database query returns NULL
# Next request: Same key, cache miss again (NULL not cached)

Solution: Cache empty result

cache_value = GET cache:user:99999
if cache_value == NULL:
user = db.query("SELECT * FROM users WHERE id = 99999")
if user == NULL:
# Cache empty result (short TTL)
SETEX cache:user:99999 60 "NULL"
else:
SETEX cache:user:99999 3600 user.to_json()

2. Cache Breakdown

Problem: Hot key expires, many requests query database simultaneously (cache stampede)

Solution: Probabilistic early expiration or lock

# Early expiration: 10% of requests refresh cache early
value = GET cache:user:123
if value != NULL:
ttl = TTL cache:user:123
if ttl < 60 and random() < 0.1: # 10% chance if TTL < 60 seconds
# Refresh cache asynchronously
async_refresh_cache(123)

3. Cache Avalanche

Problem: Many cache keys expire simultaneously (e.g., server restart, mass expiration)

Solution: Randomize TTL

# Instead of fixed TTL
SETEX cache:user:123 3600 user

# Use random TTL (3540-3660 seconds)
ttl = 3600 + random(-60, 60)
SETEX cache:user:123 ttl user

Caching Best Practices

1. Serialize Properly

# ❌ Bad: Python pickle (security risk, language-specific)
SET cache:user:123 pickle.dumps(user)

# ✅ Good: JSON (portable, human-readable)
SET cache:user:123 json.dumps(user)

# ✅ Better: MessagePack (compact, fast)
SET cache:user:123 msgpack.packb(user)

2. Use Appropriate Data Structures

# ❌ Bad: Store JSON string
SET cache:user:123 '{"name":"Alice","age":25}'
GET cache:user:123 # Parse JSON in application

# ✅ Good: Use hash
HSET cache:user:123 name "Alice" age 25
HGET cache:user:123 name # No parsing

3. Set TTL (Prevent Memory Leak)

# Always set TTL for cache data
SETEX cache:user:123 3600 user # Auto-expire after 1 hour

# Monitor memory usage
INFO memory
# Set max memory and eviction policy
CONFIG SET maxmemory 2gb
CONFIG SET maxmemory-policy allkeys-lru

4. Monitor Cache Hit Rate

# Redis stats
INFO stats
# keyspace_hits: Cache hits
# keyspace_misses: Cache misses

# Hit rate = hits / (hits + misses)
hit_rate = keyspace_hits / (keyspace_hits + keyspace_misses)

# Aim for > 90% hit rate

5. Use Namespace for Easy Invalidation

# Use colon-separated namespace
SET app:user:123:name "Alice"
SET app:product:456:price 99.99

# Invalidate all user cache (not directly supported, use scan or separate keys per user)
SCAN 0 MATCH app:user:123:*
DEL <returned keys>

Eviction Policies

When Redis reaches maxmemory, it evicts keys based on policy:

PolicyDescription
noevictionReturn error on write when memory limit reached
allkeys-lruEvict least recently used keys (any key)
volatile-lruEvict LRU among keys with TTL set
allkeys-randomEvict random keys
volatile-randomEvict random keys with TTL set
volatile-ttlEvict keys with shortest TTL first
allkeys-lfu (Redis 4.0+)Evict least frequently used keys
volatile-lfu (Redis 4.0+)Evict LFU among keys with TTL set

Recommendation: allkeys-lru for cache, volatile-lru for cache + persistent data

CONFIG SET maxmemory 2gb
CONFIG SET maxmemory-policy allkeys-lru

Interview Questions

Q1: What is cache stampede and how do you prevent it?

Answer: Cache stampede (thundering herd) occurs when hot key expires and many requests query database simultaneously. Prevent with: 1) Lock (SETNX), 2) Probabilistic early expiration (refresh cache before TTL), 3) Hot cache (never expire, update explicitly).

Q2: What's the difference between cache aside and write through?

Answer: Cache aside: Application manages cache (lazy loading, explicit invalidation). Write through: Write to cache and database synchronously on update. Cache aside: simpler, more flexible. Write through: always consistent, slower writes.

Q3: How do you handle cache invalidation?

Answer: 1) TTL (automatic expiration), 2) Write invalidation (delete/update cache on write), 3) Message queue (publish invalidation events), 4) Database triggers. Choose based on consistency requirements and complexity.

Q4: What is cache penetration?

Answer: Attacker requests non-existent data, bypassing cache (always miss). Solutions: 1) Cache empty result (NULL), 2) Bloom filter (quick check if key exists), 3) Rate limiting.

Further Reading