The Last CachebenderInterlude · Spirit
Interlude · Spirit
Interlude · Spirit · Lesson 8 of 14
Lesson Eight

The Three Reading Techniques

Technique unlocked The Three Draws cache-aside, read-through, refresh-ahead

The question

Every read has the same shape: the data might be in the cache, or it might not. What happens on a miss?

There are three answers, and the difference between the first two is who is responsible.

Cache-aside

Cache-aside — also called lazy loading — means your application manages the cache directly. It is the most common pattern in use, and the default you should assume unless there is a reason otherwise.

value = cache.get(key)

if value is not None:
    return value                 # HIT

value = database.query(key)      # MISS — application fetches
cache.set(key, value, ttl=300)   # application stores
return value
Application Cache Database 1 get miss 2 query — straight past the cache row 3 set
Three calls, all of them made by the application

The application talks to both the cache and the database, and it owns every step.

Why people use it:

  • Only requested data is cached. Nothing is stored that nobody wants.
  • If the cache is unavailable, the app can fall back to the database, as long as the database can handle the extra load.
  • It works with any cache and any database. No special library required.
  • You can see exactly what is happening.

The costs:

  • Every miss pays three trips: check cache, query database, write cache.
  • The logic is repeated everywhere you cache something, and someone will eventually get it wrong.
  • It has a specific race condition, which is the second half of this lesson.

Read-through

Read-through means your application only ever talks to the cache. The cache knows how to load missing data by itself.

Application Cache Database 1 get 2 load row value
The application never reaches past the cache
value = cache.get(key)    # cache handles the miss internally
return value

You configure the cache with a loader function — the code that fetches from the database — and the cache calls it when it needs to.

The distinction is exactly this:

Who handles the miss
Cache-aside Your application
Read-through The cache layer

That is the whole difference. Not performance, not freshness — responsibility. And because it decides where the logic lives, it decides where the bugs live.

Why people use it: the loading logic exists once, not scattered everywhere. Application code becomes simple. Many caching libraries do the hard parts (like the race below) for you.

The costs: you need a library that supports it. The cache going down is now harder to survive, because your app has no other path to the data. And the loading logic is one step further from the code that uses it.

You have already met one. In Lesson 4, the CDN fetched from origin on a miss without your application knowing. A CDN is a read-through cache. You did not implement it, so it did not feel like a pattern. It was.

Refresh-ahead

Both patterns above are reactive: a user hits a miss, and that user waits while the data loads.

t = 0 cached t = 240 refreshed t = 300 would expire background refresh — nobody is waiting
The value is replaced before anyone meets a miss

Refresh-ahead is proactive. For values you know are hot, refresh them before they expire, so no user ever encounters the miss.

TTL = 300 seconds, refresh at 240

t=0    value cached
t=240  ─→ background job refreshes it   (no user is waiting)
t=245  value replaced, timer resets
       ...no user ever sees a miss on this key

Why people use it: for hot keys, misses effectively disappear. Your p99 latency — the slow tail you will meet in Lesson 13 — improves dramatically, because the tail was the misses.

The costs: you refresh values that may never be requested again, so you do work for nothing. You need to identify which keys deserve it. Applying it to everything is a way to generate constant pointless load.

You have seen a related idea in Lesson 3. stale-while-revalidate serves a stale copy while fetching a fresh one. Refresh-ahead starts before the copy expires.

Which realm uses which

Realm Natural pattern Why
Browser Cache-first with revalidation Controlled by headers, not code
CDN Read-through Fetches from origin on a miss automatically
Local cache Cache-aside or memoization You wrote it, you own it
Redis Cache-aside The usual default
Database buffer pool Read-through The database handles its own misses

Notice the pattern: when you own the code, you get cache-aside. When someone else built the cache, you get read-through. The techniques are the same everywhere; only the ownership moves.

The race: many readers, one missing key

Here is the flaw in cache-aside, and it is not a rare edge case — it is a guaranteed event on any popular key.

The front-page product is requested 1,000 times per second. Its cached value expires.

t=0.000   TTL expires
t=0.001   Request 1    → miss → queries database
t=0.001   Request 2    → miss → queries database
t=0.001   Request 3    → miss → queries database
   ...
t=0.001   Request 1000 → miss → queries database
t=0.050   first query returns, cache is written
          — 49 milliseconds too late for the other 999
One thousand identical database queries, for one value.

The cache did not fail. Every one of those requests followed cache-aside correctly. The pattern has a gap between “the value expired” and “the value has been reloaded,” and every request arriving in that gap goes to the database.

Worse, this is self-amplifying. A thousand simultaneous queries slow the database, which makes the reload take longer, which widens the gap, which lets more requests through.

Single-flight

Request coalescing — usually called single-flight — fixes it: when many requests need the same missing key, one of them fetches it, and the rest wait for that one result.

t=0.001   Request 1    → miss → acquires the loader slot → queries database
t=0.001   Request 2    → miss → someone is loading → waits
t=0.001   Request 3    → miss → someone is loading → waits
   ...
t=0.050   Request 1 gets the value, stores it, and hands it to all 999 waiters

One database query. All 1,000 requests served.

The shape:

def get(key):
    value = cache.get(key)
    if value is not None:
        return value

    # only one caller per key gets past this point;
    # the others block here and receive the same result
    with single_flight(key):
        value = cache.get(key)        # check again — someone may have
        if value is not None:         # filled it while we waited
            return value
        value = database.query(key)
        cache.set(key, value, ttl=300)
        return value

Two details that matter:

  • The second cache.get is not redundant. By the time a waiter is released, the value usually exists. Checking again avoids a pointless second query. This is the double-checked pattern.
  • The lock is per key, not global. A global lock would make every miss on every key wait behind every other, which would be far worse than the problem.

Most mature caching libraries provide this. It is worth knowing it exists before you need it — and you are about to need it.

Remember this. This is your first weapon against the Latency Lord, and it is deliberately being handed to you four lessons before the fight.

At the Storefront

Cache-aside for product details, with single-flight — it is the front page item and it will stampede.

Refresh-ahead for the category tree — small, hot, expensive, on every page.

Nothing for the cart badge, in any pattern.

Common misconception

“Cache-aside and read-through are the same thing.”

They differ in who owns the miss, and that decides who owns the race condition above. With cache-aside, it is yours to solve. With read-through, your library probably solved it — which is a genuine reason to prefer one.

The Forge

The Latency Lord has been watching the reading techniques, and he has found the gap.

Not a flaw in the cache. A gap in time — the space between the moment a value expires and the moment it is restored. It is small. Fifty milliseconds, perhaps.

A thousand requests can arrive in fifty milliseconds.

He does not need to take the cache away. He only needs to choose the moment it empties.