Back to Blog
DatabasesJune 29, 20264 min read

Redis Cache-Aside: Preventing Stampedes and Managing Invalidation

Master the Redis cache-aside pattern to prevent cache stampedes and optimize invalidation latency. Learn practical strategies for high-performance systems.

rediscache-asidedistributed-systemsperformancebackendarchitectureCaching

When we first rolled out our distributed caching layer, we assumed the Database Caching: Mastering the Cache-Aside Pattern for Scale approach would be a simple "check, update, return" flow. We were wrong. Under heavy traffic, our Redis cache-aside implementation became a bottleneck, specifically when a popular key expired and a hundred concurrent requests hit the database simultaneously.

That "thundering herd"—or cache stampede—can bring your primary database to its knees in seconds. If you're building systems that need to scale, you need more than just basic GET/SET commands.

The Cache Stampede Problem

A cache stampede happens when a high-traffic key expires, and your application logic isn't guarding against redundant database lookups. Every incoming request sees a cache miss and fires a query to your DB.

We initially tried using simple mutex locks, but we ran into deadlocks when our worker processes crashed while holding the lock. We eventually settled on a "probabilistic early expiration" approach combined with Redis SET NX locks.

Implementing Distributed Locking

To stop the stampede, we use a lightweight locking mechanism. Before querying the database, the process attempts to acquire a lock in Redis.

PYTHON
# Pseudo-code for safe cache-aside update
def get_data(key):
    val = redis.get(key)
    if val: return val
    
    # Attempt to acquire a lock for 5 seconds
    lock_key = f"lock:{key}"
    if redis.set(lock_key, "1", nx=True, ex=5):
        try:
            data = db.query(key)
            redis.set(key, data, ex=3600)
            return data
        finally:
            redis.delete(lock_key)
    else:
        # Wait a bit and retry the read
        time.sleep(0.1)
        return get_data(key)

This ensures only one process fetches the data, while others wait or return a stale value if your business logic allows it.

Mastering Cache Invalidation Strategies

Cache invalidation is notoriously difficult. If you're struggling with stale data, you might be over-relying on TTLs (Time-To-Live). While TTLs are a great safety net, they aren't a strategy for real-time consistency.

When we need immediate consistency, we use an event-driven approach. Instead of waiting for the cache to expire, our application emits an invalidation event. If you are working within specific frameworks, Laravel Eloquent Cache-Aside: Implementing Decorators for Consistency provides a great blueprint for wrapping your models to handle these events automatically.

Comparing Invalidation Approaches

StrategyConsistencyComplexityLatency Impact
TTL ExpirationEventualLowLow
Write-ThroughStrongHighModerate
Event-Driven PurgeHighModerateLow
Versioned KeysStrongLowLow

For most systems, Versioned Keys are my favorite. Instead of deleting a key, you append a version number or a timestamp to the key name. When the underlying data changes, you simply increment the version. This avoids the "delete-miss-race" condition entirely because readers keep hitting the old version until the new one is ready.

Optimizing for Latency

If you're seeing high invalidation latency, check your network overhead. We once spent about two days debugging a performance dip only to realize we were performing thousands of individual DEL commands during a bulk update.

Switching to PIPELINE or UNLINK (which is non-blocking) in Redis significantly improved our throughput. UNLINK is particularly useful if you are deleting large hashes or sets, as it frees the memory in a background thread rather than blocking the main command loop.

Architecture Flow

The following sequence shows how we handle a write operation to ensure we don't serve stale data:

Sequence diagram: participant App; participant DB; participant Redis; App → DB: Update Record; App → Redis: UNLINK key; App → Redis: SET version+1; Note over App,Redis: Cache now invalidated

A Few Realities

One thing I'm still refining is the balance between cache size and eviction policy. We currently use allkeys-lru, but with our specific access patterns, I'm considering switching to volatile-lfu to better protect "hot" keys from being evicted during traffic spikes.

If you are dealing with more complex data relationships, you might also want to look at how to map dependencies, similar to the techniques discussed in WordPress REST API Cache Invalidation: Dependency Graph Strategies. It's easy to invalidate a single object, but invalidating a tree of cached objects requires a more robust strategy.

Next time, I’d probably instrument our Redis commands with OpenTelemetry from day one. Relying on redis-cli monitor in production is a dangerous game I’m not eager to play again.

FAQ

Q: How do I handle cache stampede if I can't use locks? A: Use "probabilistic early recomputation." Instead of waiting for the TTL to hit zero, have a small percentage of requests proactively refresh the cache before it expires.

Q: Is UNLINK always better than DEL? A: For small keys (strings/integers), the difference is negligible. For large complex structures (hashes/lists with thousands of elements), UNLINK is superior because it prevents blocking the Redis event loop.

Q: Why does my cache-aside logic still result in stale data? A: You likely have a race condition between the database write and the cache deletion. Ensure your invalidation happens after the database transaction commits, or use a message queue to ensure the invalidation event is processed.

Similar Posts