Module 15: Caching Strategies

Why Caching Matters

Caching is one of the most effective techniques for improving system performance and reducing load on backend services.
┌─────────────────────────────────────────────────────────────────┐ │ CACHING OVERVIEW │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ WITHOUT Cache: WITH Cache: │ │ ┌─────────────────────────┐ ┌─────────────────────────┐ │ │ │ Request → DB → Response │ │ Request → Cache (HIT!) │ │ │ │ Request → DB → Response │ │ Request → Cache (HIT!) │ │ │ │ Request → DB → Response │ │ Request → Cache (MISS) │ │ │ │ Request → DB → Response │ │ ↓ │ │ │ │ Request → DB → Response │ │ DB → Cache │ │ │ │ │ │ Request → Cache (HIT!) │ │ │ └─────────────────────────┘ └─────────────────────────┘ │ │ │ │ DB load: 5 queries DB load: 1 query │ │ Latency: 100ms each Latency: 1ms (cache hits) │ │ │ │ Cache Benefits: │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ • Reduce latency (memory vs disk) │ │ │ │ • Reduce database load │ │ │ │ • Reduce network calls │ │ │ │ • Handle traffic spikes │ │ │ │ • Cost savings │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ Cache Challenges: │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ • Cache invalidation ("the hard problem") │ │ │ │ • Stale data │ │ │ │ • Cold cache on startup │ │ │ │ • Memory pressure │ │ │ │ • Consistency with source of truth │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘

Cache Placement

┌─────────────────────────────────────────────────────────────────┐ │ WHERE TO CACHE │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 1. CLIENT-SIDE CACHE │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Browser cache, mobile app cache │ │ │ │ • Fastest (no network) │ │ │ │ • Limited storage │ │ │ │ • Hardest to invalidate │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ 2. CDN CACHE (Edge) │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Cloudflare, CloudFront, Fastly │ │ │ │ • Close to users │ │ │ │ • Static content │ │ │ │ • API responses │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ 3. APPLICATION CACHE (In-Process) │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Local memory in app server │ │ │ │ • Very fast │ │ │ │ • Limited to single instance │ │ │ │ • Duplicated across instances │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ 4. DISTRIBUTED CACHE │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Redis, Memcached │ │ │ │ • Shared across instances │ │ │ │ • Network overhead │ │ │ │ • Most flexible │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ 5. DATABASE CACHE │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Query cache, buffer pool │ │ │ │ • Automatic │ │ │ │ • Limited control │ │ │ │ • Transparant to application │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ Request Flow: │ │ Client → CDN → App Cache → Distributed Cache → Database │ │ ↑ ↑ ↑ ↑ │ │ HIT? HIT? HIT? SOURCE │ │ │ └─────────────────────────────────────────────────────────────────┘

Caching Patterns

Cache-Aside (Lazy Loading)

┌─────────────────────────────────────────────────────────────────┐ │ CACHE-ASIDE PATTERN │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ Read: │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ 1. App checks cache │ │ │ │ 2. If HIT: return cached value │ │ │ │ 3. If MISS: read from DB, store in cache, return │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ App Cache Database │ │ │ │ │ │ │ │──── GET key ─────►│ │ │ │ │◄──── MISS ────────│ │ │ │ │ │ │ │ │ │──────────── SELECT * FROM ... ───────►│ │ │ │◄─────────── data ────────────────────│ │ │ │ │ │ │ │ │──── SET key ─────►│ │ │ │ │◄──── OK ──────────│ │ │ │ │ │ Write: │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ 1. Write to database │ │ │ │ 2. Delete from cache (invalidate) │ │ │ │ OR │ │ │ │ 2. Update cache (if critical) │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ Pros: Cons: │ │ • Only cache what's needed • Initial cache miss │ │ • Simple to understand • Stale data possible │ │ • Resilient to cache failure • Two round trips on miss │ │ │ └─────────────────────────────────────────────────────────────────┘

Cache-Aside Implementation

go
package cache import ( "context" "encoding/json" "fmt" "time" "github.com/redis/go-redis/v9" ) // CacheAside implements the cache-aside pattern type CacheAside struct { cache *redis.Client db Database ttl time.Duration } type Database interface { Get(ctx context.Context, key string) (interface{}, error) Set(ctx context.Context, key string, value interface{}) error } func NewCacheAside(cache *redis.Client, db Database, ttl time.Duration) *CacheAside { return &CacheAside{ cache: cache, db: db, ttl: ttl, } } // Get retrieves data, checking cache first func (c *CacheAside) Get(ctx context.Context, key string) (interface{}, error) { // Step 1: Check cache cached, err := c.cache.Get(ctx, key).Bytes() if err == nil { // Cache HIT var result interface{} if err := json.Unmarshal(cached, &result); err == nil { return result, nil } } // Step 2: Cache MISS - read from database value, err := c.db.Get(ctx, key) if err != nil { return nil, err } // Step 3: Store in cache data, _ := json.Marshal(value) c.cache.Set(ctx, key, data, c.ttl) return value, nil } // Set writes data and invalidates cache func (c *CacheAside) Set(ctx context.Context, key string, value interface{}) error { // Step 1: Write to database if err := c.db.Set(ctx, key, value); err != nil { return err } // Step 2: Invalidate cache c.cache.Del(ctx, key) return nil } // GetMulti retrieves multiple keys efficiently func (c *CacheAside) GetMulti(ctx context.Context, keys []string) (map[string]interface{}, error) { results := make(map[string]interface{}) missingKeys := make([]string, 0) // Step 1: Check cache for all keys cached, err := c.cache.MGet(ctx, keys...).Result() if err != nil { // Cache error - fall through to database missingKeys = keys } else { for i, val := range cached { if val != nil { var result interface{} if err := json.Unmarshal([]byte(val.(string)), &result); err == nil { results[keys[i]] = result continue } } missingKeys = append(missingKeys, keys[i]) } } // Step 2: Load missing from database if len(missingKeys) > 0 { for _, key := range missingKeys { value, err := c.db.Get(ctx, key) if err != nil { continue } results[key] = value // Step 3: Store in cache data, _ := json.Marshal(value) c.cache.Set(ctx, key, data, c.ttl) } } return results, nil }

Write-Through Cache

┌─────────────────────────────────────────────────────────────────┐ │ WRITE-THROUGH PATTERN │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ Write to cache first, cache writes to database │ │ │ │ App Cache Database │ │ │ │ │ │ │ │──── SET key ─────►│ │ │ │ │ │──── INSERT ──────►│ │ │ │ │◄──── OK ──────────│ │ │ │◄──── OK ──────────│ │ │ │ │ │ Pros: Cons: │ │ • Cache always consistent • Write latency (sync) │ │ • Simplified app logic • Cache must be reliable │ │ • No stale data • May cache unused data │ │ │ └─────────────────────────────────────────────────────────────────┘
go
package cache import ( "context" "encoding/json" "time" "github.com/redis/go-redis/v9" ) // WriteThrough implements write-through caching type WriteThrough struct { cache *redis.Client db Database ttl time.Duration } func NewWriteThrough(cache *redis.Client, db Database, ttl time.Duration) *WriteThrough { return &WriteThrough{ cache: cache, db: db, ttl: ttl, } } // Set writes to cache and database synchronously func (w *WriteThrough) Set(ctx context.Context, key string, value interface{}) error { data, err := json.Marshal(value) if err != nil { return err } // Write to cache first if err := w.cache.Set(ctx, key, data, w.ttl).Err(); err != nil { return fmt.Errorf("cache write failed: %w", err) } // Then write to database if err := w.db.Set(ctx, key, value); err != nil { // Rollback cache w.cache.Del(ctx, key) return fmt.Errorf("database write failed: %w", err) } return nil } // Get reads from cache (data is guaranteed to be there after write) func (w *WriteThrough) Get(ctx context.Context, key string) (interface{}, error) { cached, err := w.cache.Get(ctx, key).Bytes() if err == nil { var result interface{} if err := json.Unmarshal(cached, &result); err == nil { return result, nil } } // Fallback to database on cache miss return w.db.Get(ctx, key) }

Write-Behind (Write-Back) Cache

┌─────────────────────────────────────────────────────────────────┐ │ WRITE-BEHIND PATTERN │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ Write to cache immediately, async write to database │ │ │ │ App Cache Background Worker │ │ │ │ │ Database │ │ │──── SET key ─────►│ │ │ │ │ │◄──── OK ──────────│ │ │ │ │ │ │ │ │ │ │ │ │ (async batch) │ │ │ │ │ │──── dirty keys ──►│ │ │ │ │ │ │─ BATCH INSERT ─►│ │ │ │ │◄─── OK ─────────│ │ │ │ Pros: Cons: │ │ • Fast writes • Data loss risk │ │ • Batching reduces DB load • Complex implementation │ │ • Good for high write volume • Eventually consistent │ │ │ │ Use cases: │ │ • Analytics counters │ │ • Rate limiting │ │ • Session data │ │ • Gaming leaderboards │ │ │ └─────────────────────────────────────────────────────────────────┘
go
package cache import ( "context" "encoding/json" "log" "sync" "time" "github.com/redis/go-redis/v9" ) // WriteBehind implements write-behind caching type WriteBehind struct { cache *redis.Client db Database ttl time.Duration dirtyKeys chan string pending map[string]interface{} mu sync.Mutex batchSize int flushInt time.Duration } func NewWriteBehind(cache *redis.Client, db Database, ttl time.Duration) *WriteBehind { wb := &WriteBehind{ cache: cache, db: db, ttl: ttl, dirtyKeys: make(chan string, 10000), pending: make(map[string]interface{}), batchSize: 100, flushInt: time.Second, } go wb.backgroundFlush() return wb } // Set writes to cache and queues for async database write func (w *WriteBehind) Set(ctx context.Context, key string, value interface{}) error { data, err := json.Marshal(value) if err != nil { return err } // Write to cache immediately if err := w.cache.Set(ctx, key, data, w.ttl).Err(); err != nil { return err } // Queue for async write w.mu.Lock() w.pending[key] = value w.mu.Unlock() select { case w.dirtyKeys <- key: default: // Channel full - trigger immediate flush go w.flush(ctx) } return nil } // backgroundFlush periodically writes pending data to database func (w *WriteBehind) backgroundFlush() { ticker := time.NewTicker(w.flushInt) defer ticker.Stop() batch := make([]string, 0, w.batchSize) for { select { case key := <-w.dirtyKeys: batch = append(batch, key) if len(batch) >= w.batchSize { w.flushBatch(context.Background(), batch) batch = batch[:0] } case <-ticker.C: if len(batch) > 0 { w.flushBatch(context.Background(), batch) batch = batch[:0] } } } } func (w *WriteBehind) flushBatch(ctx context.Context, keys []string) { w.mu.Lock() toWrite := make(map[string]interface{}) for _, key := range keys { if value, ok := w.pending[key]; ok { toWrite[key] = value delete(w.pending, key) } } w.mu.Unlock() // Batch write to database for key, value := range toWrite { if err := w.db.Set(ctx, key, value); err != nil { log.Printf("Write-behind failed for %s: %v", key, err) // Re-queue failed writes w.mu.Lock() w.pending[key] = value w.mu.Unlock() } } } func (w *WriteBehind) flush(ctx context.Context) { w.mu.Lock() keys := make([]string, 0, len(w.pending)) for k := range w.pending { keys = append(keys, k) } w.mu.Unlock() if len(keys) > 0 { w.flushBatch(ctx, keys) } } // Shutdown ensures all pending writes are flushed func (w *WriteBehind) Shutdown(ctx context.Context) error { w.flush(ctx) return nil }

Read-Through Cache

┌─────────────────────────────────────────────────────────────────┐ │ READ-THROUGH PATTERN │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ Cache handles loading from database automatically │ │ │ │ App Cache Database │ │ │ │ │ │ │ │──── GET key ─────►│ │ │ │ │ │──── SELECT ──────►│ │ │ │ │◄──── data ────────│ │ │ │◄──── data ────────│ │ │ │ │ │ (Cache automatically loads missing data) │ │ │ │ Similar to cache-aside but: │ │ • Cache is responsible for loading │ │ • Application code is simpler │ │ • Cache needs database connection │ │ │ └─────────────────────────────────────────────────────────────────┘
go
package cache import ( "context" "sync" "time" ) // ReadThrough implements read-through caching type ReadThrough struct { cache map[string]*cacheEntry loader func(context.Context, string) (interface{}, error) ttl time.Duration mu sync.RWMutex // Singleflight to prevent thundering herd loading map[string]*loadingEntry loadMu sync.Mutex } type cacheEntry struct { value interface{} expiresAt time.Time } type loadingEntry struct { done chan struct{} value interface{} err error } func NewReadThrough( loader func(context.Context, string) (interface{}, error), ttl time.Duration, ) *ReadThrough { rt := &ReadThrough{ cache: make(map[string]*cacheEntry), loader: loader, ttl: ttl, loading: make(map[string]*loadingEntry), } go rt.cleanup() return rt } // Get retrieves data, automatically loading on cache miss func (r *ReadThrough) Get(ctx context.Context, key string) (interface{}, error) { // Check cache r.mu.RLock() entry, ok := r.cache[key] r.mu.RUnlock() if ok && time.Now().Before(entry.expiresAt) { return entry.value, nil } // Cache miss - load with singleflight return r.loadSingleFlight(ctx, key) } // loadSingleFlight prevents multiple concurrent loads for same key func (r *ReadThrough) loadSingleFlight(ctx context.Context, key string) (interface{}, error) { r.loadMu.Lock() // Check if already loading if loading, ok := r.loading[key]; ok { r.loadMu.Unlock() <-loading.done return loading.value, loading.err } // Start loading loading := &loadingEntry{done: make(chan struct{})} r.loading[key] = loading r.loadMu.Unlock() // Load from source value, err := r.loader(ctx, key) // Store result loading.value = value loading.err = err close(loading.done) // Update cache if err == nil { r.mu.Lock() r.cache[key] = &cacheEntry{ value: value, expiresAt: time.Now().Add(r.ttl), } r.mu.Unlock() } // Cleanup loading entry r.loadMu.Lock() delete(r.loading, key) r.loadMu.Unlock() return value, err } func (r *ReadThrough) cleanup() { ticker := time.NewTicker(time.Minute) for range ticker.C { r.mu.Lock() now := time.Now() for key, entry := range r.cache { if now.After(entry.expiresAt) { delete(r.cache, key) } } r.mu.Unlock() } }

Cache Invalidation

┌─────────────────────────────────────────────────────────────────┐ │ CACHE INVALIDATION STRATEGIES │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ "There are only two hard things in Computer Science: │ │ cache invalidation and naming things." │ │ - Phil Karlton │ │ │ │ 1. TIME-BASED (TTL) │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ SET key value EX 300 (expires in 5 minutes) │ │ │ │ │ │ │ │ Pros: Simple, automatic │ │ │ │ Cons: Stale data until expiry │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ 2. EXPLICIT INVALIDATION │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ On data change → DELETE cache key │ │ │ │ │ │ │ │ Pros: Immediate consistency │ │ │ │ Cons: Must track all cache keys │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ 3. EVENT-BASED │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Database change → Publish event → Invalidate cache │ │ │ │ │ │ │ │ Pros: Decoupled, scalable │ │ │ │ Cons: Eventual consistency │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ 4. VERSION-BASED │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Key: "user:123:v5" (version in key) │ │ │ │ On change: increment version │ │ │ │ │ │ │ │ Pros: No deletion needed │ │ │ │ Cons: Version tracking overhead │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ 5. TAG-BASED │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Key "user:123" tagged with ["users", "org:5"] │ │ │ │ Invalidate tag "org:5" → all related keys cleared │ │ │ │ │ │ │ │ Pros: Group invalidation │ │ │ │ Cons: Complex implementation │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘

Tag-Based Invalidation

go
package cache import ( "context" "fmt" "github.com/redis/go-redis/v9" ) // TaggedCache implements tag-based cache invalidation type TaggedCache struct { client *redis.Client } func NewTaggedCache(client *redis.Client) *TaggedCache { return &TaggedCache{client: client} } // Set stores a value with associated tags func (t *TaggedCache) Set(ctx context.Context, key string, value interface{}, tags []string, ttl time.Duration) error { pipe := t.client.Pipeline() // Set the value pipe.Set(ctx, key, value, ttl) // Associate key with tags for _, tag := range tags { tagKey := fmt.Sprintf("tag:%s", tag) pipe.SAdd(ctx, tagKey, key) pipe.Expire(ctx, tagKey, ttl+time.Hour) // Tag lives longer than values } _, err := pipe.Exec(ctx) return err } // Get retrieves a value func (t *TaggedCache) Get(ctx context.Context, key string) (string, error) { return t.client.Get(ctx, key).Result() } // InvalidateTag removes all keys associated with a tag func (t *TaggedCache) InvalidateTag(ctx context.Context, tag string) error { tagKey := fmt.Sprintf("tag:%s", tag) // Get all keys for this tag keys, err := t.client.SMembers(ctx, tagKey).Result() if err != nil { return err } if len(keys) == 0 { return nil } // Delete all keys and the tag pipe := t.client.Pipeline() pipe.Del(ctx, keys...) pipe.Del(ctx, tagKey) _, err = pipe.Exec(ctx) return err } // InvalidateTags removes all keys associated with multiple tags func (t *TaggedCache) InvalidateTags(ctx context.Context, tags []string) error { var allKeys []string for _, tag := range tags { tagKey := fmt.Sprintf("tag:%s", tag) keys, _ := t.client.SMembers(ctx, tagKey).Result() allKeys = append(allKeys, keys...) allKeys = append(allKeys, tagKey) } if len(allKeys) == 0 { return nil } return t.client.Del(ctx, allKeys...).Err() } // Usage example func ExampleTaggedCache() { ctx := context.Background() client := redis.NewClient(&redis.Options{Addr: "localhost:6379"}) cache := NewTaggedCache(client) // Store user data with tags cache.Set(ctx, "user:123", `{"name":"Alice"}`, []string{"users", "org:5", "active"}, time.Hour) cache.Set(ctx, "user:456", `{"name":"Bob"}`, []string{"users", "org:5", "active"}, time.Hour) cache.Set(ctx, "user:789", `{"name":"Carol"}`, []string{"users", "org:7", "active"}, time.Hour) // Invalidate all users in org:5 cache.InvalidateTag(ctx, "org:5") // user:123 and user:456 are now deleted // user:789 still exists // Invalidate all active users cache.InvalidateTag(ctx, "active") // All users deleted }

Cache Eviction Policies

┌─────────────────────────────────────────────────────────────────┐ │ EVICTION POLICIES │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ When cache is full, which item to remove? │ │ │ │ LRU (Least Recently Used): │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Remove item that hasn't been accessed longest │ │ │ │ Best for: Most use cases │ │ │ │ Complexity: O(1) with hash map + doubly linked list │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ LFU (Least Frequently Used): │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Remove item with lowest access count │ │ │ │ Best for: Items with stable popularity │ │ │ │ Problem: New items may be evicted immediately │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ FIFO (First In First Out): │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Remove oldest item regardless of access │ │ │ │ Best for: Time-sensitive data │ │ │ │ Simple but not access-aware │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ Random: │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Remove random item │ │ │ │ Simple, surprisingly effective for some workloads │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ TTL-based: │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Remove items closest to expiration │ │ │ │ Best when TTL reflects importance │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ Redis Eviction Policies: │ │ • noeviction: Return error when full │ │ • allkeys-lru: LRU across all keys │ │ • volatile-lru: LRU only among keys with TTL │ │ • allkeys-lfu: LFU across all keys │ │ • volatile-lfu: LFU only among keys with TTL │ │ • allkeys-random: Random eviction │ │ • volatile-ttl: Evict shortest TTL first │ │ │ └─────────────────────────────────────────────────────────────────┘

LRU Cache Implementation

go
package cache import ( "container/list" "sync" ) // LRUCache implements a thread-safe LRU cache type LRUCache struct { capacity int cache map[string]*list.Element list *list.List mu sync.RWMutex } type entry struct { key string value interface{} } func NewLRUCache(capacity int) *LRUCache { return &LRUCache{ capacity: capacity, cache: make(map[string]*list.Element), list: list.New(), } } // Get retrieves a value and marks it as recently used func (c *LRUCache) Get(key string) (interface{}, bool) { c.mu.Lock() defer c.mu.Unlock() if elem, ok := c.cache[key]; ok { // Move to front (most recently used) c.list.MoveToFront(elem) return elem.Value.(*entry).value, true } return nil, false } // Set adds or updates a value func (c *LRUCache) Set(key string, value interface{}) { c.mu.Lock() defer c.mu.Unlock() if elem, ok := c.cache[key]; ok { // Update existing c.list.MoveToFront(elem) elem.Value.(*entry).value = value return } // Add new if c.list.Len() >= c.capacity { // Evict least recently used oldest := c.list.Back() if oldest != nil { c.list.Remove(oldest) delete(c.cache, oldest.Value.(*entry).key) } } elem := c.list.PushFront(&entry{key: key, value: value}) c.cache[key] = elem } // Delete removes a key func (c *LRUCache) Delete(key string) { c.mu.Lock() defer c.mu.Unlock() if elem, ok := c.cache[key]; ok { c.list.Remove(elem) delete(c.cache, key) } } // Len returns the number of items func (c *LRUCache) Len() int { c.mu.RLock() defer c.mu.RUnlock() return c.list.Len() }

Distributed Caching with Redis

go
package cache import ( "context" "encoding/json" "fmt" "time" "github.com/redis/go-redis/v9" ) // RedisCache provides a distributed cache backed by Redis type RedisCache struct { client *redis.Client prefix string } func NewRedisCache(addr string, prefix string) *RedisCache { return &RedisCache{ client: redis.NewClient(&redis.Options{ Addr: addr, PoolSize: 100, MinIdleConns: 10, }), prefix: prefix, } } func (r *RedisCache) key(k string) string { return fmt.Sprintf("%s:%s", r.prefix, k) } // Get retrieves and deserializes a value func (r *RedisCache) Get(ctx context.Context, key string, dest interface{}) error { data, err := r.client.Get(ctx, r.key(key)).Bytes() if err != nil { return err } return json.Unmarshal(data, dest) } // Set serializes and stores a value func (r *RedisCache) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error { data, err := json.Marshal(value) if err != nil { return err } return r.client.Set(ctx, r.key(key), data, ttl).Err() } // Delete removes a key func (r *RedisCache) Delete(ctx context.Context, key string) error { return r.client.Del(ctx, r.key(key)).Err() } // GetOrSet gets from cache or computes and caches value func (r *RedisCache) GetOrSet( ctx context.Context, key string, dest interface{}, ttl time.Duration, compute func() (interface{}, error), ) error { // Try cache first err := r.Get(ctx, key, dest) if err == nil { return nil } // Cache miss - compute value value, err := compute() if err != nil { return err } // Store in cache if err := r.Set(ctx, key, value, ttl); err != nil { // Log but don't fail fmt.Printf("Cache set failed: %v\n", err) } // Copy value to dest data, _ := json.Marshal(value) return json.Unmarshal(data, dest) } // SetNX sets only if not exists (useful for locks) func (r *RedisCache) SetNX(ctx context.Context, key string, value interface{}, ttl time.Duration) (bool, error) { data, err := json.Marshal(value) if err != nil { return false, err } return r.client.SetNX(ctx, r.key(key), data, ttl).Result() } // Increment atomically increments a counter func (r *RedisCache) Increment(ctx context.Context, key string) (int64, error) { return r.client.Incr(ctx, r.key(key)).Result() } // IncrementBy atomically increments by a value func (r *RedisCache) IncrementBy(ctx context.Context, key string, n int64) (int64, error) { return r.client.IncrBy(ctx, r.key(key), n).Result() } // MultiGet retrieves multiple keys func (r *RedisCache) MultiGet(ctx context.Context, keys []string) (map[string]string, error) { fullKeys := make([]string, len(keys)) for i, k := range keys { fullKeys[i] = r.key(k) } values, err := r.client.MGet(ctx, fullKeys...).Result() if err != nil { return nil, err } result := make(map[string]string) for i, v := range values { if v != nil { result[keys[i]] = v.(string) } } return result, nil }

Cache Warming

┌─────────────────────────────────────────────────────────────────┐ │ CACHE WARMING │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ Problem: Cold cache after restart = slow responses │ │ │ │ Solutions: │ │ │ │ 1. BACKGROUND WARMING │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ On startup, load popular data in background │ │ │ │ │ │ │ │ for item in popular_items: │ │ │ │ cache.set(item.key, item.value) │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ 2. SHADOW TRAFFIC │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ New server receives traffic but responses discarded │ │ │ │ Warms cache without affecting users │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ 3. GRADUAL TRAFFIC RAMP │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Start with 1% traffic, gradually increase │ │ │ │ 1% → 5% → 25% → 50% → 100% │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ 4. PERSISTENT CACHE │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Use disk-backed cache (Redis AOF/RDB) │ │ │ │ Cache survives restarts │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘
go
package cache import ( "context" "log" "time" ) // CacheWarmer warms cache on startup type CacheWarmer struct { cache *RedisCache db Database queries []WarmQuery } type WarmQuery struct { Name string Query string CacheKey func(row interface{}) string TTL time.Duration } func NewCacheWarmer(cache *RedisCache, db Database) *CacheWarmer { return &CacheWarmer{ cache: cache, db: db, } } // AddQuery adds a warming query func (w *CacheWarmer) AddQuery(q WarmQuery) { w.queries = append(w.queries, q) } // Warm executes all warming queries func (w *CacheWarmer) Warm(ctx context.Context) error { for _, q := range w.queries { if err := w.warmQuery(ctx, q); err != nil { log.Printf("Warning: warming %s failed: %v", q.Name, err) continue } } return nil } func (w *CacheWarmer) warmQuery(ctx context.Context, q WarmQuery) error { log.Printf("Warming cache: %s", q.Name) start := time.Now() rows, err := w.db.Query(ctx, q.Query) if err != nil { return err } count := 0 for _, row := range rows { key := q.CacheKey(row) if err := w.cache.Set(ctx, key, row, q.TTL); err != nil { continue } count++ } log.Printf("Warmed %d items for %s in %v", count, q.Name, time.Since(start)) return nil } // Usage func ExampleCacheWarmer() { warmer := NewCacheWarmer(cache, db) // Warm popular products warmer.AddQuery(WarmQuery{ Name: "popular_products", Query: "SELECT * FROM products WHERE views > 1000 LIMIT 10000", CacheKey: func(row interface{}) string { p := row.(*Product) return fmt.Sprintf("product:%d", p.ID) }, TTL: time.Hour, }) // Warm active users warmer.AddQuery(WarmQuery{ Name: "active_users", Query: "SELECT * FROM users WHERE last_login > NOW() - INTERVAL 1 DAY", CacheKey: func(row interface{}) string { u := row.(*User) return fmt.Sprintf("user:%d", u.ID) }, TTL: 30 * time.Minute, }) warmer.Warm(context.Background()) }

Best Practices

┌─────────────────────────────────────────────────────────────────┐ │ CACHING BEST PRACTICES │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 1. CACHE WHAT MATTERS │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ • Expensive computations │ │ │ │ • Frequently accessed data │ │ │ │ • Data that changes infrequently │ │ │ │ • NOT: Rapidly changing data, user-specific data │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ 2. SET APPROPRIATE TTLs │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ • Short TTL: Dynamic data (seconds to minutes) │ │ │ │ • Long TTL: Static data (hours to days) │ │ │ │ • Add jitter to prevent thundering herd │ │ │ │ ttl = baseTTL + random(0, baseTTL * 0.1) │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ 3. HANDLE CACHE FAILURES GRACEFULLY │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ • Cache is optional, not required │ │ │ │ • Fall back to source on cache miss/error │ │ │ │ • Don't let cache failure break your app │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ 4. MONITOR CACHE PERFORMANCE │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ • Hit rate (target: >90%) │ │ │ │ • Miss rate │ │ │ │ • Latency │ │ │ │ • Memory usage │ │ │ │ • Eviction rate │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ 5. USE CONSISTENT KEY NAMING │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ • Format: {type}:{id}:{version} │ │ │ │ • Examples: │ │ │ │ user:123:v2 │ │ │ │ product:456:details │ │ │ │ session:abc123 │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘

Interview Questions

  1. What's the difference between cache-aside and write-through?
    • Cache-aside: Application manages cache, writes go to DB first
    • Write-through: Cache manages DB writes, synchronous
  2. How do you handle cache stampede/thundering herd?
    • Singleflight: Only one goroutine loads, others wait
    • Probabilistic early refresh
    • Locking with short timeout
  3. When would you use write-behind vs write-through?
    • Write-behind: High write volume, can tolerate data loss
    • Write-through: Consistency critical, moderate write volume
  4. How do you invalidate cache in a distributed system?
    • Pub/sub for invalidation events
    • Short TTLs with background refresh
    • Version-based keys
  5. What cache eviction policy would you use?
    • LRU for most cases
    • LFU for stable popularity patterns
    • TTL-based for time-sensitive data

Summary

┌─────────────────────────────────────────────────────────────────┐ │ CACHING SUMMARY │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ Patterns: │ │ • Cache-Aside: App manages cache, lazy loading │ │ • Write-Through: Cache writes to DB synchronously │ │ • Write-Behind: Async DB writes, best performance │ │ • Read-Through: Cache auto-loads missing data │ │ │ │ Invalidation: │ │ • TTL: Simple but may serve stale data │ │ • Explicit: Immediate but requires tracking │ │ • Event-based: Scalable, eventually consistent │ │ │ │ Eviction: │ │ • LRU: Best for most cases │ │ • LFU: When frequency matters more than recency │ │ │ │ Key Insight: │ │ "Caching turns expensive operations into cheap lookups. │ │ The hard part is knowing when the cache is wrong." │ │ │ └─────────────────────────────────────────────────────────────────┘

All Blogs
Tags:cachingredisperformancecache-invalidation