02 Β· CS Fundamentals
Caching & Storage β latency physics and the art of lying fast
A cache is a place where you keep a copy of the truth so you can answer faster than the truth can. Everything hard about caching is the word "copy." You run Redis and OpenSearch β both are engineered answers to "Postgres is the truth but too slow for this access pattern."
1The memory hierarchy β latency numbers every programmer should know
Hardware is a pyramid: each level down is ~10β100Γ bigger and ~10β100Γ slower.
ββββββββββββββββ ~0.3β1 ns registers
β L1 cache β ~1 ns 64 KB-ish
ββ΄βββββββββββββββ΄β ~3β4 ns L2, ~1 MB
ββ΄βββββββββββββββββ΄β ~10β20 ns L3, tens of MB (shared)
ββ΄βββββββββββββββββββ΄β ~60β100 ns RAM
ββ΄βββββββββββββββββββββ΄β ~10β100 Β΅s NVMe SSD read
ββ΄βββββββββββββββββββββββ΄β ~1β10 ms spinning disk seek
ββββββββββββββββββββββββββββ
The numbers that actually run your day β with the intuition anchor: pretend an L1 hit takes 1 second (multiply everything by 10βΉ) and feel how absurd the spread is.
| Operation | Real latency | If L1 hit = 1 second⦠|
|---|---|---|
| L1 cache hit | ~1 ns | 1 second |
| RAM reference | ~100 ns | ~1.5 minutes |
| Compress 1 KB (snappy) | ~2 Β΅s | ~half an hour |
| Read 1 MB sequentially from RAM | ~10β50 Β΅s | 3β14 hours |
| NVMe SSD random read | ~20β100 Β΅s | 6 hours β 1 day |
| Round trip same DC / K8s cluster | ~0.5 ms | ~6 days |
| Redis GET (same DC, incl. network) | ~0.5β1 ms | ~1 week |
| Round trip same region, cross-AZ | ~1β2 ms | ~2β3 weeks |
| Postgres simple indexed query | ~1β5 ms | 2 weeks β 2 months |
| HDD seek | ~10 ms | ~4 months |
| Round trip India β US East | ~200 ms | ~6 years |
| LLM API call | ~500 ms β 30 s | 16 β 950 years |
Three conclusions to actually say in interviews:
shared_buffers), your-DBβRedis (skip the query), your-DCβuser's-city (CDN), serverβbrowser (Cache-Control). Same move at every scale.2Caching patterns
Cache-aside (lazy loading) β the default, probably what you run
read: app ββGET keyβββΊ Redis ββ hit βββΊ return (fast path)
β miss
βΌ
app ββqueryβββΊ Postgres βββΊ app SETs Redis (with TTL) βββΊ return
write: app ββwriteβββΊ Postgres, then DEL (invalidate) the Redis key
- App owns the logic; cache is a dumb sidecar. Only requested data gets cached; cache failure degrades to "slow," not "down."
- Costs: first request per key eats a miss; invalidate-on-write is on you (the hard part, Β§4); brief staleness windows exist.
- Prefer delete-on-write over update-on-write: deleting is idempotent and avoids racing two writers into a stale overwrite; the next read repopulates.
Write-through
Writes go through the cache layer, which synchronously writes the store and the cache. Reads are always warm and consistent-with-store; writes pay double latency; you may cache things never read. Often paired with cache-aside reads.
Write-back (write-behind)
Write to the cache only; flush to the store later, in batches:
app ββwriteβββΊ cache (ack immediately!) ββasync batchβββΊ store
Blazing write latency + write coalescing, at the price of losing acked data if the cache dies before flush. You've met this pattern everywhere respectable: CPU caches, the OS page cache (write() returns before disk; fsync forces it), and Postgres itself (shared_buffers dirty pages, WAL fsync at commit for the durable part + background checkpoints for data pages). Use in apps only for tolerable-loss data (view counters, analytics buffers).
3Eviction β the cache is full, who dies?
TTL (time-based)
Every entry gets a lifespan (SET key val EX 300). Not really an eviction policy β it's a staleness bound: "I can tolerate 5 minutes of lie." TTL is also the safety net that makes imperfect invalidation survivable β even a missed invalidation self-heals in one TTL. Almost everything you cache should have one.
LRU β Least Recently Used (+ the implementation sketch interviewers want)
Evict what's gone longest unused. The classic O(1) implementation = hash map + doubly linked list:
hash map doubly-linked list (recency order)
key βββΊ node ptr MRU βββΊ [k3] βββΊ [k1] βββΊ [k7] βββΊ LRU
evict from here βββΊ
get(k): map lookup β unlink node β move to MRU end β return value O(1)
put(k,v): if present: update + move-to-front.
else: if full β evict LRU tail (and its map entry); insert at MRU. O(1)
# In real Python: functools.lru_cache, or OrderedDict:
from collections import OrderedDict
class LRU:
def __init__(self, cap): self.cap, self.d = cap, OrderedDict()
def get(self, k):
if k not in self.d: return None
self.d.move_to_end(k) # mark most-recently used
return self.d[k]
def put(self, k, v):
if k in self.d: self.d.move_to_end(k)
self.d[k] = v
if len(self.d) > self.cap: self.d.popitem(last=False) # evict LRU
LFU β Least Frequently Used
Evict the least often used. Resists scan pollution (one touch β hot) but needs aging/decay so yesterday's celebrity keys eventually die, and costs more bookkeeping.
What Redis really does
maxmemory + maxmemory-policy: allkeys-lru, volatile-lru (only TTL'd keys), allkeys-lfu, volatile-ttl, noeviction (errors on write β the default!). And Redis's "LRU" is approximate β it samples N random keys and evicts the stalest, because a true global linked list across millions of keys costs memory and coherence. Knowing "Redis LRU is sampled, and the default policy is noeviction" is a strong flex.
4Cache invalidation & stampede protection
Invalidation strategies, in order of increasing effort
- TTL only β accept bounded staleness. Right answer surprisingly often (config, feature flags, rarely-changing lookups).
- Delete-on-write (cache-aside) β write DB,
DELkey. Gaps: multi-key dependencies (an entity cached underuser:42,user:email:x, and inside three list caches), and the write-DB-then-crash-before-DEL window. - Versioned/namespaced keys β never mutate, change the key:
user:42:v{updated_at}or a namespace counter you bump to mass-invalidate. Old entries die by TTL/eviction. Sidesteps races entirely. - Event-driven β publish change events (you have Kafka!) or CDC (Debezium) and let a consumer invalidate/update caches. Decoupled and reliable; eventual by nature.
SET NX with care.Cache stampede (thundering herd)
A hot key expires β 1,000 concurrent requests all miss β 1,000 identical DB queries β DB tips over β the outage caused by the cache:
TTL expiry
ββββββββββββ¬βββββββββββββββΊ time
hits... β ββββββββββ 1000 misses hammer Postgres simultaneously
Protections (know several):
Singleflight / request coalescing
First miss takes a mutex (in-process, or Redis SET lock:key NX PX 5000); it alone rebuilds; the rest wait or briefly serve stale. In-process async version: share one Future among concurrent callers.
Stale-while-revalidate
Serve the expired value, refresh in the background. CDNs have this as a literal Cache-Control directive.
TTL jitter
TTL + random() so keys warmed together don't expire together (mass-expiry stampede).
Early / probabilistic refresh
Hot keys refresh before expiry, probability rising as expiry nears ("XFetch").
Negative caching
Cache "not found" for a short TTL so misses-for-nonexistent-keys don't stampede either.
5Redis β core structures and what each is for
Redis = single-threaded (command execution) in-memory data-structure server. Single-threaded is a feature: every command is atomic β no locks needed β and one core over RAM still does ~100k+ ops/s. (I/O threads exist for socket work; command logic stays serial.)
| Structure | Ops | Canonical uses in your stack |
|---|---|---|
| String | GET/SET/INCR/SETEX/SETNX | cache values (JSON blobs), atomic counters, distributed locks (SET k v NX PX), rate limiting (INCR + EXPIRE per window β what Kong's Redis rate-limit plugin effectively does) |
| Hash | HSET/HGET/HGETALL/HINCRBY | an object with mutable fields (session, user profile) without rewriting the whole JSON blob |
| List | LPUSH/RPUSH/LPOP/BRPOP | simple queues (BRPOP = blocking consumer), recent-N feeds (LPUSH + LTRIM) |
| Set | SADD/SISMEMBER/SINTER | dedupe, tags, "have we processed this id?" (idempotency), audience intersections |
| Sorted set (ZSET) | ZADD/ZRANGE/ZRANGEBYSCORE | leaderboards, priority/delay queues (score = run_at timestamp), sliding-window rate limiting (score = event time, ZREMRANGEBYSCORE old, ZCARD) |
| Streams | XADD/XREADGROUP/XACK | Kafka-lite: append-only log with consumer groups + acks, for jobs/events when Kafka is overkill |
| Extras | β | Pub/Sub (fire-and-forget fanout β WebSocket backplane), HyperLogLog (~uniques in 12 KB), Bitmaps, Lua/EVAL (atomic multi-step scripts β check-and-set logic without races), GEO |
6CDNs β the same idea, planet-sized
A CDN is cache-aside at the edge of the internet: hundreds of PoPs holding copies close to users, because you cannot cache away the speed of light (~200 ms IndiaβUS) but you can move the copy.
user (Pune) ββ5msβββΊ edge PoP (Mumbai) ββ hit βββΊ done
β miss
ββββββββΊ origin (your LB/Kong/S3) β cache per headers β serve
- Controlled by HTTP headers:
Cache-Control: public, max-age=86400, stale-while-revalidate=60, ETag/If-None-Match (304s),Vary. TTL + SWR + purge APIs = the same invalidation toolkit as Β§4, over HTTP. - Static assets: fingerprinted filenames (
app.9f3ab2.js, immutable, cache ~forever) = versioned keys, again β deploys change the URL, never invalidate. - CDNs also cache APIs (public GETs), terminate TLS near users (cutting handshake RTTs), absorb DDoS, and run edge compute.
7OpenSearch / Elasticsearch β the inverted index vs the B-tree
Why Postgres is the wrong shape for search
WHERE title LIKE '%database%' can't use a B-tree (not a prefix) β full scan; no relevance ranking, fuzziness, or language handling. A B-tree maps key β row. Search needs the reverse: word β documents.
The inverted index
At index time, each document is analyzed β tokenized, lowercased, stemmed ("Running" β "run"), stopwords dropped β then each term posts into a dictionary:
doc1: "Postgres indexes B-trees" TERM DICTIONARY β POSTINGS LISTS
doc2: "Redis cache eviction" βββββββββββββββββββββββββββββββββ
doc3: "Postgres caching with Redis" "postgres" β [1, 3]
"index" β [1]
"redis" β [2, 3]
"cache" β [2, 3] (cache/caching β same stem)
"b-tree" β [1]
query "postgres cache" β lookup two postings lists β union/intersect
β score each doc (BM25: rare terms & term frequency
weigh more, long docs normalized) β ranked results
That's it β search over millions of docs = a few sorted-list lookups + merge + scoring. Wildly cheap compared to scanning.
Structural differences from a B-tree database β the part interviewers probe
| Postgres (B-tree, heap) | OpenSearch (inverted index, Lucene) | |
|---|---|---|
| Optimized for | exact/range lookup, transactional updates in place | term lookup, relevance ranking, aggregations |
| Write model | update rows + index entries in place (MVCC versions) | segments are immutable β writes create new mini-indexes; background merges compact them; deletes are tombstones until merge |
| Visibility | committed = visible immediately | near-real-time: docs searchable after a refresh (default ~1 s) |
| Consistency | ACID, source of truth | eventually consistent, derived/secondary store |
| Update cost | cheap row update | reindex the whole document |
| Extras | joins, constraints, transactions | analyzers, fuzzy/phrase queries, highlighting, facet aggregations |
QInterview questions you should be able to answer
Q1. Roughly how long do RAM, SSD, Redis, Postgres, and a cross-continent call take?
RAM ~100 ns; NVMe read ~20β100 Β΅s; same-DC round trip ~0.5 ms β so Redis GET ~0.5β1 ms, simple indexed Postgres query ~1β5 ms; cross-continent ~150β250 ms; LLM call ~seconds. Spread is ~6β9 orders of magnitude, so architecture = minimizing round trips and moving data up the hierarchy; algorithmic micro-costs drown in network time.
Q2. Explain cache-aside vs write-through vs write-back.
Cache-aside: app reads cache, on miss reads DB and populates; on write, update DB + delete key β simple, lazy, app-managed staleness. Write-through: writes go through the caching layer to both synchronously β consistent reads, slower writes. Write-back: ack from cache, flush to store async β fastest writes, data-loss window; it's how the OS page cache and DB buffer pools work, so it's everywhere, just usually not app-level. Delete-on-write beats update-on-write (idempotent, fewer races).
Q3. Sketch an O(1) LRU cache.
Hash map (key β node) + doubly linked list in recency order. Get: lookup, unlink, move to head. Put: insert at head; over capacity β evict tail + its map entry. Both O(1). Python: OrderedDict / functools.lru_cache. LRU's flaw: one full scan evicts the hot set β LFU (with decay) resists that. Redis approximates LRU by sampling keys, and its default policy is actually noeviction.
Q4. What is a cache stampede and how do you prevent it?
Hot key expires β all concurrent requests miss β identical queries hammer the DB at once. Fixes: request coalescing (single rebuild via lock/singleflight future, others wait or serve stale), stale-while-revalidate, TTL jitter to desynchronize expiries, early probabilistic refresh for hot keys, negative caching for missing keys.
Q5. How do you invalidate a cache correctly?
Layered: TTL always (bounded staleness + self-healing backstop) β delete-on-write for direct keys β versioned/namespaced keys when dependencies get complex (change the key, never mutate β same trick as fingerprinted CDN assets) β event/CDC-driven invalidation via Kafka for cross-service caches. Know the repopulate-after-delete race and that TTL is what makes imperfect invalidation survivable.
Q6. Which Redis structure would you use for X?
Cache blob/counter/lock/fixed-window rate limit β String (GET/SET/INCR/SET NX PX). Mutable object fields β Hash. Queue β List BRPOP (or Streams for consumer groups + acks). Dedupe/idempotency β Set. Leaderboard/delay queue/sliding-window rate limit β Sorted set. Uniques at scale β HyperLogLog. Multi-step atomicity β Lua script. And why it's safe: single-threaded execution makes each command atomic.
Q7. Is Redis durable? Would you use it as a primary database?
Default posture: no β RDB snapshots lose recent writes; AOF narrows but doesn't erase the window (fsync policy), and it's RAM-bound. Use as cache/coordination layer; design for cold-start. Primary-store use only for data you can afford to lose or with careful AOF-always + replication β usually the wrong tool.
Q8. How does a CDN decide what to serve, and how do you bust its cache?
Edge PoP checks its cache per Cache-Control/ETag; miss β origin fetch β cache β serve; conditional requests give 304s. Busting: fingerprinted asset URLs (immutable, infinite TTL), short TTL + stale-while-revalidate for HTML/APIs, purge API for emergencies. It's cache-aside at the internet's edge β the physics reason being you can't beat speed-of-light RTTs, only move copies closer.
Q9. How does OpenSearch's index differ from a Postgres B-tree?
B-tree: sorted key β row pointers; great for exact/range on a key; updated in place transactionally. Inverted index: analyzed term β postings list of doc ids; a query is a few postings merges + BM25 relevance scoring β that's why full-text over millions of docs is fast and LIKE '%x%' isn't. Structurally, Lucene writes immutable segments merged in the background (LSM-flavored), gives near-real-time visibility (~1 s refresh), and document updates are full reindexes. So OpenSearch is a derived, eventually-consistent store fed from Postgres (ideally via CDC/Kafka), not a source of truth.
Q10. Where would you add caching to a slow endpoint, and in what order?
First measure (is it DB, N+1, external call, serialization?). Then, in escalating order: fix the query/indexes (don't cache a bad query), in-process memoization for per-request repeats, Redis cache-aside with TTL+jitter for cross-request reuse, HTTP-level (ETag/Cache-Control/CDN) for public GETs, and precomputation/materialization for expensive aggregates. Each layer: define the invalidation story before shipping β a cache without one is a bug factory.
βSelf-test before moving on
Recite the latency ladder from L1 to LLM call
1 ns L1 β 100 ns RAM β ~2 Β΅s compress 1 KB β 20β100 Β΅s NVMe β 0.5 ms same-DC RTT (β Redis GET) β 1β5 ms indexed Postgres query β 10 ms HDD seek β 200 ms IndiaβUS β 0.5β30 s LLM call. In L1-seconds: 1 second β ~6 years β ~950 years.
Name the four write-side caching patterns and their failure modes
Cache-aside (you own invalidation; staleness windows), write-through (double write latency; may cache unread data), write-back (loses acked data if cache dies pre-flush), read-through/refresh-ahead (same as cache-aside but the layer owns miss logic / pre-warms hot keys).
Sketch the O(1) LRU from memory β both operations
Hash map keyβnode + doubly linked list in recency order. get: lookup, unlink, splice to MRU head. put: update+move if present; else insert at head, and if over capacity pop the tail node and delete its map entry. Every step is pointer surgery or a hash op β O(1).
List five stampede protections without looking
Singleflight/request coalescing (lock, one rebuild), stale-while-revalidate, TTL jitter, early probabilistic refresh (XFetch), negative caching.
Pick the Redis structure: sliding-window rate limiter, idempotency check, delayed jobs
Sliding window β ZSET (score = event timestamp, ZREMRANGEBYSCORE + ZCARD). Idempotency ("seen this id?") β Set (SADD returns 0 if already present). Delayed jobs β ZSET with score = run_at, poll ZRANGEBYSCORE now.
Why can't LIKE '%x%' use a B-tree, and what does an inverted index do instead?
A B-tree is sorted by key prefix; a mid-string pattern has no prefix to seek to β full scan. An inverted index analyzes docs into terms and stores term β postings list of doc ids, so a query is a couple of sorted-list lookups, a merge, and BM25 scoring.