Redis & Advanced Caching

Cache Invalidation Strategies

20 min Lesson 11 of 30

Cache Invalidation Strategies

Cache invalidation is one of the hardest problems in computer science. This lesson covers proven strategies for keeping your cache fresh and consistent with your data sources.

TTL-Based Expiration

The simplest invalidation strategy is to set a Time-To-Live (TTL) on cached items:

PHP Example:
// Set cache with 1 hour TTL
Cache::put('user:' . $userId, $userData, 3600);

// Or using Redis directly
Redis::setex('product:' . $productId, 3600, json_encode($product));

// Check TTL remaining
$ttl = Redis::ttl('user:' . $userId); // Returns seconds remaining
Best Practice: Choose TTL based on data volatility - frequently changing data needs shorter TTL, static data can have longer TTL (hours or days).

Event-Based Invalidation

Invalidate cache when the underlying data changes:

Laravel Example:
// In your model or controller
public function updateUser($userId, $data)
{
    // Update database
    $user = User::findOrFail($userId);
    $user->update($data);

    // Invalidate cache
    Cache::forget('user:' . $userId);
    Cache::tags(['users'])->flush(); // If using cache tags

    return $user;
}

Tag-Based Invalidation

Group related cache keys under tags for bulk invalidation:

Laravel Cache Tags:
// Store with tags
Cache::tags(['users', 'user:' . $userId])->put('profile', $data, 3600);
Cache::tags(['users', 'admins'])->put('admin:list', $admins, 7200);

// Invalidate all user-related caches
Cache::tags(['users'])->flush();

// Invalidate specific user
Cache::tags(['user:' . $userId])->flush();
Important: Cache tags are not supported by the file or database cache drivers in Laravel. Use Redis or Memcached for tag support.

Version-Based Cache Busting

Append a version number to cache keys instead of deleting old data:

Versioned Cache Keys:
// Store current version in Redis
Redis::set('cache_version:users', 1);

// Generate cache key with version
$version = Redis::get('cache_version:users');
$cacheKey = "users:list:v{$version}";
$users = Cache::get($cacheKey);

// To invalidate, increment version
Redis::incr('cache_version:users'); // Now version is 2
// Old cache at v1 becomes stale automatically
Advantage: Version-based invalidation allows old cache entries to expire naturally via TTL, reducing cache stampede risk.

Cache Stampede Prevention

When cache expires on high-traffic keys, multiple requests may simultaneously try to rebuild the cache (stampede). Solutions:

Solution 1: Lock-Based Approach
public function getUserData($userId)
{
    $cacheKey = 'user:' . $userId;
    $lockKey = $cacheKey . ':lock';

    // Try to get from cache
    $data = Cache::get($cacheKey);
    if ($data) return $data;

    // Acquire lock (5 second timeout)
    $lock = Cache::lock($lockKey, 5);

    try {
        if ($lock->get()) {
            // Double-check cache (another process may have filled it)
            $data = Cache::get($cacheKey);
            if ($data) return $data;

            // Rebuild cache
            $data = User::find($userId);
            Cache::put($cacheKey, $data, 3600);

            return $data;
        } else {
            // Couldn't get lock, wait briefly and retry
            usleep(100000); // 100ms
            return Cache::get($cacheKey) ?? User::find($userId);
        }
    } finally {
        $lock->release();
    }
}
Solution 2: Probabilistic Early Expiration
public function getWithProbabilisticRefresh($key, $ttl, $callback)
{
    $data = Redis::get($key);

    if ($data) {
        // Get remaining TTL
        $remaining = Redis::ttl($key);

        // Calculate probability of early refresh
        // As TTL approaches 0, probability increases
        $probability = 1 - ($remaining / $ttl);

        if (mt_rand() / mt_getrandmax() < $probability) {
            // Probabilistically refresh cache in background
            dispatch(new RefreshCacheJob($key, $callback));
        }

        return json_decode($data, true);
    }

    // Cache miss - rebuild
    $data = $callback();
    Redis::setex($key, $ttl, json_encode($data));
    return $data;
}

Stale-While-Revalidate Pattern

Serve stale cache while refreshing in the background:

Implementation:
public function getWithStaleWhileRevalidate($key, $freshTtl, $staleTtl, $callback)
{
    $data = Redis::get($key);
    $freshUntil = Redis::get($key . ':fresh_until');

    if ($data) {
        $now = time();

        if ($freshUntil && $now < $freshUntil) {
            // Data is still fresh
            return json_decode($data, true);
        }

        // Data is stale but usable - trigger background refresh
        if (Redis::set($key . ':refreshing', 1, 'EX', 60, 'NX')) {
            // We got the refresh lock
            dispatch(new RefreshCacheJob($key, $freshTtl, $staleTtl, $callback));
        }

        // Return stale data immediately
        return json_decode($data, true);
    }

    // Cache miss - synchronous rebuild
    $data = $callback();
    Redis::setex($key, $staleTtl, json_encode($data));
    Redis::setex($key . ':fresh_until', $staleTtl, time() + $freshTtl);

    return $data;
}
Pattern Benefits: Users always get fast responses (stale data is acceptable for many use cases), cache is refreshed proactively, no stampede risk.

Cache Invalidation Decision Tree

When to Use Each Strategy:
1. TTL-Based Expiration
   ✓ Simple, predictable data
   ✓ Low consistency requirements
   ✓ Example: Analytics dashboards, feeds

2. Event-Based Invalidation
   ✓ High consistency requirements
   ✓ Clear data change events
   ✓ Example: User profiles, product details

3. Tag-Based Invalidation
   ✓ Related data groups
   ✓ Bulk invalidation needs
   ✓ Example: Category products, user permissions

4. Version-Based Busting
   ✓ Complex invalidation logic
   ✓ Gradual rollout needs
   ✓ Example: Configuration, feature flags

5. Stale-While-Revalidate
   ✓ Expensive data generation
   ✓ High traffic keys
   ✓ Acceptable stale data
   ✓ Example: Homepage data, trending items
Practice Exercise:
  1. Implement a product cache with tag-based invalidation (tags: products, category:{id}, brand:{id})
  2. Add cache stampede prevention using locks
  3. Create a method to invalidate all products in a category when category is updated
  4. Implement stale-while-revalidate for a homepage feed with 5-minute fresh TTL and 1-hour stale TTL
  5. Add monitoring to track cache hit rates and stampede occurrences