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.

OperationReal latencyIf L1 hit = 1 second…
L1 cache hit~1 ns1 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 Β΅s3–14 hours
NVMe SSD random read~20–100 Β΅s6 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 ms2 weeks – 2 months
HDD seek~10 ms~4 months
Round trip India ↔ US East~200 ms~6 years
LLM API call~500 ms – 30 s16 – 950 years

Three conclusions to actually say in interviews:

1. RAM is ~1,000,000Γ— faster than a cross-continent round trip. The hierarchy spans ~9 orders of magnitude β€” a nanosecond-to-30-seconds spread. Anchor: if L1 = 1 second, a US round trip = ~6 years.
2. In a service, network round trips dominate everything. Whether a lookup is a hash probe or a B-tree walk is noise next to "did I make 1 call or 101 calls" (the N+1 lesson, again). Batching, pipelining, and co-location beat micro-optimization.
3. Caching = deliberately moving data up the pyramid. disk→RAM (Postgres shared_buffers), your-DB→Redis (skip the query), your-DC→user's-city (CDN), server→browser (Cache-Control). Same move at every scale.
The modern asterisk: an LLM call is ~3 orders of magnitude slower than everything else in your stack β€” which is why semantic/response caching around LLMs pays off like nothing else.

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).

Related knobs: read-through β€” the cache library fetches on miss itself (cache-aside with the logic moved into the layer); refresh-ahead β€” proactively refresh hot keys before expiry.

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
Know the weakness: a one-off scan (batch job reads everything once) flushes the whole hot set.

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.

πŸ§ͺ LRU Cache Simulator β€” capacity 3

Each click is an access to that key. Hits slide the key to the front (most-recent, left). Misses insert it β€” and if the cache is full, the least-recent key (right) gets evicted.

 Β· 

MRU β†’ (empty) ← LRU

Click a key to access it.

Hit rate: –

4Cache invalidation & stampede protection

"There are only two hard things in Computer Science: cache invalidation and naming things."

Invalidation strategies, in order of increasing effort

  1. TTL only β€” accept bounded staleness. Right answer surprisingly often (config, feature flags, rarely-changing lookups).
  2. Delete-on-write (cache-aside) β€” write DB, DEL key. Gaps: multi-key dependencies (an entity cached under user:42, user:email:x, and inside three list caches), and the write-DB-then-crash-before-DEL window.
  3. 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.
  4. Event-driven β€” publish change events (you have Kafka!) or CDC (Debezium) and let a consumer invalidate/update caches. Decoupled and reliable; eventual by nature.
Classic subtle race worth narrating: a cache-aside read (miss β†’ query DB) interleaved with a write (update DB β†’ DEL) can repopulate the old value after the DEL if the read's SET lands last. Mitigations: short TTLs as backstop, versioned keys, or 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.)

StructureOpsCanonical uses in your stack
StringGET/SET/INCR/SETEX/SETNXcache 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)
HashHSET/HGET/HGETALL/HINCRBYan object with mutable fields (session, user profile) without rewriting the whole JSON blob
ListLPUSH/RPUSH/LPOP/BRPOPsimple queues (BRPOP = blocking consumer), recent-N feeds (LPUSH + LTRIM)
SetSADD/SISMEMBER/SINTERdedupe, tags, "have we processed this id?" (idempotency), audience intersections
Sorted set (ZSET)ZADD/ZRANGE/ZRANGEBYSCOREleaderboards, priority/delay queues (score = run_at timestamp), sliding-window rate limiting (score = event time, ZREMRANGEBYSCORE old, ZCARD)
StreamsXADD/XREADGROUP/XACKKafka-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
Persistence honesty: RDB (snapshots β€” can lose minutes) vs AOF (command log, configurable fsync). Treat Redis as ephemeral by default β€” architecture should survive a cold cache (maybe slowly). If losing it loses business data, it's being misused as a database. Scaling: replicas for reads/failover (Sentinel), Cluster mode for sharding by key slot.

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.

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 forexact/range lookup, transactional updates in placeterm lookup, relevance ranking, aggregations
Write modelupdate rows + index entries in place (MVCC versions)segments are immutable β€” writes create new mini-indexes; background merges compact them; deletes are tombstones until merge
Visibilitycommitted = visible immediatelynear-real-time: docs searchable after a refresh (default ~1 s)
ConsistencyACID, source of trutheventually consistent, derived/secondary store
Update costcheap row updatereindex the whole document
Extrasjoins, constraints, transactionsanalyzers, fuzzy/phrase queries, highlighting, facet aggregations
Interesting symmetry to drop: immutable segments + background merging is the LSM-ish write pattern (Cassandra, RocksDB share the spirit: sequential writes + compaction, vs B-tree's in-place random writes) β€” write-optimized vs read-in-place-optimized storage, the same tradeoff axis again.
Architecture consequence you live: OpenSearch is derived data β€” Postgres holds truth, a pipeline (app dual-write, or better Kafka/CDC) feeds the index, and staleness-by-a-second is accepted. Search results can briefly disagree with the DB; design UX and reconciliation for it. (Vector/kNN search for your LLM work rides the same deployment: embeddings + HNSW indexes in the same engine.)

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.