Caching — Deep Dive Reference
From fundamentals to production patterns.
1. Why Cache
| Access Type | Latency | Notes |
|---|---|---|
| L1 CPU cache | ~1 ns | Per core |
| L2/L3 CPU cache | 4–40 ns | Shared across cores |
| RAM | ~100 ns | Main memory |
| Redis (local) | ~0.1–1 ms | In-process network |
| SSD disk | ~100 µs | NVMe |
| HDD disk | ~5–10 ms | Rotational |
| DB query (cold) | 5–50 ms | Index + disk I/O |
| Cross-region network | 50–200 ms | Geographic distance |
Read amplification: a single app query can fan out to dozens of DB reads. A cache short-circuits this.
Cost reduction: DB CPU is expensive; Redis/Memcached nodes are cheap per RPS served.
2. Cache Tier Hierarchy
graph TD
Client["Client Browser"]
CDN["CDN Edge Cache<br/>~5ms, geographic"]
AppL1["App In-Process Cache<br/>~0.01ms, per instance"]
Redis["Distributed Cache Redis/Memcached<br/>~0.5ms, shared"]
DBQueryCache["DB Query Cache<br/>~1ms, per DB node"]
DB["Database Disk<br/>~10ms+"]
CPU["CPU L1/L2/L3 Cache<br/>1-40ns, per core"]
Client --> CDN
CDN --> AppL1
AppL1 --> Redis
Redis --> DBQueryCache
DBQueryCache --> DB
CPU -.->|"used by app process"| AppL1
3. Cache-Aside (Lazy Loading)
The application owns the cache interaction.
sequenceDiagram
participant App
participant Cache
participant DB
App->>Cache: GET key
alt cache hit
Cache-->>App: return value
else cache miss
Cache-->>App: nil
App->>DB: SELECT ...
DB-->>App: row data
App->>Cache: SET key value EX ttl
App-->>App: return value
end
Code pattern (Python/Redis):
def get_user(user_id):
key = f"user:{user_id}"
data = redis.get(key)
if data:
return json.loads(data)
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
redis.setex(key, 300, json.dumps(user))
return user
Pros: Only caches what's actually read. Cache failures don't break writes.
Cons: First read is always slow (cold miss). Stale data possible if DB changes outside the app.
Thundering herd risk: Many requests miss simultaneously on cold start or expiry — all hit DB at once. See §11.
4. Write-Through
Every write goes to cache AND DB in the same operation.
sequenceDiagram
participant App
participant Cache
participant DB
App->>Cache: SET key value
Cache->>DB: INSERT/UPDATE (synchronous)
DB-->>Cache: ack
Cache-->>App: ack
Pros: Cache is always consistent with DB. No stale reads after writes.
Cons: Write latency = cache latency + DB latency. Cache fills with data that may never be read.
5. Write-Behind (Write-Back)
Write to cache immediately; flush to DB asynchronously.
sequenceDiagram
participant App
participant Cache
participant DB
App->>Cache: SET key value
Cache-->>App: ack (fast)
Note over Cache: dirty key queued
Cache-)DB: async flush (batch)
DB-->>Cache: ack
Pros: Extremely fast writes. Batch DB writes reduce I/O.
Cons: Data loss if cache crashes before flush. Complexity in failure handling.
6. Read-Through
Cache sits in front of DB and fetches transparently on miss.
sequenceDiagram
participant App
participant Cache
participant DB
App->>Cache: GET key
alt cache hit
Cache-->>App: value
else cache miss
Cache->>DB: SELECT ...
DB-->>Cache: data
Cache-->>App: data (and stores it)
end
Difference from cache-aside: App only talks to cache. Cache library/provider handles DB fetching. Example: Spring Cache with @Cacheable.
7. All 4 Patterns — Side-by-Side
graph LR
subgraph CacheAside["Cache-Aside"]
CA_App["App"] -->|"read: GET"| CA_Cache["Cache"]
CA_App -->|"on miss: SELECT"| CA_DB["DB"]
CA_App -->|"on miss: SET"| CA_Cache
CA_App -->|"write: UPDATE"| CA_DB
end
subgraph WriteThrough["Write-Through"]
WT_App["App"] -->|"write: SET"| WT_Cache["Cache"]
WT_Cache -->|"sync write"| WT_DB["DB"]
end
subgraph WriteBehind["Write-Behind"]
WB_App["App"] -->|"write: SET"| WB_Cache["Cache"]
WB_Cache -.->|"async flush"| WB_DB["DB"]
end
subgraph ReadThrough["Read-Through"]
RT_App["App"] -->|"read: GET"| RT_Cache["Cache"]
RT_Cache -->|"on miss: SELECT"| RT_DB["DB"]
end
8. Eviction Policies
Algorithms
| Policy | Description | Use Case |
|---|---|---|
| LRU | Evict least recently used | General purpose |
| LFU | Evict least frequently used | Skewed access patterns |
| FIFO | Evict oldest inserted key | Simple queues |
| TTL | Expire after time-to-live | Session data, tokens |
| Random | Evict random key | When access is uniform |
| LRU-K | LRU using K-th most recent access | Avoids one-time scan pollution |
| 2Q | Two queues: probationary + protected | Better than LRU for scan resistance |
Redis maxmemory-policy options
# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru
| Policy | Behavior |
|---|---|
noeviction |
Return error when memory full |
allkeys-lru |
Evict any key by LRU |
volatile-lru |
Evict keys with TTL set, by LRU |
allkeys-lfu |
Evict any key by LFU |
volatile-lfu |
Evict keys with TTL set, by LFU |
allkeys-random |
Evict any key randomly |
volatile-random |
Evict TTL keys randomly |
volatile-ttl |
Evict keys with shortest TTL first |
Rule of thumb: Use allkeys-lru for pure caches. Use volatile-ttl when mixing persistent and ephemeral keys.
9. Redis Data Structures for Caching
String — simple key-value
SET user:1001 '{"name":"alice","role":"admin"}' EX 300
GET user:1001
INCR page:views:home # atomic counter
Hash — object fields without JSON serialization
HSET user:1001 name alice role admin age 30
HGET user:1001 name
HGETALL user:1001
# Update one field without fetching/re-serializing whole object
HSET user:1001 age 31
Sorted Set — leaderboards, rate limiting
# Leaderboard
ZADD leaderboard 9500 player:alice
ZADD leaderboard 8200 player:bob
ZREVRANGE leaderboard 0 9 WITHSCORES # top 10
# Sliding window rate limit (requests in last 60s)
ZADD rate:user:1001 1719651233 req:uuid1
ZREMRANGEBYSCORE rate:user:1001 -inf (now-60)
ZCARD rate:user:1001 # current count
List — queues, recent activity
LPUSH recent:user:1001 "viewed:product:42"
LTRIM recent:user:1001 0 99 # keep last 100
LRANGE recent:user:1001 0 -1
Bitmap — feature flags, daily active users
# User 1001 active today (bit at offset = user_id)
SETBIT dau:2026-06-29 1001 1
BITCOUNT dau:2026-06-29 # total DAU
GETBIT dau:2026-06-29 1001
HyperLogLog — unique count (approximate, 0.81% error)
PFADD unique:visitors:home user:1001 user:1002 user:1001
PFCOUNT unique:visitors:home # returns 2, not 3
Uses ~12KB regardless of cardinality — far cheaper than a Set for billions of IDs.
10. Redis vs Memcached
| Feature | Redis | Memcached |
|---|---|---|
| Data structures | String, Hash, List, Set, ZSet, Stream, Geo, HLL | String only |
| Persistence | RDB snapshots + AOF | None |
| Replication | Primary-replica, async | No built-in |
| Cluster | Redis Cluster (hash slots) | Client-side sharding only |
| Pub/Sub | Yes | No |
| Lua scripting | Yes (EVAL) |
No |
| Transactions | MULTI/EXEC + WATCH | No |
| Memory efficiency | Higher overhead per key (object metadata) | Lower overhead, better for simple KV at scale |
| Multi-threading | Single-threaded command loop (I/O multi-threaded since 6.0) | Multi-threaded |
| Max value size | 512 MB | 1 MB |
Choose Memcached when: pure string caching, extreme memory efficiency matters, multi-threaded performance on many cores.
Choose Redis when: you need persistence, replication, complex data structures, pub/sub, or Lua atomicity.
11. Cache Stampede / Thundering Herd
Problem: A hot key expires. Thousands of requests all miss simultaneously, all hit DB at once, DB collapses.
sequenceDiagram
participant R1 as Request 1
participant R2 as Request 2
participant R3 as Request N
participant Cache
participant DB
R1->>Cache: GET hot_key → miss
R2->>Cache: GET hot_key → miss
R3->>Cache: GET hot_key → miss
R1->>DB: SELECT (stampede)
R2->>DB: SELECT (stampede)
R3->>DB: SELECT (stampede)
Solution 1: Mutex / Distributed Lock
def get_with_lock(key):
val = redis.get(key)
if val:
return val
lock_key = f"lock:{key}"
if redis.set(lock_key, "1", nx=True, ex=5): # acquire lock
try:
val = db.fetch(key)
redis.setex(key, 300, val)
return val
finally:
redis.delete(lock_key)
else:
time.sleep(0.05) # wait and retry
return get_with_lock(key)
Solution 2: XFetch (Probabilistic Early Expiration)
Recompute before expiry based on probability that increases as TTL decreases.
import math, random, time
def xfetch(key, ttl, beta=1.0):
value, delta, expiry = cache_get_with_metadata(key)
if time.time() - beta * delta * math.log(random.random()) >= expiry:
# probabilistically recompute early
value = db.fetch(key)
cache_set_with_metadata(key, value, ttl)
return value
Solution 3: Stale-While-Revalidate
Return stale value immediately; refresh in background.
def get_stale_ok(key, ttl, stale_ttl=60):
val = redis.get(key)
if val:
return val
# Check extended stale window
stale = redis.get(f"stale:{key}")
if stale:
threading.Thread(target=refresh, args=(key, ttl)).start()
return stale
return refresh_sync(key, ttl)
Solution 4: Background Refresh
Cron/worker refreshes hot keys before they expire. Works well for known-hot keys.
12. Cache Warming
Why it matters: A cold cache after deploy → all traffic hits DB → DB overload → cascading failure.
Strategies
Pre-warming on deploy (best for known hot keys):
# During deploy pipeline, before shifting traffic
python warm_cache.py --keys top_products,homepage,config
Lazy warming: Cache-aside naturally warms on first reads. Acceptable if DB can handle initial cold traffic.
Scheduled warming job:
# K8s CronJob, runs every 5 min for known-hot queries
def warm():
top_products = db.query("SELECT * FROM products ORDER BY views DESC LIMIT 100")
for p in top_products:
redis.setex(f"product:{p.id}", 600, json.dumps(p))
Shadow traffic replay: Replay recent production logs against new cache before cutover.
Rule: Never deploy a stateful cache service without a warm-up phase. Use feature flags or canary to shift traffic gradually.
13. Cache Invalidation Patterns
"There are only two hard things in computer science: cache invalidation and naming things." — Phil Karlton
TTL-Based
Simplest. Set EX on every key. Accept eventual staleness.
SET product:42 '...' EX 300
Event-Driven Invalidation
DB triggers or application events delete cache entries on writes.
def update_product(product_id, data):
db.execute("UPDATE products SET ... WHERE id = %s", product_id)
redis.delete(f"product:{product_id}") # invalidate
redis.delete(f"product:list:category:{data.category_id}") # invalidate list
Tag-Based Invalidation
Group related keys under a tag; invalidate all by tag.
# On write: record tag → keys mapping
redis.sadd("tag:category:5", "product:42", "product:43")
# On category update:
keys = redis.smembers("tag:category:5")
redis.delete(*keys)
redis.delete("tag:category:5")
Version Keys (Cache Busting)
Append a version to the key. Old keys expire naturally via TTL.
version = redis.get("product:42:version") or 1
key = f"product:42:v{version}"
data = redis.get(key)
# To invalidate:
redis.incr("product:42:version")
14. Distributed Cache Consistency
The Write Race Problem
Two app instances update the same key simultaneously — last write wins but may overwrite a newer value.
sequenceDiagram
participant A as App Instance A
participant B as App Instance B
participant Cache
A->>Cache: GET user:1 → v1
B->>Cache: GET user:1 → v1
A->>Cache: SET user:1 v2
B->>Cache: SET user:1 v3 (overwrites v2)
Note over Cache: v2 lost
Redis WATCH / MULTI / EXEC (Optimistic Locking)
WATCH user:1
val = GET user:1
MULTI
SET user:1 <new_value>
EXEC # returns nil if user:1 changed since WATCH → retry
def safe_update(key, transform):
with redis.pipeline() as pipe:
while True:
try:
pipe.watch(key)
current = pipe.get(key)
new_val = transform(current)
pipe.multi()
pipe.set(key, new_val)
pipe.execute()
break
except redis.WatchError:
continue # retry on conflict
Compare-and-Swap with Lua (atomic)
-- SET only if value matches expected
local current = redis.call('GET', KEYS[1])
if current == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[2])
return 1
end
return 0
EVAL "<script>" 1 user:1 expected_val new_val
15. CDN vs Application Cache vs DB Cache
| Dimension | CDN Edge Cache | App In-Process Cache | Distributed Cache Redis | DB Query Cache |
|---|---|---|---|---|
| What it caches | Static assets, rendered HTML, API responses | Hot objects in heap | Shared computed data, sessions | Query result sets |
| Scope | Global, per-PoP | Per app instance | Shared across all instances | Per DB node |
| Latency | ~5–50ms (geographic) | ~0.01ms (local RAM) | ~0.5–2ms (network) | ~1ms (shared memory) |
| Invalidation | Purge API, TTL, surrogate keys | In-memory eviction, restart | DEL/EXPIRE, event-driven | AUTO on write (MySQL 8 removed it) |
| Use when | Public static/cacheable content | Single-instance hot data | Multi-instance shared state | Read-heavy reporting queries |
| Cost | CDN egress pricing | Free (heap memory) | Redis node cost | Included with DB |
| Risk | Stale public content | No sharing across pods | Network latency + connection pool | MySQL removed query cache in 8.0 |
16. Sizing & Monitoring
Working Set Estimation
working_set = hot_keys × avg_value_size × replication_factor
# Example: 1M hot keys × 2KB avg × 2 replicas = 4 GB
Hit Rate Math
hit_rate = keyspace_hits / (keyspace_hits + keyspace_misses)
effective_latency = hit_rate × cache_latency + (1 - hit_rate) × db_latency
# Example: 90% hit, 1ms cache, 20ms DB:
# = 0.9 × 1 + 0.1 × 20 = 0.9 + 2 = 2.9ms avg
# vs no cache: 20ms avg
Redis Monitoring
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses|evicted_keys|expired_keys"
redis-cli INFO memory | grep used_memory_human
redis-cli INFO keyspace
Key metrics:
keyspace_hits/keyspace_misses→ compute hit rate; target > 90%evicted_keys> 0 → memory pressure, increasemaxmemoryor evict more aggressivelyused_memory_rss>>used_memory→ memory fragmentation (see §17)connected_clientsnearmaxclients(default 10000) → connection pool exhaustion
Prometheus / Grafana
Use redis_exporter. Key PromQL:
# Hit rate
rate(redis_keyspace_hits_total[5m]) /
(rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m]))
# Eviction rate
rate(redis_evicted_keys_total[5m])
# Memory fragmentation ratio (>1.5 = fragmented)
redis_memory_used_rss_bytes / redis_memory_used_bytes
17. Common Issues
Cache Poisoning
Attacker inserts malicious data into cache (e.g., via HTTP response splitting, unvalidated key construction).
Fix: Validate and sanitize all keys. Never build cache keys from raw user input without normalization. Use signed tokens for sensitive cache values.
Stale Reads After Write
Write updates DB but forgets to invalidate cache. Reads return old data.
Fix: Always pair DB writes with cache invalidation in the same transaction scope (or use write-through). Use short TTLs as a safety net.
Memory Fragmentation
Redis allocates memory in chunks; after many DEL/EXPIRE cycles, RSS grows above used_memory.
# Check fragmentation ratio (>1.5 is concerning)
redis-cli INFO memory | grep mem_fragmentation_ratio
# Enable active defragmentation (Redis 4+)
# redis.conf
activedefrag yes
active-defrag-ignore-bytes 100mb
active-defrag-threshold-lower 10 # start at 10% fragmentation
active-defrag-threshold-upper 100
Hot Keys
A single key receives millions of requests/sec, saturating a single Redis shard.
Detection:
redis-cli --hotkeys # requires maxmemory-policy != noeviction
redis-cli monitor | head -100 # live command stream
Fix: Local L1 shadow cache in each app instance for the hot key (small TTL, 1–5s):
local_cache = {} # in-process dict
def get_hot(key):
if key in local_cache and time.time() < local_cache[key]['exp']:
return local_cache[key]['val']
val = redis.get(key)
local_cache[key] = {'val': val, 'exp': time.time() + 1} # 1s local TTL
return val
Alternative: replicate hot keys across multiple Redis nodes with a prefix shard (hot:0:key, hot:1:key, ...) and randomly select a shard on read.
Connection Pool Exhaustion
Each app thread holding a Redis connection; pool depleted under load.
Fix:
# redis-py with connection pool
pool = redis.ConnectionPool(host='redis', port=6379, max_connections=50)
r = redis.Redis(connection_pool=pool)
- Set
max_connectionsto (app_threads × 0.5) as a starting point - Monitor
connected_clientsin Redis - Use pipelining to batch commands and reduce round-trips
Quick Reference
Cache-aside → app manages read/write, lazy population
Write-through → sync write to cache + DB, no stale reads
Write-behind → fast writes, async DB flush, data loss risk
Read-through → cache handles DB fetch transparently
LRU → general purpose
LFU → skewed/Zipf access patterns
volatile-ttl → mixed persistent + ephemeral keys
Stampede fix → mutex lock OR XFetch probabilistic OR stale-while-revalidate
Invalidation → TTL (simple) → event-driven (consistent) → tag-based (grouped)
Hot key fix → L1 local shadow cache with short TTL
Fragmentation → activedefrag yes in redis.conf