RizTech Academy logo
RizTech Academy
Key-Value Stores and CachingLesson 3 of 635 min

Cache-aside, write-through, and cache invalidation

Knowing Redis is fast is not knowing how to cache. The patterns — how the cache and the database relate on a read and on a write — are what separate a cache that speeds things up from one that serves wrong data. This lesson is the handful of named patterns, when each applies, and the famous hard problem underneath them all: invalidation.

Cache-aside — the one you will use

Also called lazy loading. The application talks to both, and the cache is checked first:

def get_book(book_id):
    key = f"book:{book_id}"
    cached = redis.get(key)
    if cached is not None:              # cache HIT
        return json.loads(cached)

    book = db.query("SELECT ... FROM books WHERE id = %s", (book_id,))   # cache MISS
    redis.set(key, json.dumps(book), ex=3600)     # populate, with a TTL
    return book

Three steps: check the cache; on a miss, read the database and store the result; return it. The next read of that book is a hit. This is the default pattern — most caching you write is this — and it has two properties worth understanding.

Only requested data is cached. The cache fills lazily with what is actually read, so it never holds cold data. Good.

The first read of anything is always a miss, and pays the full database cost plus the cache write. Fine at steady state, a problem on a cold cache — see stampedes below.

The TTL is doing quiet, essential work here: even with no explicit invalidation, a stale entry corrects itself within ex=3600 seconds. Cache-aside with a sensible TTL is often all the invalidation you need, because "wrong for at most an hour" is acceptable for most data. Never cache without a TTL unless you have a specific reason — a cache that only ever grows and never expires is a memory leak with extra steps.

The write patterns, and how the cache stays correct

Cache-aside says nothing about writes. When the underlying data changes, the cached copy is now wrong until its TTL expires. Three ways to handle a write:

Write-through — write to the cache and the database together, synchronously:

def update_book(book_id, data):
    db.execute("UPDATE books SET ... WHERE id = %s", (book_id,))
    redis.set(f"book:{book_id}", json.dumps(data), ex=3600)   # keep the cache correct

The cache is always fresh, but every write pays for both, and you cache data that may never be read. Best when you write something you will immediately read a lot.

Write-behind (write-back) — write to the cache now, flush to the database later, asynchronously. Fast writes, but you can lose data if the cache dies before the flush, and it is genuinely hard to get right. Rare in application code; leave it unless you have a specific need and understand the risk.

Write-around with invalidation — usually the right answer. Write to the database, and simply delete the cached key rather than update it:

def update_book(book_id, data):
    db.execute("UPDATE books SET ... WHERE id = %s", (book_id,))
    redis.delete(f"book:{book_id}")           # invalidate; next read repopulates

Delete, do not update. Deleting is simpler and safer than recomputing the cached value on every write path — the next read rebuilds it correctly through cache-aside, from the source of truth. Updating the cache in place means every writer must know how to construct the exact cached shape, which drifts. Cache-aside for reads plus delete-on-write is the combination to reach for first, and it covers the large majority of caching.

Why deleting beats updating, concretely

There is a subtle correctness reason, not just simplicity. Consider two concurrent operations — one reading (and about to populate the cache with an old value it just fetched) and one writing. If the writer updates the cache, the reader's stale write can land after it and leave the cache wrong indefinitely. If the writer deletes, the worst case is an extra miss. This is a race that bites real systems, and "delete on write" sidesteps most of it. (The airtight version, when you need it, deletes the key both before and after the database write.)

The three failures every cache has

Name these, because they are how caches take production down.

Stampede (the thundering herd)

A popular key expires. In the gap before anything repopulates it, a thousand concurrent requests all miss at once and all hit the database with the same expensive query. The database, sized for one such query per hour, gets a thousand in one second and falls over — and a cold cache after a restart is this for every key simultaneously.

Defences, in rough order of reach:

  • A short lock. The first miss takes a Redis lock (SET key ... NX EX 10) and does the recompute; the others wait briefly or serve stale. One database query instead of a thousand.
  • Stale-while-revalidate. Serve the just-expired value while one request refreshes it in the background — nobody waits, the database sees one query.
  • Jittered TTLs. Add a random spread to expiry times so a batch of keys written together does not all expire in the same second.

Penetration (caching the misses)

Requests for data that does not exist — a bogus book:999999, often an attack — miss the cache every time (nothing to cache) and hit the database every time. The fix: cache the "not found" too, with a short TTL: SET book:999999 "" EX 60. A Bloom filter is the heavier-duty answer for a large keyspace.

Avalanche (mass expiry)

A large set of keys expires at the same instant — because they were all written together with the same TTL — and the database is hit by all the misses at once. Same cause as a cold start. The fix is the same jittered TTL: ex = 3600 + random(0, 600) spreads the expiry out.

Choosing a pattern

Situation Pattern
General read caching Cache-aside + TTL
Data written then immediately read heavily Write-through
Keeping cache correct on writes Delete the key (write-around)
Extreme write throughput, some loss tolerable Write-behind (rarely, carefully)
Popular key, expensive recompute Cache-aside + lock or stale-while-revalidate
Queries for non-existent data Cache the negative result

The one to internalise: cache-aside for reads, delete-on-write for correctness, a TTL on everything, and jitter the TTLs. That combination handles the overwhelming majority of caching correctly, and everything else is a refinement for a specific pressure you have measured.

The famous quote, and what it really means

"There are only two hard things in computer science: cache invalidation and naming things." It is a joke, but the first half is serious. Invalidation is hard because the moment you copy data, you own the problem of the copy disagreeing with the original — across concurrency, failures, and time. The patterns here do not solve it; they make the disagreement small and bounded (a TTL caps how stale anything gets) and its consequences cheap (a miss, not a wrong answer). That is the achievable goal: not a cache that is never stale, but a cache whose staleness is bounded and whose failures are misses, not corruption.

Check your work

The cache-aside pattern. Check cache; on a miss, read the database, store the result, return it.

Two properties of cache-aside. It caches only requested data, and the first read always misses.

What the TTL does even without explicit invalidation. Bounds staleness — a stale entry self-corrects when it expires.

Write-through. Write cache and database together; always fresh, but every write pays twice.

Write-behind and its risk. Write cache now, flush later; fast but can lose data if the cache dies first.

Write-around with invalidation. Write the database, delete the cached key; the next read repopulates.

Why delete rather than update the cache on a write. Simpler, and it avoids a race where a concurrent stale populate leaves the cache permanently wrong.

The stampede, and three defences. Many concurrent misses on one expired hot key; a lock, stale-while-revalidate, and jittered TTLs.

Cache penetration and its fix. Repeated requests for non-existent data; cache the negative result with a short TTL.

Cache avalanche and its fix. Mass simultaneous expiry; jitter the TTLs.

The combination to reach for first. Cache-aside reads, delete-on-write, a TTL on everything, jittered.

What "cache invalidation is hard" really means, and the achievable goal. A copy can disagree with the source across concurrency, failure and time; aim not for never-stale but for bounded-stale and misses-not-corruption.

Practice

  1. Implement cache-aside for one query. Log hit or miss and watch the first read miss and the second hit.
  2. Add a TTL and confirm the entry disappears and the next read repopulates it.
  3. Update the underlying row without touching the cache. Observe the stale read until the TTL expires.
  4. Add delete-on-write and confirm the next read is fresh.
  5. Try updating the cache in place on write instead. Construct the concurrent-race scenario where it goes permanently wrong.
  6. Simulate a stampede: expire a hot key and fire many concurrent reads. Count the database queries.
  7. Add a lock so only the first miss recomputes. Recount.
  8. Request a non-existent key repeatedly and confirm every request hits the database. Cache the negative result and recount.
  9. Write 100 keys with an identical TTL and watch them all expire together. Add jitter and watch the expiry spread out.
  10. For a real cached value, write down its TTL and justify the number: what is the worst acceptable staleness?

Official documentation

Next: sessions, rate limiting and leaderboards — caching's cousins.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship