Walk through the read path of a cache-aside (lazy-loading) cache built on Redis: which commands run on a hit, which run on a miss, and what exactly gets written back?
answer
- GET → nil → load → SET EX
- one atomic SET, never SET+EXPIRE
- miss is normal, not an error
- Redis error = treat as miss
- cache negatives with short TTL
basics
~20 sRead with GET. Hit: return the cached value. Miss (nil): load from the system of record, serialize it, write it back with a single SET key value EX <ttl>, and return it. Readers fill the cache lazily; a miss is normal, not an error.
solid answer
~50 sCache-aside means the application, not Redis, owns the loading logic. 1. `GET cachekey`. A non-nil reply is a hit — deserialize and return. 2. A `nil` reply is a miss. Query the database, serialize the object, then `SET cachekey <payload> EX <ttl>` and return the value. Use the single-command form `SET ... EX` rather than `SET` followed by `EXPIRE`: two commands can be split by a crash or a failover, leaving an immortal key that never refreshes. Every cache entry should be written with a TTL so the cache is self-healing when invalidation is missed. Things I always add: treat Redis errors and timeouts as a miss and fall through to the database (the cache is an optimization, never the source of truth); cache negative results explicitly with a short TTL or a sentinel so "row does not exist" doesn't hammer the database; and keep serialization and key format decisions consistent across every service that reads the key.
code
text · 13 lines# 1. try the cache
GET product:v1:915
(nil)
# 2. miss -> load from the database, serialize, write back atomically
SET product:v1:915 "{\"id\":915,\"name\":\"Kettle\",\"price\":2999}" EX 300
OK
# 3. next reader hits
GET product:v1:915
"{\"id\":915,\"name\":\"Kettle\",\"price\":2999}"
TTL product:v1:915
(integer) 287go deeper
Be able to state the three steps and the exact commands: GET, on nil load from the database, SET with EX. Know that a miss is normal.
Add why SET EX is atomic, why every entry gets a TTL, and how negative caching and client timeouts protect the database.
Frame it as an availability contract: cache errors degrade to misses, the database must survive the miss rate, and the cached unit should match the unit of change and consumption.
Discuss the pattern's eventual-consistency contract, cold-start and cache-loss blast radius, and where cache-aside stops being the right shape versus materialized or write-through paths.
## What cache-aside is Cache-aside (also called lazy loading) is the pattern where the **application code** orchestrates the cache. Redis is a dumb key-value store here: it does not know about your database and will never load anything by itself. The application asks Redis first, and only on a miss does it go to the system of record and then put the result back. The contrasting patterns — read-through and write-through — put the loading logic *inside* the cache layer, which requires a cache that can call your data source. Plain Redis cannot; a client library or a framework abstraction (for example a Spring `@Cacheable`-style layer) can make it *look* read-through, but underneath it is still emitting the same GET/SET pair. ## The concrete command sequence ``` GET product:v1:915 -> (nil) # miss <load row from Postgres, serialize to JSON> SET product:v1:915 "{...}" EX 300 # write back with TTL GET product:v1:915 -> "{...}" # subsequent hit ``` That is the whole mechanism. The interesting parts are the details around it. ## Why `SET ... EX` and not `SET` then `EXPIRE` `SET key value EX 300` is one command, so it is atomic: the key never exists without its expiry. If you write `SET key value` followed by `EXPIRE key 300`, the process can crash, the connection can drop, or a failover can happen between the two, and you are left with a key that never expires. Over months that produces a slowly growing population of immortal, stale entries that only `maxmemory` eviction will ever remove. `SET` has accepted `EX`/`PX` since Redis 2.6.12; there is no reason to use the two-command form. Related options worth knowing: `SET ... KEEPTTL` (6.0) overwrites the value while preserving the remaining TTL, and `GETEX key EX n` (6.2) reads and re-arms the expiry in one round trip if you want a sliding window. ## A miss is a normal outcome Juniors often treat `nil` as an error condition. It is not. On a cold start every read is a miss; after eviction or invalidation, misses reappear. What matters is that the miss path is correct and bounded: it must produce a value, write it back, and return it. Two consequences follow. First, **the database must be able to survive your miss rate**, because the miss path is just normal database load. Second, **an unbounded stream of misses for keys that will never exist** — the classic "look up a product ID that does not exist" probe — bypasses the cache entirely. The standard fix is to cache the negative result too: store a sentinel value (an empty marker) with a short TTL, so repeated lookups for a nonexistent row are answered from Redis. Be explicit about the sentinel so you can distinguish "cached: does not exist" from "not cached". ## Redis failure must not become application failure Because the cache is an optimization and the database is the truth, a Redis timeout, connection error, or `MOVED`-storm during a resharding event should be caught and treated as a miss. Set aggressive client timeouts (single-digit to low-double-digit milliseconds is typical for a cache) so that a struggling Redis does not become the latency of your request path. The inverse failure — the application refusing to serve because the cache is down — is a self-inflicted outage and is the single most common design mistake in this pattern. It is worth stress-testing the reverse case too: if Redis is emptied, every request becomes a database request at once. The read path above is what you exercise in that drill. ## What the value contains The cached payload should be whatever the caller actually needs, serialized once. Caching a half-assembled object that requires three more database queries to be usable defeats the point. Conversely, caching a giant aggregate that changes on any of twenty different writes makes invalidation nearly impossible. The unit of caching should match the unit of change and the unit of consumption. ## Freshness expectations Cache-aside is eventually consistent by construction. Between the moment a row changes and the moment the corresponding key is invalidated or expires, readers see stale data. The TTL is the backstop that bounds that staleness even when explicit invalidation is missed — a network blip, a crashed worker, a code path that forgot to invalidate. Design the read path assuming invalidation will sometimes be lost, because it will.
- What should the application do when the Redis call itself times out?Treat it exactly like a miss: log it, increment an error metric, and serve from the database. The cache is an optimization and the database is the source of truth, so a cache failure must degrade latency, not availability. Keep client timeouts tight (often 10-50 ms) so a slow Redis cannot dominate request latency, and make sure the database has enough headroom to absorb the resulting miss traffic.
- Why write SET key value EX 300 instead of SET followed by EXPIRE?SET with EX is a single atomic command, so the key can never exist without an expiry. With two commands, a crash, dropped connection, or failover between them leaves a key that lives forever and serves stale data until maxmemory eviction happens to pick it. Immortal keys accumulate silently and are painful to find later.
- How does cache-aside differ from read-through caching?In cache-aside the application code performs the miss handling: it reads Redis, queries the database, and writes back. In read-through the cache layer itself knows how to load from the source, so callers only ever talk to the cache. Plain Redis has no loader, so read-through on Redis is really cache-aside hidden inside a client library or framework abstraction.
Like checking your desk drawer for a form before walking to the records room: if the drawer is empty you fetch the form, use it, and drop a copy in the drawer with a sticky note saying when to throw it out.
saying these in an interview costs you the question
- Treating a nil reply as an error rather than the normal miss path.
- Writing cache entries without any TTL, relying purely on explicit invalidation.
- Using SET then EXPIRE as two separate commands.
- Failing the request when Redis is unreachable instead of falling through to the database.
- Letting nonexistent-key lookups bypass the cache forever because negative results are never cached.