System Design Part 5: Caching - The Complete Guide

Introduction

Caching is often called the "silver bullet" of system design - a simple concept that dramatically improves performance. But like any powerful tool, misusing it leads to subtle bugs, stale data, and eventually, 3 AM production incidents. Let's master caching from first principles.

1. Designing a Multi-Level Cache for Product Catalog

Why Multi-Level?

mermaid
graph LR subgraph "Request Latency" L1[L1: In-Memory<br/>~1μs] --> L2[L2: Redis<br/>~1ms] L2 --> L3[L3: CDN<br/>~10ms] L3 --> DB[Database<br/>~50ms] end subgraph "Cache Hierarchy" Browser[Browser Cache] --> CDN[CDN Edge] CDN --> AppCache[App Memory] AppCache --> Redis[Redis Cluster] Redis --> PostgreSQL[PostgreSQL] end

Complete Multi-Level Cache Implementation

go
package cache import ( "context" "encoding/json" "errors" "fmt" "sync" "time" "github.com/hashicorp/golang-lru/v2" "github.com/redis/go-redis/v9" ) type Product struct { ID string `json:"id"` Name string `json:"name"` Price float64 `json:"price"` Category string `json:"category"` Description string `json:"description"` ImageURL string `json:"image_url"` Stock int `json:"stock"` UpdatedAt time.Time `json:"updated_at"` } // L1: In-memory LRU cache type L1Cache struct { cache *lru.Cache[string, *cacheEntry] ttl time.Duration maxSize int hits int64 misses int64 mu sync.RWMutex } type cacheEntry struct { product *Product expiresAt time.Time } func NewL1Cache(maxSize int, ttl time.Duration) (*L1Cache, error) { cache, err := lru.New[string, *cacheEntry](maxSize) if err != nil { return nil, err } l1 := &L1Cache{ cache: cache, ttl: ttl, maxSize: maxSize, } // Start background cleanup go l1.cleanupLoop() return l1, nil } func (c *L1Cache) Get(key string) (*Product, bool) { c.mu.RLock() defer c.mu.RUnlock() entry, ok := c.cache.Get(key) if !ok { c.misses++ return nil, false } if time.Now().After(entry.expiresAt) { c.cache.Remove(key) c.misses++ return nil, false } c.hits++ return entry.product, true } func (c *L1Cache) Set(key string, product *Product) { c.mu.Lock() defer c.mu.Unlock() c.cache.Add(key, &cacheEntry{ product: product, expiresAt: time.Now().Add(c.ttl), }) } func (c *L1Cache) Delete(key string) { c.mu.Lock() defer c.mu.Unlock() c.cache.Remove(key) } func (c *L1Cache) cleanupLoop() { ticker := time.NewTicker(time.Minute) defer ticker.Stop() for range ticker.C { c.mu.Lock() keys := c.cache.Keys() now := time.Now() for _, key := range keys { if entry, ok := c.cache.Peek(key); ok { if now.After(entry.expiresAt) { c.cache.Remove(key) } } } c.mu.Unlock() } } func (c *L1Cache) Stats() (hits, misses int64, hitRate float64) { c.mu.RLock() defer c.mu.RUnlock() total := c.hits + c.misses if total > 0 { hitRate = float64(c.hits) / float64(total) } return c.hits, c.misses, hitRate } // L2: Redis cache type L2Cache struct { client *redis.Client ttl time.Duration prefix string } func NewL2Cache(client *redis.Client, prefix string, ttl time.Duration) *L2Cache { return &L2Cache{ client: client, ttl: ttl, prefix: prefix, } } func (c *L2Cache) key(id string) string { return fmt.Sprintf("%s:product:%s", c.prefix, id) } func (c *L2Cache) Get(ctx context.Context, id string) (*Product, error) { data, err := c.client.Get(ctx, c.key(id)).Bytes() if err != nil { if err == redis.Nil { return nil, nil // Cache miss } return nil, err } var product Product if err := json.Unmarshal(data, &product); err != nil { return nil, err } return &product, nil } func (c *L2Cache) Set(ctx context.Context, product *Product) error { data, err := json.Marshal(product) if err != nil { return err } return c.client.Set(ctx, c.key(product.ID), data, c.ttl).Err() } func (c *L2Cache) Delete(ctx context.Context, id string) error { return c.client.Del(ctx, c.key(id)).Err() } // Batch operations for efficiency func (c *L2Cache) MGet(ctx context.Context, ids []string) (map[string]*Product, error) { if len(ids) == 0 { return nil, nil } keys := make([]string, len(ids)) for i, id := range ids { keys[i] = c.key(id) } values, err := c.client.MGet(ctx, keys...).Result() if err != nil { return nil, err } result := make(map[string]*Product) for i, val := range values { if val == nil { continue } var product Product if err := json.Unmarshal([]byte(val.(string)), &product); err != nil { continue } result[ids[i]] = &product } return result, nil } func (c *L2Cache) MSet(ctx context.Context, products []*Product) error { if len(products) == 0 { return nil } pipe := c.client.Pipeline() for _, product := range products { data, err := json.Marshal(product) if err != nil { continue } pipe.Set(ctx, c.key(product.ID), data, c.ttl) } _, err := pipe.Exec(ctx) return err } // Multi-level cache coordinator type MultiLevelCache struct { l1 *L1Cache l2 *L2Cache db ProductRepository metrics *CacheMetrics } type ProductRepository interface { GetByID(ctx context.Context, id string) (*Product, error) GetByIDs(ctx context.Context, ids []string) ([]*Product, error) } type CacheMetrics struct { L1Hits int64 L1Misses int64 L2Hits int64 L2Misses int64 DBHits int64 } func NewMultiLevelCache(l1 *L1Cache, l2 *L2Cache, db ProductRepository) *MultiLevelCache { return &MultiLevelCache{ l1: l1, l2: l2, db: db, metrics: &CacheMetrics{}, } } func (c *MultiLevelCache) Get(ctx context.Context, id string) (*Product, error) { // Check L1 (in-memory) if product, ok := c.l1.Get(id); ok { c.metrics.L1Hits++ return product, nil } c.metrics.L1Misses++ // Check L2 (Redis) product, err := c.l2.Get(ctx, id) if err != nil { return nil, err } if product != nil { c.metrics.L2Hits++ // Populate L1 c.l1.Set(id, product) return product, nil } c.metrics.L2Misses++ // Fetch from database product, err = c.db.GetByID(ctx, id) if err != nil { return nil, err } if product != nil { c.metrics.DBHits++ // Populate both cache levels c.l1.Set(id, product) c.l2.Set(ctx, product) } return product, nil } // Batch get with cache-aside pattern func (c *MultiLevelCache) GetMany(ctx context.Context, ids []string) ([]*Product, error) { results := make(map[string]*Product) var missingL1 []string // Check L1 for all for _, id := range ids { if product, ok := c.l1.Get(id); ok { results[id] = product } else { missingL1 = append(missingL1, id) } } if len(missingL1) == 0 { return c.mapToSlice(ids, results), nil } // Check L2 for L1 misses l2Results, err := c.l2.MGet(ctx, missingL1) if err != nil { return nil, err } var missingL2 []string for _, id := range missingL1 { if product, ok := l2Results[id]; ok { results[id] = product c.l1.Set(id, product) // Populate L1 } else { missingL2 = append(missingL2, id) } } if len(missingL2) == 0 { return c.mapToSlice(ids, results), nil } // Fetch remaining from database dbProducts, err := c.db.GetByIDs(ctx, missingL2) if err != nil { return nil, err } for _, product := range dbProducts { results[product.ID] = product c.l1.Set(product.ID, product) } // Batch set to L2 c.l2.MSet(ctx, dbProducts) return c.mapToSlice(ids, results), nil } func (c *MultiLevelCache) mapToSlice(ids []string, m map[string]*Product) []*Product { result := make([]*Product, 0, len(ids)) for _, id := range ids { if p, ok := m[id]; ok { result = append(result, p) } } return result } // Invalidation func (c *MultiLevelCache) Invalidate(ctx context.Context, id string) error { c.l1.Delete(id) return c.l2.Delete(ctx, id) } // Write-through (updates cache on write) func (c *MultiLevelCache) Update(ctx context.Context, product *Product) error { // Update database first // ... db update logic ... // Then update caches c.l1.Set(product.ID, product) return c.l2.Set(ctx, product) }

Load Scaling Behavior

Normal Load (1K req/s): ├── L1 hit rate: 80% ├── L2 hit rate: 95% (of L1 misses) ├── DB queries: ~10/sec └── Avg latency: 2ms Peak Load (10K req/s): ├── L1 hit rate: 85% (hot items stay cached) ├── L2 hit rate: 98% (Redis handles more) ├── DB queries: ~30/sec └── Avg latency: 5ms Extreme Load (100K req/s): ├── L1 hit rate: 90% (increase L1 size) ├── L2 hit rate: 99% ├── DB queries: ~100/sec (consider read replica) └── Avg latency: 3ms (L1 dominates)

2. Cache Invalidation Strategies

The Two Hardest Problems in Computer Science

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

Strategy 1: Time-Based Expiration (TTL)

go
// Simple but risks stale data func (c *Cache) SetWithTTL(key string, value interface{}, ttl time.Duration) { c.client.Set(ctx, key, value, ttl) } // Trade-off: Lower TTL = fresher data but higher DB load // Higher TTL = stale data but better performance

Strategy 2: Event-Based Invalidation

go
type CacheInvalidator struct { cache *MultiLevelCache consumer MessageConsumer } // Listen for database change events func (ci *CacheInvalidator) Start(ctx context.Context) { events := ci.consumer.Subscribe("product_updates") for { select { case <-ctx.Done(): return case event := <-events: ci.handleEvent(ctx, event) } } } func (ci *CacheInvalidator) handleEvent(ctx context.Context, event Event) { switch event.Type { case "product.updated", "product.deleted": ci.cache.Invalidate(ctx, event.ProductID) case "product.price_changed": // Invalidate product and related caches ci.cache.Invalidate(ctx, event.ProductID) ci.cache.InvalidatePattern(ctx, fmt.Sprintf("category:%s:*", event.CategoryID)) case "category.updated": // Invalidate all products in category ci.cache.InvalidatePattern(ctx, fmt.Sprintf("category:%s:*", event.CategoryID)) } } // Pattern-based invalidation (use with caution - expensive) func (c *L2Cache) InvalidatePattern(ctx context.Context, pattern string) error { var cursor uint64 var keys []string for { var err error var batch []string batch, cursor, err = c.client.Scan(ctx, cursor, pattern, 100).Result() if err != nil { return err } keys = append(keys, batch...) if cursor == 0 { break } } if len(keys) > 0 { return c.client.Del(ctx, keys...).Err() } return nil }

Strategy 3: Write-Through Cache

go
// Update cache synchronously with database type WriteThroughCache struct { cache *L2Cache db ProductRepository } func (wtc *WriteThroughCache) UpdateProduct(ctx context.Context, product *Product) error { // Start transaction tx, err := wtc.db.BeginTx(ctx) if err != nil { return err } defer tx.Rollback() // Update database if err := tx.UpdateProduct(product); err != nil { return err } // Update cache if err := wtc.cache.Set(ctx, product); err != nil { // Cache update failed - should we rollback DB? // Usually no - log error and continue log.Printf("Cache update failed: %v", err) } return tx.Commit() }

Strategy 4: Write-Behind (Write-Back) Cache

go
// Buffer writes, flush to database asynchronously type WriteBehindCache struct { cache *L2Cache writeQueue chan *Product db ProductRepository batchSize int } func (wbc *WriteBehindCache) UpdateProduct(ctx context.Context, product *Product) error { // Update cache immediately if err := wbc.cache.Set(ctx, product); err != nil { return err } // Queue for async database write select { case wbc.writeQueue <- product: return nil default: // Queue full - write directly return wbc.db.UpdateProduct(ctx, product) } } func (wbc *WriteBehindCache) flushWorker(ctx context.Context) { batch := make([]*Product, 0, wbc.batchSize) ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): // Flush remaining on shutdown if len(batch) > 0 { wbc.db.BatchUpdate(context.Background(), batch) } return case product := <-wbc.writeQueue: batch = append(batch, product) if len(batch) >= wbc.batchSize { wbc.db.BatchUpdate(ctx, batch) batch = batch[:0] } case <-ticker.C: if len(batch) > 0 { wbc.db.BatchUpdate(ctx, batch) batch = batch[:0] } } } }

Strategy 5: Cache-Aside with Versioning

go
type VersionedCache struct { cache *L2Cache db ProductRepository } type VersionedProduct struct { Product Version int64 `json:"version"` } func (vc *VersionedCache) Get(ctx context.Context, id string) (*Product, error) { cached, err := vc.cache.GetVersioned(ctx, id) if err != nil { return nil, err } // Always check version with database currentVersion, err := vc.db.GetVersion(ctx, id) if err != nil { return nil, err } if cached != nil && cached.Version == currentVersion { return &cached.Product, nil } // Version mismatch or cache miss - fetch fresh product, err := vc.db.GetByID(ctx, id) if err != nil { return nil, err } // Cache with version vc.cache.SetVersioned(ctx, &VersionedProduct{ Product: *product, Version: currentVersion, }) return product, nil }

Invalidation Decision Matrix

StrategyData FreshnessComplexityUse When
TTLMinutes staleSimpleRead-heavy, tolerance for staleness
Event-basedSeconds staleMediumReal-time requirements
Write-throughAlways freshMediumConsistency critical
Write-behindSeconds staleComplexHigh write throughput
VersionedAlways freshMediumMixed read/write

3. Cache Stampede Prevention

The Stampede Problem

mermaid
sequenceDiagram participant R1 as Request 1 participant R2 as Request 2 participant R3 as Request 3 participant Cache participant DB Note over Cache: Cache expires R1->>Cache: GET product:123 Cache-->>R1: MISS R2->>Cache: GET product:123 Cache-->>R2: MISS R3->>Cache: GET product:123 Cache-->>R3: MISS R1->>DB: SELECT * FROM products... R2->>DB: SELECT * FROM products... R3->>DB: SELECT * FROM products... Note over DB: Database overwhelmed!

Solution 1: Locking (Single-Flight)

go
package stampede import ( "context" "sync" "time" "golang.org/x/sync/singleflight" ) type StampedeProtectedCache struct { cache *L2Cache db ProductRepository group singleflight.Group } func (c *StampedeProtectedCache) Get(ctx context.Context, id string) (*Product, error) { // Try cache first if product, err := c.cache.Get(ctx, id); err == nil && product != nil { return product, nil } // Use singleflight to prevent stampede result, err, _ := c.group.Do(id, func() (interface{}, error) { // Double-check cache (another request might have populated it) if product, err := c.cache.Get(ctx, id); err == nil && product != nil { return product, nil } // Fetch from database product, err := c.db.GetByID(ctx, id) if err != nil { return nil, err } // Populate cache c.cache.Set(ctx, product) return product, nil }) if err != nil { return nil, err } return result.(*Product), nil } // Distributed locking for multi-instance deployments type DistributedStampedeCache struct { cache *L2Cache db ProductRepository redis *redis.Client lockTTL time.Duration } func (c *DistributedStampedeCache) Get(ctx context.Context, id string) (*Product, error) { // Try cache if product, err := c.cache.Get(ctx, id); err == nil && product != nil { return product, nil } // Try to acquire distributed lock lockKey := fmt.Sprintf("lock:product:%s", id) acquired, err := c.redis.SetNX(ctx, lockKey, "1", c.lockTTL).Result() if err != nil { return nil, err } if acquired { // We got the lock - fetch from DB defer c.redis.Del(ctx, lockKey) product, err := c.db.GetByID(ctx, id) if err != nil { return nil, err } c.cache.Set(ctx, product) return product, nil } // Another instance is fetching - wait and retry cache for i := 0; i < 10; i++ { time.Sleep(50 * time.Millisecond) if product, err := c.cache.Get(ctx, id); err == nil && product != nil { return product, nil } } // Timeout - fetch ourselves return c.db.GetByID(ctx, id) }

Solution 2: Probabilistic Early Expiration

go
// XFetch algorithm - probabilistically refresh before expiration type XFetchCache struct { cache *L2Cache db ProductRepository beta float64 // Typically 1.0 } type CachedValue struct { Product *Product Delta time.Duration // Time to compute value ExpiresAt time.Time } func (c *XFetchCache) Get(ctx context.Context, id string) (*Product, error) { cached, err := c.cache.GetWithMeta(ctx, id) if err != nil { return nil, err } if cached != nil { // XFetch formula: should we refresh early? now := time.Now() ttl := cached.ExpiresAt.Sub(now) // gap = delta * beta * log(random) gap := time.Duration(float64(cached.Delta) * c.beta * (-math.Log(rand.Float64()))) if ttl-gap <= 0 { // Probabilistically refresh in background go c.refresh(context.Background(), id) } return cached.Product, nil } return c.refresh(ctx, id) } func (c *XFetchCache) refresh(ctx context.Context, id string) (*Product, error) { start := time.Now() product, err := c.db.GetByID(ctx, id) if err != nil { return nil, err } delta := time.Since(start) c.cache.SetWithMeta(ctx, &CachedValue{ Product: product, Delta: delta, ExpiresAt: time.Now().Add(c.cache.ttl), }) return product, nil }

Solution 3: Background Refresh

go
type BackgroundRefreshCache struct { cache *L2Cache db ProductRepository refreshQueue chan string softTTL time.Duration // When to start background refresh hardTTL time.Duration // When cache actually expires } func (c *BackgroundRefreshCache) Get(ctx context.Context, id string) (*Product, error) { cached, meta, err := c.cache.GetWithTTL(ctx, id) if err != nil { return nil, err } if cached != nil { // Check if we should trigger background refresh if meta.TTL < c.hardTTL-c.softTTL { select { case c.refreshQueue <- id: default: // Queue full, skip refresh } } return cached, nil } // Cache miss - synchronous fetch product, err := c.db.GetByID(ctx, id) if err != nil { return nil, err } c.cache.Set(ctx, product) // Uses hardTTL return product, nil } func (c *BackgroundRefreshCache) refreshWorker(ctx context.Context) { for { select { case <-ctx.Done(): return case id := <-c.refreshQueue: product, err := c.db.GetByID(ctx, id) if err != nil { log.Printf("Background refresh failed for %s: %v", id, err) continue } c.cache.Set(ctx, product) } } }

4. How to Handle Cache Warm-up After Restart

The Cold Cache Problem

mermaid
graph TB subgraph "Before Restart" C1[Cache: Full<br/>Hit Rate: 95%] DB1[Database: Low Load] end subgraph "After Restart" C2[Cache: Empty<br/>Hit Rate: 0%] DB2[Database: OVERWHELMED!] end C1 -->|Restart| C2 DB1 -->|Restart| DB2

Solution 1: Predictive Warm-up

go
package warmup type CacheWarmer struct { cache *L2Cache db ProductRepository analytics AnalyticsService } func (cw *CacheWarmer) WarmUp(ctx context.Context) error { log.Println("Starting cache warm-up...") // Get frequently accessed items from analytics hotItems, err := cw.analytics.GetTopAccessedProducts(ctx, 10000) if err != nil { return fmt.Errorf("failed to get hot items: %w", err) } // Batch load in parallel const batchSize = 100 const workers = 10 itemChan := make(chan string, len(hotItems)) for _, id := range hotItems { itemChan <- id } close(itemChan) var wg sync.WaitGroup errChan := make(chan error, workers) for i := 0; i < workers; i++ { wg.Add(1) go func() { defer wg.Done() batch := make([]string, 0, batchSize) for id := range itemChan { batch = append(batch, id) if len(batch) >= batchSize { if err := cw.warmBatch(ctx, batch); err != nil { errChan <- err } batch = batch[:0] } } // Remaining items if len(batch) > 0 { if err := cw.warmBatch(ctx, batch); err != nil { errChan <- err } } }() } wg.Wait() close(errChan) // Collect errors var errors []error for err := range errChan { errors = append(errors, err) } if len(errors) > 0 { return fmt.Errorf("warm-up completed with %d errors", len(errors)) } log.Printf("Cache warm-up complete: %d items loaded", len(hotItems)) return nil } func (cw *CacheWarmer) warmBatch(ctx context.Context, ids []string) error { products, err := cw.db.GetByIDs(ctx, ids) if err != nil { return err } return cw.cache.MSet(ctx, products) }

Solution 2: Gradual Traffic Ramp-up

go
type TrafficController struct { currentPct float64 targetPct float64 rampDuration time.Duration startTime time.Time mu sync.RWMutex } func NewTrafficController(rampDuration time.Duration) *TrafficController { return &TrafficController{ currentPct: 0.0, targetPct: 100.0, rampDuration: rampDuration, startTime: time.Now(), } } func (tc *TrafficController) ShouldServe() bool { tc.mu.RLock() defer tc.mu.RUnlock() elapsed := time.Since(tc.startTime) if elapsed >= tc.rampDuration { return true } currentPct := (elapsed.Seconds() / tc.rampDuration.Seconds()) * 100 return rand.Float64()*100 < currentPct } // HTTP middleware func (tc *TrafficController) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !tc.ShouldServe() { // Return cached/static response or redirect to healthy instance http.Error(w, "Service warming up", http.StatusServiceUnavailable) return } next.ServeHTTP(w, r) }) }

Solution 3: Persistent Cache (Redis with AOF)

go
// Redis configuration for persistence type PersistentCacheConfig struct { // AOF (Append Only File) - reconstructs cache from write log AOFEnabled bool AOFSync string // "always", "everysec", "no" // RDB snapshots RDBEnabled bool RDBInterval time.Duration } func ConfigureRedisPersistence(cfg PersistentCacheConfig) string { var config strings.Builder if cfg.AOFEnabled { config.WriteString("appendonly yes\n") config.WriteString(fmt.Sprintf("appendfsync %s\n", cfg.AOFSync)) } if cfg.RDBEnabled { config.WriteString("save 900 1\n") // Save after 15min if 1 key changed config.WriteString("save 300 10\n") // Save after 5min if 10 keys changed config.WriteString("save 60 10000\n") // Save after 1min if 10000 keys changed } return config.String() }

Solution 4: Cache Replication from Healthy Instances

go
type CacheReplicator struct { localCache *L2Cache peerClients []*redis.Client } func (cr *CacheReplicator) ReplicateFrom(ctx context.Context, peer *redis.Client) error { var cursor uint64 for { keys, newCursor, err := peer.Scan(ctx, cursor, "*", 1000).Result() if err != nil { return err } if len(keys) > 0 { // Get values from peer values, err := peer.MGet(ctx, keys...).Result() if err != nil { return err } // Set in local cache pipe := cr.localCache.client.Pipeline() for i, key := range keys { if values[i] != nil { pipe.Set(ctx, key, values[i], 0) // Keep original TTL } } pipe.Exec(ctx) } cursor = newCursor if cursor == 0 { break } } return nil }

5. CDN Caching for API Responses

CDN Caching Architecture

mermaid
graph LR subgraph "User Requests" U1[User NYC] --> CDN1[CDN Edge NYC] U2[User London] --> CDN2[CDN Edge London] U3[User Tokyo] --> CDN3[CDN Edge Tokyo] end CDN1 --> Origin[Origin Server] CDN2 --> Origin CDN3 --> Origin subgraph "Origin" Origin --> App[Application] App --> Cache[Redis Cache] Cache --> DB[Database] end

Cache-Control Headers

go
package cdn import ( "net/http" "time" ) type CacheConfig struct { Public bool Private bool MaxAge time.Duration SMaxAge time.Duration // CDN-specific max age MustRevalidate bool NoCache bool NoStore bool Immutable bool } func SetCacheHeaders(w http.ResponseWriter, cfg CacheConfig) { var directives []string if cfg.NoStore { directives = append(directives, "no-store") } else if cfg.NoCache { directives = append(directives, "no-cache") } else { if cfg.Public { directives = append(directives, "public") } if cfg.Private { directives = append(directives, "private") } if cfg.MaxAge > 0 { directives = append(directives, fmt.Sprintf("max-age=%d", int(cfg.MaxAge.Seconds()))) } if cfg.SMaxAge > 0 { directives = append(directives, fmt.Sprintf("s-maxage=%d", int(cfg.SMaxAge.Seconds()))) } if cfg.MustRevalidate { directives = append(directives, "must-revalidate") } if cfg.Immutable { directives = append(directives, "immutable") } } w.Header().Set("Cache-Control", strings.Join(directives, ", ")) } // Middleware for different content types func CacheMiddleware(contentType string) func(http.Handler) http.Handler { var config CacheConfig switch contentType { case "product-list": // Cacheable at CDN for 1 minute, browser for 30 seconds config = CacheConfig{ Public: true, MaxAge: 30 * time.Second, SMaxAge: 60 * time.Second, } case "product-detail": // Longer cache for individual products config = CacheConfig{ Public: true, MaxAge: 60 * time.Second, SMaxAge: 300 * time.Second, } case "user-specific": // Never cache user-specific data at CDN config = CacheConfig{ Private: true, MaxAge: 60 * time.Second, } case "static-asset": // Immutable with long cache config = CacheConfig{ Public: true, MaxAge: 365 * 24 * time.Hour, Immutable: true, } case "sensitive": // Never cache config = CacheConfig{ NoStore: true, } } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { SetCacheHeaders(w, config) next.ServeHTTP(w, r) }) } }

Cache Key Design for APIs

go
type CacheKeyBuilder struct { baseKey string params map[string]string } func NewCacheKey(path string) *CacheKeyBuilder { return &CacheKeyBuilder{ baseKey: path, params: make(map[string]string), } } func (b *CacheKeyBuilder) WithParam(key, value string) *CacheKeyBuilder { b.params[key] = value return b } func (b *CacheKeyBuilder) WithVersion(v string) *CacheKeyBuilder { return b.WithParam("v", v) } func (b *CacheKeyBuilder) WithLocale(locale string) *CacheKeyBuilder { return b.WithParam("locale", locale) } func (b *CacheKeyBuilder) Build() string { if len(b.params) == 0 { return b.baseKey } // Sort params for consistent keys keys := make([]string, 0, len(b.params)) for k := range b.params { keys = append(keys, k) } sort.Strings(keys) var parts []string for _, k := range keys { parts = append(parts, fmt.Sprintf("%s=%s", k, b.params[k])) } return b.baseKey + "?" + strings.Join(parts, "&") } // Vary header for CDN key variation func SetVaryHeaders(w http.ResponseWriter, headers ...string) { w.Header().Set("Vary", strings.Join(headers, ", ")) } // Example API handler func ProductHandler(w http.ResponseWriter, r *http.Request) { // Vary cache by Accept-Language and Accept-Encoding SetVaryHeaders(w, "Accept-Language", "Accept-Encoding") // Set cache headers SetCacheHeaders(w, CacheConfig{ Public: true, MaxAge: 60 * time.Second, SMaxAge: 300 * time.Second, }) // Add ETag for conditional requests etag := generateETag(product) w.Header().Set("ETag", etag) // Check If-None-Match if r.Header.Get("If-None-Match") == etag { w.WriteHeader(http.StatusNotModified) return } // Return product json.NewEncoder(w).Encode(product) }

CDN Cache Purging

go
type CDNPurger struct { cloudflareClient *cloudflare.API fastlyClient *fastly.Client } // Purge specific URLs func (p *CDNPurger) PurgeURLs(ctx context.Context, urls []string) error { // Cloudflare _, err := p.cloudflareClient.PurgeCache(ctx, zoneID, cloudflare.PurgeCacheRequest{ Files: urls, }) if err != nil { return fmt.Errorf("cloudflare purge failed: %w", err) } return nil } // Purge by cache tag (more efficient) func (p *CDNPurger) PurgeByTag(ctx context.Context, tags []string) error { // Cloudflare cache tags _, err := p.cloudflareClient.PurgeCache(ctx, zoneID, cloudflare.PurgeCacheRequest{ Tags: tags, }) return err } // Purge everything (use sparingly!) func (p *CDNPurger) PurgeAll(ctx context.Context) error { _, err := p.cloudflareClient.PurgeCache(ctx, zoneID, cloudflare.PurgeCacheRequest{ Everything: true, }) return err } // Usage with cache tags func ProductHandler(w http.ResponseWriter, r *http.Request) { productID := chi.URLParam(r, "id") product := getProduct(productID) // Set cache tags for targeted purging w.Header().Set("Cache-Tag", strings.Join([]string{ fmt.Sprintf("product:%s", productID), fmt.Sprintf("category:%s", product.CategoryID), "products", }, ",")) // ... rest of handler } // When product updates, purge by tag func OnProductUpdate(productID, categoryID string) { purger.PurgeByTag(ctx, []string{ fmt.Sprintf("product:%s", productID), }) } // When category updates, purge all products in category func OnCategoryUpdate(categoryID string) { purger.PurgeByTag(ctx, []string{ fmt.Sprintf("category:%s", categoryID), }) }

Summary: Caching Best Practices

Quick Reference

What to Cache: ✅ Static content (images, CSS, JS) ✅ Database query results ✅ Computed/aggregated data ✅ Session data ✅ API responses (with care) What NOT to Cache: ❌ User-specific sensitive data at CDN ❌ Real-time data (stock prices, live scores) ❌ Frequently changing data with low TTL ❌ Large objects that exceed memory

Cache Hierarchy Decision

LayerLatencySizeUse Case
L1 (Process)~1μs100MB-1GBHot data, computed values
L2 (Redis)~1ms10GB-100GBSession, frequent queries
L3 (CDN)~10msUnlimitedStatic assets, API responses
Database~50msUnlimitedSource of truth

Key Metrics to Monitor

MetricTargetAction if Breached
Hit Rate>90%Increase cache size, review TTL
Eviction Rate<5%Increase memory allocation
Memory Usage<80%Plan capacity increase
Latency p99<5msCheck Redis cluster health
Stampede Events0Implement singleflight/locking
This guide covers the essential caching concepts for system design interviews. Remember: caching is a trade-off between freshness and performance. Always start with the simplest solution that meets your consistency requirements.
All Blogs
Tags:cachingredissystem-designscalabilityperformancedistributed-systemsbackendinterview-prep