jonnify·redis /redis-patterns/caching/cache-aside

R1.01 · Caching — module hub

Cache-aside (lazy loading).

Use cache-aside for read-heavy workloads: on a cache miss, fetch from the database and populate the cache; on a write, invalidate or update the cache explicitly. The application owns the cache — Valkey never reads the source on its own.

Grounding: echo/apps/echo_cache · EchoCache.Table.fetch · content/bcs4.1.md — the declared near-cache, L1 ETS over L2 Valkey, every figure verbatim from a committed record.

The two moves — read fills the cache, write clears it. Select a step to light its path and read the command it runs.
Valkey cachedisposable copy application the sourcesource of record GET read SET PX write source DEL on write
readout · Select a step. The read path is GET → (miss) read source → SET PX; the write path is write source → DEL.

Static verdicts — read · hit → GET, serve, no source read · read · miss → GET returns nil · fill → SET … PX with a TTL · write source → commit the change · invalidate → DEL, the next read re-fills.

§1

How it works

cache-aside.md.txt · echo_cache

When the application needs data, it follows one sequence. Check the cache first: query Valkey for the key with GET. On a hit: return the cached value immediately. On a miss: query the primary database, write the result back to Valkey with a TTL, then return it. The key insight is that the application populates the cache — Valkey never fetches from the database on its own.

Two moves define the pattern. On a read: GET, and on a miss read the source then SET … PX the value with an expiry. On a write to the source: DEL the cached key so the next read re-fills it. The cache is a disposable copy of a source of record; nothing keeps the two in lockstep, and the pattern bounds the lag with a TTL.

The application reads from the cache, fills it on a miss, and clears it on a write. Valkey holds a disposable copy; the source of record stays the source of record.

§2

Redis commands used

echo_cache/keyspace.ex · table.ex

A typical cache-aside flow uses three commands — a read, a fill with an expiry, and an invalidation. The key is EchoCache’s own keyspace, ecc:{<table>}:<id> (EchoCache.Keyspace.key/2), with the table name hash-tagged so one cache lands in one cluster slot:

GET ecc:{quotes}:AST0NuE6bV7FoH                       # => nil  (a miss)
SET ecc:{quotes}:AST0NuE6bV7FoH "{…value…}" PX 300     # fill the cache with a 300 ms TTL
DEL ecc:{quotes}:AST0NuE6bV7FoH                       # invalidate on a write

Set the value and the TTL in one command; a separate EXPIRE after SET leaves an immortal key if the process dies between the two. In EchoCache the read surface is one function: EchoCache.Table.fetch/3 returns {:ok, value, source} with source :hit | :l2 | :fill — an L1 ETS hit in the caller’s process, an L2 Valkey hit, or a loader fill. The functional-Elixir craft behind the echo data layer is taught by the /elixir state chapter; this module is the cache placed in front of the source.

Notes on ValkeySET … PX sets the value and its expiry atomically — a separate EXPIRE leaves an immortal key if the process dies between the two — valkey.io/commands/set.

§3

Advantages

cache-aside.md.txt · Advantages
  • Resilience to cache failure. If Valkey becomes unavailable, the application degrades gracefully to the database. Latency rises; the system stays functional.
  • Memory efficiency. Only data that is actually requested gets cached, so the working set is demand-shaped and unused data does not occupy cache memory.
  • Simplicity. The pattern is straightforward to implement and reason about.
§4

The staleness problem

cache-aside.md.txt · bcs4.1

The primary disadvantage is stale data. A reader takes a cache hit; another process updates the row in the database; the cached copy stays unchanged until the TTL expires. The cache now holds outdated information, for up to the remaining TTL.

The cost of avoiding that round trip is what makes the trade worth managing. EchoCache’s committed record measures it: an L1 hit reads in 762 ns against 31 us per L2 GET on the same wire — the hit is 40 times cheaper than the round trip it replaces.

content/bcs4.1.md · EchoCache, L1 hit versus L2 GET, against live Valkeyfrozen
L1 hit reads          1311621 / s   ← 762 ns each, caller-side ETS
L2 GET                31 us          ← the round trip on the same wire
L1 versus L2          40 times cheaper
L2 expiry             PTTL 300 ms of 300   ← written with SET … PX

Cache-aside does not remove staleness; it bounds it with a TTL. The third dive measures that window.

§5

Mitigating staleness

cache-aside.md.txt · table.ex:171

The standard mitigation is cache invalidation on write: when the application updates the source, it immediately deletes the cached key with DEL. The next read misses and fetches fresh data. Deleting is simpler and safer than updating the cache in place, which risks a race — two set-on-write writers can interleave and leave the older value in place indefinitely, whereas delete-on-write is wrong for at most one TTL. The TTL is therefore a correctness bound, not a memory knob. In EchoCache the unconditional admin verb is Table.invalidate/3 — a DEL on L2 plus an :ets.delete on L1, dropping one name from both layers.

§6

When to use — and when to avoid

cache-aside.md.txt · When

Use cache-aside when the workload is read-heavy, brief staleness is acceptable, graceful degradation on cache failure is wanted, and fine-grained control over what gets cached is needed.

Avoid it when strong consistency between cache and database is required, write-then-immediately-read is common (the read may hit a stale cache), or the cost of a single cache miss is extremely high.

the application · EchoCache’s declared near-cache

The fill discipline, measured against live Valkey

The miss-fill, the keyspace, and the expiry are not aspirations — SET key value PX <ms> sets value and TTL atomically (valkey.io/commands/set), and the ecc:{<table>}:<id> hash-tag key lands one cache in one cluster slot. The committed connector record is figure by figure:

content/bcs4.1.md · the cache surface, three sources of one answerfrozen
fetch source                  :hit | :l2 | :fill   ← L1 ETS, L2 Valkey, or loader
cold read                     loader ran 1   ← a miss fills both layers
warm read                     hit, owner untouched
§7

The three dives

fill → invalidate → bound

Each dive takes one of the three problems cache-aside has to get right, in the arc fill → invalidate → bound: how a miss fills the cache, how a write clears it without resurrecting a stale value, and how a TTL backstops a missed invalidation. Read them in order — the read path starts.

the pattern

The application owns the cache: read Valkey first, fill on a miss with an expiry, invalidate on a write. The cache is a disposable copy of a source of record, and its lag is bounded, not zero.

its EchoCache application

Table.fetch returns :hit | :l2 | :fill — an L1 ETS hit, an L2 Valkey hit, or a loader fill; invalidate drops both layers. The L1 hit is 40 times cheaper than the L2 round trip it replaces.

Three dives, one strategy: fill the cache on a miss, clear it on a write, and let a TTL catch what an invalidation misses.

After R1.01, the chapter continues with write-through, write-behind, client-side caching, stampede prevention, and sessions. Return to the Caching chapter to continue, or open the Overview for where Valkey sits under the Exchange Platform.

§8

References

sources · related

Sources

  • Redis — Documentation — the strings, expiry, and key commands cache-aside is built from.
  • Redis — GET — the read that starts every cache-aside lookup; returns the value or nil on a miss.
  • Valkey — SET — set a value with the PX time-to-live in one command; the fill move that backs every key with an expiry.
  • Sanfilippo, S. — antirez weblog — the Redis creator on expiry, eviction, and keeping the cache a disposable copy.

Related in this course