Object caching is fundamentally about trading CPU cycles for I/O.

Here’s a look at a common scenario using Redis as our cache backend. Imagine a web application that frequently needs to fetch user profiles. Without caching, each request for a user profile would hit the database, execute a query, and then serialize the data for the API response. This can become a bottleneck, especially with high traffic.

Let’s see how a simple Redis cache can improve this.

The Scenario: Fetching User Profiles

Our application has a UserService that handles fetching user data.

package main

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

	"github.com/go-redis/redis/v8"
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
)

// User represents a user entity
type User struct {
	ID    uint   `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}

// UserService handles user operations
type UserService struct {
	db    *gorm.DB
	redis *redis.Client
}

// NewUserService creates a new UserService
func NewUserService(db *gorm.DB, redis *redis.Client) *UserService {
	return &UserService{db: db, redis: redis}
}

// GetUserByID fetches a user, prioritizing the cache
func (s *UserService) GetUserByID(ctx context.Context, userID uint) (*User, error) {
	cacheKey := fmt.Sprintf("user:%d", userID)

	// 1. Try to get from cache
	val, err := s.redis.Get(ctx, cacheKey).Result()
	if err == nil {
		var user User
		if err := json.Unmarshal([]byte(val), &user); err == nil {
			log.Printf("Cache hit for user %d", userID)
			return &user, nil
		}
		// If unmarshalling fails, it means the cached data is corrupt,
		// so we'll proceed to fetch from DB as if it was a cache miss.
		log.Printf("Cache corrupted for user %d: %v", userID, err)
	} else if err != redis.Nil {
		// Log other Redis errors but don't treat as a cache miss
		log.Printf("Redis error for user %d: %v", userID, err)
	}

	// 2. Cache miss or error, fetch from database
	log.Printf("Cache miss for user %d, fetching from DB", userID)
	var user User
	if result := s.db.First(&user, userID); result.Error != nil {
		return nil, result.Error
	}

	// 3. Serialize user data for caching
	userBytes, err := json.Marshal(user)
	if err != nil {
		log.Printf("Failed to marshal user %d: %v", userID, err)
		return &user, nil // Return user even if caching fails
	}

	// 4. Store in cache with a TTL (e.g., 5 minutes)
	err = s.redis.Set(ctx, cacheKey, userBytes, 5*time.Minute).Err()
	if err != nil {
		log.Printf("Failed to set cache for user %d: %v", userID, err)
	}

	return &user, nil
}

func main() {
	// --- Database Setup (PostgreSQL) ---
	// Replace with your actual DB connection string
	dsn := "host=localhost user=gorm password=gorm dbname=gorm port=5432 sslmode=disable TimeZone=Asia/Shanghai"
	db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
	if err != nil {
		log.Fatalf("Failed to connect to database: %v", err)
	}
	log.Println("Database connected")
	db.AutoMigrate(&User{}) // Ensure User table exists

	// Seed some data if no users exist
	var count int64
	db.Model(&User{}).Count(&count)
	if count == 0 {
		db.Create(&User{Name: "Alice", Email: "alice@example.com"})
		db.Create(&User{Name: "Bob", Email: "bob@example.com"})
		log.Println("Seeded database with users")
	}

	// --- Redis Setup ---
	rdb := redis.NewClient(&redis.Options{
		Addr:     "localhost:6379", // Replace with your Redis address
		Password: "",               // No password by default
		DB:       0,                // Default DB
	})

	pong, err := rdb.Ping(context.Background()).Result()
	if err != nil {
		log.Fatalf("Failed to connect to Redis: %v", err)
	}
	log.Printf("Redis connected: %s", pong)

	// --- Service Initialization ---
	userService := NewUserService(db, rdb)

	// --- Usage Example ---
	ctx := context.Background()
	userID := uint(1)

	// First call: Cache miss, fetches from DB, stores in cache
	log.Println("--- First call ---")
	user1, err := userService.GetUserByID(ctx, userID)
	if err != nil {
		log.Fatalf("Error getting user %d: %v", userID, err)
	}
	log.Printf("Retrieved user: %+v\n", user1)

	// Second call: Cache hit, retrieves directly from Redis
	log.Println("\n--- Second call ---")
	user2, err := userService.GetUserByID(ctx, userID)
	if err != nil {
		log.Fatalf("Error getting user %d: %v", userID, err)
	}
	log.Printf("Retrieved user: %+v\n", user2)

	// Verify cache content (optional, for demonstration)
	cachedVal, err := rdb.Get(ctx, fmt.Sprintf("user:%d", userID)).Result()
	if err == nil {
		log.Printf("\nDirectly from Redis: %s\n", cachedVal)
	} else {
		log.Printf("\nFailed to get from Redis directly: %v\n", err)
	}
}

When GetUserByID is called the first time for user:1, Redis won’t have the key. The code logs a "Cache miss," fetches the user from the PostgreSQL database, serializes the User struct into JSON, and then stores this JSON string in Redis with a Time-To-Live (TTL) of 5 minutes. The second call for user:1 finds the data in Redis. It retrieves the JSON string, unmarshals it back into a User struct, logs "Cache hit," and returns the user data. This bypasses the database entirely, making the retrieval much faster.

The core idea is to store the serialized representation of your entities. This is because most caching systems, like Redis, Memcached, or even in-memory maps, store data as byte strings or simple key-value pairs. Your application’s domain objects (like the User struct) are complex in-memory structures. To store them in a cache, you must convert them into a storable format (like JSON or Protocol Buffers) and then, when retrieving, convert them back.

The levers you control are primarily:

  • Cache Key Design: How you name the keys in your cache is crucial. A common pattern is object_type:id (e.g., user:123, product:456). This ensures uniqueness and allows for easy lookup.
  • Serialization Format: JSON is human-readable and widely supported. Protocol Buffers or MessagePack offer more compact serialization and faster parsing, which can be beneficial for high-throughput systems.
  • Time-To-Live (TTL): This determines how long data remains in the cache. A shorter TTL means fresher data but more cache misses. A longer TTL reduces database load but risks serving stale data. Choosing the right TTL depends on how frequently the underlying data changes and how critical real-time accuracy is.
  • Cache Invalidation Strategy: What happens when the underlying data changes? You need a mechanism to remove or update the stale cache entry. This could be:
    • Write-through: Update the cache immediately after updating the database.
    • Write-behind: Update the database first, then asynchronously update the cache.
    • Cache-aside (as shown above): Let the application logic handle cache misses and updates. When data is updated in the DB, explicitly delete the corresponding cache key. The next read will then miss, fetch the new data, and repopulate the cache.

The most surprising thing about object caching is how often the cache itself becomes the bottleneck, not by being too slow, but by being too full. When a cache like Redis runs out of memory, it needs to evict items. The default eviction policies (like LRU - Least Recently Used) are generally good, but if your cache is undersized or your access patterns are highly skewed, you can end up with a thundering herd of cache misses as Redis constantly swaps items in and out to make space.

Once you’ve mastered object caching with a key-value store like Redis, the next logical step is exploring distributed caching patterns and cache coherency strategies across multiple application instances.

Want structured learning?

Take the full Caching-strategies course →