Why Cache?
A cache is a fast, usually in-memory store that keeps copies of expensive-to-produce data close to where it is consumed. The goal is to trade a small amount of memory and staleness for large gains in latency and throughput, and to shed load from an origin (a database, a downstream service, or an object store).
Caching works because real workloads are skewed: a minority of keys serve a majority of requests (the 80/20 or Zipf pattern). If the hot set fits in RAM, most reads never touch the origin.
| Tier | Typical latency |
|---|---|
| L1/L2 CPU cache | ~1–10 ns |
| Local RAM | ~100 ns |
| Redis / Memcached (same DC) | ~0.3–1 ms |
| SSD / local disk | ~0.1–1 ms |
| Disk-backed DB query | ~5–50 ms |
| Cross-region round trip | ~70–150 ms |
Hit Ratio — the Number That Matters
The cache hit ratio is hits / (hits + misses). Effective average latency is a weighted blend of cache and origin latency:
avg_latency = hit_ratio * cache_latency + (1 - hit_ratio) * origin_latency
Example: cache = 1 ms, origin = 40 ms
hit_ratio 0.50 -> 0.50*1 + 0.50*40 = 20.5 ms
hit_ratio 0.90 -> 0.90*1 + 0.10*40 = 4.9 ms
hit_ratio 0.99 -> 0.99*1 + 0.01*40 = 1.4 ms
Notice the curve is non-linear: moving from 90% to 99% cuts latency roughly 3.5x, and it cuts origin load by 10x (from 10% of traffic to 1%). The last few percent of hit ratio protect the origin more than they speed up any single request.
Back-of-envelope
At 50k req/s with a 95% hit ratio, the origin sees only 2,500 req/s. Drop the hit ratio to 90% and origin load doubles to 5,000 req/s. Capacity plan for the miss traffic, not the total.
Where to Cache
Browser CDN / Edge App server Shared cache Database
(private) (POP, global) (local heap) (Redis cluster) (buffer pool)
| | | | |
[==cache==] -> [==cache==] -> [==cache==] -> [==cache==] -> [==cache==]
fastest, near user, per-instance, shared & page/query
per-user static assets low latency, coherent, cache,
not coherent network hop transparent
| Layer | Good for | Caveat |
|---|---|---|
| Client / browser | Static assets, HTTP Cache-Control, ETags | Cannot invalidate remotely |
| CDN / edge | Images, JS/CSS, cacheable API responses | Purge lag; per-POP hit ratio |
| App-local (in-process) | Config, feature flags, tiny hot data | Per-instance; inconsistent across nodes |
| Distributed (Redis) | Shared session/state, query results | Network hop; own scaling concern |
| Database buffer pool | Hot pages, transparent | You don't control it directly |
Caching Patterns
Cache-aside (lazy loading)
The application manages the cache. On read: check cache; on miss, read origin, populate cache, return. This is the most common pattern — only requested data is cached, and the cache can be down without breaking correctness (only latency suffers).
value = cache.get(key)
if value is None: # cache miss
value = db.read(key)
cache.set(key, value, ttl=300)
return value
Read-through is the same logic, but the cache library (not your code) loads from the origin on a miss. Write-through writes to cache and origin synchronously, keeping them consistent at the cost of write latency. Write-behind (write-back) writes to cache first and flushes to the origin asynchronously — fast writes, but a crash can lose un-flushed data. Refresh-ahead proactively reloads popular keys before they expire, hiding miss latency for hot data.
| Pattern | Write path | Consistency | Risk |
|---|---|---|---|
| Cache-aside | App writes DB, invalidates key | Eventual | Stale on race |
| Write-through | Cache + DB, synchronous | Strong | Higher write latency |
| Write-behind | Cache now, DB async | Eventual | Data loss on crash |
| Refresh-ahead | Reload before expiry | Eventual | Wasted refreshes for cold keys |
Eviction Policies
A cache has finite memory, so it must decide what to evict when full. TTL controls staleness; the eviction policy controls capacity.
| Policy | Evicts | Best when |
|---|---|---|
| LRU | Least recently used | Recency predicts reuse (default choice) |
| LFU | Least frequently used | Stable hot set; resists scan pollution |
| FIFO | Oldest inserted | Simple; order-of-arrival matters |
| TTL | Anything past its expiry | Bounded staleness required |
| Random | A random key | Cheap; uniform access, no metadata cost |
Redis maxmemory-policy options combine these (e.g. allkeys-lru, volatile-lfu). Modern caches often use TinyLFU/W-TinyLFU (as in Caffeine) which approximates LFU with a small frequency sketch and beats plain LRU on most workloads.
Cache Invalidation — the Hard Problem
"There are only two hard things in computer science: cache invalidation and naming things." The challenge: when the underlying data changes, cached copies become stale. Strategies:
| Strategy | How | Trade-off |
|---|---|---|
| TTL expiry | Let entries age out | Simple; bounded staleness |
| Explicit delete | Delete key on write | Fresh, but easy to miss a path |
| Write-through | Update cache on write | Consistent; slower writes |
| Versioned keys | Embed version in key | No delete needed; old keys linger |
The delete-then-write race
With cache-aside, prefer invalidate (delete) after DB commit rather than updating the cache. A concurrent reader can still repopulate a stale value: reader misses, reads old DB row, then writer commits and deletes, then reader sets the stale value. Mitigations: delete-after-commit plus a short TTL, or delayed double-delete.
Stampede, Penetration, and Hot Keys
Thundering herd / cache stampede
When a popular key expires, thousands of concurrent requests miss simultaneously and all hit the origin at once, which can topple it. Mitigations:
1. Request coalescing / single-flight
Only ONE request recomputes; others wait for the result.
2. Locking (mutex per key)
First miss acquires lock, recomputes, sets; others retry-read.
3. Jittered TTL
ttl = base + random(0, spread) # spread expiry over time
4. Early / probabilistic refresh (XFetch)
Refresh a bit BEFORE expiry with probability rising as TTL nears 0.
Cache penetration
Requests for keys that do not exist always miss the cache and always hit the origin (common in credential-stuffing or scraping). Defenses: cache the negative result (a short-TTL "null" sentinel) and use a Bloom filter in front of the cache — a probabilistic set that answers "definitely not present" cheaply, short-circuiting lookups for keys that cannot exist.
Hot key
One key (a celebrity profile, a flash-sale item) receives so much traffic it saturates a single cache node. Fixes: replicate the key across nodes with a random suffix (key#1..key#N), pin it to a local in-process cache, or add a small client-side cache tier.
Distributed Caching
A single cache node has bounded memory and throughput. Distributed caches spread keys across many nodes. The routing question — "which node owns this key?" — is answered with consistent hashing, which minimizes the fraction of keys that move when a node is added or removed.
Naive: node = hash(key) % N
Add one node (N -> N+1): almost ALL keys remap -> mass miss storm.
Consistent hashing: place nodes and keys on a ring [0, 2^32).
A key belongs to the next node clockwise.
Adding/removing a node only moves keys in ONE arc (~1/N of keys).
Virtual nodes (many points per physical node) smooth the load.
0/2^32
* nodeA
key3 | \
nodeC * | * nodeB
\| /
key1 * key2
Redis Cluster instead uses 16,384 hash slots assigned to nodes; a key maps to CRC16(key) % 16384. Client-side sharding, twemproxy, or Redis Cluster all solve the same routing problem.
Redis vs Memcached
| Aspect | Redis | Memcached |
|---|---|---|
| Data types | Strings, hashes, lists, sets, sorted sets, streams, bitmaps, HLL | Opaque strings only |
| Persistence | RDB snapshots + AOF | None (pure cache) |
| Threading | Mostly single-threaded core (+ I/O threads) | Multi-threaded |
| Replication / HA | Replicas, Sentinel, Cluster | Client-side sharding only |
| Extras | Pub/sub, Lua, TTL, geo, transactions | Minimal, very predictable |
| Pick when | You need structures, persistence, or HA | Pure LRU string cache, max simplicity |
Consistency & Coherence
A cache is a second copy of the truth, so it can disagree with the origin. Write-through keeps them in lock-step (strong-ish consistency) but pays on every write. Write-back lets them diverge until the flush, so reads through the cache are consistent but the origin lags. Across multiple app instances holding local caches, you get a coherence problem: node A updates, node B still serves the old value. Solutions include a shared distributed cache (single source), pub/sub invalidation messages that tell every node to drop a key, or short TTLs to bound divergence.
Rule of thumb
Cache what is read often and changes rarely. Always set a TTL (a cache with no expiry is a memory leak with extra steps). Measure hit ratio and origin load, not just latency — and design for the cache being unavailable.