Skip to content
SP StackPractices
advanced By Mathias Paulenko

Complete Guide to Redis Caching Strategies

Master Redis caching with cache-aside, read-through, write-through, write-behind, and refresh-ahead patterns. Covers eviction policies, TTL tuning, serialization, and production operations.

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Introduction

Redis is the most popular in-memory data store for caching. It is fast, versatile, and supports multiple data structures. But using Redis effectively requires choosing the right caching strategy for each use case. The wrong strategy leads to stale data, cache stampedes, or wasted memory. Below is a practical guide to the five main Redis caching patterns, eviction policies, TTL tuning, serialization choices, and production operations.

Caching Patterns Overview

Pattern           Read Flow                    Write Flow
─────────────────────────────────────────────────────────────────
Cache-Aside       App → Redis → DB → Redis     App → DB, then invalidate Redis
Read-Through      App → Cache Layer → DB       App → Cache Layer → DB
Write-Through     App → Cache → DB (sync)      App → Cache, Cache → DB
Write-Behind      App → Cache (async → DB)     App → Cache, Cache async → DB
Refresh-Ahead     Background refresh before TTL App → DB, then update Redis

Each pattern has different tradeoffs in consistency, latency, and complexity.

Cache-Aside (Lazy Loading)

The application manages the cache explicitly. On a read, check Redis first. If the data is not there (cache miss), read from the database, write to Redis, and return. On a write, update the database and delete the cache entry.

Read Flow

import redis
import json

r = redis.Redis(host="localhost", port=6379, db=0)

def get_user(user_id: int) -> dict | None:
    cache_key = f"user:{user_id}"
    cached = r.get(cache_key)
    
    if cached:
        return json.loads(cached)
    
    # Cache miss: read from database
    user = db.users.find_by_id(user_id)
    if user:
        r.setex(cache_key, 3600, json.dumps(user))
    return user

Write Flow

def update_user(user_id: int, data: dict) -> dict:
    user = db.users.update(user_id, data)
    # Invalidate cache entry
    r.delete(f"user:{user_id}")
    return user

Advantages

  • Simple to implement
  • Cache only contains data that is actually requested
  • Resilient to cache failures (falls back to database)

Disadvantages

  • Cache miss adds latency (database read + cache write)
  • Data can be stale between writes and next read
  • Cache stampede on cold cache or after mass invalidation

Preventing Cache Stampede

When many requests miss the cache simultaneously, they all hit the database. Use a lock to let only one request fetch from the database.

import time

def get_user_safe(user_id: int) -> dict | None:
    cache_key = f"user:{user_id}"
    lock_key = f"lock:user:{user_id}"
    
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)
    
    # Try to acquire lock
    acquired = r.set(lock_key, "1", nx=True, ex=10)
    if acquired:
        try:
            # Double-check cache after acquiring lock
            cached = r.get(cache_key)
            if cached:
                return json.loads(cached)
            
            user = db.users.find_by_id(user_id)
            if user:
                r.setex(cache_key, 3600, json.dumps(user))
            return user
        finally:
            r.delete(lock_key)
    else:
        # Wait and retry
        time.sleep(0.1)
        return get_user_safe(user_id)

Read-Through

The application talks to a cache layer that transparently fetches from the database on a miss. The application does not know about the database.

class ReadThroughCache:
    def __init__(self, redis_client, db_loader):
        self.redis = redis_client
        self.db_loader = db_loader
    
    def get(self, key: str, ttl: int = 3600) -> dict | None:
        cached = self.redis.get(key)
        if cached:
            return json.loads(cached)
        
        # Load from database
        data = self.db_loader(key)
        if data:
            self.redis.setex(key, ttl, json.dumps(data))
        return data

user_cache = ReadThroughCache(r, lambda key: db.users.find_by_id(int(key.split(":")[1])))
user = user_cache.get("user:42")

Advantages

  • Application code is cleaner (no cache logic)
  • Consistent caching behavior across all read paths
  • Easy to add caching to existing code

Disadvantages

  • Cache layer is a single point of failure
  • Less control over when to cache and when not to
  • Stale data until TTL expires

Write-Through

Writes go to the cache first, then synchronously to the database. The cache always has the latest data.

class WriteThroughCache:
    def __init__(self, redis_client, db_writer):
        self.redis = redis_client
        self.db_writer = db_writer
    
    def set(self, key: str, value: dict, ttl: int = 3600) -> dict:
        # Write to database first
        result = self.db_writer(key, value)
        # Then write to cache
        self.redis.setex(key, ttl, json.dumps(result))
        return result

user_cache = WriteThroughCache(r, lambda key, val: db.users.update(int(key.split(":")[1]), val))
user = user_cache.set("user:42", {"name": "Alice"})

Advantages

  • Cache is always consistent with database
  • No stale data
  • Simple to reason about

Disadvantages

  • Write latency is higher (two synchronous writes)
  • If cache fails, write fails (or you need a fallback)
  • Not suitable for write-heavy workloads

Write-Behind (Write-Back)

Writes go to the cache first. A background process asynchronously writes to the database. This reduces write latency but introduces a window of potential data loss.

import threading
import queue

write_queue = queue.Queue()

class WriteBehindCache:
    def __init__(self, redis_client, db_writer):
        self.redis = redis_client
        self.db_writer = db_writer
    
    def set(self, key: str, value: dict, ttl: int = 3600) -> dict:
        # Write to cache immediately
        self.redis.setex(key, ttl, json.dumps(value))
        # Queue for async database write
        write_queue.put((key, value))
        return value

# Background worker
def write_worker():
    while True:
        key, value = write_queue.get()
        try:
            db_writer(key, value)
        except Exception as e:
            print(f"Write failed for {key}: {e}")
            # Retry logic here
        finally:
            write_queue.task_done()

threading.Thread(target=write_worker, daemon=True).start()

Advantages

  • Very low write latency (only Redis write is synchronous)
  • High throughput for write-heavy workloads
  • Database load is smoothed (batch writes possible)

Disadvantages

  • Data loss risk if Redis crashes before database write
  • Complex to implement correctly (ordering, retries, idempotency)
  • Hard to debug consistency issues

When to Use Write-Behind

  • For alternatives, see Cache Database Query Results with Redis and Python.

  • High-volume writes where slight data loss is acceptable (analytics, counters)

  • Write-heavy workloads where database is the bottleneck

  • Scenarios where eventual consistency is acceptable

Refresh-Ahead

A background process refreshes cache entries before they expire. This prevents cache misses and keeps data fresh.

import threading
import time

def refresh_ahead():
    while True:
        # Find keys expiring soon (within next 5 minutes)
        keys = r.scan(match="user:*", count=100)
        for key in keys:
            ttl = r.ttl(key)
            if 0 < ttl < 300:  # Expires in less than 5 minutes
                user_id = int(key.split(":")[1])
                user = db.users.find_by_id(user_id)
                if user:
                    r.setex(key, 3600, json.dumps(user))
        time.sleep(60)  # Check every minute

threading.Thread(target=refresh_ahead, daemon=True).start()

Advantages

  • No cache misses for hot keys
  • Data stays fresh
  • Smooth database load (proactive, not reactive)

Disadvantages

  • Wastes resources refreshing cold keys that are no longer accessed
  • Complex to implement (need to track which keys are hot)
  • Stale data if refresh fails

Eviction Policies

When Redis reaches its memory limit, it evicts keys based on the configured policy.

PolicyDescriptionBest For
noevictionReturns error on writes when memory is fullCritical data that must not be lost
allkeys-lruEvicts least recently used key (any key)General-purpose caching
allkeys-lfuEvicts least frequently used key (any key)Skewed access patterns
volatile-lruEvicts LRU key among keys with TTL setMix of cached and persistent data
volatile-lfuEvicts LFU key among keys with TTL setMix of cached and persistent data
volatile-ttlEvicts key with shortest TTLTime-sensitive data
volatile-randomEvicts random key among keys with TTL setSimple, low overhead
allkeys-randomEvicts random key (any key)Uniform access patterns

Choosing an Eviction Policy

# Set eviction policy in redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru

For most caching workloads, allkeys-lru is the right choice. It evicts the least recently used keys, which are likely to be the least useful. Use volatile-lru when you store both cached data (with TTL) and persistent data (without TTL) in the same Redis instance.

TTL Tuning

Setting TTLs

Always set a TTL on cached data. Without a TTL, data stays in Redis forever, consuming memory.

# Short TTL for frequently changing data
r.setex("session:abc123", 900, session_data)  # 15 minutes

# Medium TTL for moderately changing data
r.setex("user:42", 3600, user_data)  # 1 hour

# Long TTL for rarely changing data
r.setex("config:app", 86400, config_data)  # 24 hours

TTL Jitter

When many keys expire at the same time, a cache stampede occurs. Add random jitter to TTLs to spread expirations.

import random

def set_with_jitter(key: str, value: str, base_ttl: int = 3600, jitter: int = 300):
    actual_ttl = base_ttl + random.randint(0, jitter)
    r.setex(key, actual_ttl, value)

TTL vs Eviction

TTL controls when data expires. Eviction controls what gets removed when memory is full. Both are needed: TTL prevents stale data, eviction prevents out-of-memory.

Serialization

JSON

JSON is human-readable and supported everywhere. Use it for most cached data.

import json

r.set("user:42", json.dumps({"id": 42, "name": "Alice"}))
user = json.loads(r.get("user:42"))

MessagePack

MessagePack is more compact than JSON. Use it for large objects where memory matters.

import msgpack

r.set("user:42", msgpack.packb({"id": 42, "name": "Alice"}))
user = msgpack.unpackb(r.get("user:42"), raw=False)

Redis Hashes

For objects with many fields, use Redis hashes instead of serialized strings. This allows partial updates.

r.hset("user:42", mapping={"name": "Alice", "email": "alice@example.com", "age": "30"})
name = r.hget("user:42", "name")

Production Operations

Monitoring

Track these metrics in production:

  • Hit rate: hits / (hits + misses) — should be above 80% for most workloads
  • Memory usage: used_memory / maxmemory — should stay below 80%
  • Eviction rate: keys evicted per second — high rate means you need more memory
  • Latency: p99 for GET and SET — should be under 1ms
  • Connected clients: monitor for connection leaks
  • Persistence status: if using RDB/AOF, check last save time
# Check Redis stats
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses|evicted_keys"

# Check memory
redis-cli INFO memory | grep -E "used_memory_human|maxmemory_human"

Persistence

Redis offers two persistence options:

  • RDB (snapshotting): Point-in-time snapshots. Fast recovery, potential data loss between snapshots.
  • AOF (append-only file): Logs every write operation. Slower, minimal data loss.

For caching workloads where data can be rebuilt from the database, RDB is sufficient. For write-behind patterns where Redis is the source of truth temporarily, use AOF.

# redis.conf
save 900 1      # Save if at least 1 key changed in 15 minutes
save 300 10     # Save if at least 10 keys changed in 5 minutes
appendonly yes
appendfsync everysec

Clustering

When a single Redis instance is not enough, use Redis Cluster for horizontal scaling.

from redis.cluster import RedisCluster

rc = RedisCluster(host="localhost", port=7000)

# Redis Cluster automatically shards keys across nodes
rc.set("user:42", json.dumps({"name": "Alice"}))

Connection Pooling

Always use connection pooling in production. Creating a new connection per request is expensive.

pool = redis.ConnectionPool(host="localhost", port=6379, max_connections=50)
r = redis.Redis(connection_pool=pool)

Common Pitfalls

Storing Large Objects

Large objects (>1MB) in Redis consume memory and slow down operations. Store references instead of full objects, or use a dedicated blob store.

# Bad: storing a 5MB image in Redis
r.set("image:42", large_image_bytes)

# Good: store metadata in Redis, image in S3
r.hset("image:42", mapping={"url": "s3://bucket/image42.jpg", "width": "1920", "height": "1080"})

Using KEYS in Production

The KEYS command blocks Redis while scanning all keys. Use SCAN instead.

# Bad: blocks Redis
keys = r.keys("user:*")

# Good: non-blocking, returns in batches
for key in r.scan_iter(match="user:*", count=100):
    process(key)

Not Handling Cache Failures

If Redis goes down, your application should fall back to the database, not crash.

def get_user_resilient(user_id: int) -> dict | None:
    try:
        cached = r.get(f"user:{user_id}")
        if cached:
            return json.loads(cached)
    except redis.ConnectionError:
        pass  # Fall back to database
    
    user = db.users.find_by_id(user_id)
    if user:
        try:
            r.setex(f"user:{user_id}", 3600, json.dumps(user))
        except redis.ConnectionError:
            pass  # Cache is down, continue without caching
    return user

FAQ

Which caching pattern should I start with?

Start with cache-aside. It is the simplest to implement and reason about. Add more complex patterns (write-through, write-behind) only when you have specific latency or throughput requirements that cache-aside cannot meet.

How much memory should I allocate to Redis?

Allocate enough memory to hold your working set (frequently accessed data) with 20-30% headroom. Monitor eviction rate: if it is high, increase memory or reduce TTLs.

Should I use Redis for session storage?

Yes. Redis is well-suited for session storage. Set a TTL equal to the session timeout. Use allkeys-lru or volatile-lru eviction policy so old sessions are evicted when memory is full.

How do I test cache behavior?

Write integration tests that verify: cache hits return cached data, cache misses fetch from database and populate cache, writes invalidate cache, TTL expiration triggers database fetch. Use a test Redis instance (not production).

What is the difference between Redis and Memcached?

Redis supports multiple data structures (strings, hashes, lists, sets, sorted sets), persistence, pub/sub, and clustering. Memcached is simpler (key-value only) and multithreaded. Use Redis for most caching workloads. Use Memcached for simple, high-throughput key-value caching where you do not need persistence or advanced data structures.

Should I use Redis Cluster or Redis Sentinel?

Redis Cluster shards data across multiple nodes for horizontal scaling. Redis Sentinel provides high availability (automatic failover) without sharding. Use Cluster when you need more capacity than a single node can provide. Use Sentinel when you need high availability but a single node has enough capacity.