On this page
- Purpose
- The Problem
- The Solution
- What You’ll Learn
- Prerequisites & Tooling
- Knowledge Base
- Environment
- Redis Setup
- High-Level Architecture
- The Multi-Tiered Cache Strategy
- Cache Key Hierarchy
- The Library
- The Implementation
- Understanding the Naive Caching Approach
- Defining Cache TTL Constants
- Designing the Cache Key Structure
- Implementing Cache Storage Methods
- Pattern-Based Cache Invalidation
- Event-Driven Cache Invalidation
- Cache Warming Strategy
- Batch Caching for Performance
- Cache Monitoring and Statistics
- Under the Hood
- Redis Data Structures and Memory Layout
- Cache Stampede Problem and Solution
- TTL Selection: The Math Behind It
- The CAP Theorem in Practice
- Edge Cases & Pitfalls
- Cache Key Collisions
- Serialization Issues
- Thundering Herd on Popular Content
- When NOT to Cache
- Cache Poisoning
- Testing Cache Logic
- Conclusion
- What You’ve Learned
- The Key Insights
- Performance Impact: Real Numbers
- Next Steps
- Real-World Applications
- Advanced Topics to Explore
- Appendix: Quick Reference
- Cache TTL Cheat Sheet
- Common Cache Patterns
- Redis Commands Reference
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:
- Stale data: Activity feed cached for 1 hour, but new activities appear every minute
- Wasted memory: Caching rarely-accessed data with the same TTL as hot data
- Invalidation nightmare: How do you clear cache when user updates profile?
- 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:
- Hierarchical:
user:42:friendsgroups all user 42’s data - Predictable: Same inputs always produce same key
- Namespaced: Prefix prevents collisions with other apps
- 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:
- First request acquires lock
- Other requests wait (up to 10 seconds)
- First request computes and caches data
- Lock is released
- 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);
Thundering Herd on Popular Content
// 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:
- Highly volatile data: Real-time stock prices, live scores
- User-specific sensitive data: Credit cards, passwords
- Large binary data: Videos, large images (use CDN instead)
- 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:
- Implements different TTLs based on data volatility and access patterns
- Uses pattern-based invalidation to surgically clear related caches
- Leverages Redis efficiently with SCAN, pipelines, and atomic operations
- Handles cache stampede with locking mechanisms
- Monitors performance with hit rates and memory metrics
- 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