Redis · caching · performance patterns

Redis Caching Strategies: From Basics to Advanced Patterns

Unlock the full potential of Redis for high‑performance applications

❝ Redis is not just a cache—it's a versatile data structure server. But using it effectively requires understanding caching patterns that balance speed, consistency, and reliability. From simple key‑value lookups to advanced patterns like leaderboards, rate limiting, and distributed locks, Redis can be the backbone of your application's performance layer.❞

This guide takes you from foundational caching strategies (cache‑aside, read‑through, write‑through) to advanced patterns like cache invalidation with pub/sub, Bloom filters, and Redis modules. You'll learn how to choose the right strategy, avoid common pitfalls, and build robust, scalable systems.

1. Why Redis Excels as a Cache

Redis is designed for caching, but its features make it suitable for many secondary use cases (queues, real‑time analytics, session stores).

2. Cache‑Aside (Lazy Loading)

The most common pattern. The application first checks the cache; if not present, it loads from the database and stores the result.

def get_user(user_id):
    # 1. Try cache
    user = redis.get(f"user:{user_id}")
    if user:
        return json.loads(user)
    
    # 2. Cache miss: fetch from DB
    user = db.query("SELECT * FROM users WHERE id = %s", user_id)
    
    # 3. Store in cache with TTL
    redis.setex(f"user:{user_id}", 3600, json.dumps(user))
    return user

Pros: Simple, only caches data that is actually requested. Cons: First request is slow; cache misses can spike database load.

Tip: Use SETNX or a distributed lock to prevent thundering herd (multiple simultaneous cache misses for the same key).

3. Write Patterns: Write‑Through and Write‑Behind

Cache‑aside handles reads; writes require special handling to keep cache consistent.

✍️ Write‑Through

Update cache and database atomically (or in the same transaction).

def update_user(user_id, data):
    db.execute("UPDATE users SET ... WHERE id = %s", user_id)
    redis.set(f"user:{user_id}", json.dumps(data))

Pros: Cache always consistent. Cons: Write latency increases.

⏱️ Write‑Behind (Write‑Back)

Write to cache first, asynchronously persist to DB (using a queue).

redis.set(f"user:{user_id}", data)
queue.enqueue(update_db, user_id, data)

Pros: Very fast writes. Cons: Risk of data loss if cache fails before persistence.

4. Cache Invalidation: The Hardest Problem

Keeping cache fresh is challenging. Common approaches:

# Redis Pub/Sub example for invalidation
redis.publish('cache_invalidations', f'user:{user_id}')
# In worker:
def on_message(msg):
    redis.delete(msg['data'])

5. Memory Management: Eviction Policies

Redis offers several eviction policies when maxmemory is reached:

Choose based on data importance. For pure cache, allkeys‑lru is common. For mixed use (cache + persistent data), volatile‑lru protects keys without TTL.

6. Beyond Strings: Using Hashes, Sets, and Sorted Sets

📦 Hashes

Store objects as field‑value pairs. Efficient for partial updates.

HSET user:1001 name "Alice" email "alice@example.com"
HGET user:1001 name

📊 Sorted Sets

Ideal for leaderboards, time‑series, rate limiting.

ZADD leaderboard 1000 "user1" 2000 "user2"
ZRANGE leaderboard 0 10 WITHSCORES

🔢 Bitmaps & HyperLogLogs

Space‑efficient for unique counts (e.g., daily active users).

PFADD page_views:2025-04-02 "user123"
PFCOUNT page_views:2025-04-02

7. Common Caching Pitfalls

Solution for stampede: use Redis `SET` with `NX` and `EX` to atomically set a lock; the first client populates the cache.

8. Distributed Locks with Redlock

Redis can implement distributed locks for coordinating access to shared resources. The Redlock algorithm (by Antirez) ensures safety across multiple Redis instances.

import redis
import time
import uuid

def acquire_lock(conn, lockname, acquire_timeout=10):
    identifier = str(uuid.uuid4())
    lock_key = f"lock:{lockname}"
    end = time.time() + acquire_timeout
    while time.time() < end:
        if conn.set(lock_key, identifier, nx=True, ex=10):
            return identifier
        time.sleep(0.001)
    return False

def release_lock(conn, lockname, identifier):
    lock_key = f"lock:{lockname}"
    # Lua script for atomic check‑and‑delete
    script = """
    if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
    else
        return 0
    end
    """
    return conn.eval(script, 1, lock_key, identifier)

Use locks sparingly; prefer optimistic concurrency when possible.

9. Rate Limiting Patterns

Redis excels at implementing rate limiters. Two common algorithms:

⏱️ Fixed Window

Simple but can allow bursts at boundaries.

key = f"rate:{user_id}"
current = redis.incr(key)
if current == 1:
    redis.expire(key, 60)
if current > 100:
    raise RateLimitExceeded()

⏲️ Sliding Window (Sorted Set)

More accurate, tracks timestamps.

key = f"ratelimit:{user_id}"
now = time.time()
window_start = now - 60
redis.zremrangebyscore(key, 0, window_start)
count = redis.zcard(key)
if count < 100:
    redis.zadd(key, {now: now})
    redis.expire(key, 60)
else:
    raise RateLimitExceeded()

10. Bloom Filters: Avoid Cache Penetration

Cache penetration occurs when requests for non‑existent keys bypass cache and hit the database. A Bloom filter can quickly tell if a key probably exists, preventing expensive DB queries for absent data.

# Using RedisBloom module
redis.execute_command('BF.ADD', 'user_ids', user_id)
# Before querying cache or DB:
if not redis.execute_command('BF.EXISTS', 'user_ids', user_id):
    return None  # definitely not exists

Bloom filters are memory‑efficient with a tunable false‑positive rate.

11. Atomic Operations with Lua Scripting

Lua scripts run atomically in Redis, allowing complex operations without race conditions. Example: increment and get with TTL reset.

-- atomic_increment.lua
local key = KEYS[1]
local ttl = tonumber(ARGV[1])
local value = redis.call('INCR', key)
if value == 1 then
    redis.call('EXPIRE', key, ttl)
end
return value

Use EVAL to execute. This pattern is great for counters, rate limiters, and conditional updates.

12. Scaling Redis: Replication and Clustering

For large‑scale applications:

When using cluster, be aware of cross‑slot operations limitations.

13. Real‑Time Updates with Redis Streams

Redis Streams can be used to propagate cache invalidations or for event sourcing. Example: broadcast invalidation events to all application instances.

# Producer
redis.xadd('cache_events', {'key': 'user:123', 'action': 'invalidate'})

# Consumer (each instance)
last_id = '0-0'
while True:
    events = redis.xread({'cache_events': last_id}, block=1000)
    for stream, msgs in events:
        for msg_id, fields in msgs:
            redis.delete(fields[b'key'])
            last_id = msg_id

This ensures all nodes evict stale data without polling.

14. Case Study: Caching a Social Media Feed

A social network needed to cache user feeds (list of post IDs). They used:

Result: 99.9% of feed reads served from Redis with < 10ms latency, handling 200k QPS.

15. Monitoring Redis Performance

Essential metrics:

Set up alerts for low hit ratio, high evictions, or OOM.

16. Redis Caching Best Practices

Final Thoughts: Redis as Your Performance Layer

Redis caching is both an art and a science. Starting with simple cache‑aside and TTL, you can evolve to sophisticated patterns like distributed locks, bloom filters, and real‑time invalidation streams. The key is to understand your data access patterns, measure, and iterate. Redis's flexibility lets you adapt as your application grows, ensuring that your users always get a blazing‑fast experience.

Happy caching — may your hit ratio be high and your latency low.