Skip to content
EvergreenSystemsNovember 13, 2023

Cache invalidation is a distributed systems problem

The TTL is not a cache policy. It is a probability distribution, and somebody has to own it.

Vedant · 4 notes in the margin · reviewed 1mo ago

Viewing revision 1 — the first published version. Written 3 years ago.

First published version.

← Back to the current version

Every team I have joined has had a cache, and every team has eventually been surprised by it. The surprise is always the same shape: something was correct, then it was not, and nothing in the logs said why.

1 note

Comment

nothing in the logs said why

The logs never say why because "why" is a semantic question and logs are a syntactic record. We eventually added a stale-read counter to the read path specifically so that this question had an answer.

Mira Okonkwo3 years agolink

Was this worth reading?
Reply
Report

On one machine, cache invalidation is a function call and you can stop thinking about it. You hold a reference to the map, you call delete, and the next reader either sees the new value or blocks until it is written. There is a happens-before edge and it is obvious.

1 note

Correction · accepted

There is a happens-before edge

Small correction: this is only true if the reader and the map are actually ordered. Under a per-key lock you have the edge; under a striped lock you have an edge only for keys in the same stripe, which is a weaker guarantee than it looks.

Mira Okonkwolast yearlink

Was this worth reading?
Reply
Report
typescript
                      
                          class LocalCache {
  private entries = new Map<string, Entry>();
  private hits = 0;
  private misses = 0;

  get(key: string): Entry | undefined {
    const entry = this.entries.get(key);
    if (!entry) { this.misses++; return undefined; }
    if (entry.expiresAt < Date.now()) { this.entries.delete(key); this.misses++; return undefined; }
    this.hits++;
    return entry;
  }

  invalidate(key: string) { this.entries.delete(key); }
}
                          class LocalCache {
  private entries = new Map<string, Entry>();
  private hits = 0;
  private misses = 0;

  get(key: string): Entry | undefined {
    const entry = this.entries.get(key);
    if (!entry) { this.misses++; return undefined; }
    if (entry.expiresAt < Date.now()) { this.entries.delete(key); this.misses++; return undefined; }
    this.hits++;
    return entry;
  }

  invalidate(key: string) { this.entries.delete(key); }
}
                        
                    
A cache with an explicit invalidation call. Correct, and trivially so.

This works because the map and the reader are in the same process, ordered by one thread or guarded by one mutex. When invalidate returns, every subsequent get sees the new value. You can reason about it locally.

Now put the cache in Redis and the reader in fifty pods, and the guarantee quietly evaporates. DEL returns. The writer moves on. But a pod that read the key eight milliseconds ago still has it in a local LRU with a five-minute TTL, and that pod will happily serve the stale value for five more minutes.

You have not fixed a bug. You have introduced a distributed systems problem and given it a friendly name. The tell is that nobody can now state what the system is allowed to get wrong — and that was the entire value of the local version.

1 note

Extension

a friendly name

This is also why the local-cache version survives in so many codebases: it is the only version where correctness is *obvious*, and obviousness is a real engineering property that people will trade performance to keep.

Sam Reyes2 years agolink

Was this worth reading?
Reply
Report
Staleness against TTL, for a key changing every 10s
0%25%50%75%100%1300s

39% of reads can be served stale at a 20s TTL.

Assumes independent (Poisson) changes and exponential decay on read. Real change is burstier, so expect worse than this.

There are exactly three coherent answers, and every production cache is some mixture of them. Knowing which mixture you are running is most of the work.

StrategyFreshnessCostFails when
Write-throughImmediate on the writer sideWrite latency doublesThe reader cannot see the writer's write — you need a barrier
Invalidate + TTLBounded by TTLCheapest to runYou accept staleness and must say so out loud
Push to all readersImmediateO(replicas) per writeA replica is down, or lagging, or you forgot to subscribe

The third strategy is the one people reach for and then discover is a distributed system with a fan-out and no backpressure. If you push invalidations, you are building a broadcast protocol. You now have to think about what a subscriber does when it reconnects and has missed forty messages.

The interactive above assumes a key whose value changes on a Poisson-ish process. Real change is bursty — deploys, backfills, a customer uploading in bulk. Under bursty load the expected staleness is far worse than the TTL suggests, and the only defence is to make invalidation a first-class write-path event rather than hoping the timer covers you.

Pick invalidate-plus-TTL, set the TTL from the rate of change of the data, and write the TTL down next to the data. Then tell the people reading through the cache what window of staleness they are inside, because they will otherwise assume they are looking at truth.

The TTL is not a cache policy. It is a promise about how wrong you are willing to be, made in a config file that nobody reads.

The corollary, which cost me a very long week: a TTL is not a cache policy, it is a documented error budget. If you cannot write down the number, you do not have a cache, you have a source of intermittent lies.

Everything else is a way of deferring that conversation.

How deep do you want this?

First time here? Set your depth once and every post honors it — skim the argument, read it properly, or take the footnotes too.

Full argument, prerequisites explained inline.

Revision history

current →
  1. v4Added a note on bursty change and a corollary on error budgets.1mo agocurrent
  2. v3Corrected the direction of the TTL rule. It was backwards.5mo agomajor
  3. v2Added an interactive staleness-vs-TTL figure, and rewrote the TTL guidance.1y agomajor
  4. v1First published version.2y agomajorviewing

Marginalia

3 notes from 3 readers, attached to specific sentences. Select any sentence in the text to add yours.

Ask this article

Answers are quotes from this post, not generated text — so they can be wrong by omission but never by invention. What it does not cover, it says so. If you have brought your own model key, a model summarizes those same quotes, and the quotes stay the source of truth.

Answer with a specific model

Your key is used for your provider if you added one, otherwise the site's. Automatic stays on free-tier models only.

Try to break this argument

The claims this post makes most concretely — the ones with numbers, absolutes or prescriptions in them — put to a model that has read only the sentence in question. Every objection quotes the post, and a claim nothing can be said against is reported as standing.

Costs a few model calls against your key or the site's.

Where this sits

It leans on 1 other post. 2 posts have picked it up since. Oldest at the top, so a claim resting on something written after it would be visible as an arc running upward.

Cache invalidation is a distributed systems problem extends We deleted our staging environmentThe p99 is a lie you tell yourself cites Cache invalidation is a distributed systems problemWe deleted our staging environment cites Cache invalidation is a distributed systems problem▸ Cache invalidation is a distr…Cache invalidation is a distributed systems problem — Vedant, 4 revisionsThe p99 is a lie you tell you…The p99 is a lie you tell yourself — Vedant, 2 revisionsWe deleted our staging enviro…We deleted our staging environment — Vedant, 1 revision
  • extends
  • cites

When this changes

Leave an address and you will hear if this post is ever substantially revised — once, and only when it actually changes.