I've been running Redis as a cache layer for years, and most setups I see out there are either over-engineered or barely doing anything useful. There's a sweet spot between caching everything and caching nothing, and it's smaller than you think.
Here's how I actually use Redis in production. Not the textbook version, the real one.
The Basics ( That Everyone Gets Wrong )
Redis is an in-memory key-value store. That's it. It's not a database replacement, it's not a message queue ( well, it can be, but that's a different post ), and it's definitely not a search engine. It holds data in RAM and gives it back fast.
The first mistake I see: people set TTLs on everything with no strategy. Random 3600-second expiry on all keys is not a strategy. It's cargo culting.
Cache-Aside: The Default Pattern
This is what 90% of people should be using. Your app checks Redis first. Miss? Go to the database, write the result to Redis, return. Hit? Return directly.
Here's the Python implementation I use everywhere:
import redis
import json
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
def cache_aside(key: str, fetch_fn, ttl: int = 300):
"""
key: cache key
fetch_fn: callable that returns the data if cache misses
ttl: time to live in seconds
"""
cached = r.get(key)
if cached is not None:
return json.loads(cached)
data = fetch_fn()
r.setex(key, ttl, json.dumps(data))
return data
# Usage
user = cache_aside(
f"user:{user_id}",
lambda: db.query(User).get(user_id),
ttl=600
)Simple. The function doesn't know about Redis. Redis doesn't know about your database. Clean separation. I've seen people build caching "layers" with 15 classes and dependency injection. You don't need that. A function with a key, a fallback, and a TTL. Done.
Write-Through: When Consistency Matters
Cache-aside is great for reads, but what about writes? If you write to the database and the cache still has stale data, you're serving garbage. I use write-through for data that changes frequently and absolutely must be consistent.
def write_through(key: str, data: dict, db_save_fn, ttl: int = 300):
"""
Write to database AND cache simultaneously.
If either fails, the whole operation fails.
"""
# Save to database first
db_save_fn(data)
# Update cache
r.setex(key, ttl, json.dumps(data))
return data
# Usage
def save_user(user_data):
return write_through(
f"user:{user_data['id']}",
user_data,
lambda d: db.query(User).update(d),
ttl=600
)Trade-off: every write now hits both Redis and the database. Slower writes, but you never serve stale data from cache. I use this for user profiles, account settings, anything where stale data causes real problems.
TTL Strategy: Stop Guessing
My TTL rules are straightforward:
- Reference data ( config, settings ): 1 hour or more. This stuff barely changes.
- User-specific data: 5-10 minutes. Long enough to help, short enough to not cause confusion.
- Aggregated / computed data: 1-5 minutes. Depends on how much you tolerate staleness.
- Rate limit counters: No TTL, manual expiry. These need precision.
I add jitter to TTLs when I'm caching something that might cause a thundering herd on expiry. Adding a random 0-60 seconds on top of a 5-minute TTL means not all keys expire at the same instant.
import random
def set_with_jitter(key: str, value: str, base_ttl: int = 300, jitter: int = 60):
actual_ttl = base_ttl + random.randint(0, jitter)
r.setex(key, actual_ttl, value)Cache Invalidation: The Hard Problem
Phil Karlton said there are only two hard things in computer science: cache invalidation and naming things. He was right about the first one.
My approach: invalidate on write, accept TTL-based expiry for everything else. When a user updates their profile, I delete the cache key. No waiting for TTL, no serving 10 minutes of stale data.
def invalidate_pattern(pattern: str):
"""Delete all keys matching a pattern."""
cursor = 0
while True:
cursor, keys = r.scan(cursor, match=pattern, count=100)
if keys:
r.delete(*keys)
if cursor == 0:
break
# When a user updates
invalidate_pattern(f"user:{user_id}:*")
# Or just the specific key
r.delete(f"user:{user_id}")Be careful with KEYS * in production. It blocks Redis. Use SCAN instead. Always. I learned this the hard way at 3am on a Saturday.
Monitoring Your Cache
If you're not tracking hit rate, you're not caching. You're just burning RAM. I check three metrics:
def get_cache_stats():
info = r.info('stats')
hits = info.get('keyspace_hits', 0)
misses = info.get('keyspace_misses', 0)
total = hits + misses
hit_rate = (hits / total * 100) if total > 0 else 0
return {
'hit_rate': round(hit_rate, 2),
'hits': hits,
'misses': misses,
'memory_used': r.info('memory')['used_memory_human'],
'keys': r.dbsize()
}
# Quick health check
stats = get_cache_stats()
# {'hit_rate': 87.5, 'hits': 1750, 'misses': 250, 'memory_used': '128M', 'keys': 3421}
# If hit_rate < 50%, your cache strategy is wrong. Fix the keys or the TTLs.Hit rate below 50% means your cache is useless. 80%+ is where you want to be. Anything above 95% and you might be caching too aggressively ( stale data risk ).
Redis Data Structures You're Not Using
Everyone uses STRING. There's more:
- HASH for object caching. Instead of JSON serializing entire objects, store fields individually. Update one field without rewriting everything.
- SORTED SET for leaderboards and ranking. ZADD + ZREVRANGE and you're done. No database queries needed.
- SET for deduplication. SADD is O(1) and prevents duplicates naturally. Great for tracking seen items or unique visitors.
# Hash for user profile caching
r.hset(f"user:{uid}", mapping={
"name": user.name,
"email": user.email,
"plan": user.plan
})
r.hget(f"user:{uid}", "name") # Just the name, no full decode
# Sorted set for leaderboard
r.zadd("leaderboard", {player_id: score})
top_10 = r.zrevrange("leaderboard", 0, 9, withscores=True)
# Set for tracking
r.sadd("seen:article:123", user_id) # O(1), no duplicates
is_new = r.sismember("seen:article:123", user_id) == 0These data structures are the reason Redis is more than memcached with extra features. Use them.
Conclusion
Start with cache-aside. Add write-through where consistency matters. Set proper TTLs per data type. Invalidate on writes. Monitor hit rate. Don't over-engineer it.
Redis is a tool, not an architecture. Treat it like one.
:)