Unit 3.1 · Redis and Caching
Data Structures and Caching Patterns
Redis's core types and what each is for, TTLs, cache-aside vs write-through, invalidation, cache stampedes, and sessions and rate limiting in Redis.
Start here
The mental model
Redis is a giant dictionary living in RAM, where the values aren't just strings but data structures: lists, sets, sorted sets, hashes. Reading RAM is ~1000× faster than reading a database's disk-backed row over a query planner, so we put copies of hot data in Redis to protect the database and speed up pages.
A cache is a bet that you can serve slightly stale data in exchange for speed. Every caching bug is one of three things: the data is stale for too long (invalidation), too many requests miss at once and flood the database (stampede), or the cache disappears and the database can't cope without it (dependency).
Go deeper
How it works inside
01The core types
STRING: bytes up to 512 MB, counters with INCR (atomic). HASH: a small object's fields (HSET user:42 name Asha plan pro). LIST: a linked list, used as a simple queue (LPUSH/BRPOP). SET: unique members (SADD online-users). SORTED SET: members ranked by score, for leaderboards, top-N, and time-ordered indexes (ZADD, ZRANGE ... REV). Plus streams (Unit 2.4), bitmaps, HyperLogLog (approximate unique counts in 12 KB), and geo indexes.
Redis executes commands on ONE main thread, one at a time, so every single command is atomic, and no locks are needed for INCR or ZINCRBY. The flip side: one slow command (KEYS * on 10 million keys, SMEMBERS on a huge set, a big Lua script) blocks every other client (Unit 3.2).
02Caching patterns
CACHE-ASIDE (lazy loading), the most common: the app reads Redis; on a miss, it reads the database and writes the result to Redis with a TTL. Simple, and the cache only holds what's used, but the first request after expiry is slow. WRITE-THROUGH: the app writes to the database and updates the cache in the same code path, so data is fresh but you cache things nobody reads. WRITE-BEHIND: write to the cache and flush to the database asynchronously. Fast, but a Redis failure loses writes; rarely worth it.
Invalidation: on update, DELETE the cache key rather than setting it (setting it risks racing with a concurrent reader that writes back an older value). Always set a TTL anyway, as a safety net for any invalidation you missed. Add small random JITTER to TTLs so keys written together don't all expire together.
03Cache stampede
When a very hot key expires, hundreds of concurrent requests miss at the same instant and all run the same expensive query: the THUNDERING HERD. Defences: a short lock so only one request rebuilds (SET lock:product:42 1 NX EX 5, and others briefly wait or serve stale data), early probabilistic refresh before expiry, 'stale-while-revalidate' (store a soft expiry inside the value and refresh in the background), and request coalescing in the app.
04Beyond caching: sessions and rate limits
Sessions in Redis let any app replica serve any user, which is what makes the web tier stateless (Kubernetes course, Deployments). Give them a TTL that matches the session lifetime. RATE LIMITING: a fixed window is INCR rl:user42:202609271014 with EXPIRE 60; a sliding window uses a sorted set of request timestamps or a small Lua script for atomicity. API gateways and NGINX (Networking course, NGINX features) often use Redis for exactly this.
Do it
Hands-on lab
- 1
Start Redis and try each type
terminal$ docker run -d --name redis -p 6379:6379 redis:8alias rc='docker exec -i redis redis-cli'rc SET page:views 0 && rc INCR page:viewsrc HSET user:42 name Asha plan pro && rc HGETALL user:42rc ZADD leaderboard 1200 asha 950 ravi 1500 mei && rc ZRANGE leaderboard 0 2 REV WITHSCORES── expected output ──OK(integer) 1(integer) 21) "name"2) "Asha"3) "plan"4) "pro"(integer) 31) "mei"2) "1500"3) "asha"4) "1200"5) "ravi"6) "950" - 2
TTLs and expiry
EXsets seconds;TTLshows what remains (-1 = no expiry, -2 = key gone).terminal$ rc SET product:42 '{"name":"Mug","price":399}' EX 5 && rc TTL product:42sleep 6; rc GET product:42; rc TTL product:42── expected output ──OK(integer) 5(nil)(integer) -2 - 3
Cache-aside with a stampede lock (Python)
The rebuild lock means only one request queries the database when a hot key expires; the others wait briefly and re-check. The random jitter spreads expiry times.
product_cache.pywhole filepython import json, random, time, redis r = redis.Redis() def get_product(pid: int): key = f"product:{pid}" for _ in range(50): # wait up to ~1s for a rebuild if (cached := r.get(key)) is not None: return json.loads(cached) if r.set(f"lock:{key}", "1", nx=True, ex=5): # I rebuild it try: row = db_fetch_product(pid) # the expensive query r.set(key, json.dumps(row), ex=300 + random.randint(0, 60)) return row finally: r.delete(f"lock:{key}") time.sleep(0.02) # someone else is rebuilding return db_fetch_product(pid) # fallback: don't fail the request def update_product(pid: int, fields: dict): db_update_product(pid, fields) r.delete(f"product:{pid}") # invalidate, don't set - 4
A fixed-window rate limiter
INCRreturns the new count;EXPIRE ... NXstarts the 60-second window only on the first request. The app allows at most 5 per window. In production, send both in one pipeline or a MULTI/EXEC so a crash between them can't leave a key without expiry.terminal$ for i in 1 2 3 4 5 6; do rc INCR rl:user42; rc EXPIRE rl:user42 60 NX > /dev/null; done── expected output ──(integer) 1(integer) 2(integer) 3(integer) 4(integer) 5(integer) 6 <- over the limit: the app returns HTTP 429
Operate it
Knobs that matter
| Setting | Default | What it does | When to change it |
|---|---|---|---|
| TTL per key | none | When the key expires. | Always set one for cache entries; add jitter; shorter for data that changes often. |
| Client timeouts | library-specific (often none) | How long the app waits for Redis. | Set short (50–200 ms) connect and command timeouts so a sick cache fails fast instead of hanging requests. |
| Connection pool size | library-specific | Connections per app instance. | Small is fine: Redis is single-threaded and pipelining is more effective than many connections. |
3am practice
Failure drills
Each drill is a real failure mode. Read the scenario and the output, decide what's wrong, then reveal the diagnosis.
Drill #1
The database melts every hour on the hour
A popular homepage query is cached with EX 3600. Every hour, DB CPU spikes to 100% for about a minute and p99 latency jumps.
Drill #2
Users see old prices after an update
Price changes appear on some product pages minutes later, randomly, even though the update code deletes the cache key.
Decide
Caching patterns
| Pattern | Read path | Write path | Good for | Risk |
|---|---|---|---|---|
| Cache-aside | Cache, then DB on miss | Write DB, delete key | Most read-heavy data | Stale on races, stampedes |
| Write-through | Cache | Write DB and cache together | Data read soon after writing | Caching unread data |
| Write-behind | Cache | Cache now, DB later | Very high write rates (counters) | Losing writes if Redis dies |
| Read-through | Cache loads from DB itself | — | Managed caches / libraries | Same as cache-aside |
The bigger picture
Connects to
System Design · Cache-aside (Lazy Loading)
The app checks the cache, loads from the DB on miss, and writes the cache itself — the default strategy.
System Design · Cache Invalidation
The hardest problem in computer science, at least for interviews: keeping the cache honest after writes.
System Design · System 12.2 — Rate Limiter (HLD)
The LLD limiter from Phase 4, now distributed: sticky to one instance won't work, so the counters move to Redis.
AWS · DynamoDB & ElastiCache
Managed Redis/Valkey on AWS.
Networking · NGINX features
Rate limiting and caching at the proxy layer, before requests reach Redis.
Prove it
Interview questions
Explain cache-aside and its main pitfalls.
How do you prevent a cache stampede?
Why is Redis single-threaded and why is it still fast?