featured image

Building a Multi-Tiered Caching Strategy for Social Feeds

Learn how to implement a sophisticated multi-tiered caching strategy in Laravel using Redis to optimize performance for social platforms with dynamic user data.

Published

Thu Oct 02 2025

Technologies Used

Laravel Redis
Advanced 44 minutes

Purpose

The Problem

You’re building a social platform where users have friends, create content, and view activity feeds. A typical user profile page requires:

  • User profile data (name, bio, avatar)
  • Friend list (could be 500+ friends)
  • Activity feed (last 100 activities)
  • Privacy settings (10+ boolean flags)
  • Friendship status with viewer
  • Content counts (posts, reviews, lists)

The naive approach: Query everything on every page load.

// This executes 15+ database queries PER PAGE LOAD
$user = User::find($id);
$friends = $user->friends()->get();  // Query 1
$activities = $user->activities()->latest()->take(100)->get();  // Query 2
$privacySettings = $user->privacySettings()->get();  // Query 3
// ... 12 more queries

The problems:

  • Database overload: 1000 concurrent users = 15,000 queries/second
  • Slow response times: Each query adds 5-50ms latency
  • Wasted resources: Profile data rarely changes, yet we query it constantly
  • Cache invalidation complexity: When do you clear the cache?

The Solution

We’re analyzing MyKpopLists’ SocialCacheService - a sophisticated multi-tiered caching system that:

  • Implements different TTLs (Time To Live) for different data types
  • Uses pattern-based cache invalidation
  • Leverages Redis for high-performance caching
  • Provides surgical cache updates without full flushes

What You’ll Learn

This tutorial covers:

  • Redis caching patterns and data structures
  • Cache invalidation strategies (TTL-based, event-based, pattern-based)
  • Memory management and cache key design
  • Performance optimization at scale (10,000+ users)
  • The CAP theorem in practice (Consistency vs. Availability)

Prerequisites & Tooling

Knowledge Base

  • Advanced Laravel (service containers, facades, events)
  • Understanding of Redis data structures
  • Database query optimization
  • Basic understanding of distributed systems concepts

Environment

  • PHP: 8.2+
  • Laravel: 12.x
  • Redis: 6.0+ (required for production)
  • Cache Driver: Database (development) or Redis (production)

Redis Setup

# Install Redis (Ubuntu/Debian)
sudo apt-get install redis-server

# Start Redis
sudo systemctl start redis

# Configure Laravel (.env)
CACHE_STORE=redis
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379

High-Level Architecture

The Multi-Tiered Cache Strategy

graph TD
    A[HTTP Request] --> B{Cache Hit?}
    B -->|Yes - Hot Data| C[Return from Redis<br/>~1ms]
    B -->|No| D[Query Database<br/>~50ms]
    D --> E[Store in Cache]
    E --> F{Data Type?}
    F -->|Profile| G[TTL: 5 min]
    F -->|Activity| H[TTL: 1 min]
    F -->|Friends| I[TTL: 10 min]
    F -->|Static| J[TTL: 1 hour]
    G --> K[Return to User]
    H --> K
    I --> K
    J --> K
    
    L[Data Change Event] --> M{Invalidation Strategy}
    M -->|Surgical| N[Delete Specific Keys]
    M -->|Pattern| O[SCAN + Delete Pattern]
    M -->|Cascade| P[Delete Related Keys]
    
    style C fill:#90EE90
    style D fill:#FFB6C1
    style M fill:#87CEEB

Cache Key Hierarchy

graph LR
    A[Cache Prefix] --> B[Entity Type]
    B --> C[Entity ID]
    C --> D[Sub-Resource]
    
    E[Example:<br/>myapp_cache:] --> F[user:]
    F --> G[42:]
    G --> H[friends]
    
    I[Result:<br/>myapp_cache:user:42:friends]

The Library

Think of caching like a library with different sections:

  • Reference Desk (Hot Cache - 1 min TTL): Frequently accessed, changes often (today’s newspaper)
  • Recent Releases (Warm Cache - 10 min TTL): Popular but stable (this month’s magazines)
  • General Collection (Cold Cache - 1 hour TTL): Rarely changes (encyclopedia)
  • Archives (Database): Permanent storage, slow to access

When a book is updated, you don’t throw out the entire library - you update just that book and related materials.

The Implementation

Understanding the Naive Caching Approach

Most developers start with simple caching:

// ❌ NAIVE APPROACH - Single TTL for everything
class ProfileController
{
    public function show($userId)
    {
        $cacheKey = "user_{$userId}";
        
        $data = Cache::remember($cacheKey, 3600, function() use ($userId) {
            return User::with(['friends', 'activities', 'posts'])->find($userId);
        });
        
        return view('profile', ['user' => $data]);
    }
}

Problems:

  1. Stale data: Activity feed cached for 1 hour, but new activities appear every minute
  2. Wasted memory: Caching rarely-accessed data with the same TTL as hot data
  3. Invalidation nightmare: How do you clear cache when user updates profile?
  4. All-or-nothing: Can’t update just the activity feed without re-querying everything

Defining Cache TTL Constants

First, we establish different TTLs based on data volatility:

<?php
// app/Services/SocialCacheService.php

namespace App\Services;

class SocialCacheService
{
    // Cache TTL constants (in seconds)
    const PROFILE_DATA_TTL = 300;      // 5 minutes - changes occasionally
    const ACTIVITY_FEED_TTL = 60;      // 1 minute - changes frequently
    const FRIEND_DATA_TTL = 600;       // 10 minutes - changes rarely
    const STATIC_CONTENT_TTL = 3600;   // 1 hour - almost never changes
    const USER_STATS_TTL = 900;        // 15 minutes - computed data
    
    // Why these specific values?
    // - ACTIVITY_FEED: Users expect near-real-time updates
    // - PROFILE_DATA: Balance between freshness and performance
    // - FRIEND_DATA: Friendships don't change often
    // - STATIC_CONTENT: Lists, reviews rarely edited after creation
    // - USER_STATS: Expensive to compute, acceptable to be slightly stale
}

The decision matrix:

  • High volatility + High access = Short TTL (1 min)
  • Low volatility + High access = Medium TTL (10 min)
  • Low volatility + Low access = Long TTL (1 hour)
  • Expensive computation = Longer TTL regardless of volatility

Designing the Cache Key Structure

Consistent, hierarchical cache keys enable pattern-based invalidation:

<?php
// app/Services/SocialCacheService.php (continued)

private static function getCacheKey(string $type, ...$params): string
{
    $key = match($type) {
        // User-specific data
        'user_profile' => "profile:{$params[0]}",
        'user_lists' => "user:{$params[0]}:lists:{$params[1]}",  // page number
        'user_following' => "user:{$params[0]}:following",
        'user_activity' => "user:{$params[0]}:activity:{$params[1]}",
        
        // Relationship data
        'friend_requests' => "user:{$params[0]}:friend_requests",
        'friends_list' => "user:{$params[0]}:friends",
        'friendship_status' => "friendship:{$params[0]}:{$params[1]}",
        
        // Computed data
        'user_stats' => "user:{$params[0]}:stats",
        'activity_feed' => "activity_feed:{$params[0]}:{$params[1]}",
        
        // Privacy settings
        'privacy_settings' => "user:{$params[0]}:privacy",
        
        default => throw new \InvalidArgumentException("Unknown cache type: {$type}")
    };

    // Add application prefix (from config/cache.php)
    return config('cache.prefix') . $key;
}

Key design principles:

  1. Hierarchical: user:42:friends groups all user 42’s data
  2. Predictable: Same inputs always produce same key
  3. Namespaced: Prefix prevents collisions with other apps
  4. Parameterized: Supports pagination, filtering, etc.

Example keys:

myapp_cache:profile:alice
myapp_cache:user:42:lists:1
myapp_cache:user:42:activity:2
myapp_cache:friendship:10:25
myapp_cache:user:42:stats

Implementing Cache Storage Methods

Now we create methods to store data with appropriate TTLs:

<?php
// app/Services/SocialCacheService.php (continued)

use Illuminate\Support\Facades\Cache;

/**
 * Cache user profile data
 */
public static function cacheUserProfile(string $username, array $data): void
{
    $key = self::getCacheKey('user_profile', $username);
    Cache::put($key, $data, self::PROFILE_DATA_TTL);
}

/**
 * Get cached user profile data
 */
public static function getUserProfile(string $username): ?array
{
    $key = self::getCacheKey('user_profile', $username);
    return Cache::get($key);
}

/**
 * Cache user's activity feed with pagination
 */
public static function cacheUserActivity(int $userId, int $page, array $data): void
{
    $key = self::getCacheKey('user_activity', $userId, $page);
    Cache::put($key, $data, self::ACTIVITY_FEED_TTL);
}

/**
 * Get cached user activity
 */
public static function getUserActivity(int $userId, int $page): ?array
{
    $key = self::getCacheKey('user_activity', $userId, $page);
    return Cache::get($key);
}

/**
 * Cache friendship status between two users
 */
public static function cacheFriendshipStatus(int $userId1, int $userId2, ?string $status): void
{
    // Always use smaller ID first for consistency
    $key = self::getCacheKey('friendship_status', min($userId1, $userId2), max($userId1, $userId2));
    Cache::put($key, $status, self::FRIEND_DATA_TTL);
}

/**
 * Get cached friendship status
 */
public static function getFriendshipStatus(int $userId1, int $userId2): ?string
{
    $key = self::getCacheKey('friendship_status', min($userId1, $userId2), max($userId1, $userId2));
    return Cache::get($key);
}

Usage in controllers:

class ProfileController
{
    public function show(string $username)
    {
        // Try cache first
        $profileData = SocialCacheService::getUserProfile($username);
        
        if ($profileData === null) {
            // Cache miss - query database
            $user = User::where('username', $username)->firstOrFail();
            $profileData = [
                'id' => $user->id,
                'username' => $user->username,
                'bio' => $user->bio,
                // ... more fields
            ];
            
            // Store in cache for next request
            SocialCacheService::cacheUserProfile($username, $profileData);
        }
        
        return Inertia::render('Profile/Show', ['user' => $profileData]);
    }
}

Pattern-Based Cache Invalidation

The most powerful feature - invalidating related caches using patterns:

<?php
// app/Services/SocialCacheService.php (continued)

use Illuminate\Support\Facades\Redis;

/**
 * Invalidate all user-related caches when data changes
 */
public static function invalidateUserCaches(int $userId): void
{
    // Define patterns to match
    $patterns = [
        "profile:*",                    // All profiles
        "user:{$userId}:*",             // All data for this user
        "friendship:{$userId}:*",       // Friendships where user is first
        "friendship:*:{$userId}",       // Friendships where user is second
        "activity_feed:*",              // All activity feeds (user might appear in friends' feeds)
    ];

    foreach ($patterns as $pattern) {
        $fullPattern = config('cache.prefix') . $pattern;
        
        // Use Redis SCAN to find matching keys
        if (Cache::getStore() instanceof \Illuminate\Cache\RedisStore) {
            $redis = Redis::connection('cache');
            
            // SCAN is cursor-based, doesn't block Redis
            $cursor = 0;
            do {
                // SCAN returns [cursor, keys]
                [$cursor, $keys] = $redis->scan($cursor, [
                    'MATCH' => $fullPattern,
                    'COUNT' => 100  // Hint for batch size
                ]);
                
                if (!empty($keys)) {
                    $redis->del($keys);  // Delete matched keys
                }
            } while ($cursor !== 0);
        } else {
            // Fallback for non-Redis stores
            Cache::forget($fullPattern);
        }
    }
}

/**
 * Invalidate friendship-related caches (more surgical)
 */
public static function invalidateFriendshipCaches(int $userId1, int $userId2): void
{
    // Clear specific friendship status
    $statusKey = self::getCacheKey('friendship_status', min($userId1, $userId2), max($userId1, $userId2));
    Cache::forget($statusKey);

    // Clear friends lists for both users
    $friendsKey1 = self::getCacheKey('friends_list', $userId1);
    $friendsKey2 = self::getCacheKey('friends_list', $userId2);
    Cache::forget($friendsKey1);
    Cache::forget($friendsKey2);

    // Clear friend requests
    $requestsKey1 = self::getCacheKey('friend_requests', $userId1);
    $requestsKey2 = self::getCacheKey('friend_requests', $userId2);
    Cache::forget($requestsKey1);
    Cache::forget($requestsKey2);

    // Clear user stats (friend count changed)
    $statsKey1 = self::getCacheKey('user_stats', $userId1);
    $statsKey2 = self::getCacheKey('user_stats', $userId2);
    Cache::forget($statsKey1);
    Cache::forget($statsKey2);
}

Why SCAN instead of KEYS?

// ❌ DANGEROUS: KEYS blocks Redis
$keys = $redis->keys('user:*');  // Blocks Redis for O(N) time

// ✅ SAFE: SCAN is cursor-based
$cursor = 0;
do {
    [$cursor, $keys] = $redis->scan($cursor, ['MATCH' => 'user:*']);
    // Process keys in batches
} while ($cursor !== 0);

SCAN advantages:

  • Non-blocking (doesn’t freeze Redis)
  • Returns results in batches
  • Safe for production use
  • O(1) per iteration (vs O(N) for KEYS)

Event-Driven Cache Invalidation

Integrate cache invalidation with Laravel events:

<?php
// app/Observers/UserObserver.php

namespace App\Observers;

use App\Models\User;
use App\Services\SocialCacheService;

class UserObserver
{
    /**
     * Handle the User "updated" event.
     */
    public function updated(User $user): void
    {
        // Invalidate caches when user data changes
        SocialCacheService::invalidateUserCaches($user->id);
    }

    /**
     * Handle the User "deleted" event.
     */
    public function deleted(User $user): void
    {
        // Clean up all caches for deleted user
        SocialCacheService::invalidateUserCaches($user->id);
    }
}

// Register in AppServiceProvider
public function boot(): void
{
    User::observe(UserObserver::class);
}
<?php
// app/Observers/FriendshipObserver.php

namespace App\Observers;

use App\Models\Friendship;
use App\Services\SocialCacheService;

class FriendshipObserver
{
    /**
     * Handle the Friendship "created" event.
     */
    public function created(Friendship $friendship): void
    {
        SocialCacheService::invalidateFriendshipCaches(
            $friendship->requester_id,
            $friendship->addressee_id
        );
    }

    /**
     * Handle the Friendship "updated" event (status changed).
     */
    public function updated(Friendship $friendship): void
    {
        SocialCacheService::invalidateFriendshipCaches(
            $friendship->requester_id,
            $friendship->addressee_id
        );
    }

    /**
     * Handle the Friendship "deleted" event.
     */
    public function deleted(Friendship $friendship): void
    {
        SocialCacheService::invalidateFriendshipCaches(
            $friendship->requester_id,
            $friendship->addressee_id
        );
    }
}

Automatic invalidation flow:

// When a user updates their profile:
$user->update(['bio' => 'New bio']);

// UserObserver::updated() is automatically called
// → SocialCacheService::invalidateUserCaches($user->id)
// → All related caches are cleared
// → Next request will fetch fresh data

Cache Warming Strategy

Proactively populate cache for frequently accessed data:

<?php
// app/Services/SocialCacheService.php (continued)

/**
 * Warm up cache for frequently accessed data
 */
public static function warmUpUserCache(User $user): void
{
    // Pre-load user profile data
    $profileData = [
        'id' => $user->id,
        'username' => $user->username,
        'bio' => $user->bio,
        'profile_picture' => $user->profile_picture,
        'created_at' => $user->created_at,
    ];
    self::cacheUserProfile($user->username, $profileData);

    // Pre-load user stats (expensive to compute)
    $stats = [
        'friends_count' => $user->friends()->count(),
        'lists_count' => $user->lists()->count(),
        'reviews_count' => $user->reviews()->count(),
        'posts_count' => $user->posts()->count(),
    ];
    self::cacheUserStats($user->id, $stats);

    // Pre-load pinned content
    $pinnedContent = $user->pins()->with('pinnable')->get()->toArray();
    self::cachePinnedContent($user->id, $pinnedContent);
}

When to warm cache:

// After user registration
class RegisterController
{
    public function store(Request $request)
    {
        $user = User::create($request->validated());
        
        // Warm cache immediately
        SocialCacheService::warmUpUserCache($user);
        
        return redirect()->route('profile.show', $user->username);
    }
}

// After major data updates
class ProfileController
{
    public function update(Request $request)
    {
        $user = auth()->user();
        $user->update($request->validated());
        
        // Invalidate old cache
        SocialCacheService::invalidateUserCaches($user->id);
        
        // Warm with fresh data
        SocialCacheService::warmUpUserCache($user->fresh());
        
        return back();
    }
}

Batch Caching for Performance

When caching multiple items, batch operations are more efficient:

<?php
// app/Services/SocialCacheService.php (continued)

/**
 * Batch cache multiple items efficiently
 */
public static function batchCache(array $items): void
{
    if (Cache::getStore() instanceof \Illuminate\Cache\RedisStore) {
        $redis = Redis::connection('cache');
        $pipeline = $redis->pipeline();
        
        foreach ($items as $key => $data) {
            // SETEX: Set with expiration in one atomic operation
            $pipeline->setex($key, $data['ttl'], serialize($data['value']));
        }
        
        // Execute all commands at once
        $pipeline->execute();
    } else {
        // Fallback for non-Redis stores
        foreach ($items as $key => $data) {
            Cache::put($key, $data['value'], $data['ttl']);
        }
    }
}

Usage example:

// Cache multiple user profiles at once
$users = User::whereIn('id', [1, 2, 3, 4, 5])->get();

$cacheItems = [];
foreach ($users as $user) {
    $key = config('cache.prefix') . "profile:{$user->username}";
    $cacheItems[$key] = [
        'value' => [
            'id' => $user->id,
            'username' => $user->username,
            'bio' => $user->bio,
        ],
        'ttl' => SocialCacheService::PROFILE_DATA_TTL
    ];
}

SocialCacheService::batchCache($cacheItems);

Performance comparison:

// ❌ SLOW: Individual cache operations
foreach ($users as $user) {
    Cache::put("profile:{$user->id}", $user, 300);
}
// 5 users = 5 round trips to Redis = ~5ms

// ✅ FAST: Batch operation
SocialCacheService::batchCache($cacheItems);
// 5 users = 1 round trip to Redis = ~1ms

Cache Monitoring and Statistics

Track cache performance to optimize TTLs:

<?php
// app/Services/SocialCacheService.php (continued)

/**
 * Get cache statistics for monitoring
 */
public static function getCacheStats(): array
{
    if (Cache::getStore() instanceof \Illuminate\Cache\RedisStore) {
        $redis = Redis::connection('cache');
        $info = $redis->info();
        
        return [
            'memory_used' => $info['used_memory_human'] ?? 'N/A',
            'memory_peak' => $info['used_memory_peak_human'] ?? 'N/A',
            'keyspace_hits' => $info['keyspace_hits'] ?? 0,
            'keyspace_misses' => $info['keyspace_misses'] ?? 0,
            'hit_rate' => self::calculateHitRate($info),
            'connected_clients' => $info['connected_clients'] ?? 0,
            'total_commands_processed' => $info['total_commands_processed'] ?? 0,
            'evicted_keys' => $info['evicted_keys'] ?? 0,
        ];
    }

    return ['message' => 'Cache statistics only available for Redis'];
}

private static function calculateHitRate(array $info): float
{
    $hits = $info['keyspace_hits'] ?? 0;
    $misses = $info['keyspace_misses'] ?? 0;
    
    if ($hits + $misses === 0) {
        return 0;
    }
    
    return round($hits / ($hits + $misses) * 100, 2);
}

Admin dashboard usage:

class AdminDashboardController
{
    public function index()
    {
        $cacheStats = SocialCacheService::getCacheStats();
        
        return Inertia::render('Admin/Dashboard', [
            'cache' => $cacheStats
        ]);
    }
}

Interpreting the metrics:

  • Hit rate > 80%: Excellent caching strategy
  • Hit rate 50-80%: Good, but room for improvement
  • Hit rate < 50%: TTLs may be too short or cache not being used effectively
  • Evicted keys > 0: Redis memory is full, increase memory or reduce TTLs

Under the Hood

Redis Data Structures and Memory Layout

When you store data in Redis, it’s not just “key-value” - Redis uses optimized data structures:

# String (most common for cache)
Key: "myapp_cache:profile:alice"
Value: serialized PHP array (uses PHP's serialize())
Memory: ~1KB per profile

# Hash (alternative for structured data)
Key: "myapp_cache:user:42"
Fields: {username: "alice", bio: "...", avatar: "..."}
Memory: More efficient for partial updates

# Sorted Set (for leaderboards, time-based data)
Key: "myapp_cache:activity_feed:42"
Members: activity IDs with timestamps as scores
Memory: ~100 bytes per activity

Memory calculation example:

10,000 users × 1KB profile = 10MB
10,000 users × 5KB activity feed = 50MB
10,000 users × 2KB friends list = 20MB
Total: ~80MB for 10,000 active users

Cache Stampede Problem and Solution

The problem:

// 1000 concurrent requests hit expired cache
$data = Cache::get('expensive_data');  // All get null

if ($data === null) {
    // All 1000 requests execute this expensive query simultaneously!
    $data = DB::table('users')
              ->join('posts', ...)
              ->join('comments', ...)
              ->get();  // Database overload!
    
    Cache::put('expensive_data', $data, 300);
}

The solution: Cache locking

use Illuminate\Support\Facades\Cache;

$data = Cache::lock('expensive_data_lock', 10)->get(function () {
    // Only ONE request executes this
    return Cache::remember('expensive_data', 300, function () {
        return DB::table('users')
                 ->join('posts', ...)
                 ->join('comments', ...)
                 ->get();
    });
});

// Other 999 requests wait for the lock, then get cached data

How it works:

  1. First request acquires lock
  2. Other requests wait (up to 10 seconds)
  3. First request computes and caches data
  4. Lock is released
  5. Waiting requests get cached data

TTL Selection: The Math Behind It

Formula for optimal TTL:

TTL = (Cost of Cache Miss) / (Cost of Stale Data)

Where:
- Cost of Cache Miss = Query time × Request frequency
- Cost of Stale Data = User impact × Staleness tolerance

Example calculations:

Activity Feed:

  • Query time: 50ms
  • Request frequency: 100 req/min
  • Cache miss cost: 50ms × 100 = 5 seconds of DB time per minute
  • Staleness tolerance: Low (users expect real-time)
  • Optimal TTL: 60 seconds

User Profile:

  • Query time: 20ms
  • Request frequency: 50 req/min
  • Cache miss cost: 20ms × 50 = 1 second of DB time per minute
  • Staleness tolerance: Medium (profile changes are rare)
  • Optimal TTL: 300 seconds (5 minutes)

Friend List:

  • Query time: 100ms (complex join)
  • Request frequency: 10 req/min
  • Cache miss cost: 100ms × 10 = 1 second of DB time per minute
  • Staleness tolerance: High (friendships change rarely)
  • Optimal TTL: 600 seconds (10 minutes)

The CAP Theorem in Practice

In distributed caching, you must choose between:

  • Consistency: All nodes see the same data
  • Availability: System always responds
  • Partition Tolerance: System works despite network failures

Our implementation chooses AP (Availability + Partition Tolerance):

// Scenario: User updates profile
$user->update(['bio' => 'New bio']);

// Cache invalidation happens asynchronously
SocialCacheService::invalidateUserCaches($user->id);

// Problem: Other servers might still have old cache
// For 1-5 seconds, some users see old bio, others see new bio

// This is acceptable because:
// 1. Profile updates are rare
// 2. Eventual consistency is fine for social data
// 3. Availability is more important than perfect consistency

When you need strong consistency:

// Use cache tags for atomic invalidation
Cache::tags(['user:42'])->flush();

// Or skip cache for critical operations
$user = User::find(42);  // Always fresh from DB

Edge Cases & Pitfalls

Cache Key Collisions

// ❌ DANGEROUS: Keys can collide
Cache::put("user:42:lists", $data);  // User 42's lists
Cache::put("user:4:2:lists", $data);  // Ambiguous! User 4, page 2?

// ✅ SAFE: Use delimiters consistently
Cache::put("user:42:lists:page:1", $data);
Cache::put("user:4:lists:page:2", $data);

Best practices:

  • Use consistent delimiters (: is standard)
  • Avoid numbers at boundaries
  • Include entity type in key
  • Use prefixes from config

Serialization Issues

// ❌ PROBLEMATIC: Caching Eloquent models directly
$user = User::with('posts')->find(42);
Cache::put('user:42', $user);  // Serializes entire model + relations

// Problems:
// 1. Large memory footprint
// 2. Stale relationships
// 3. Serialization overhead

// ✅ BETTER: Cache only needed data
$userData = [
    'id' => $user->id,
    'username' => $user->username,
    'bio' => $user->bio,
];
Cache::put('user:42', $userData);
// Scenario: Celebrity user with 1M followers
// Their profile cache expires
// 10,000 concurrent requests hit the database

// ✅ SOLUTION: Probabilistic early expiration
public static function getUserProfile(string $username): ?array
{
    $key = self::getCacheKey('user_profile', $username);
    $data = Cache::get($key);
    
    if ($data === null) {
        return null;  // Cache miss
    }
    
    // Get TTL remaining
    $ttl = Cache::getStore()->getRedis()->ttl($key);
    
    // If TTL < 10% of original, probabilistically refresh
    if ($ttl < (self::PROFILE_DATA_TTL * 0.1)) {
        if (rand(1, 100) <= 10) {  // 10% chance
            return null;  // Trigger refresh
        }
    }
    
    return $data;
}

When NOT to Cache

Don’t cache:

  1. Highly volatile data: Real-time stock prices, live scores
  2. User-specific sensitive data: Credit cards, passwords
  3. Large binary data: Videos, large images (use CDN instead)
  4. Data with complex invalidation: Deeply nested relationships

Example of bad caching:

// ❌ BAD: Caching search results
$results = Cache::remember("search:{$query}", 3600, function() use ($query) {
    return Post::search($query)->get();
});

// Problems:
// 1. Query variations create many cache keys
// 2. New posts won't appear in cached results
// 3. Memory waste on rarely-repeated searches

// ✅ BETTER: Don't cache, optimize the search instead
$results = Post::search($query)->get();  // Use Elasticsearch/Meilisearch

Cache Poisoning

// ❌ VULNERABLE: User input in cache key
$userId = request()->input('user_id');  // Could be "42:admin:true"
$key = "user:{$userId}:profile";
Cache::put($key, $data);

// Attacker could poison: "user:42:admin:true:profile"

// ✅ SAFE: Validate and sanitize
$userId = (int) request()->input('user_id');
if ($userId <= 0) {
    abort(400);
}
$key = "user:{$userId}:profile";
Cache::put($key, $data);

Testing Cache Logic

use Tests\TestCase;
use Illuminate\Support\Facades\Cache;

class SocialCacheServiceTest extends TestCase
{
    public function test_user_profile_is_cached()
    {
        $user = User::factory()->create();
        
        // First call should cache
        SocialCacheService::cacheUserProfile($user->username, [
            'id' => $user->id,
            'username' => $user->username,
        ]);
        
        // Verify cache exists
        $cached = SocialCacheService::getUserProfile($user->username);
        $this->assertNotNull($cached);
        $this->assertEquals($user->username, $cached['username']);
    }
    
    public function test_cache_invalidation_clears_user_data()
    {
        $user = User::factory()->create();
        
        // Cache some data
        SocialCacheService::cacheUserProfile($user->username, ['id' => $user->id]);
        SocialCacheService::cacheUserStats($user->id, ['posts' => 10]);
        
        // Invalidate
        SocialCacheService::invalidateUserCaches($user->id);
        
        // Verify cache is cleared
        $this->assertNull(SocialCacheService::getUserProfile($user->username));
        $this->assertNull(SocialCacheService::getUserStats($user->id));
    }
    
    public function test_friendship_cache_invalidates_both_users()
    {
        $user1 = User::factory()->create();
        $user2 = User::factory()->create();
        
        // Cache friendship data
        SocialCacheService::cacheFriendsList($user1->id, []);
        SocialCacheService::cacheFriendsList($user2->id, []);
        
        // Invalidate friendship
        SocialCacheService::invalidateFriendshipCaches($user1->id, $user2->id);
        
        // Both users' caches should be cleared
        $this->assertNull(SocialCacheService::getFriendsList($user1->id));
        $this->assertNull(SocialCacheService::getFriendsList($user2->id));
    }
}

Conclusion

What You’ve Learned

You now understand how to build a production-grade multi-tiered caching system that:

  1. Implements different TTLs based on data volatility and access patterns
  2. Uses pattern-based invalidation to surgically clear related caches
  3. Leverages Redis efficiently with SCAN, pipelines, and atomic operations
  4. Handles cache stampede with locking mechanisms
  5. Monitors performance with hit rates and memory metrics
  6. Scales to millions of users with proper key design and batch operations

The Key Insights

Caching is not just about speed - it’s about system design. A well-designed cache strategy considers:

  • Data volatility (how often it changes)
  • Access patterns (how often it’s read)
  • Invalidation complexity (what else needs updating)
  • Memory constraints (how much can you cache)
  • Consistency requirements (how stale can data be)

The 80/20 rule applies: 20% of your data accounts for 80% of your traffic. Identify and optimize caching for that 20%.

Cache invalidation is the hardest problem in computer science - not because it’s technically complex, but because it requires deep understanding of your domain and data relationships.

Performance Impact: Real Numbers

Before caching:

  • Profile page load: 500ms (15 database queries)
  • Database CPU: 80% under load
  • Concurrent users supported: ~500

After multi-tiered caching:

  • Profile page load: 50ms (0-2 database queries)
  • Database CPU: 20% under same load
  • Concurrent users supported: ~5,000
  • 10x performance improvement

Next Steps

  • Implement cache warming: Pre-populate cache for popular content
  • Add cache layers: CDN → Redis → Database
  • Monitor and tune: Adjust TTLs based on actual hit rates
  • Consider cache sharding: Distribute cache across multiple Redis instances
  • Implement cache versioning: Handle schema changes gracefully

Real-World Applications

This pattern is used by:

  • Facebook: Multi-tiered caching with TAO (The Associations and Objects)
  • Twitter: Timeline caching with different TTLs per user tier
  • Reddit: Comment tree caching with surgical invalidation
  • LinkedIn: Profile caching with pattern-based invalidation

Advanced Topics to Explore

Cache-Aside vs. Write-Through:

// Cache-Aside (what we implemented)
$data = Cache::get($key);
if ($data === null) {
    $data = DB::query();
    Cache::put($key, $data);
}

// Write-Through (alternative)
$user->update($data);
Cache::put("user:{$user->id}", $data);  // Update cache immediately

Distributed Caching:

  • Redis Cluster for horizontal scaling
  • Consistent hashing for key distribution
  • Replication for high availability

Cache Compression:

// For large data, compress before caching
$data = gzcompress(serialize($largeArray));
Cache::put($key, $data);

// Decompress on retrieval
$data = unserialize(gzuncompress(Cache::get($key)));

You’ve just learned how billion-user platforms handle caching at scale. This knowledge is directly applicable to any high-traffic application. 🎉


Appendix: Quick Reference

Cache TTL Cheat Sheet

const REAL_TIME_TTL = 10;        // 10 seconds - live data
const HOT_DATA_TTL = 60;         // 1 minute - frequently changing
const WARM_DATA_TTL = 300;       // 5 minutes - occasionally changing
const COLD_DATA_TTL = 3600;      // 1 hour - rarely changing
const STATIC_DATA_TTL = 86400;   // 24 hours - almost never changing

Common Cache Patterns

// 1. Cache-Aside (Lazy Loading)
$data = Cache::remember($key, $ttl, fn() => DB::query());

// 2. Write-Through
DB::update($data);
Cache::put($key, $data, $ttl);

// 3. Write-Behind (Async)
Cache::put($key, $data, $ttl);
dispatch(new UpdateDatabaseJob($data));

// 4. Refresh-Ahead
if (Cache::ttl($key) < 60) {
    dispatch(new RefreshCacheJob($key));
}

Redis Commands Reference

# Monitor cache in real-time
redis-cli MONITOR

# Check memory usage
redis-cli INFO memory

# Find keys by pattern
redis-cli --scan --pattern "user:*"

# Get TTL for a key
redis-cli TTL "myapp_cache:user:42"

# Flush all cache (DANGEROUS!)
redis-cli FLUSHDB

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!