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.

Data analytics dashboard
If your cache hit rate looks like this, you're doing something right.

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.

Technology infrastructure
Your cache layer should be simpler than the infrastructure it sits on top of.

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:

  1. Reference data ( config, settings ): 1 hour or more. This stuff barely changes.
  2. User-specific data: 5-10 minutes. Long enough to help, short enough to not cause confusion.
  3. Aggregated / computed data: 1-5 minutes. Depends on how much you tolerate staleness.
  4. 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)
Laptop with code
The face you make when you realize your cache TTL was 86400 on everything.

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:

  1. HASH for object caching. Instead of JSON serializing entire objects, store fields individually. Update one field without rewriting everything.
  2. SORTED SET for leaderboards and ranking. ZADD + ZREVRANGE and you're done. No database queries needed.
  3. 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) == 0

These 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.

:)