abhi-g.dev
Distributed systemsNov 20185 min read

Cache invalidation: the three strategies that actually ship

Every cache has to answer one question: when does a stored value stop being trusted? There are three practical answers, and most systems end up combining two of them.

Originally written on 19 November 2018. Migrated from a WordPress blog and reformatted.

A cache stores a copy of a value so that reads are cheaper. The copy is correct until the source changes. Invalidation is the mechanism by which the cache learns that it has, and the choice of mechanism determines how stale a value can be and how much complexity the write path carries.

There are three strategies in real use.

Three ways a cache learns that a value changed1. expiry(TTL)nothing to do on writestale for up to TTL2. delete on writewriter must know every keydelete AFTER commit3. versioned keysbump one number, old keys age outproduct:42:v17 -> v18most systems: 1 as the floor, plus 2 or 3 for freshness
Three ways a cache learns that a value changed

1. Expiry (TTL)

Every entry carries a lifetime. Reads within the lifetime return the cached value; after it, the next read misses and repopulates.

SET product:42 {...} EX 300

Bound on staleness: the TTL. A product price changed at 12:00:01 can be served at the old value until 12:05:00.

Write path complexity: none. The writer does not know the cache exists.

Where it fits: anything where a bounded delay is acceptable and the write path cannot be modified: third-party data, derived aggregates, most read-heavy pages. It is the correct default because it is the only strategy that cannot be broken by a write path that forgets to invalidate.

Failure mode: the thundering herd. When a popular key expires, every concurrent reader misses at once and all of them hit the source. Mitigate with a short lock around repopulation, or by having one reader refresh in the background slightly before expiry while others continue to serve the old value.

2. Explicit invalidation on write

The writer, after updating the source, deletes or overwrites the cache entry.

DB::transaction(function () use ($product) {
    $product->save();
});
Cache::forget("product:{$product->id}");

Bound on staleness: the gap between commit and forget, typically milliseconds.

Write path complexity: every write path must know every cache key that depends on the value. A product's price may be cached under product:42, category:7:listing, and search:... keys. Missing one produces a permanently stale entry, which is why this strategy is usually combined with a TTL as a backstop.

Ordering matters. Delete after commit, not before. If the delete precedes the commit, a concurrent reader can miss, read the old value from the database, and repopulate the cache with it after the write has committed.

Delete, don't update. Writing the new value into the cache from the write path seems efficient but races with concurrent reads that may write an older value afterward. Deleting and letting the next read repopulate is safe.

3. Versioned keys

Instead of invalidating, change the key. The cache key includes a version that the source increments on write:

version = GET product:42:version        # 17
GET product:42:v17

On update, the writer increments the version. Old entries are never touched; they simply stop being read and expire on their own TTL.

Bound on staleness: the read of the version number, which can itself be cached briefly.

Write path complexity: one increment per logical entity, regardless of how many derived keys exist. A category listing can carry its own version, incremented whenever any product in the category is written, so one increment invalidates every derived key for that category.

Where it fits: entities with many derived cache entries, and anything where a namespace-wide invalidation ("everything for tenant X") is needed. Bumping one version number is cheaper and safer than enumerating keys.

Combining them

Most production systems use expiry as the floor and one of the other two for freshness:

The strategy that is not on the list is "the cache is always right". Design for the case where invalidation is missed, because it will be.