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.
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.
How it works
cache-aside.md.txt · echo_cacheWhen 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.
Redis commands used
echo_cache/keyspace.ex · table.exA 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.
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.
The staleness problem
cache-aside.md.txt · bcs4.1The 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.
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.
Mitigating staleness
cache-aside.md.txt · table.ex:171The 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.
When to use — and when to avoid
cache-aside.md.txt · WhenUse 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 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:
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
The three dives
fill → invalidate → boundEach 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.
GET / SET PX miss-fill
The read path: GET the key, and on a miss read the source and write it back with
SET … PX. A near-cache read served from L1, filled on the first miss.
Explicit invalidation
An explicit DEL on every write, and the write-then-invalidate ordering that
stops a concurrent read from resurrecting the stale value into the cache.
TTL & staleness
The TTL as a safety net under a missed DEL, and the staleness window between a
write and its invalidation — what cache-aside bounds rather than removes.
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.
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.
References
sources · relatedSources
- 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
PXtime-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
- R1.01.1 · GET / SET PX miss-fillthe read path
- R1.01.2 · Explicit invalidationDEL on write
- R1.01.3 · TTL & stalenessthe safety net
- R1 · Cachingthe chapter
- /echomqthe EchoMQ protocol the coherence job lane rides
- /elixir · Statethe functional-Elixir craft behind the echo data layer