What a Cache Really Is
A cache is that notepad. Redis holds a fast copy of the data your app reads most. The database stays the source of truth. The whole art of caching is keeping the notepad useful without letting it go out of date. This tutorial covers the patterns that get it right.
Why Caching Is Needed — The Read/Write Problem
A database is great at keeping data safe. It is not great at answering the same read a million times a second. Every read means a disk touch, a query plan, maybe a row lock. Under load, the database becomes the slow point for the whole app.
Without a cache, every read lands on the database. Popular data means the same query runs thousands of times a second and the database heats up.
Put Redis between the app and the database. Most reads are then answered from memory in about a millisecond, and only the first read (or a rare miss) touches the database. The database load drops sharply. The rest of this tutorial is how to do that safely.
Cache-Aside (Lazy Loading), Step by Step
Cache-aside is the most common pattern. The app manages the cache itself. It looks in Redis first; on a miss it reads the database and copies the result into Redis. The cache fills "lazily", only with data that is actually asked for.
A hit returns straight from Redis. A miss reads the database, then fills Redis so the next read is fast.
# cache-aside read, in pseudo-code
function get_user(id):
val = GET user:{id} # 1. look in Redis
if val is nil: # miss
val = db.query("SELECT ... WHERE id = ?", id) # 2. read DB
SET user:{id} val EX 600 # 3. fill cache, 10 min TTL
return val
It is simple, and the cache only holds data that is actually used. If Redis goes down, the app still works — it just reads the database more. That safety net is why most teams start here.
Write-Through and Write-Behind
Cache-aside fills the cache on reads. But what about writes? Two patterns keep the cache fresh when data changes: write-through and write-behind.
Write-through saves to cache and database together, then returns. Write-behind saves to the cache now and to the database a moment later, in the background.
Write-behind is the fastest for writes, but if Redis crashes before the flush, those writes are gone. Use it only where losing a few recent writes is acceptable, such as counters or metrics — never for orders or payments.
Cache Invalidation — The Hard Part
There is an old joke: the two hardest things in computing are naming things, cache invalidation, and off-by-one errors. Invalidation is hard because a cached copy can quietly go stale after the database changes. Here are the three safe ways to handle it.
# when the user is updated, invalidate the cache
db.query("UPDATE users SET city = ? WHERE id = ?", city, id)
DEL user:{id} # drop the stale copy; next read rebuilds it
# safest default: short TTL as a backstop, even if you also DEL
SET user:{id} val EX 300
On a write, delete the cache key rather than trying to write the new value into it. Deleting is simpler and avoids a race where two writers leave a wrong value behind. Let the next read rebuild the key from the database.
Even with delete-on-write, bugs happen and an event can be missed. A short TTL guarantees any stale key clears itself. TTL turns "wrong forever" into "wrong for a few seconds".
Eviction Policies and maxmemory
Memory is limited. You set maxmemory, and when Redis is full it must remove
something to store new data. The maxmemory-policy decides which key to drop. Picking
the right policy keeps the useful data and evicts the rest.
When memory is full, a new key needs room. The policy chooses a victim — here the least-recently-used key — and evicts it to make space.
| Policy | What it drops | Good for |
|---|---|---|
noeviction | Nothing — new writes error | When losing data is worse than failing |
allkeys-lru | Least recently used, any key | A general cache (most common) |
allkeys-lfu | Least frequently used, any key | When some keys are always popular |
volatile-lru | Least recently used, but only keys with a TTL | Mixing cache + permanent data |
volatile-ttl | The key closest to expiring | Dropping soon-to-die keys first |
# set a memory limit and a policy
CONFIG SET maxmemory 2gb
CONFIG SET maxmemory-policy allkeys-lru
# check what is set
CONFIG GET maxmemory-policy
allkeys-lru is the safe default for a pure cache. If a few keys are always hot
and should survive quiet spells, allkeys-lfu keeps the frequently used ones. Use
noeviction only when Redis holds data you cannot drop.
Cache Stampede, Thundering Herd, and Hot Keys
A popular key expires. In the same instant, thousands of requests look for it, all miss, and all rush to the database at once. This is a cache stampede (or thundering herd), and it can knock the database over — the very thing the cache was meant to prevent.
The moment a hot key expires, every waiting request misses together and stampedes to the database.
How to prevent it
# single-flight: only the first request rebuilds; others back off
val = GET product:9
if val is nil:
got = SET lock:product:9 1 NX EX 5 # try to grab the rebuild lock
if got == "OK":
val = db.query("SELECT ... WHERE id = 9") # only one query hits the DB
SET product:9 val EX (600 + rand(0,60)) # jittered TTL
DEL lock:product:9
else:
sleep(50 ms); val = GET product:9 # wait for the rebuild, then read
The lock means only one request rebuilds the key while the rest wait a moment and then read the fresh value. The database sees one query instead of a flood. Jittered TTLs stop many keys expiring together in the first place.
Golden Rules
allkeys-lru for a general cache.
Without a limit, Redis can eat all the memory on the box.