Progress · 0/8 sections
- 01 — Load Balancing at Scale (50M Users)
- 02 — Caching Strategies (50M Users, Multi-Layer)
- 03 — Service Discovery & Service Mesh (50M Users
- 04 — Observability in Production (50M Users)
- 05 — Failure Handling & Resilience (50M Users)
- 06 — Distributed Locking & Coordination (50M Use
- 07 — Data Pipelines & Stream Processing (50M Use
- 08 — Deployment & Infrastructure at Scale (50M U
02 — Caching Strategies (50M Users, Multi-Layer)
12 min read
02 — Caching Strategies (50M Users, Multi-Layer)
Scenario: 50M users. PostgreSQL pe directly 200K req/sec daalna matlab DB marna. Caching ke bina koi bhi large-scale system nahi chalta. Caching galat karna = stale data, inconsistency, cache stampede.
Cache Layers — Bahar Se Andar Tak
User Request
│
▼
┌─────────────┐
│ Browser/App │ Layer 0: Client-side cache (HTTP Cache-Control)
│ Cache │ TTL: seconds-minutes
└──────┬──────┘
│
┌──────▼──────┐
│ CDN │ Layer 1: Edge cache (Cloudflare, CloudFront)
│ Cache │ TTL: minutes-hours. Static + cacheable API responses.
└──────┬──────┘
│
┌──────▼──────┐
│ API Gateway│ Layer 2: Gateway/reverse proxy cache (Nginx, Envoy)
│ Cache │ TTL: seconds-minutes. Hot API responses.
└──────┬──────┘
│
┌──────▼──────┐
│ Application │ Layer 3: In-process cache (local memory)
│ Local Cache │ TTL: seconds. No network hop. Fastest.
└──────┬──────┘
│
┌──────▼──────┐
│ Redis / │ Layer 4: Distributed cache
│ Memcached │ TTL: minutes-hours. Shared across app servers.
└──────┬──────┘
│
┌──────▼──────┐
│ PostgreSQL │ Layer 5: Database
│ │ Source of truth. Slowest.
└─────────────┘
50M users pe typical hit rates:
CDN: ~95% for static assets, ~40-60% for cacheable APIs
Local cache: ~80% hit for hot config/metadata
Redis: ~90% hit for user sessions, profiles
DB: Only ~10-20% of total requests reach here!
Without caching: 200K req/sec → DB
With caching: 200K req/sec → 20-40K req/sec → DB
10x load reduction!
Caching Patterns
Cache-Aside (Lazy Loading) — MOST COMMON
func GetUser(ctx context.Context, userID string) (*User, error) {
// 1. Check cache first
cached, err := redis.Get(ctx, "user:"+userID).Result()
if err == nil {
var user User
json.Unmarshal([]byte(cached), &user)
return &user, nil // CACHE HIT ✅
}
// 2. Cache miss → read from DB
user, err := db.GetUser(ctx, userID)
if err != nil {
return nil, err
}
// 3. Populate cache for next time
data, _ := json.Marshal(user)
redis.Set(ctx, "user:"+userID, data, 15*time.Minute)
return user, nil
}Read flow:
App → Redis (hit?) → YES → return cached
App → Redis (miss?) → DB → write to Redis → return
Write flow (cache invalidation):
App → DB (write) → Redis DEL key → done
(next read will re-populate cache)
Advantages:
- Simple to implement
- Cache sirf wahi data rakhta hai jo actually read hota hai (demand-driven)
- Cache down? App still works (slower, directly from DB)
Disadvantages:
- Cache miss = extra latency (DB read + cache write)
- Stale data possible (DB updated, cache not yet invalidated)
- Cache stampede risk on cold start
Read-Through
App → Cache → (miss) → Cache ITSELF fetches from DB → returns to App
Difference from cache-aside:
Cache-aside: APPLICATION fetches from DB on miss
Read-through: CACHE LAYER fetches from DB on miss
App doesn't know about DB directly for reads.
Cache is a unified read interface.
Implementation: Usually needs a cache library/proxy that supports data loading callbacks. Less common in practice — cache-aside is simpler.
Write-Through
App writes → Cache → Cache writes to DB → Both updated atomically
Write flow:
App → Cache.Set(key, value) → Cache → DB.Insert(value)
Both cache and DB always in sync!
Read flow:
App → Cache.Get(key) → always fresh ✅
Advantages: Cache never stale (always in sync with DB) Disadvantages: Write latency increases (every write = cache + DB). Data written but never read still takes cache space.
Write-Behind (Write-Back)
App writes → Cache (FAST) → Background: batch flush to DB (ASYNC)
Write flow:
App → Cache.Set(key, value) → return immediately
Background worker (every 1s): flush dirty cache entries to DB
⚠️ DANGER: Cache mein hai but DB mein nahi. Cache crash = DATA LOSS.
Use cases: High write throughput where small data loss acceptable:
- Analytics counters
- View counts
- Rate limit counters
- Non-critical metrics
NEVER use for: Payments, user data, orders — anything where data loss matters.
Cache Invalidation — "The Hardest Problem in CS"
Strategy 1: TTL-Based Expiry
redis.Set(ctx, "user:123", data, 15*time.Minute)
After 15 min → key expires → next read = cache miss → re-fetch from DB
✅ Simple, automatic cleanup
❌ Stale data for up to TTL duration
❌ TTL too short = too many DB hits, TTL too long = too stale
Strategy 2: Explicit Invalidation on Write
func UpdateUser(ctx context.Context, userID string, update UserUpdate) error {
// 1. Update DB
err := db.UpdateUser(ctx, userID, update)
if err != nil {
return err
}
// 2. Invalidate cache
redis.Del(ctx, "user:"+userID)
// Next read will fetch fresh data from DB
return nil
}⚠️ Race condition:
Thread A: UPDATE DB (user name = "New")
Thread B: READ DB (user name = "New")
Thread B: SET cache (user name = "New")
Thread A: DEL cache ← Deletes Thread B's fresh cache!
Next read: Cache miss → DB → "New" → cache repopulated. OK in this case.
Worse race:
Thread A: UPDATE DB (name = "New")
Thread B: READ DB (name = "Old") ← read before A's write committed!
Thread A: DEL cache
Thread B: SET cache (name = "Old") ← STALE data in cache!
Fix: Delete-then-write pattern + short TTL as safety net
// Safest pattern:
func UpdateUser(ctx context.Context, userID string, update UserUpdate) error {
// 1. Delete cache first
redis.Del(ctx, "user:"+userID)
// 2. Update DB
err := db.UpdateUser(ctx, userID, update)
if err != nil {
return err
}
// 3. Delete cache again (double-delete)
// Catches the race where a read between step 1 and 2
// re-populated cache with old data
go func() {
time.Sleep(500 * time.Millisecond) // delay for replication lag
redis.Del(context.Background(), "user:"+userID)
}()
return nil
}Strategy 3: Event-Based Invalidation
DB write → Kafka event → Cache invalidation consumer
User Service: UpdateUser() → DB → publish "user.updated" event
Cache Invalidator: consumes "user.updated" → DEL cache key
✅ Decoupled, reliable (Kafka guarantees delivery)
✅ Multiple caches can listen to same event
❌ Eventual consistency (event processing delay)
❌ More infrastructure complexity
Strategy 4: Version-Based Cache Keys
// Cache key includes version
key := fmt.Sprintf("user:%s:v%d", userID, user.Version)
// Update increments version
// Old cache key simply expires (TTL)
// New reads use new version key → cache miss → fresh data
✅ No explicit invalidation needed
✅ No race conditions
❌ Old versions waste cache space until TTL
❌ Need a way to know current version (often from DB)Cache Stampede / Thundering Herd
Problem
Popular cache key "trending_posts" expires
10,000 concurrent users request it simultaneously
Cache MISS (key expired)
/ | | \
Thread1 Thread2 Thread3 ... Thread10000
| | | |
▼ ▼ ▼ ▼
DB query DB query DB query ... DB query
10,000 IDENTICAL queries hit DB simultaneously → DB overloaded → cascading failure
Solution 1: Mutex/Lock (Single Flight)
import "golang.org/x/sync/singleflight"
var group singleflight.Group
func GetTrendingPosts(ctx context.Context) ([]Post, error) {
// Check cache
cached, err := redis.Get(ctx, "trending_posts").Result()
if err == nil {
return unmarshal(cached), nil
}
// singleflight: 10,000 concurrent calls → only 1 DB query
// Others wait for the result
result, err, _ := group.Do("trending_posts", func() (interface{}, error) {
posts, err := db.GetTrendingPosts(ctx)
if err != nil {
return nil, err
}
// Populate cache
redis.Set(ctx, "trending_posts", marshal(posts), 5*time.Minute)
return posts, nil
})
return result.([]Post), err
}singleflight: Same key ke concurrent calls mein sirf PEHLA call execute hota hai. Baaki sab wait karte hain aur same result milta hai. Go backend mein MUST USE pattern.
Solution 2: Early Expiry / Background Refresh
// Cache with soft TTL + hard TTL
type CacheEntry struct {
Data []byte
SoftTTL time.Time // refresh after this (background)
HardTTL time.Time // actually expire after this
}
func GetPosts(ctx context.Context) ([]Post, error) {
entry := cache.Get("trending_posts")
if entry != nil && time.Now().Before(entry.HardTTL) {
// Still valid
if time.Now().After(entry.SoftTTL) {
// Soft expired → trigger background refresh
// Current request gets stale-but-valid data
go refreshCache("trending_posts")
}
return unmarshal(entry.Data), nil
}
// Hard expired → must fetch
return fetchAndCache(ctx)
}
// Example:
// SoftTTL = 4 minutes, HardTTL = 5 minutes
// At 4 min: background refresh starts, users still get cached data
// At 4 min 2 sec: fresh data in cache
// Users NEVER see a cache miss for hot keys!Solution 3: Probabilistic Early Expiration (XFetch)
Instead of everyone refreshing at exact TTL:
Each request has a small probability of refreshing BEFORE TTL
remaining_ttl = key.TTL()
if random() < (1.0 / remaining_ttl_seconds) {
// I'll refresh it
refreshCache(key)
}
Result: As TTL approaches, probability increases
Statistically, exactly 1 request refreshes before expiry
No stampede!
In-Process Cache — Zero Network Latency
import "github.com/dgraph-io/ristretto"
// Ristretto: high-performance concurrent cache
cache, _ := ristretto.NewCache(&ristretto.Config{
NumCounters: 1e7, // 10M keys to track frequency
MaxCost: 1 << 30, // 1GB max memory
BufferItems: 64,
})
// Set with cost (memory estimate)
cache.Set("user:123", user, int64(unsafe.Sizeof(user)))
// Get
value, found := cache.Get("user:123")
if found {
user := value.(*User)
// ZERO network hop. ~50ns access time.
}Problem: Har app server ka apna local cache hai. Ek server pe invalidation hua, dusre pe nahi.
Server A cache: user:123 = {name: "Old"}
Server B cache: user:123 = {name: "Old"}
User updates name to "New" → hits Server A
Server A: DB update + local cache invalidate ✅
Server B: still has old cache ❌
User's next request hits Server B → sees "Old" name 😱
Solutions:
- Very short TTL (5-30 seconds): Accept brief staleness
- Pub/Sub invalidation: Redis pub/sub ya Kafka event → all servers invalidate
- Two-tier cache: Local cache (5s TTL) → Redis (15 min TTL) → DB
func GetUser(ctx context.Context, id string) (*User, error) {
// Tier 1: Local cache (50ns)
if user, ok := localCache.Get("user:" + id); ok {
return user.(*User), nil
}
// Tier 2: Redis (0.5ms)
if data, err := redis.Get(ctx, "user:"+id).Result(); err == nil {
user := unmarshal(data)
localCache.Set("user:"+id, user, 10*time.Second) // short local TTL
return user, nil
}
// Tier 3: DB (5-50ms)
user, err := db.GetUser(ctx, id)
if err != nil {
return nil, err
}
redis.Set(ctx, "user:"+id, marshal(user), 15*time.Minute)
localCache.Set("user:"+id, user, 10*time.Second)
return user, nil
}CDN Caching — The First Line of Defense
Cache-Control Headers
# Static assets (CSS, JS, images) — long cache
Cache-Control: public, max-age=31536000, immutable
# 1 year. File name includes hash (app.a1b2c3.js). Content change = new filename.
# API responses — short cache
Cache-Control: public, max-age=60, stale-while-revalidate=300
# Fresh for 60s. After 60s, serve stale while revalidating in background for up to 300s.
# Private data — no CDN cache
Cache-Control: private, no-store
# User-specific data. CDN must NOT cache.
# API list endpoints — conditional caching
Cache-Control: public, max-age=0, must-revalidate
ETag: "abc123"
# CDN always checks with origin. If ETag matches → 304 Not Modified (no body transfer).
Vary Header — Same URL, Different Content
GET /api/feed
Accept-Language: en → English feed
Accept-Language: hi → Hindi feed
Response:
Vary: Accept-Language
# CDN caches separate versions per Accept-Language value
⚠️ Vary: Cookie → DO NOT DO THIS
Every user has different cookies → cache useless (1 entry per user)
Cache Purge at Scale
Jab content update ho:
1. Tag-based purge (Cloudflare Surrogate-Key):
Response header: Surrogate-Key: user-123 profile-page
Purge: "Delete all cache entries tagged user-123"
→ All pages showing user-123's data purged
2. Prefix purge:
Purge: /api/users/123/*
→ All cached responses under this path purged
3. Full purge:
⚠️ AVOID. Kills cache hit ratio. Thundering herd on origin.
Redis Cache — Production Patterns
Connection Pooling
rdb := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{
"redis-1:6379", "redis-2:6379", "redis-3:6379",
},
PoolSize: 100, // connections per node
MinIdleConns: 20, // keep warm
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
// Retry on MOVED/ASK (cluster resharding)
MaxRedirects: 3,
})Pipeline — Batch Operations
// ❌ SLOW — 10 sequential round trips
for _, id := range userIDs {
redis.Get(ctx, "user:"+id) // each = 0.5ms network RTT
}
// Total: 10 × 0.5ms = 5ms
// ✅ FAST — 1 round trip for all
pipe := redis.Pipeline()
cmds := make([]*redis.StringCmd, len(userIDs))
for i, id := range userIDs {
cmds[i] = pipe.Get(ctx, "user:"+id)
}
pipe.Exec(ctx)
// Total: 1 × 0.5ms = 0.5ms (10x faster!)Cache Warming
// Server startup pe hot data pre-load karo
func warmCache(ctx context.Context) {
// Top 1000 users by activity
users, _ := db.GetTopUsers(ctx, 1000)
pipe := redis.Pipeline()
for _, u := range users {
pipe.Set(ctx, "user:"+u.ID, marshal(u), 15*time.Minute)
}
pipe.Exec(ctx)
log.Printf("cache warmed with %d users", len(users))
}Numbers to Remember
Cache access latency:
L1 CPU cache: ~1ns
Local in-process: ~50ns
Redis (same AZ): ~0.5ms
Redis (cross-AZ): ~1-2ms
DB (simple query): ~5-20ms
DB (complex query): ~50-500ms
CDN edge (cache hit): ~5-20ms (from user perspective)
Cache hit ratio targets (50M users):
CDN static: > 95%
CDN API: > 40% (cacheable endpoints)
Redis: > 85%
Local cache: > 70% (hot data)
Overall: < 20% of requests should reach DB
Memory planning (Redis):
50M users × 1KB avg per cached user = ~50GB
Redis cluster: 6 nodes × 16GB = 96GB total (50% utilization target)
Eviction policy: allkeys-lfu