Caching is the most effective way to improve performance. Here’s how to do it right.
Cache Layers
Client → CDN Cache → App Cache → Database Cache → Database
Each layer reduces load on the next.
Common Patterns
Cache-Aside (Lazy Loading)
1
2
3
4
5
6
7
8
9
10
11
12
|
def get_user(user_id):
# Check cache
cached = redis.get(f"user:{user_id}")
if cached:
return json.loads(cached)
# Load from database
user = db.query("SELECT * FROM users WHERE id = ?", user_id)
# Store in cache
redis.setex(f"user:{user_id}", 3600, json.dumps(user))
return user
|
Pros: Only caches what’s needed
Cons: Cache miss penalty, potential stale data
Write-Through
1
2
3
4
5
6
|
def update_user(user_id, data):
# Update database
db.query("UPDATE users SET ... WHERE id = ?", user_id)
# Update cache immediately
redis.setex(f"user:{user_id}", 3600, json.dumps(data))
|
Pros: Cache always consistent
Cons: Write latency, unused data cached
Write-Behind
1
2
3
4
5
6
|
def update_user(user_id, data):
# Update cache immediately
redis.setex(f"user:{user_id}", 3600, json.dumps(data))
# Queue database update
queue.publish("user_updates", {"id": user_id, "data": data})
|
Pros: Fast writes
Cons: Complexity, potential data loss
Cache Invalidation
The two hard things in computer science:
- Cache invalidation
- Naming things
Time-Based (TTL)
1
|
redis.setex("user:123", 3600, data) # Expires in 1 hour
|
Simple but may serve stale data.
Event-Based
1
2
3
|
def update_user(user_id, data):
db.update(user_id, data)
redis.delete(f"user:{user_id}") # Invalidate
|
More accurate but more complex.
Versioned Keys
1
2
|
version = redis.incr("user:123:version")
redis.set(f"user:123:v{version}", data)
|
What to Cache
Good candidates:
- Expensive computations
- Frequently accessed data
- Rarely changing data
- External API responses
Poor candidates:
- Rapidly changing data
- User-specific data (low hit rate)
- Large objects
Redis Examples
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
import redis
r = redis.Redis(host='localhost', port=6379)
# String
r.set("key", "value")
r.get("key")
# Hash (for objects)
r.hset("user:123", mapping={"name": "John", "email": "john@example.com"})
r.hgetall("user:123")
# List (for queues)
r.lpush("tasks", "task1")
r.rpop("tasks")
# Set (for unique items)
r.sadd("online_users", "user1", "user2")
r.smembers("online_users")
# Sorted Set (for leaderboards)
r.zadd("scores", {"player1": 100, "player2": 200})
r.zrevrange("scores", 0, 10, withscores=True)
|
Cache Metrics
Track these:
- Hit rate: % of requests served from cache
- Miss rate: % requiring database
- Eviction rate: Items removed before TTL
- Memory usage: Cache size
1
2
3
4
5
|
# Redis INFO
info = redis.info()
hits = info['keyspace_hits']
misses = info['keyspace_misses']
hit_rate = hits / (hits + misses)
|
Common Mistakes
- No TTL: Cache grows forever
- Cache stampede: Many requests miss simultaneously
- Over-caching: Caching cheap operations
- Ignoring consistency: Stale data causes bugs
Cache Stampede Prevention
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
def get_with_lock(key, compute_fn, ttl=3600):
cached = redis.get(key)
if cached:
return cached
# Try to acquire lock
lock_key = f"{key}:lock"
if redis.setnx(lock_key, "1"):
redis.expire(lock_key, 30)
value = compute_fn()
redis.setex(key, ttl, value)
redis.delete(lock_key)
return value
else:
# Wait and retry
time.sleep(0.1)
return get_with_lock(key, compute_fn, ttl)
|
Caching is powerful but requires careful design. Start simple, measure, and optimize.
Comments