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:
- Cache-Aside Pattern - The most common caching strategy with manual cache management
- Write-Through Caching - Synchronous writes to both cache and database
- Write-Behind (Write-Back) - Asynchronous writes for better performance
- Refresh-Ahead - Proactive cache warming to prevent cache misses
- Cache Invalidation - Strategies for maintaining data consistency
- Fallback Mechanisms - Handling cache failures gracefully
- 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:
- Application checks cache for data
- If cache hit: return cached data
- 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:
- Application writes data to cache and database simultaneously
- Write operation succeeds only when both succeed
- 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:
- Application writes data to cache immediately
- Cache asynchronously writes to database in background
- 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
-
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
-
Problem: Cache stampede overwhelming the database Cause: Multiple requests trying to populate expired cache simultaneously Solution: Use distributed locking or jittered TTLs
-
Problem: Memory issues from unbounded cache growth Cause: No TTL set on cache entries Solution: Always set appropriate TTL and configure eviction policies
-
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
- Choose the right pattern based on your consistency and performance requirements
- Cache-aside is most flexible but requires careful invalidation management
- Write-through ensures consistency at the cost of write performance
- Write-behind maximizes performance but requires careful error handling
- Always implement fallback mechanisms for cache failures
- 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
- What’s the main difference between cache-aside and write-through patterns?
- How do you prevent cache stampede problems?
- When would you choose write-behind over write-through caching?
- What metrics should you monitor for cache performance?
- 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