0

Caching strategies — cache-aside, write-through, write-behind

Advanced5 min read·eng-11-006
interviewperformance

Concept

Caching strategies — how and when to write and read from cache.

Cache-aside (lazy loading — the most common pattern):

  • Application code is responsible for loading data into the cache.
  • On read: check cache → if miss, load from DB → store in cache → return.
  • On write: update DB → invalidate (delete) or update the cache entry.
  • Pros: Only cached data that's actually needed. Cache failure doesn't block reads.
  • Cons: First request always a cache miss (cold start). Can lead to stale data if invalidation is missed.
  • Use case: General-purpose caching, variable data access patterns.

Write-through:

  • On write: write to DB AND to cache synchronously (both must succeed).
  • On read: check cache → always hit (data always in cache if it was ever written).
  • Pros: Cache never stale for written data. Reads are always fast.
  • Cons: All written data takes cache space (even if rarely read). Write latency slightly higher.
  • Use case: Data that is read frequently after being written.

Write-behind (write-back):

  • On write: write to cache only, return immediately. Asynchronously flush to DB later.
  • Pros: Very fast writes (no DB latency).
  • Cons: Data can be lost if cache crashes before flush. Complex implementation.
  • Use case: High-write, loss-tolerant data (analytics counters, game scores).

Read-through:

  • Cache sits in front of DB. Application only talks to the cache.
  • Cache handles DB reads on miss automatically.
  • Similar to cache-aside but the cache manages DB interaction (e.g., Cloudflare cache, CDN).

Cache stampede / thundering herd: When cache expires, many requests simultaneously miss and all hit the DB. Fix: lock (only one request populates the cache), random TTL variation, pre-warming.

Code Example

php
<?php
// ============================================================
// CACHE-ASIDE — most common pattern in Laravel
// ============================================================
class ProductRepository
{
    private const TTL = 3600;

    public function findById(int $id): ?Product
    {
        return Cache::remember("product:{$id}", self::TTL, function () use ($id) {
            return Product::with('category')->find($id);
        });
    }

    public function update(Product $product, array $data): Product
    {
        $product->update($data);
        Cache::forget("product:{$product->id}"); // invalidate — next read will re-cache
        return $product->fresh();
    }

    public function findFeatured(): Collection
    {
        return Cache::remember('products:featured', 300, function () {
            return Product::where('featured', true)->orderBy('sort_order')->get();
        });
    }
}

// ============================================================
// WRITE-THROUGH — write to DB and cache together
// ============================================================
class UserProfileRepository
{
    public function update(int $userId, array $data): User
    {
        DB::transaction(function () use ($userId, $data) {
            $user = User::findOrFail($userId);
            $user->update($data);
            Cache::put("user:{$userId}", $user->fresh(), 86400); // always in sync
        });
        return Cache::get("user:{$userId}");
    }

    public function find(int $userId): ?User
    {
        return Cache::get("user:{$userId}"); // always a hit if user was ever written
    }
}

// ============================================================
// CACHE STAMPEDE PREVENTION
// ============================================================
class SafeCache
{
    // Option 1: Lock — only one process populates the cache
    public function rememberLocked(string $key, int $ttl, callable $callback): mixed
    {
        $value = Cache::get($key);
        if ($value !== null) return $value;

        $lock = Cache::lock("lock:{$key}", 30); // 30s lock
        if ($lock->get()) {
            try {
                $value = $callback();
                Cache::put($key, $value, $ttl);
            } finally {
                $lock->release();
            }
        } else {
            // Another process is computing — wait for the lock to release, then read
            $lock->block(30); // wait up to 30s
            $value = Cache::get($key);
        }
        return $value;
    }

    // Option 2: Random TTL — spread expiration across time
    public function rememberWithJitter(string $key, int $baseTtl, callable $callback): mixed
    {
        $jitter = random_int(0, (int) ($baseTtl * 0.1)); // ±10% jitter
        return Cache::remember($key, $baseTtl + $jitter, $callback);
    }
}