Phase 4 · Chapter 1
Caching
Why it exists, how Redis works internally, and how to not destroy yourself with it
What this chapter does to you: Caching is the most misused tool in backend engineering. Developers add it to make things fast, then spend weeks debugging stale data, cache stampedes, and mysterious inconsistencies. After this chapter, you will know exactly when to cache, how to cache, what will kill you if you cache it wrong — and why Redis can handle 100,000 operations per second on a single thread.
Part 1: Why Caching Exists — Back to the Speed Hierarchy
In Chapter 1, you learned this table. It's worth revisiting:
CPU registers : ~0.3 nanoseconds
L1 cache (CPU) : ~1 nanosecond
RAM access : ~100 nanoseconds
SSD read : ~100 microseconds (1,000× slower than RAM)
Network + DB query: ~10–100 milliseconds (100,000× slower than RAM)
A Postgres query involves:
- Network hop: browser → your server (~150ms)
- Your server → Postgres server (~1ms if same datacenter)
- Postgres reading pages from SSD: ~100μs per page, dozens of pages
- Total: 5–50ms per query, easily
Redis is RAM. Reading a key from Redis:
- Network hop: your Node.js → Redis socket (~0.1ms local)
- Redis hash table lookup in RAM: ~microseconds
- Total: ~0.2–1ms
For data that doesn't change often — test questions, user profiles, leaderboard snapshots — you can answer in 0.5ms instead of 20ms. That's a 40× improvement with one architectural decision.
But caching is not just "make things faster." It's a tradeoff — you're trading consistency for speed. The cache might be stale. The question is always: how stale is acceptable, and for how long?
Part 2: Redis Internals — What It Actually Is
Most engineers treat Redis as a magic fast key-value store. Let's go inside.
Redis is a Single-Threaded Process
Redis processes all commands on a single thread. Sound familiar? That's the same design decision as Node.js from Chapter 1.
The reasoning is identical:
- Almost all Redis operations are RAM lookups — microseconds
- A single thread avoids lock contention on shared data structures
- The bottleneck is never CPU computation — it's network I/O
- One thread + OS event loop (epoll) handles thousands of connections concurrently
Precision for interviews: since Redis 6 you can turn on extra I/O threads that read and write network sockets in parallel. Command execution is still single-threaded, so everything below still holds. Say "Redis executes commands on one thread," not "Redis has one thread."
The implication: one slow Redis command blocks all other commands.
KEYS * — returns all keys matching a pattern. On a Redis instance with
10 million keys, this blocks Redis for seconds while it scans every key.
Every command queued behind it waits.
This is the Redis version of blocking the event loop. You never run
KEYS * in production. You use SCAN — a cursor-based iterator that
returns keys in small batches without blocking.
Everything Lives in RAM
Redis stores all data in RAM. Always. This is what makes it fast. This is also what makes it dangerous — RAM is finite and expensive.
When Redis runs out of RAM, it either:
- Rejects new writes (
noevictionpolicy — returns an error) - Evicts existing keys to make room (various eviction policies — covered shortly)
RAM sizing matters. A Redis instance with 1GB can store roughly:
- ~10 million simple string keys of average 50 bytes each
- Less if you store large objects or have overhead from complex data structures
Design rule: Cache hot data only. If you cache everything, you cache nothing useful — the hot data gets evicted to make room for cold data.
How Redis Stores Data Internally
At the core, Redis uses a global hash table to store all keys.
A hash table is an array of buckets. To store key user:123:
- Hash the key string → get an integer (e.g., 4821)
- 4821 % array_size = bucket 21
- Store the value in bucket 21
Lookup is the reverse: hash → bucket → value. O(1) average case.
Each entry in the hash table stores:
- The key (string)
- A pointer to the value object
- The value object contains: type, encoding, actual data, LRU clock timestamp
Redis is written in C. The value objects are carefully optimized. Small integers (0-9999) are stored as actual integers, not strings. Short strings use a compact encoding. Lists with few items use a ziplist (a compact array) instead of a linked list. These encoding tricks are why Redis uses far less memory than you'd expect.
Part 3: Redis Data Structures — Not Just What, But Why
Redis has 8 data structures. Each exists because a common problem pattern is better solved with that structure than a plain string. Know why each one exists.
String — The Swiss Army Knife
The most basic type. Binary-safe — can store text, numbers, JSON, even binary data.
Use cases:
SET session:abc123 "{\"userId\":\"xyz\",\"role\":\"student\"}" EX 3600
GET session:abc123
SET page:dashboard:html "<html>...</html>" EX 60
GET page:dashboard:html
INCR api_calls:user:123 → atomic increment (returns new value)
INCRBY score:user:123 10 → add 10 to a counter
INCR is atomic — even though Redis is single-threaded, this matters because
a read-modify-write on a string is a single command. No race condition possible.
The important subtlety: SET key value EX seconds is one atomic command.
Don't do SET key value then EXPIRE key seconds as two separate commands —
a crash between them leaves the key with no TTL. Forever.
Hash — Object Storage Without Serialization
A hash stores a map of field → value under one key.
Without hashes:
// Store user as JSON string — must parse entire object to read one field
await redis.set('user:123', JSON.stringify(user))
const user = JSON.parse(await redis.get('user:123'))
With hashes:
// Store individual fields — read/update individual fields without touching others
await redis.hset('user:123', 'name', 'Trilok', 'score', '1500', 'plan', 'pro')
await redis.hget('user:123', 'score') // read one field
await redis.hincrby('user:123', 'score', 50) // atomic increment one field
await redis.hgetall('user:123') // read all fields
When to use hashes vs strings: Use hashes when you have an "object" with multiple fields and you frequently read/update individual fields. Use strings when you always read/write the whole thing.
List — Queue and Timeline
A doubly-linked list. O(1) push/pop from both ends.
LPUSH recent_activity:user:123 "submitted test xyz" // prepend
LRANGE recent_activity:user:123 0 9 // last 10 items
LTRIM recent_activity:user:123 0 99 // keep only last 100
RPUSH queue:email_jobs "job1" // enqueue (right push)
BLPOP queue:email_jobs 30 // blocking dequeue (left pop)
// blocks up to 30 seconds
BLPOP is how simple job queues are built. A worker does BLPOP — blocks
until an item appears, then processes it. This is how BullMQ worked before
Redis Streams existed.
The limit: Lists don't have deduplication, priority, or delayed jobs. BullMQ uses Sorted Sets for scheduling and priority. Lists are for simple FIFO.
Set — Unique Membership
An unordered collection of unique strings. O(1) add, remove, lookup.
SADD test:xyz:participants "user:1" "user:2" "user:3"
SISMEMBER test:xyz:participants "user:2" // is user:2 in this set? → 1 (yes)
SMEMBERS test:xyz:participants // all members (don't use on large sets)
SCARD test:xyz:participants // count of members
// Set operations — powerful for social features
SUNION followers:userA followers:userB // combined followers of A and B
SINTER following:userA following:userB // mutual follows
SDIFF following:userA following:userB // who A follows that B doesn't
Real use case on AttemptIQ: Track which users have seen a notification.
SADD notification:xyz:seen "user:1" "user:2". Then SISMEMBER to check.
Fast, no duplicates, automatic.
Sorted Set (ZSet) — The Most Powerful Structure
A sorted set is like a set — unique members — but every member has a score (a float). Members are always sorted by score. O(log N) add/remove/lookup.
ZADD leaderboard:test:xyz 1850 "user:abc"
ZADD leaderboard:test:xyz 2200 "user:def"
ZADD leaderboard:test:xyz 1600 "user:ghi"
ZREVRANGE leaderboard:test:xyz 0 9 WITHSCORES // top 10, highest score first
ZRANK leaderboard:test:xyz "user:abc" // what rank is user:abc? → 1 (0-indexed)
ZSCORE leaderboard:test:xyz "user:abc" // what's their score? → 1850
ZRANGEBYSCORE leaderboard:test:xyz 1500 2000 // users with score between 1500-2000
Leaderboards: The canonical Sorted Set use case. Instant top-N query.
Instant rank lookup. Instant score update. Without a sorted set, a leaderboard
requires a database query with ORDER BY score DESC LIMIT 10 — which needs
an index and still hits the database.
Rate limiting: BullMQ uses sorted sets to store delayed jobs.
Score = Unix timestamp of when the job should run.
ZRANGEBYSCORE queue:delayed 0 NOW fetches all jobs ready to execute.
This is how delay: 5000 in BullMQ works.
Sliding window rate limiting: Store each request's timestamp as score.
ZADD rate:user:123 NOW request_id. ZREMRANGEBYSCORE rate:user:123 0 NOW-60000
removes requests older than 60 seconds. ZCARD rate:user:123 counts remaining.
One data structure, correct sliding window behavior. (More in the Rate Limiting chapter.)
Bitmap — Analytics at Scale
A bitmap stores bits. Redis strings can be used as bitmaps where each bit represents a boolean flag for an offset.
SETBIT daily_active:2024-01-15 userId 1 // user visited today
GETBIT daily_active:2024-01-15 userId // did user visit today?
BITCOUNT daily_active:2024-01-15 // how many users visited today?
BITOP AND result key1 key2 // users active on both days
Why this matters at scale: 10 million users = 10 million bits = 1.25 MB. The same data as a set of user IDs would be ~80MB. Bitmaps are the right tool for large-scale boolean analytics: daily active users, feature flag rollouts, A/B test participation.
HyperLogLog — Approximate Counting
HyperLogLog answers: "how many unique values have I seen?" — with ~0.8% error margin, using only 12KB of memory regardless of how many values.
PFADD unique_visitors:page:dashboard "user:1" "user:2" "user:3"
PFCOUNT unique_visitors:page:dashboard // approximate unique count
PFMERGE total_visitors key1 key2 key3 // merge multiple HLLs
Exact unique counting would require storing every user ID seen — gigabytes at scale. HyperLogLog uses probabilistic counting — mathematical genius reduces this to 12KB. Use it for analytics where approximate is acceptable (unique page visitors, unique searches).
Part 4: TTL and Eviction — What Happens When Redis Runs Out of Space
TTL — The Most Common Cache Expiry Mechanism
Every Redis key can have a TTL (Time To Live) in seconds. After the TTL expires, Redis automatically deletes the key.
SET user:123:profile "{...}" EX 300 // expires in 5 minutes
TTL user:123:profile // how many seconds left?
PERSIST user:123:profile // remove TTL, make permanent
PEXPIRE user:123:profile 30000 // set TTL in milliseconds
How Redis actually expires keys:
Redis uses two mechanisms, not one:
Lazy expiration: When you GET an expired key, Redis checks if it's expired,
deletes it, and returns nil. No background work needed for this.
Active expiration: Redis randomly samples 20 keys with TTLs every 100ms. It deletes the expired ones. If more than 25% of sampled keys are expired, it immediately samples again. This prevents expired keys from accumulating indefinitely even if they're never accessed.
The combination means: expired keys might live slightly past their TTL if they're never accessed and the random sampler hasn't hit them yet. For caching this is fine. For rate limiting TTLs, be careful.
Eviction Policies — When RAM Is Full
When Redis hits its maxmemory limit, it needs to evict keys to make room.
You configure the eviction policy in redis.conf:
noeviction — return an error when memory is full. New writes fail.
Use for: session stores, primary data stores where data loss is unacceptable.
allkeys-lru — evict the Least Recently Used key, from all keys.
Use for: general caching where all data could reasonably be evicted.
LRU evicts keys not accessed recently — keeps your hot data alive.
volatile-lru — evict LRU keys, but only from keys that have a TTL set.
Keys without TTL are never evicted.
Use for: mix of permanent data (no TTL) and cached data (has TTL).
allkeys-lfu — evict the Least Frequently Used key.
LFU tracks how often a key is accessed, not just recency.
Better than LRU when access patterns are non-uniform — a key accessed
1000 times last month but not today is more valuable than LRU thinks.
allkeys-random — evict a random key. Almost never the right choice.
volatile-ttl — evict the key with the soonest expiry first.
Sensible: keys about to expire anyway get evicted first.
LRU vs LFU — when it matters:
LRU: "I haven't seen this key in a while, evict it." LFU: "This key is rarely accessed overall, evict it."
For a leaderboard cache: a yearly leaderboard is accessed heavily in December, not much in February. LRU would evict it in March. LFU would keep it because it was frequently accessed historically. Neither is always right. Know the tradeoff.
Part 5: Cache Strategies — Not Just "Store It and Return It"
There are four patterns for reading/writing through a cache. Most engineers only know one.
Pattern 1: Cache-Aside (Lazy Loading)
The most common pattern. The application manages the cache explicitly.
async function getUserProfile(userId) {
// 1. Check cache
const cached = await redis.get(`user:${userId}:profile`)
if (cached) return JSON.parse(cached) // cache hit
// 2. Cache miss — load from DB
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId])
// 3. Write to cache for next time
await redis.set(`user:${userId}:profile`, JSON.stringify(user), 'EX', 300)
return user
}
Advantages:
- Cache only contains data that was actually requested — no wasted memory
- Cache failure is graceful — fall through to DB, no crash
- Easy to understand and debug
Disadvantages:
- Cache miss incurs full DB latency
- Race condition: two concurrent requests both miss → both query DB → both write to cache. Wasteful but harmless for reads.
- Cache is cold on startup — first requests are slow until cache warms up
When to use: Almost always. This is the default pattern.
Pattern 2: Write-Through
Every write goes to both the cache and the DB, synchronously.
async function updateUserScore(userId, score) {
// Write to DB
await db.query('UPDATE users SET score = $1 WHERE id = $2', [score, userId])
// Write to cache immediately
await redis.set(`user:${userId}:score`, score, 'EX', 3600)
}
Advantages:
- Cache is always up to date. No stale reads.
- Cache hit rate is high — data is pre-warmed on write
Disadvantages:
- Every write pays double latency (DB + Redis)
- Cache fills with data that might never be read (you write everything, but only read hot data)
- If Redis is down, do you fail the write? Or proceed and leave cache inconsistent?
When to use: When reads are far more frequent than writes and you need read consistency. Leaderboard scores, user balances.
Pattern 3: Write-Behind (Write-Back)
Write to cache first, return success. Asynchronously flush to DB.
async function recordView(contentId) {
// Instant — just increment Redis counter
await redis.incr(`views:${contentId}`)
// Background job periodically flushes counters to DB
// (every 60 seconds, or every 1000 views, etc.)
}
Advantages:
- Writes are extremely fast (RAM only)
- Reduces DB write load — batch many increments into one DB update
Disadvantages:
- Data loss risk. If Redis crashes before the async flush, those writes are gone. Only acceptable for data where approximation is fine (view counts, analytics counters) — never for financial data.
When to use: High-frequency counters and analytics where slight data loss is acceptable. YouTube view counts. Page visit analytics. Never for orders, payments, or user-critical data.
Pattern 4: Read-Through
The cache sits transparently in front of the DB. Application only ever talks to cache. Cache handles its own misses.
Most Redis use in Node.js is actually cache-aside in practice. Read-through is more common with specialized caching layers (Memcached, AWS DAX for DynamoDB).
Part 6: Cache Invalidation — The Hardest Problem
"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton
This is not a joke. Cache invalidation is genuinely hard. Let's see why.
The Staleness Problem
You cache a user's profile with TTL = 5 minutes. The user updates their name. For up to 5 minutes, other users see the old name.
How stale is too stale? It depends entirely on your product:
- Test results: must be real-time. Don't cache.
- User profile: 5 minutes of staleness is probably fine.
- Homepage content: 1 hour might be fine.
- Exchange rate: seconds matter.
TTL is not a bug — it's a feature with a defined staleness budget. Set your TTL based on how old the data can be before users notice.
Strategy 1: TTL-Based Invalidation
Set a TTL. Accept that data might be stale for up to TTL duration. Done.
This is the correct strategy for most caching. Simple, reliable, predictable.
The risk is choosing the wrong TTL — too short: DB load, no benefit. Too long: users see stale data.
Strategy 2: Event-Driven Invalidation (Cache Busting)
When data changes in the DB, immediately delete (or update) the cache key.
async function updateUserProfile(userId, data) {
await db.query('UPDATE users SET ... WHERE id = $1', [userId])
await redis.del(`user:${userId}:profile`) // invalidate cache
}
Next read will miss and re-populate from DB with fresh data.
The problem: distributed systems don't have atomic cross-store operations.
If you write to Postgres and then delete from Redis:
- What if your server crashes between the two? DB updated, cache not invalidated. Cache serves stale data until TTL.
If you delete from Redis and then write to Postgres:
- Another request might repopulate the cache with old DB data between your delete and your DB write.
The correct pattern: Write to DB first, delete cache after. The worst case is a brief window of stale data — cache gets repopulated fresh on the next miss. Acceptable for most use cases.
For financial operations where you can't tolerate any staleness: don't cache it.
Strategy 3: Versioned Cache Keys
Instead of invalidating, change the key.
// When saving user profile
const version = await redis.incr(`user:${userId}:version`)
await redis.set(`user:${userId}:profile:v${version}`, data, 'EX', 3600)
// When reading
const version = await redis.get(`user:${userId}:version`)
const profile = await redis.get(`user:${userId}:profile:v${version}`)
Advantage: No invalidation race condition. Old version keys just expire via TTL. Disadvantage: Two Redis round-trips per read. Old versions waste memory until TTL.
When to use: Complex objects where multiple services read from cache. A version bump atomically shifts all readers to the new version.
Part 7: Cache Stampede — The Production Problem Nobody Talks About
This is the failure mode that takes down production systems.
The Scenario
Your AttemptIQ homepage shows a leaderboard. You cache it for 5 minutes. Sunday evening — exam season — 10,000 students simultaneously on the site.
The cache entry expires at 6:00:00pm exactly.
All 10,000 students happen to load the page at 6:00:01pm.
All 10,000 get a cache miss.
All 10,000 fire a DB query simultaneously.
Your Postgres gets 10,000 simultaneous SELECT queries for the same data.
Postgres melts. Response times spike. Site goes down.
This is called a cache stampede or thundering herd.
Here's that 6:00pm expiry in slow motion. Run it with no protection first, then try the lock, stale-while-revalidate and early refresh below and compare the numbers.
Solution 1: Mutex Lock (Request Coalescing)
Only one request recomputes on cache miss. Others wait.
// Delete the lock ONLY if it is still mine (atomic compare-and-delete)
const RELEASE_IF_OWNER = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
`
async function getLeaderboard() {
for (let attempt = 0; attempt < 20; attempt++) { // bounded wait (~1s)
const cached = await redis.get('leaderboard')
if (cached) return JSON.parse(cached)
// Try to acquire the lock. The value is a random token that proves ownership.
const token = crypto.randomUUID()
const lockAcquired = await redis.set('leaderboard:lock', token, 'NX', 'EX', 10)
// NX = only set if not exists (atomic). EX 10 = expires in 10 seconds
if (lockAcquired) {
try {
// This request won the race — compute and cache
const data = await db.query('SELECT ...')
await redis.set('leaderboard', JSON.stringify(data), 'EX', 300)
return data
} finally {
// Runs even if the query throws. Never a plain DEL — see below.
await redis.eval(RELEASE_IF_OWNER, 1, 'leaderboard:lock', token)
}
}
// Lock exists — another request is computing. Wait briefly and retry.
await sleep(50)
}
// Waited too long: fall back instead of queueing forever
return db.query('SELECT ...') // or serve stale data / return 503
}
The subtlety: SET key value NX EX seconds is a single atomic command.
NX means "set only if Not eXists." Only one caller gets OK back (acquired lock).
All others get nil (lock already held). Not GET then SET — that has a race
condition between the two commands.
Three details that separate a correct version from a buggy one:
- Release only your own lock. If the query takes 12s, your 10s lock has already
expired and someone else holds it. A plain
DELwould delete their lock. Storing a random token and deleting with compare-and-delete (the Lua script) prevents that. - Release in
finally. Otherwise an exception leaves everyone waiting for the TTL. - Bound the waiting. An unbounded retry loop turns a slow database into piled-up requests and exhausted memory. Give up after a limit and fall back.
This is fine for coalescing cache rebuilds — if two requests sometimes compute at once, nothing breaks. It is not a lock you can rely on for correctness (e.g. "only one payment runs"); a paused process can outlive its TTL. For that, see fencing tokens in the Leader Election chapter (Phase 3, Chapter 3).
Solution 2: Probabilistic Early Expiration
Recompute the cache slightly before it expires, not after.
async function getLeaderboard() {
const cached = await redis.get('leaderboard:meta')
if (cached) {
const { data, expiry } = JSON.parse(cached)
const ttlRemaining = expiry - Date.now()
// If within 30 seconds of expiry, 10% chance to recompute early
if (ttlRemaining < 30000 && Math.random() < 0.1) {
recomputeInBackground() // fire and forget, don't await
}
return data
}
// ... load from DB
}
As the TTL approaches, a small percentage of requests trigger a background refresh. By the time the TTL actually expires, the cache has already been refreshed. Stampede prevented. No locking needed.
Solution 3: Stale-While-Revalidate
Return stale data immediately. Refresh in background.
async function getLeaderboard() {
const cached = await redis.get('leaderboard')
if (cached) {
const ttl = await redis.ttl('leaderboard')
if (ttl < 30) {
// Stale but close to expiry — refresh in background, return stale now
refreshLeaderboardInBackground()
}
return JSON.parse(cached) // return immediately, even if stale
}
// No cache at all — must wait
return await loadAndCache()
}
User gets instant response. Cache gets refreshed asynchronously. No stampede. The tradeoff: a user might see data up to TTL seconds stale. For leaderboards: absolutely fine. For bank balances: absolutely not.
Part 8: Redis Persistence — What Survives a Restart
Redis is RAM. Power off = data gone. Unless you configure persistence.
RDB — Snapshot
Redis periodically writes a point-in-time snapshot of all data to disk.
# redis.conf
save 900 1 # save if 1 key changed in last 900 seconds
save 300 10 # save if 10 keys changed in last 300 seconds
save 60 10000 # save if 10000 keys changed in last 60 seconds
Redis forks a child process to write the snapshot. The parent keeps serving requests.
The snapshot file (dump.rdb) is a compact binary representation of all data.
Restart: Redis loads the RDB file. Fast startup. But you lose all writes since the last snapshot — potentially minutes of data.
Use RDB when: Fast restarts matter more than zero data loss. Good for caches — losing a few minutes of cached data is fine, everything will be re-populated from DB.
AOF — Append-Only File
Redis logs every write command to a file, in order.
# redis.conf
appendonly yes
appendfsync everysec # fsync to disk every 1 second (recommended)
# (or: always = every command, no = OS decides)
Every SET, DEL, INCR is appended to appendonly.aof.
Restart: Redis replays the entire AOF file. Slower startup.
Data loss is at most 1 second (with everysec setting).
AOF rewrite: Over time, the AOF file grows huge. Redis periodically
rewrites it — replaces the log history with a minimal set of commands
that produce the current state. SET key 1, INCR key, INCR key becomes SET key 3.
Use AOF when: Data loss is unacceptable. Sessions, rate limit counters, any data you'd be upset to lose.
Hybrid — The Production Default
# redis.conf
appendonly yes
save 900 1
Use both. On restart: if AOF exists, use it (more up-to-date). If not, use RDB. This is what most production Redis instances run.
Part 9: What Not to Cache
Caching everything is as bad as caching nothing.
Never cache:
Financial data — account balances, transaction totals. If the cache is stale by even $0.01, you have a correctness bug. The DB is the source of truth. Query it.
Real-time data — live test scores as a quiz is happening, live stock prices, anything where staleness causes user harm.
Highly personalized data at granular level — caching user:123456:personalized_feed
for 10 million users means 10 million cache keys, most of which are cold.
Your cache is full of data nobody reads. Use a different strategy.
Large objects that change frequently — a 500KB JSON blob with a 30-second TTL means you're deserializing 500KB 120 times per minute. The CPU cost of serialization/deserialization might exceed the DB savings.
Sensitive data without careful thought — cached tokens, passwords, PII in logs. Redis is in-memory and if it's not properly secured (no auth, public network), your cache is a data breach.
Part 10: The Vocabulary Map
| Word | What it actually is |
|---|---|
Cache hit | Key found in Redis. Sub-millisecond response. |
Cache miss | Key not found. Fall through to DB. Cold path. |
TTL | Expiry time in seconds. After this, key is deleted. |
Eviction | Redis deleting keys to free RAM when maxmemory is hit. |
LRU | Least Recently Used eviction. Evicts coldest keys. |
LFU | Least Frequently Used eviction. Evicts rarest-accessed keys. |
Cache-aside | App checks cache, misses load DB, write back to cache. |
Write-through | Every write goes to cache AND DB synchronously. |
Write-behind | Write to cache, async flush to DB. Risk of data loss. |
Cache stampede | Mass cache miss triggers mass concurrent DB queries. Kills DB. |
Thundering herd | Same as cache stampede. Same disaster, different name. |
Mutex lock | Redis SET NX — only one caller acquires, others wait. |
Stale-while-revalidate | Return stale data immediately, refresh in background. |
RDB | Snapshot persistence. Fast restart. Minutes of possible data loss. |
AOF | Append-only log. Slow restart. ~1 second of possible data loss. |
SCAN | Cursor-based key iterator. Non-blocking. Use instead of KEYS *. |
Sorted Set | Members with float scores, always sorted. Leaderboards, rate limiting. |
HyperLogLog | Approximate unique count. 12KB regardless of cardinality. |
Bitmap | Per-offset boolean flags. Extremely memory-efficient analytics. |
Cache busting | Deleting a cache key on data change to force fresh load. |
Versioned keys | user:123:v4 — version bump shifts readers without invalidation. |
Part 11: Practice Problems
Problem 1 (Architecture — AttemptIQ specific):
AttemptIQ shows a "top 100 leaderboard" on every test results page.
This query joins submissions, users, and scores — takes ~80ms.
The test has 50,000 participants. Leaderboard changes every few seconds
as results are graded.
Design the full caching strategy:
- What data structure do you use in Redis?
- What is the TTL?
- What is the invalidation strategy?
- How do you handle stampede when TTL expires?
- What happens when a user's score is updated?
Problem 2 (Data Structure Choice): For each of these, name the Redis data structure you'd use and why:
a) Store which users have already seen an announcement. 50 million users. b) Store the 10 most recent notifications for each user. c) Rate limiting: how many API calls user:123 made in the last 60 seconds. d) Count unique visitors to a product page. Approximate is fine. e) Store a user's settings (theme, language, notification preferences).
Problem 3 (Failure Reasoning): You use cache-aside with TTL = 5 minutes. Your code does:
await redis.set(key, data) // Step 1
await redis.expire(key, 300) // Step 2
Your server crashes between Step 1 and Step 2.
What is the result? How do you fix it? What is the single Redis command that prevents this entirely?
Problem 4 (Production Debug):
Your Redis instance is using 95% of its maxmemory. You check and find:
allkeys-lrueviction is on- Hit rate has dropped from 94% to 71% over the past week
- The most frequently evicted keys are your leaderboard caches
Explain what is happening and why the hit rate is falling. What is the likely cause? What do you change?
Problem 5 (Go Deeper): Redis is single-threaded and processes one command at a time. Yet it can handle 100,000 operations per second.
Node.js is also single-threaded. Yet it can handle thousands of concurrent HTTP connections.
These two facts use identical underlying reasoning. Explain exactly why — connecting what you learned in Chapter 1 (event loop, non-blocking I/O, epoll) to how Redis achieves its throughput.
What's Coming in Chapter 2 of Phase 4
Chapter 1 was Caching. You now know the full Redis model — not as an API, but as a system with physical constraints and tradeoffs.
Chapter 2 is Rate Limiting — one of the most underestimated backend problems.
We'll build every major rate limiting algorithm from scratch:
Token Bucket, Fixed Window, Sliding Window Log, Sliding Window Counter.
We'll see why distributed rate limiting is harder than it looks,
and why your INCR + EXPIRE approach has a race condition you haven't noticed.
After Chapter 2, you'll know how to protect any API at any scale — not by adding middleware you don't understand, but by reasoning about which algorithm fits your constraints.
End of Phase 4, Chapter 1. Estimated reading time: 75-90 minutes. Practice problems: 60-90 minutes. Do not proceed to Chapter 2 until you can answer Problem 5 — connecting Redis's single-threaded throughput to Node.js's event loop. If you can explain both in one breath, you understand the most important performance model in modern backend engineering.