Caching Strategies and Patterns

Learning Objectives: By the end of this lesson, you will be able to:

  • Implement different caching patterns: cache-aside, write-through, write-behind, and refresh-ahead
  • Design effective cache invalidation strategies to maintain data consistency
  • Build cache layers that improve application performance by 10x or more
  • Handle cache failures gracefully with fallback mechanisms and circuit breakers
  • Optimize cache performance with appropriate TTL strategies and memory management

1. Welcome and Introduction (5 minutes)

Icebreaker Activity

Have you ever wondered why Netflix can serve millions of users simultaneously, or how Amazon shows you product recommendations instantly? The secret lies in sophisticated caching strategies that make data access lightning-fast. Today we’ll learn how to implement these enterprise-grade caching patterns using Go and Redis!

Lesson Overview

  • Duration: 120 minutes
  • Prerequisites: Lessons 1-3 completed, understanding of Go interfaces and error handling
  • Materials Needed: Go environment, Redis server, database for testing (SQLite/PostgreSQL)

2. Learning Objectives and Session Outline (5 minutes)

What We’ll Cover Today:

  1. Cache-Aside Pattern - The most common caching strategy with manual cache management
  2. Write-Through Caching - Synchronous writes to both cache and database
  3. Write-Behind (Write-Back) - Asynchronous writes for better performance
  4. Refresh-Ahead - Proactive cache warming to prevent cache misses
  5. Cache Invalidation - Strategies for maintaining data consistency
  6. Fallback Mechanisms - Handling cache failures gracefully
  7. Performance Optimization - TTL strategies, compression, and monitoring

Success Criteria:

By the end of this session, you should be able to design and implement a comprehensive caching layer that improves application performance while maintaining data consistency and reliability.

3. Foundation Knowledge (15-20 minutes)

Core Concepts

What is Caching?

Caching is a technique that stores frequently accessed data in a fast storage layer (like Redis) to reduce the time needed to access that data. It’s based on the principle of locality - data that was recently accessed is likely to be accessed again soon.

Real-World Analogy: Think of caching like keeping frequently used tools on your workbench instead of walking to the toolshed every time. Your workbench (cache) has limited space but provides instant access, while the toolshed (database) has everything but takes time to reach.

Why This Matters: Proper caching can reduce database load by 80-90%, improve response times from seconds to milliseconds, and enable applications to scale to millions of users without proportional infrastructure costs.

Cache Hierarchy

Client Request
     ↓
Browser Cache (client-side)
     ↓
CDN Cache (edge locations)
     ↓
Load Balancer Cache
     ↓
Application Cache (Redis)
     ↓
Database Query Cache
     ↓
Database Storage

Key Terminology

  • Cache Hit: When requested data is found in the cache
  • Cache Miss: When requested data is not in the cache and must be fetched from the source
  • Hit Ratio: Percentage of requests served from cache (higher is better)
  • TTL (Time To Live): How long data stays in cache before expiring
  • Cache Warming: Pre-loading cache with data before it’s requested
  • Cache Stampede: Multiple simultaneous requests for the same expired cache key
  • Hot Key: A cache key that receives disproportionately high traffic

4. Detailed Explanation and Examples (25-30 minutes)

1. Cache-Aside Pattern (Lazy Loading)

Cache-aside is the most common pattern where the application manages the cache manually. The application first checks the cache, and if data isn’t found, fetches from the database and stores it in the cache.

Flow:

  1. Application checks cache for data
  2. If cache hit: return cached data
  3. If cache miss: fetch from database, store in cache, return data

Advantages:

  • Simple to implement and understand
  • Cache only contains data that’s actually requested
  • Resilient to cache failures

Disadvantages:

  • Cache miss penalty (extra latency on first request)
  • Potential for stale data
  • Cache stampede problems

2. Write-Through Caching

In write-through caching, data is written to both the cache and the database simultaneously. This ensures cache and database are always in sync.

Flow:

  1. Application writes data to cache and database simultaneously
  2. Write operation succeeds only when both succeed
  3. Reads always get fresh data from cache

Advantages:

  • Data consistency between cache and database
  • Cache always contains fresh data
  • Good for read-heavy workloads

Disadvantages:

  • Higher write latency (must write to both systems)
  • May cache data that’s never read
  • Database and cache must both be available for writes

3. Write-Behind (Write-Back) Caching

Write-behind caching writes data to the cache immediately and to the database asynchronously. This provides the best write performance but with some consistency trade-offs.

Flow:

  1. Application writes data to cache immediately
  2. Cache asynchronously writes to database in background
  3. Reads get data from cache (very fast)

Advantages:

  • Excellent write performance
  • Reduces database load
  • Can batch database writes for efficiency

Disadvantages:

  • Risk of data loss if cache fails before database write
  • Complex error handling for failed database writes
  • Eventual consistency model

Cache Invalidation Strategies

1. TTL-Based Expiration

// Set data with TTL
cache.Set("user:123", userData, 30*time.Minute)

2. Event-Based Invalidation

// When user data changes, invalidate cache
cache.Delete("user:123")

3. Tag-Based Invalidation

// Tag related cache entries
cache.SetWithTags("product:123", productData, []string{"category:electronics", "brand:apple"})
// Invalidate all products in category
cache.InvalidateByTag("category:electronics")

5. Live Coding Demonstration (20-25 minutes)

Example 1: Complete Cache-Aside Implementation

package main

import (
    "context"
    "database/sql"
    "encoding/json"
    "fmt"
    "log"
    "time"

    "github.com/redis/go-redis/v9"
    _ "github.com/mattn/go-sqlite3"
)

// User represents a user entity
type User struct {
    ID        int       `json:"id" db:"id"`
    Username  string    `json:"username" db:"username"`
    Email     string    `json:"email" db:"email"`
    CreatedAt time.Time `json:"created_at" db:"created_at"`
    UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}

// UserRepository handles user data operations
type UserRepository struct {
    db    *sql.DB
    cache *redis.Client
    ctx   context.Context
}

func NewUserRepository(db *sql.DB, cache *redis.Client) *UserRepository {
    return &UserRepository{
        db:    db,
        cache: cache,
        ctx:   context.Background(),
    }
}

// GetUser implements cache-aside pattern
func (r *UserRepository) GetUser(userID int) (*User, error) {
    cacheKey := fmt.Sprintf("user:%d", userID)
    
    // Step 1: Try to get from cache
    cached, err := r.cache.Get(r.ctx, cacheKey).Result()
    if err == nil {
        // Cache hit - unmarshal and return
        var user User
        if err := json.Unmarshal([]byte(cached), &user); err != nil {
            log.Printf("Failed to unmarshal cached user: %v", err)
            // Continue to database fallback
        } else {
            log.Printf("Cache hit for user %d", userID)
            return &user, nil
        }
    } else if err != redis.Nil {
        // Redis error (not a miss) - log but continue to database
        log.Printf("Redis error: %v", err)
    }

    // Step 2: Cache miss - get from database
    log.Printf("Cache miss for user %d, fetching from database", userID)
    user, err := r.getUserFromDB(userID)
    if err != nil {
        return nil, fmt.Errorf("failed to get user from database: %w", err)
    }

    // Step 3: Store in cache for future requests
    userJSON, err := json.Marshal(user)
    if err != nil {
        log.Printf("Failed to marshal user for cache: %v", err)
        // Return user anyway - caching failure shouldn't break functionality
        return user, nil
    }

    // Cache for 1 hour
    if err := r.cache.Set(r.ctx, cacheKey, userJSON, time.Hour).Err(); err != nil {
        log.Printf("Failed to cache user: %v", err)
        // Continue - caching failure shouldn't break functionality
    }

    return user, nil
}

func (r *UserRepository) getUserFromDB(userID int) (*User, error) {
    query := `SELECT id, username, email, created_at, updated_at FROM users WHERE id = ?`
    
    var user User
    err := r.db.QueryRow(query, userID).Scan(
        &user.ID, &user.Username, &user.Email, &user.CreatedAt, &user.UpdatedAt,
    )
    if err != nil {
        if err == sql.ErrNoRows {
            return nil, fmt.Errorf("user %d not found", userID)
        }
        return nil, err
    }

    return &user, nil
}

// UpdateUser implements cache invalidation
func (r *UserRepository) UpdateUser(user *User) error {
    // Step 1: Update database
    query := `UPDATE users SET username = ?, email = ?, updated_at = ? WHERE id = ?`
    user.UpdatedAt = time.Now()
    
    _, err := r.db.Exec(query, user.Username, user.Email, user.UpdatedAt, user.ID)
    if err != nil {
        return fmt.Errorf("failed to update user in database: %w", err)
    }

    // Step 2: Invalidate cache
    cacheKey := fmt.Sprintf("user:%d", user.ID)
    if err := r.cache.Del(r.ctx, cacheKey).Err(); err != nil {
        log.Printf("Failed to invalidate cache for user %d: %v", user.ID, err)
        // Don't fail the operation - data is already updated in DB
    }

    return nil
}

// CreateUser adds new user and caches it
func (r *UserRepository) CreateUser(user *User) error {
    user.CreatedAt = time.Now()
    user.UpdatedAt = time.Now()

    // Insert into database
    query := `INSERT INTO users (username, email, created_at, updated_at) VALUES (?, ?, ?, ?)`
    result, err := r.db.Exec(query, user.Username, user.Email, user.CreatedAt, user.UpdatedAt)
    if err != nil {
        return fmt.Errorf("failed to create user: %w", err)
    }

    id, err := result.LastInsertId()
    if err != nil {
        return fmt.Errorf("failed to get new user ID: %w", err)
    }
    user.ID = int(id)

    // Cache the new user
    cacheKey := fmt.Sprintf("user:%d", user.ID)
    userJSON, err := json.Marshal(user)
    if err != nil {
        log.Printf("Failed to marshal new user for cache: %v", err)
        return nil // User created successfully, caching is bonus
    }

    if err := r.cache.Set(r.ctx, cacheKey, userJSON, time.Hour).Err(); err != nil {
        log.Printf("Failed to cache new user: %v", err)
    }

    return nil
}

func main() {
    // Setup database
    db, err := sql.Open("sqlite3", ":memory:")
    if err != nil {
        log.Fatal("Failed to connect to database:", err)
    }
    defer db.Close()

    // Create users table
    createTable := `
    CREATE TABLE users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        username TEXT NOT NULL,
        email TEXT NOT NULL,
        created_at DATETIME NOT NULL,
        updated_at DATETIME NOT NULL
    )`
    if _, err := db.Exec(createTable); err != nil {
        log.Fatal("Failed to create table:", err)
    }

    // Setup Redis
    redisClient := redis.NewClient(&redis.Options{
        Addr: "localhost:6379",
    })
    defer redisClient.Close()

    // Initialize repository
    repo := NewUserRepository(db, redisClient)

    // Demo: Create user
    user := &User{
        Username: "john_doe",
        Email:    "john@example.com",
    }
    
    if err := repo.CreateUser(user); err != nil {
        log.Fatal("Failed to create user:", err)
    }
    fmt.Printf("Created user: %+v\n", user)

    // Demo: Get user (cache miss first time)
    fetchedUser, err := repo.GetUser(user.ID)
    if err != nil {
        log.Fatal("Failed to get user:", err)
    }
    fmt.Printf("Fetched user (cache miss): %+v\n", fetchedUser)

    // Demo: Get user again (cache hit)
    fetchedUser2, err := repo.GetUser(user.ID)
    if err != nil {
        log.Fatal("Failed to get user:", err)
    }
    fmt.Printf("Fetched user (cache hit): %+v\n", fetchedUser2)

    // Demo: Update user (invalidates cache)
    user.Email = "john.doe@example.com"
    if err := repo.UpdateUser(user); err != nil {
        log.Fatal("Failed to update user:", err)
    }
    fmt.Println("Updated user and invalidated cache")

    // Demo: Get user after update (cache miss again)
    fetchedUser3, err := repo.GetUser(user.ID)
    if err != nil {
        log.Fatal("Failed to get user:", err)
    }
    fmt.Printf("Fetched user after update (cache miss): %+v\n", fetchedUser3)
}

Explanation:

  • Lines 35-65: Cache-aside implementation with proper error handling
  • Lines 67-85: Database fallback when cache fails
  • Lines 87-105: Cache invalidation on updates
  • Lines 107-135: Cache warming on creates
  • The code demonstrates resilience - cache failures don’t break core functionality

Example 2: Write-Through Caching Implementation

package main

import (
    "context"
    "database/sql"
    "encoding/json"
    "fmt"
    "log"
    "time"

    "github.com/redis/go-redis/v9"
)

// WriteThroughCache implements write-through caching pattern
type WriteThroughCache struct {
    db    *sql.DB
    cache *redis.Client
    ctx   context.Context
}

func NewWriteThroughCache(db *sql.DB, cache *redis.Client) *WriteThroughCache {
    return &WriteThroughCache{
        db:    db,
        cache: cache,
        ctx:   context.Background(),
    }
}

// Product represents a product entity
type Product struct {
    ID          int     `json:"id"`
    Name        string  `json:"name"`
    Price       float64 `json:"price"`
    Description string  `json:"description"`
    UpdatedAt   time.Time `json:"updated_at"`
}

// WriteProduct implements write-through pattern
func (w *WriteThroughCache) WriteProduct(product *Product) error {
    product.UpdatedAt = time.Now()
    
    // Start a transaction for consistency
    tx, err := w.db.Begin()
    if err != nil {
        return fmt.Errorf("failed to start transaction: %w", err)
    }
    defer tx.Rollback() // Will be ignored if tx.Commit() succeeds

    // Step 1: Write to database
    query := `INSERT OR REPLACE INTO products (id, name, price, description, updated_at) 
              VALUES (?, ?, ?, ?, ?)`
    _, err = tx.Exec(query, product.ID, product.Name, product.Price, 
                     product.Description, product.UpdatedAt)
    if err != nil {
        return fmt.Errorf("failed to write to database: %w", err)
    }

    // Step 2: Write to cache
    cacheKey := fmt.Sprintf("product:%d", product.ID)
    productJSON, err := json.Marshal(product)
    if err != nil {
        return fmt.Errorf("failed to marshal product: %w", err)
    }

    // Write to cache with TTL
    if err := w.cache.Set(w.ctx, cacheKey, productJSON, 2*time.Hour).Err(); err != nil {
        // If cache write fails, rollback database transaction
        return fmt.Errorf("failed to write to cache: %w", err)
    }

    // Step 3: Commit transaction only if both writes succeeded
    if err := tx.Commit(); err != nil {
        // If commit fails, try to remove from cache
        w.cache.Del(w.ctx, cacheKey)
        return fmt.Errorf("failed to commit transaction: %w", err)
    }

    log.Printf("Successfully wrote product %d using write-through pattern", product.ID)
    return nil
}

// ReadProduct always reads from cache (since cache is always up-to-date)
func (w *WriteThroughCache) ReadProduct(productID int) (*Product, error) {
    cacheKey := fmt.Sprintf("product:%d", productID)
    
    cached, err := w.cache.Get(w.ctx, cacheKey).Result()
    if err == redis.Nil {
        return nil, fmt.Errorf("product %d not found", productID)
    } else if err != nil {
        // Cache error - fallback to database
        return w.readProductFromDB(productID)
    }

    var product Product
    if err := json.Unmarshal([]byte(cached), &product); err != nil {
        // Unmarshal error - fallback to database
        return w.readProductFromDB(productID)
    }

    return &product, nil
}

func (w *WriteThroughCache) readProductFromDB(productID int) (*Product, error) {
    query := `SELECT id, name, price, description, updated_at FROM products WHERE id = ?`
    
    var product Product
    err := w.db.QueryRow(query, productID).Scan(
        &product.ID, &product.Name, &product.Price, 
        &product.Description, &product.UpdatedAt,
    )
    if err != nil {
        if err == sql.ErrNoRows {
            return nil, fmt.Errorf("product %d not found", productID)
        }
        return nil, fmt.Errorf("database error: %w", err)
    }

    return &product, nil
}

Example 3: Advanced Caching with Circuit Breaker

package main

import (
    "context"
    "errors"
    "sync"
    "time"

    "github.com/redis/go-redis/v9"
)

// CircuitBreakerState represents the state of the circuit breaker
type CircuitBreakerState int

const (
    CircuitClosed CircuitBreakerState = iota
    CircuitOpen
    CircuitHalfOpen
)

// CircuitBreaker implements circuit breaker pattern for cache
type CircuitBreaker struct {
    mutex           sync.RWMutex
    state           CircuitBreakerState
    failureCount    int
    successCount    int
    failureThreshold int
    successThreshold int
    timeout         time.Duration
    lastFailureTime time.Time
}

func NewCircuitBreaker() *CircuitBreaker {
    return &CircuitBreaker{
        state:           CircuitClosed,
        failureThreshold: 5,  // Open after 5 failures
        successThreshold: 3,  // Close after 3 successes in half-open
        timeout:         30 * time.Second, // Try again after 30 seconds
    }
}

// Execute runs the operation if circuit is closed or half-open
func (cb *CircuitBreaker) Execute(operation func() error) error {
    cb.mutex.Lock()
    defer cb.mutex.Unlock()

    if cb.state == CircuitOpen {
        if time.Since(cb.lastFailureTime) > cb.timeout {
            cb.state = CircuitHalfOpen
            cb.successCount = 0
        } else {
            return errors.New("circuit breaker is open")
        }
    }

    err := operation()
    
    if err != nil {
        cb.onFailure()
        return err
    }

    cb.onSuccess()
    return nil
}

func (cb *CircuitBreaker) onFailure() {
    cb.failureCount++
    cb.lastFailureTime = time.Now()
    
    if cb.state == CircuitHalfOpen || cb.failureCount >= cb.failureThreshold {
        cb.state = CircuitOpen
    }
}

func (cb *CircuitBreaker) onSuccess() {
    cb.failureCount = 0
    
    if cb.state == CircuitHalfOpen {
        cb.successCount++
        if cb.successCount >= cb.successThreshold {
            cb.state = CircuitClosed
        }
    } else {
        cb.state = CircuitClosed
    }
}

// ResilientCache combines caching with circuit breaker
type ResilientCache struct {
    client  *redis.Client
    breaker *CircuitBreaker
    ctx     context.Context
}

func NewResilientCache(client *redis.Client) *ResilientCache {
    return &ResilientCache{
        client:  client,
        breaker: NewCircuitBreaker(),
        ctx:     context.Background(),
    }
}

func (r *ResilientCache) Get(key string) (string, error) {
    var result string
    var err error
    
    breakerErr := r.breaker.Execute(func() error {
        result, err = r.client.Get(r.ctx, key).Result()
        return err
    })
    
    if breakerErr != nil {
        return "", breakerErr
    }
    
    return result, err
}

func (r *ResilientCache) Set(key, value string, expiration time.Duration) error {
    return r.breaker.Execute(func() error {
        return r.client.Set(r.ctx, key, value, expiration).Err()
    })
}

func main() {
    client := redis.NewClient(&redis.Options{
        Addr: "localhost:6379",
    })
    defer client.Close()

    cache := NewResilientCache(client)

    // Test circuit breaker
    for i := 0; i < 10; i++ {
        err := cache.Set(fmt.Sprintf("test:%d", i), "value", time.Minute)
        if err != nil {
            log.Printf("Set operation %d failed: %v", i, err)
        } else {
            log.Printf("Set operation %d succeeded", i)
        }
        
        time.Sleep(100 * time.Millisecond)
    }
}

Debugging Demonstration

// Cache monitoring and debugging utilities
type CacheMonitor struct {
    hits   int64
    misses int64
    errors int64
    mutex  sync.RWMutex
}

func (m *CacheMonitor) RecordHit() {
    m.mutex.Lock()
    defer m.mutex.Unlock()
    m.hits++
}

func (m *CacheMonitor) RecordMiss() {
    m.mutex.Lock()
    defer m.mutex.Unlock()
    m.misses++
}

func (m *CacheMonitor) RecordError() {
    m.mutex.Lock()
    defer m.mutex.Unlock()
    m.errors++
}

func (m *CacheMonitor) GetStats() (hits, misses, errors int64, hitRatio float64) {
    m.mutex.RLock()
    defer m.mutex.RUnlock()
    
    total := m.hits + m.misses
    if total == 0 {
        return m.hits, m.misses, m.errors, 0.0
    }
    
    return m.hits, m.misses, m.errors, float64(m.hits) / float64(total)
}

// Usage example
monitor := &CacheMonitor{}

// In your cache operations
if cached, err := cache.Get(key).Result(); err == nil {
    monitor.RecordHit()
    return cached, nil
} else if err == redis.Nil {
    monitor.RecordMiss()
    // Continue to database
} else {
    monitor.RecordError()
    // Handle error
}

// Periodic stats reporting
go func() {
    ticker := time.NewTicker(1 * time.Minute)
    for range ticker.C {
        hits, misses, errors, hitRatio := monitor.GetStats()
        log.Printf("Cache stats - Hits: %d, Misses: %d, Errors: %d, Hit Ratio: %.2f%%", 
                   hits, misses, errors, hitRatio*100)
    }
}()

6. Hands-On Practice (30-40 minutes)

Exercise 1: Build a News Article Caching System (15 minutes)

Objective: Implement cache-aside pattern for a news article system

Requirements:

  • Cache articles with 1-hour expiration
  • Implement view counter caching
  • Add cache invalidation when articles are updated
  • Include cache warming for popular articles

Starter Code:

type Article struct {
    ID        int       `json:"id"`
    Title     string    `json:"title"`
    Content   string    `json:"content"`
    Author    string    `json:"author"`
    ViewCount int       `json:"view_count"`
    UpdatedAt time.Time `json:"updated_at"`
}

type ArticleService struct {
    db    *sql.DB
    cache *redis.Client
    ctx   context.Context
}

func (a *ArticleService) GetArticle(articleID int) (*Article, error) {
    // TODO: Implement cache-aside pattern
    // TODO: Include view count increment
    // TODO: Handle cache failures gracefully
}

func (a *ArticleService) UpdateArticle(article *Article) error {
    // TODO: Update database
    // TODO: Invalidate cache
    // TODO: Optionally warm cache with new data
}

func (a *ArticleService) WarmPopularArticles() error {
    // TODO: Pre-load top 100 most viewed articles
}

Solution:

package main

import (
    "context"
    "database/sql"
    "encoding/json"
    "fmt"
    "log"
    "time"

    "github.com/redis/go-redis/v9"
)

type Article struct {
    ID        int       `json:"id"`
    Title     string    `json:"title"`
    Content   string    `json:"content"`
    Author    string    `json:"author"`
    ViewCount int       `json:"view_count"`
    UpdatedAt time.Time `json:"updated_at"`
}

type ArticleService struct {
    db    *sql.DB
    cache *redis.Client
    ctx   context.Context
}

func NewArticleService(db *sql.DB, cache *redis.Client) *ArticleService {
    return &ArticleService{
        db:    db,
        cache: cache,
        ctx:   context.Background(),
    }
}

func (a *ArticleService) GetArticle(articleID int) (*Article, error) {
    cacheKey := fmt.Sprintf("article:%d", articleID)
    
    // Try cache first
    cached, err := a.cache.Get(a.ctx, cacheKey).Result()
    if err == nil {
        var article Article
        if err := json.Unmarshal([]byte(cached), &article); err == nil {
            // Increment view count asynchronously
            go a.incrementViewCount(articleID)
            log.Printf("Cache hit for article %d", articleID)
            return &article, nil
        }
    }

    // Cache miss - get from database
    article, err := a.getArticleFromDB(articleID)
    if err != nil {
        return nil, err
    }

    // Increment view count
    article.ViewCount++
    a.updateViewCount(articleID, article.ViewCount)

    // Cache the article
    articleJSON, _ := json.Marshal(article)
    a.cache.Set(a.ctx, cacheKey, articleJSON, time.Hour)

    log.Printf("Cache miss for article %d", articleID)
    return article, nil
}

func (a *ArticleService) getArticleFromDB(articleID int) (*Article, error) {
    query := `SELECT id, title, content, author, view_count, updated_at 
              FROM articles WHERE id = ?`
    
    var article Article
    err := a.db.QueryRow(query, articleID).Scan(
        &article.ID, &article.Title, &article.Content,
        &article.Author, &article.ViewCount, &article.UpdatedAt,
    )
    if err != nil {
        return nil, fmt.Errorf("article not found: %w", err)
    }

    return &article, nil
}

func (a *ArticleService) incrementViewCount(articleID int) {
    counterKey := fmt.Sprintf("article:%d:views", articleID)
    
    // Use atomic increment
    newCount, err := a.cache.Incr(a.ctx, counterKey).Result()
    if err != nil {
        log.Printf("Failed to increment view counter: %v", err)
        return
    }

    // Update database every 10 views to reduce DB load
    if newCount%10 == 0 {
        a.updateViewCount(articleID, int(newCount))
    }
}

func (a *ArticleService) updateViewCount(articleID int, count int) {
    query := `UPDATE articles SET view_count = ? WHERE id = ?`
    _, err := a.db.Exec(query, count, articleID)
    if err != nil {
        log.Printf("Failed to update view count in DB: %v", err)
    }
}

func (a *ArticleService) UpdateArticle(article *Article) error {
    article.UpdatedAt = time.Now()
    
    // Update database
    query := `UPDATE articles SET title = ?, content = ?, author = ?, updated_at = ? 
              WHERE id = ?`
    _, err := a.db.Exec(query, article.Title, article.Content, 
                        article.Author, article.UpdatedAt, article.ID)
    if err != nil {
        return fmt.Errorf("failed to update article: %w", err)
    }

    // Invalidate cache
    cacheKey := fmt.Sprintf("article:%d", article.ID)
    a.cache.Del(a.ctx, cacheKey)

    // Optional: Warm cache with updated data
    articleJSON, _ := json.Marshal(article)
    a.cache.Set(a.ctx, cacheKey, articleJSON, time.Hour)

    return nil
}

func (a *ArticleService) WarmPopularArticles() error {
    // Get top 100 most viewed articles
    query := `SELECT id, title, content, author, view_count, updated_at 
              FROM articles ORDER BY view_count DESC LIMIT 100`
    
    rows, err := a.db.Query(query)
    if err != nil {
        return fmt.Errorf("failed to query popular articles: %w", err)
    }
    defer rows.Close()

    warmed := 0
    for rows.Next() {
        var article Article
        err := rows.Scan(&article.ID, &article.Title, &article.Content,
                        &article.Author, &article.ViewCount, &article.UpdatedAt)
        if err != nil {
            log.Printf("Failed to scan article: %v", err)
            continue
        }

        // Cache the article
        cacheKey := fmt.Sprintf("article:%d", article.ID)
        articleJSON, _ := json.Marshal(article)
        
        if err := a.cache.Set(a.ctx, cacheKey, articleJSON, time.Hour).Err(); err != nil {
            log.Printf("Failed to warm cache for article %d: %v", article.ID, err)
        } else {
            warmed++
        }
    }

    log.Printf("Warmed cache for %d popular articles", warmed)
    return nil
}

Exercise 2: Implement Write-Behind Caching (15 minutes)

Objective: Build a write-behind cache for high-performance writes

Requirements:

  • Immediate writes to cache
  • Asynchronous writes to database
  • Batch database operations for efficiency
  • Handle write failures with retry logic

Implementation:

package main

import (
    "context"
    "database/sql"
    "encoding/json"
    "log"
    "sync"
    "time"

    "github.com/redis/go-redis/v9"
)

// WriteBehindCache implements write-behind caching pattern
type WriteBehindCache struct {
    cache      *redis.Client
    db         *sql.DB
    ctx        context.Context
    writeQueue chan WriteOperation
    batchSize  int
    flushInterval time.Duration
    wg         sync.WaitGroup
}

type WriteOperation struct {
    Key   string
    Value interface{}
    Table string
}

func NewWriteBehindCache(cache *redis.Client, db *sql.DB) *WriteBehindCache {
    wbc := &WriteBehindCache{
        cache:         cache,
        db:            db,
        ctx:           context.Background(),
        writeQueue:    make(chan WriteOperation, 1000),
        batchSize:     10,
        flushInterval: 5 * time.Second,
    }

    // Start background writer
    wbc.wg.Add(1)
    go wbc.backgroundWriter()

    return wbc
}

func (w *WriteBehindCache) Write(key string, value interface{}, table string) error {
    // Step 1: Write to cache immediately
    valueJSON, err := json.Marshal(value)
    if err != nil {
        return fmt.Errorf("failed to marshal value: %w", err)
    }

    if err := w.cache.Set(w.ctx, key, valueJSON, 0).Err(); err != nil {
        return fmt.Errorf("failed to write to cache: %w", err)
    }

    // Step 2: Queue for database write
    select {
    case w.writeQueue <- WriteOperation{Key: key, Value: value, Table: table}:
        return nil
    default:
        return fmt.Errorf("write queue is full")
    }
}

func (w *WriteBehindCache) backgroundWriter() {
    defer w.wg.Done()
    
    ticker := time.NewTicker(w.flushInterval)
    defer ticker.Stop()
    
    batch := make([]WriteOperation, 0, w.batchSize)

    for {
        select {
        case op, ok := <-w.writeQueue:
            if !ok {
                // Channel closed, flush remaining and exit
                w.flushBatch(batch)
                return
            }
            
            batch = append(batch, op)
            if len(batch) >= w.batchSize {
                w.flushBatch(batch)
                batch = batch[:0] // Reset slice
            }

        case <-ticker.C:
            if len(batch) > 0 {
                w.flushBatch(batch)
                batch = batch[:0] // Reset slice
            }
        }
    }
}

func (w *WriteBehindCache) flushBatch(operations []WriteOperation) {
    if len(operations) == 0 {
        return
    }

    tx, err := w.db.Begin()
    if err != nil {
        log.Printf("Failed to start transaction: %v", err)
        return
    }
    defer tx.Rollback()

    success := 0
    for _, op := range operations {
        if err := w.writeToDatabase(tx, op); err != nil {
            log.Printf("Failed to write %s to database: %v", op.Key, err)
        } else {
            success++
        }
    }

    if err := tx.Commit(); err != nil {
        log.Printf("Failed to commit batch: %v", err)
        return
    }

    log.Printf("Flushed batch: %d/%d operations successful", success, len(operations))
}

func (w *WriteBehindCache) writeToDatabase(tx *sql.Tx, op WriteOperation) error {
    // This is a simplified example - in practice, you'd have
    // specific logic for different tables and data types
    switch op.Table {
    case "users":
        user := op.Value.(*User)
        query := `INSERT OR REPLACE INTO users (id, username, email, updated_at) 
                  VALUES (?, ?, ?, ?)`
        _, err := tx.Exec(query, user.ID, user.Username, user.Email, user.UpdatedAt)
        return err

    case "products":
        product := op.Value.(*Product)
        query := `INSERT OR REPLACE INTO products (id, name, price, updated_at) 
                  VALUES (?, ?, ?, ?)`
        _, err := tx.Exec(query, product.ID, product.Name, product.Price, product.UpdatedAt)
        return err

    default:
        return fmt.Errorf("unsupported table: %s", op.Table)
    }
}

func (w *WriteBehindCache) Close() {
    close(w.writeQueue)
    w.wg.Wait()
}

func main() {
    // Setup
    cache := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
    defer cache.Close()

    db, _ := sql.Open("sqlite3", ":memory:")
    defer db.Close()

    wbc := NewWriteBehindCache(cache, db)
    defer wbc.Close()

    // Test writes
    user := &User{ID: 1, Username: "john", Email: "john@example.com", UpdatedAt: time.Now()}
    
    if err := wbc.Write("user:1", user, "users"); err != nil {
        log.Printf("Write failed: %v", err)
    } else {
        log.Println("Write successful (immediate cache, async DB)")
    }

    // Wait for background processing
    time.Sleep(6 * time.Second)
}

Exercise 3: Cache Stampede Prevention (10 minutes)

Objective: Implement cache stampede prevention using distributed locking

Solution:

package main

import (
    "context"
    "crypto/rand"
    "encoding/hex"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

// StampedeProtectedCache prevents cache stampede using distributed locks
type StampedeProtectedCache struct {
    client *redis.Client
    ctx    context.Context
}

func NewStampedeProtectedCache(client *redis.Client) *StampedeProtectedCache {
    return &StampedeProtectedCache{
        client: client,
        ctx:    context.Background(),
    }
}

func (s *StampedeProtectedCache) GetOrCompute(key string, ttl time.Duration, 
    computeFunc func() (interface{}, error)) (interface{}, error) {
    
    // Try to get from cache first
    val, err := s.client.Get(s.ctx, key).Result()
    if err == nil {
        return val, nil // Cache hit
    } else if err != redis.Nil {
        return nil, fmt.Errorf("cache error: %w", err)
    }

    // Cache miss - try to acquire lock
    lockKey := key + ":lock"
    lockValue := s.generateLockValue()
    lockTTL := 30 * time.Second

    acquired, err := s.client.SetNX(s.ctx, lockKey, lockValue, lockTTL).Result()
    if err != nil {
        return nil, fmt.Errorf("failed to acquire lock: %w", err)
    }

    if acquired {
        // We got the lock - compute the value
        defer s.releaseLock(lockKey, lockValue)
        
        // Double-check cache (might have been populated while waiting)
        val, err := s.client.Get(s.ctx, key).Result()
        if err == nil {
            return val, nil
        }

        // Compute the value
        result, err := computeFunc()
        if err != nil {
            return nil, fmt.Errorf("compute function failed: %w", err)
        }

        // Store in cache
        if err := s.client.Set(s.ctx, key, result, ttl).Err(); err != nil {
            log.Printf("Failed to cache computed value: %v", err)
            // Return result anyway
        }

        return result, nil
    } else {
        // Someone else has the lock - wait and retry
        time.Sleep(100 * time.Millisecond)
        
        // Try cache again
        for attempts := 0; attempts < 30; attempts++ {
            val, err := s.client.Get(s.ctx, key).Result()
            if err == nil {
                return val, nil
            }
            time.Sleep(100 * time.Millisecond)
        }

        // Fallback - compute without lock (emergency)
        return computeFunc()
    }
}

func (s *StampedeProtectedCache) generateLockValue() string {
    bytes := make([]byte, 8)
    rand.Read(bytes)
    return hex.EncodeToString(bytes)
}

func (s *StampedeProtectedCache) releaseLock(lockKey, lockValue string) {
    // Use Lua script for atomic release
    script := `
        if redis.call("GET", KEYS[1]) == ARGV[1] then
            return redis.call("DEL", KEYS[1])
        else
            return 0
        end
    `
    s.client.Eval(s.ctx, script, []string{lockKey}, lockValue)
}

func main() {
    client := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
    defer client.Close()

    cache := NewStampedeProtectedCache(client)

    // Simulate expensive computation
    expensiveCompute := func() (interface{}, error) {
        log.Println("Computing expensive value...")
        time.Sleep(2 * time.Second) // Simulate slow operation
        return "computed_value_" + time.Now().Format("15:04:05"), nil
    }

    // Simulate multiple concurrent requests
    for i := 0; i < 5; i++ {
        go func(id int) {
            result, err := cache.GetOrCompute("expensive:key", 
                                             5*time.Minute, expensiveCompute)
            if err != nil {
                log.Printf("Request %d failed: %v", id, err)
            } else {
                log.Printf("Request %d got result: %v", id, result)
            }
        }(i)
    }

    time.Sleep(5 * time.Second) // Wait for all requests to complete
}

7. Common Pitfalls and Troubleshooting (10 minutes)

Frequent Mistakes

  1. Problem: Cache and database getting out of sync Cause: Not invalidating cache after database updates Solution: Always invalidate cache after writes, or use write-through pattern

  2. Problem: Cache stampede overwhelming the database Cause: Multiple requests trying to populate expired cache simultaneously Solution: Use distributed locking or jittered TTLs

  3. Problem: Memory issues from unbounded cache growth Cause: No TTL set on cache entries Solution: Always set appropriate TTL and configure eviction policies

  4. Problem: Poor cache hit ratio Cause: TTL too short, cache keys not optimized, or wrong caching strategy Solution: Monitor hit ratios, optimize TTL, and review caching patterns

Debugging Tips

// Cache performance monitoring
type CacheMetrics struct {
    hits, misses, errors int64
    totalLatency        time.Duration
    mutex               sync.RWMutex
}

func (m *CacheMetrics) RecordOperation(hit bool, latency time.Duration, err error) {
    m.mutex.Lock()
    defer m.mutex.Unlock()
    
    if err != nil {
        m.errors++
    } else if hit {
        m.hits++
    } else {
        m.misses++
    }
    
    m.totalLatency += latency
}

// TTL analysis
func analyzeTTL(client *redis.Client, pattern string) {
    keys, _ := client.Keys(context.Background(), pattern).Result()
    
    for _, key := range keys {
        ttl, _ := client.TTL(context.Background(), key).Result()
        keyType, _ := client.Type(context.Background(), key).Result()
        size, _ := client.MemoryUsage(context.Background(), key).Result()
        
        fmt.Printf("Key: %s, Type: %s, TTL: %v, Size: %d bytes\n", 
                   key, keyType, ttl, size)
    }
}

Performance Considerations

  • Monitor cache hit ratios (aim for >80%)
  • Use pipelining for multiple cache operations
  • Consider cache warming for predictable access patterns
  • Implement proper monitoring and alerting
  • Size cache appropriately for your workload

8. Best Practices and Professional Tips (5 minutes)

Industry Standards

  • Cache-Aside for Read-Heavy: Most flexible and commonly used
  • Write-Through for Consistency: When data consistency is critical
  • Write-Behind for Performance: When write performance is paramount
  • TTL Strategy: Set based on data change frequency and business requirements
  • Monitoring: Track hit ratios, latency, and error rates

Performance Optimization

// Optimal cache configuration
func configureCache() *redis.Client {
    return redis.NewClient(&redis.Options{
        Addr:         "localhost:6379",
        PoolSize:     20,                    // Adjust based on concurrency
        MinIdleConns: 10,                    // Keep connections warm
        MaxRetries:   3,                     // Retry failed operations
        ReadTimeout:  200 * time.Millisecond, // Fast timeout for cache
        WriteTimeout: 200 * time.Millisecond,
    })
}

// Batch operations for efficiency
func batchCacheOperations(client *redis.Client, operations map[string]string) error {
    pipe := client.Pipeline()
    
    for key, value := range operations {
        pipe.Set(context.Background(), key, value, time.Hour)
    }
    
    _, err := pipe.Exec(context.Background())
    return err
}

Next Steps in Your Learning Journey

  • Explore Redis pub/sub for cache invalidation events
  • Learn about Redis modules for specialized caching (RedisJSON, RedisGraph)
  • Study distributed caching patterns and consistency models
  • Master cache warming and pre-loading strategies
  • Investigate CDN integration for global caching

9. Summary and Review (5 minutes)

Key Takeaways

  1. Choose the right pattern based on your consistency and performance requirements
  2. Cache-aside is most flexible but requires careful invalidation management
  3. Write-through ensures consistency at the cost of write performance
  4. Write-behind maximizes performance but requires careful error handling
  5. Always implement fallback mechanisms for cache failures
  6. Monitor cache performance to optimize hit ratios and identify issues

Pattern Selection Guide

Pattern Best For Consistency Performance Complexity
Cache-Aside Read-heavy workloads Eventually Good Low
Write-Through Strong consistency Strong Moderate Medium
Write-Behind Write-heavy workloads Eventually Excellent High
Refresh-Ahead Predictable access Strong Excellent High

Quick Review Questions

  1. What’s the main difference between cache-aside and write-through patterns?
  2. How do you prevent cache stampede problems?
  3. When would you choose write-behind over write-through caching?
  4. What metrics should you monitor for cache performance?
  5. How do you handle cache failures gracefully?

For Instructors

Teaching Tips

  • Use real-world examples (Netflix, Amazon) to demonstrate caching impact
  • Show performance comparisons with and without caching
  • Demonstrate cache failures and recovery scenarios
  • Use monitoring tools to show cache hit ratios in real-time
  • Encourage students to think about consistency trade-offs

Common Student Questions

  • Q: “Which caching pattern should I use?” A: Start with cache-aside for simplicity, then consider others based on specific requirements

  • Q: “How do I know if my cache is working well?” A: Monitor hit ratio (>80% is good), response times, and error rates

  • Q: “What happens if Redis goes down?” A: Implement graceful degradation - your app should still work, just slower

Assessment Ideas

  • Design caching strategy for a given application scenario
  • Implement cache monitoring and alerting
  • Debug cache consistency issues
  • Performance testing with different caching patterns
  • Build a complete cache layer with fallback mechanisms

Adaptation Notes

  • Beginners: Focus on cache-aside pattern and basic monitoring
  • Advanced students: Cover write-behind, distributed locking, and advanced monitoring
  • Time constraints: Prioritize cache-aside and write-through patterns
  • Hands-on focus: Spend more time on exercises and less on theory