0

Cache invalidation — removing or updating a stale cache entry (one of the hard problems)

Intermediate5 min read·eng-20-004
interviewperformance

Concept

Cache invalidation — the process of removing or updating cached data when the underlying data changes, to prevent serving stale (outdated) responses.

"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton. It's famously hard because you must ensure cache coherence between the cache and the source of truth.

Strategies:

  • TTL (Time-to-Live): Cache expires after X seconds. Simple, but serves stale data during the TTL window. Acceptable for data that doesn't need to be immediately fresh.
  • Write-through: Update cache whenever you write to the database. Cache is always fresh. Overhead on writes. Complex if multiple services write.
  • Write-behind: Update cache immediately, write to DB asynchronously. Fast writes, risk of data loss.
  • Event-based invalidation: When an event fires (OrderUpdated), invalidate the relevant cache keys. Real-time freshness. More complex.
  • Cache-aside with explicit invalidation: When updating a record, call Cache::forget('key'). Cache is stale only between the write and the forget().

The problem with TTL: Short TTL = fresh data but many cache misses. Long TTL = more cache hits but potentially stale data. The right TTL depends on how often the data changes and how stale data can be tolerated.

Cache tags: Group related cache items under a tag, then invalidate by tag. Cache::tags(['product:1'])->flush() deletes all caches tagged with product:1. Useful when one model change invalidates many cache entries.

Partial invalidation: Avoid flushing too much. Cache::flush() in production is catastrophic — all cache gone, massive DB load spike. Always invalidate specific keys or tags.

Code Example

php
<?php
// STRATEGY 1: TTL only — data may be up to 3600 seconds stale
class ProductRepository
{
    public function find(int $id): Product
    {
        return Cache::remember("product:{$id}", 3600, fn() => Product::findOrFail($id));
    }
}
// Simple, but if someone updates the product, cache still returns old data for up to 1 hour

// STRATEGY 2: Event-based invalidation
class ProductObserver
{
    public function updated(Product $product): void
    {
        Cache::forget("product:{$product->id}");
        Cache::tags(['products'])->flush(); // also invalidate the list
    }

    public function deleted(Product $product): void
    {
        Cache::forget("product:{$product->id}");
    }
}

// Register the observer
// App\Providers\AppServiceProvider::boot():
Product::observe(ProductObserver::class);

// STRATEGY 3: Cache tags for grouped invalidation
class ProductRepository
{
    public function find(int $id): Product
    {
        return Cache::tags(["product:{$id}", 'products'])
            ->remember("product:{$id}:detail", 3600, fn() => Product::with('variants')->findOrFail($id));
    }

    public function list(): \Illuminate\Database\Eloquent\Collection
    {
        return Cache::tags(['products'])->remember('products:all', 3600, fn() => Product::all());
    }

    public function update(int $id, array $data): Product
    {
        $product = Product::findOrFail($id)->fill($data)->save();
        Cache::tags(["product:{$id}", 'products'])->flush(); // invalidates both detail and list
        return $product;
    }
}

// STRATEGY 4: Write-through
public function create(array $data): Product
{
    $product = Product::create($data);
    Cache::put("product:{$product->id}", $product, 3600); // immediately cache
    Cache::tags(['products'])->forget('products:all');     // invalidate list
    return $product;
}

// AVOID: flushing everything
Cache::flush(); // DANGEROUS in production — all cache gone at once = DB stampede