Skip to main content
โšก Calmops

Caching Strategies: Redis, Memcached, and Distributed Caching Patterns

Caching Strategies: Redis, Memcached, and Distributed Caching Patterns

Every database has a breaking point. As your application grows, database queries become the bottleneck. A single query that takes 10ms might seem fast, but when you’re handling 10,000 requests per second, that’s 100 seconds of database time per secondโ€”impossible.

Caching is the solution. By storing frequently accessed data in memory, you can serve requests in microseconds instead of milliseconds. The difference between a system that handles 100 requests per second and one that handles 10,000 is often just a well-implemented caching layer.

In this guide, we’ll explore caching fundamentals, compare Redis and Memcached, and dive into distributed caching patterns that power the world’s largest applications.

Caching Fundamentals

Core Concepts

Cache Hit: Request is served from cache (fast, ~1-5ms) Cache Miss: Data not in cache, fetched from source (slow, ~10-100ms) Hit Rate: Percentage of requests served from cache (target: 80-95%) TTL (Time To Live): How long data stays in cache before expiring

Why Caching Matters

Without caching:
User Request โ†’ Database Query (10ms) โ†’ Response (10ms)
1000 req/s = 10,000ms database time/s = 10 database servers needed

With caching (90% hit rate):
User Request โ†’ Cache Hit (1ms) 90% of time
User Request โ†’ Database Query (10ms) 10% of time
Average: 1.9ms per request
1000 req/s = 1,900ms database time/s = 2 database servers needed

Eviction Policies

When cache is full, old data must be removed:

  • LRU (Least Recently Used): Remove least recently accessed data
  • LFU (Least Frequently Used): Remove least frequently accessed data
  • FIFO (First In First Out): Remove oldest data
  • TTL-based: Remove expired data

Redis: The Powerhouse

Redis is an in-memory data structure store that supports strings, lists, sets, hashes, and more. It’s the most popular caching solution for good reason.

Redis Features

Data Structures: Beyond simple key-value storage

import redis

r = redis.Redis(host='localhost', port=6379)

# Strings
r.set('user:1:name', 'Alice')
r.get('user:1:name')  # b'Alice'

# Lists (queues, stacks)
r.lpush('notifications', 'message1', 'message2')
r.lrange('notifications', 0, -1)

# Sets (unique items)
r.sadd('user:1:tags', 'python', 'redis', 'caching')
r.smembers('user:1:tags')

# Hashes (objects)
r.hset('user:1', mapping={'name': 'Alice', 'email': '[email protected]'})
r.hgetall('user:1')

# Sorted Sets (leaderboards, rankings)
r.zadd('leaderboard', {'alice': 100, 'bob': 95, 'charlie': 90})
r.zrange('leaderboard', 0, -1, withscores=True)

Expiration and TTL:

# Set with expiration
r.setex('session:123', 3600, 'session_data')  # Expires in 1 hour

# Set TTL on existing key
r.expire('user:1:name', 3600)

# Get remaining TTL
r.ttl('user:1:name')  # Returns seconds until expiration

Atomic Operations:

# Increment counter atomically
r.incr('page:views')  # Thread-safe counter

# Atomic compare-and-set
r.set('lock', 'locked', nx=True, ex=10)  # Set only if not exists

# Transactions
pipe = r.pipeline()
pipe.incr('counter')
pipe.expire('counter', 3600)
pipe.execute()

Pub/Sub for Real-time Updates:

# Publisher
r.publish('notifications', 'New message for user 1')

# Subscriber
pubsub = r.pubsub()
pubsub.subscribe('notifications')
for message in pubsub.listen():
    print(message)

When to Use Redis

  • Complex data structures needed
  • Real-time features (leaderboards, notifications)
  • Session storage
  • Rate limiting
  • Job queues
  • Pub/Sub messaging
  • Distributed locks

Memcached: The Lightweight Alternative

Memcached is a simpler, lightweight caching solution focused purely on key-value storage.

Memcached Features

Simple Key-Value Store:

import memcache

mc = memcache.Client(['127.0.0.1:11211'])

# Basic operations
mc.set('user:1:name', 'Alice', time=3600)
mc.get('user:1:name')  # b'Alice'

# Increment
mc.incr('page:views')

# Delete
mc.delete('user:1:name')

# Batch operations
mc.set_multi({
    'user:1:name': 'Alice',
    'user:2:name': 'Bob'
}, time=3600)

mc.get_multi(['user:1:name', 'user:2:name'])

Consistent Hashing for Distribution:

# Memcached automatically distributes across servers
mc = memcache.Client([
    '127.0.0.1:11211',
    '127.0.0.1:11212',
    '127.0.0.1:11213'
])

# Data is automatically distributed across servers
# Consistent hashing ensures same key always goes to same server

When to Use Memcached

  • Simple key-value caching
  • High throughput, low latency needed
  • Horizontal scaling required
  • Memory efficiency important
  • No complex data structures needed

Redis vs. Memcached: Direct Comparison

Feature Redis Memcached
Data Types Strings, Lists, Sets, Hashes, Sorted Sets Strings only
Persistence Yes (RDB, AOF) No
Replication Yes (Master-Slave) No
Pub/Sub Yes No
Transactions Yes No
Lua Scripting Yes No
Memory Efficiency Good Excellent
Throughput High Very High
Complexity Moderate Low
Use Case Complex, feature-rich Simple, high-throughput

Choose Redis if: You need complex data structures, persistence, or advanced features Choose Memcached if: You need maximum simplicity and throughput for basic caching

Distributed Caching Patterns

Pattern 1: Cache-Aside (Lazy Loading)

Application checks cache first, fetches from database if miss.

def get_user(user_id):
    # Check cache
    cache_key = f'user:{user_id}'
    user = cache.get(cache_key)
    
    if user is None:
        # Cache miss: fetch from database
        user = database.query(f'SELECT * FROM users WHERE id = {user_id}')
        
        # Store in cache
        cache.set(cache_key, user, ttl=3600)
    
    return user

Pros: Simple, works with existing databases Cons: Cache misses cause database hits, stale data possible

Pattern 2: Write-Through

Data written to cache and database simultaneously.

def update_user(user_id, data):
    # Write to cache
    cache_key = f'user:{user_id}'
    cache.set(cache_key, data, ttl=3600)
    
    # Write to database
    database.update(f'UPDATE users SET ... WHERE id = {user_id}', data)
    
    return data

Pros: Cache always consistent with database Cons: Slower writes, extra latency

Pattern 3: Write-Behind (Write-Back)

Data written to cache immediately, database updated asynchronously.

def update_user(user_id, data):
    # Write to cache immediately
    cache_key = f'user:{user_id}'
    cache.set(cache_key, data, ttl=3600)
    
    # Queue database update for later
    queue.enqueue('update_user_in_db', user_id, data)
    
    return data

# Background job
def update_user_in_db(user_id, data):
    database.update(f'UPDATE users SET ... WHERE id = {user_id}', data)

Pros: Fast writes, reduced database load Cons: Risk of data loss if cache fails before database update

Pattern 4: Read-Through

Cache loader automatically fetches from database on miss.

class CacheLoader:
    def __init__(self, cache, database):
        self.cache = cache
        self.database = database
    
    def get(self, key):
        # Check cache
        value = self.cache.get(key)
        
        if value is None:
            # Automatic load from database
            value = self.load_from_database(key)
            self.cache.set(key, value, ttl=3600)
        
        return value
    
    def load_from_database(self, key):
        # Parse key to get query parameters
        user_id = key.split(':')[1]
        return self.database.query(f'SELECT * FROM users WHERE id = {user_id}')

# Usage
loader = CacheLoader(cache, database)
user = loader.get('user:123')

Pros: Transparent to application, automatic loading Cons: Requires cache loader implementation

Cache Invalidation Strategies

Time-Based Expiration (TTL)

# Set expiration time
cache.set('user:1', user_data, ttl=3600)  # Expires in 1 hour

# Refresh TTL
cache.expire('user:1', 3600)

Pros: Simple, automatic cleanup Cons: Stale data until expiration

Event-Based Invalidation

def update_user(user_id, data):
    # Update database
    database.update(user_id, data)
    
    # Invalidate cache
    cache.delete(f'user:{user_id}')
    cache.delete(f'user:{user_id}:posts')  # Related data
    cache.delete('users:list')  # Aggregated data

Pros: Immediate consistency Cons: Must remember all related keys

Tag-Based Invalidation

# Store with tags
cache.set('user:1', user_data, tags=['user', 'user:1'])
cache.set('user:1:posts', posts, tags=['user', 'user:1', 'posts'])

# Invalidate by tag
cache.invalidate_by_tag('user:1')  # Invalidates all related data

Pros: Invalidate related data easily Cons: Requires cache system support

Probabilistic Early Expiration (XFetch)

import random
import time

def get_with_xfetch(key, ttl=3600):
    value = cache.get(key)
    
    if value is None:
        # Cache miss
        return fetch_from_database(key)
    
    # Check if near expiration
    remaining_ttl = cache.ttl(key)
    
    # Probabilistically refresh before expiration
    if remaining_ttl < ttl * 0.25:  # Last 25% of TTL
        if random.random() < 0.1:  # 10% chance
            # Refresh in background
            queue.enqueue('refresh_cache', key)
    
    return value

Pros: Prevents thundering herd on expiration Cons: More complex implementation

Common Pitfalls

Pitfall 1: Cache Stampede

Multiple requests hit database simultaneously when cache expires.

# Bad: Multiple threads fetch simultaneously
def get_user(user_id):
    user = cache.get(f'user:{user_id}')
    if user is None:
        user = database.query(user_id)  # All threads do this!
        cache.set(f'user:{user_id}', user)
    return user

# Good: Use lock to prevent stampede
def get_user(user_id):
    cache_key = f'user:{user_id}'
    user = cache.get(cache_key)
    
    if user is None:
        # Try to acquire lock
        lock_key = f'{cache_key}:lock'
        if cache.set(lock_key, '1', nx=True, ex=10):
            try:
                user = database.query(user_id)
                cache.set(cache_key, user, ttl=3600)
            finally:
                cache.delete(lock_key)
        else:
            # Wait for other thread to populate cache
            time.sleep(0.1)
            user = cache.get(cache_key)
    
    return user

Pitfall 2: Cache Invalidation Complexity

Forgetting to invalidate related cache entries.

# Bad: Only invalidates one key
def update_user(user_id, data):
    database.update(user_id, data)
    cache.delete(f'user:{user_id}')  # Misses related data!

# Good: Invalidate all related data
def update_user(user_id, data):
    database.update(user_id, data)
    
    # Invalidate all related keys
    cache.delete(f'user:{user_id}')
    cache.delete(f'user:{user_id}:profile')
    cache.delete(f'user:{user_id}:settings')
    cache.delete('users:list')
    cache.delete(f'users:by_email:{data["email"]}')

Pitfall 3: Caching Mutable Objects

Modifying cached objects affects cache.

# Bad: Mutable object
user = cache.get('user:1')
user['name'] = 'Bob'  # Modifies cache!

# Good: Serialize/deserialize
import json

user_json = cache.get('user:1')
user = json.loads(user_json)
user['name'] = 'Bob'
cache.set('user:1', json.dumps(user))

Pitfall 4: Insufficient Monitoring

# Monitor cache health
def monitor_cache():
    hit_rate = cache.get_stats()['hits'] / (cache.get_stats()['hits'] + cache.get_stats()['misses'])
    
    if hit_rate < 0.8:
        alert('Low cache hit rate')
    
    memory_usage = cache.get_stats()['bytes']
    if memory_usage > cache.max_memory * 0.9:
        alert('Cache near capacity')

Performance Considerations

Benchmarking

import time

def benchmark_cache(cache, iterations=10000):
    # Write performance
    start = time.time()
    for i in range(iterations):
        cache.set(f'key:{i}', f'value:{i}')
    write_time = time.time() - start
    
    # Read performance
    start = time.time()
    for i in range(iterations):
        cache.get(f'key:{i}')
    read_time = time.time() - start
    
    print(f'Write: {write_time/iterations*1000:.3f}ms per operation')
    print(f'Read: {read_time/iterations*1000:.3f}ms per operation')

# Typical results:
# Redis: ~0.1ms per operation
# Memcached: ~0.05ms per operation

Sizing Cache

Cache Size = (Average Object Size) ร— (Number of Objects) ร— (Replication Factor)

Example:
- Average user object: 1KB
- Number of users: 1,000,000
- Replication factor: 2
- Required cache: 1KB ร— 1,000,000 ร— 2 = 2GB

Network Considerations

# Batch operations to reduce network round-trips
# Bad: Multiple round-trips
user1 = cache.get('user:1')
user2 = cache.get('user:2')
user3 = cache.get('user:3')

# Good: Single round-trip
users = cache.get_multi(['user:1', 'user:2', 'user:3'])

Conclusion

Caching is essential for building scalable applications. The right caching strategy can reduce database load by 90% and improve response times dramatically.

Key takeaways:

  1. Choose the right tool: Redis for complex needs, Memcached for simplicity
  2. Pick appropriate patterns: Cache-aside for simplicity, write-through for consistency
  3. Invalidate carefully: Plan invalidation strategy from the start
  4. Avoid common pitfalls: Cache stampede, stale data, mutable objects
  5. Monitor continuously: Track hit rates and memory usage
  6. Benchmark your setup: Understand your cache performance characteristics

Start with cache-aside pattern and simple TTL-based expiration. As your system grows, evolve to more sophisticated patterns. The investment in a well-designed caching layer pays dividends in scalability and performance.

Happy caching!

Comments