Skip to main content

Command Palette

Search for a command to run...

Redis Caching Patterns for High-Performance Microservices

Published
8 min read
E

Backend Developer | Golang & Python I enjoy building reliable APIs, distributed systems, and automation tools. Writing here about backend engineering, system design, and real-world dev experiences.

Introduction

I once built an order service that hit the database for every single request. Worked fine during development. Then we launched.

Load test showed P99 latency at 800ms. Database CPU spiked to 90%. Under real traffic, the service would melt down.

So I added Redis caching. P99 latency dropped to 50ms. Database load went down 80%. Same hardware, 10x better performance.

Caching seems simple until you start asking questions: What do I cache? For how long? How do I invalidate? What about consistency?

In this post, I'll show you the caching patterns I use in production microservices. These aren't academic—these are the patterns that keep services running at scale.

Why Redis?

There are other caching options (Memcached, in-memory maps, etc.), but Redis wins for microservices:

  • Fast: Sub-millisecond latency

  • Rich data structures: Strings, hashes, lists, sets, sorted sets

  • TTL support: Auto-expire old data

  • Persistence: Can survive restarts

  • Clustering: Scales horizontally

  • Pub/Sub: Bonus feature for cache invalidation

The Cache-Aside Pattern

This is the most common pattern. Application checks cache first, falls back to database on miss.

type OrderRepository struct {
    db    *mongo.Collection
    cache *redis.Client
}

func (r *OrderRepository) FindByID(ctx context.Context, orderID string) (*Order, error) {
    // 1. Try cache first
    cacheKey := fmt.Sprintf("order:%s", orderID)
    cached, err := r.cache.Get(ctx, cacheKey).Result()

    if err == nil {
        // Cache hit
        var order Order
        if err := json.Unmarshal([]byte(cached), &order); err == nil {
            return &order, nil
        }
    }

    // 2. Cache miss - query database
    var order Order
    err = r.db.FindOne(ctx, bson.M{"_id": orderID}).Decode(&order)
    if err != nil {
        if err == mongo.ErrNoDocuments {
            return nil, ErrOrderNotFound
        }
        return nil, err
    }

    // 3. Store in cache for next time
    orderJSON, _ := json.Marshal(order)
    r.cache.Set(ctx, cacheKey, orderJSON, 5*time.Minute)

    return &order, nil
}

Pros:

  • Simple to implement

  • Cache only what's requested

  • Failure-tolerant (cache down = slower, not broken)

Cons:

  • First request always misses (cold start)

  • Extra latency on cache miss

Cache Invalidation Strategies

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

Strategy 1: Time-Based (TTL)

Set an expiration time. After that, data is evicted.

// Cache for 5 minutes
r.cache.Set(ctx, key, value, 5*time.Minute)

When to use:

  • Data changes infrequently

  • Stale data is acceptable for short periods

  • Simple, no coordination needed

Example: Product catalog (changes once a day, 1-hour TTL is fine)

Strategy 2: Write-Through

Update cache whenever you update the database.

func (r *OrderRepository) Update(ctx context.Context, order *Order) error {
    // 1. Update database
    filter := bson.M{"_id": order.ID, "version": order.Version - 1}
    update := bson.M{"$set": order}

    result, err := r.db.UpdateOne(ctx, filter, update)
    if err != nil {
        return err
    }

    if result.MatchedCount == 0 {
        return ErrOptimisticLockFailed
    }

    // 2. Update cache
    cacheKey := fmt.Sprintf("order:%s", order.ID)
    orderJSON, _ := json.Marshal(order)
    r.cache.Set(ctx, cacheKey, orderJSON, 5*time.Minute)

    return nil
}

Pros:

  • Cache always has latest data

  • No stale data issues

Cons:

  • More complex (two writes)

  • What if cache write fails?

Strategy 3: Write-Invalidate

Delete from cache when you update the database.

func (r *OrderRepository) Update(ctx context.Context, order *Order) error {
    // 1. Update database
    if err := r.updateInDB(ctx, order); err != nil {
        return err
    }

    // 2. Invalidate cache
    cacheKey := fmt.Sprintf("order:%s", order.ID)
    r.cache.Del(ctx, cacheKey)

    // Next read will cache fresh data from DB
    return nil
}

Pros:

  • Simpler than write-through

  • No stale data

Cons:

  • First read after update is a cache miss

This is my preferred approach. Simple and effective.

What to Cache (and What Not to Cache)

Not everything should be cached. Here's my decision tree:

✅ CACHE THESE:

1. Hot data (frequently accessed):

// User profiles, popular products, etc.
r.cache.Set(ctx, key, value, 10*time.Minute)

2. Expensive queries:

// Complex aggregations, joins, etc.
// Cache longer since they're expensive to recompute
r.cache.Set(ctx, key, value, 30*time.Minute)

3. External API calls:

// Third-party APIs (payment gateways, shipping APIs)
// Cache to reduce costs and improve reliability
r.cache.Set(ctx, key, value, 15*time.Minute)

4. Session data:

// User sessions, shopping carts
r.cache.Set(ctx, "session:"+sessionID, data, 30*time.Minute)

❌ DON'T CACHE THESE:

1. Data that changes frequently:

// Stock prices, live sports scores
// By the time you cache it, it's stale

2. User-specific data at scale:

// If you have millions of users, caching per-user data blows up memory
// Cache only for active users

3. Large objects:

// Images, videos, large documents
// Use CDN or object storage instead

4. Data that must be consistent:

// Financial transactions, inventory counts
// Read from database for critical operations

Caching Patterns by Use Case

Pattern 1: List Caching

Cache expensive list queries:

func (r *OrderRepository) FindByCustomerID(ctx context.Context, customerID string) ([]*Order, error) {
    cacheKey := fmt.Sprintf("orders:customer:%s", customerID)

    // Try cache
    cached, err := r.cache.Get(ctx, cacheKey).Result()
    if err == nil {
        var orders []*Order
        json.Unmarshal([]byte(cached), &orders)
        return orders, nil
    }

    // Query database
    cursor, err := r.db.Find(ctx, bson.M{"customerId": customerID})
    if err != nil {
        return nil, err
    }

    var orders []*Order
    cursor.All(ctx, &orders)

    // Cache for 2 minutes (shorter TTL for user-specific data)
    ordersJSON, _ := json.Marshal(orders)
    r.cache.Set(ctx, cacheKey, ordersJSON, 2*time.Minute)

    return orders, nil
}

Invalidation strategy:

func (r *OrderRepository) Create(ctx context.Context, order *Order) error {
    if err := r.db.InsertOne(ctx, order); err != nil {
        return err
    }

    // Invalidate customer's order list
    cacheKey := fmt.Sprintf("orders:customer:%s", order.CustomerID)
    r.cache.Del(ctx, cacheKey)

    return nil
}

Pattern 2: Computed Values

Cache expensive calculations:

func (s *AnalyticsService) GetDailySales(ctx context.Context, date time.Time) (float64, error) {
    cacheKey := fmt.Sprintf("sales:daily:%s", date.Format("2006-01-02"))

    // Try cache
    cached, err := s.cache.Get(ctx, cacheKey).Result()
    if err == nil {
        sales, _ := strconv.ParseFloat(cached, 64)
        return sales, nil
    }

    // Expensive aggregation query
    pipeline := []bson.M{
        {"$match": bson.M{"date": date}},
        {"$group": bson.M{"_id": nil, "total": bson.M{"$sum": "$amount"}}},
    }

    var result []struct{ Total float64 }
    s.db.Aggregate(ctx, pipeline).All(ctx, &result)

    total := 0.0
    if len(result) > 0 {
        total = result[0].Total
    }

    // Cache for 24 hours (past data doesn't change)
    s.cache.Set(ctx, cacheKey, fmt.Sprintf("%f", total), 24*time.Hour)

    return total, nil
}

Pattern 3: Negative Caching

Cache "not found" results to prevent repeated DB hits:

func (r *OrderRepository) FindByID(ctx context.Context, orderID string) (*Order, error) {
    cacheKey := fmt.Sprintf("order:%s", orderID)

    cached, err := r.cache.Get(ctx, cacheKey).Result()
    if err == nil {
        if cached == "NOT_FOUND" {
            return nil, ErrOrderNotFound  // cached negative result
        }

        var order Order
        json.Unmarshal([]byte(cached), &order)
        return &order, nil
    }

    // Query database
    var order Order
    err = r.db.FindOne(ctx, bson.M{"_id": orderID}).Decode(&order)
    if err == mongo.ErrNoDocuments {
        // Cache the "not found" result for 1 minute
        r.cache.Set(ctx, cacheKey, "NOT_FOUND", 1*time.Minute)
        return nil, ErrOrderNotFound
    }
    if err != nil {
        return nil, err
    }

    // Cache the order
    orderJSON, _ := json.Marshal(order)
    r.cache.Set(ctx, cacheKey, orderJSON, 5*time.Minute)

    return &order, nil
}

This prevents attackers from hammering your DB with invalid IDs.

Cache Warming

Don't wait for users to populate the cache. Pre-load hot data:

func (s *CacheWarmingService) WarmCache() error {
    // Get top 100 most popular products
    products, err := s.db.Find(ctx, bson.M{}).Sort("-views").Limit(100)
    if err != nil {
        return err
    }

    // Pre-cache them
    for _, product := range products {
        cacheKey := fmt.Sprintf("product:%s", product.ID)
        productJSON, _ := json.Marshal(product)
        s.cache.Set(ctx, cacheKey, productJSON, 30*time.Minute)
    }

    logger.Info("cache warmed", zap.Int("products", len(products)))
    return nil
}

// Run on startup and periodically
func main() {
    service := NewCacheWarmingService()
    service.WarmCache()  // on startup

    // Refresh every hour
    ticker := time.NewTicker(1 * time.Hour)
    go func() {
        for range ticker.C {
            service.WarmCache()
        }
    }()
}

Handling Cache Failures

Redis goes down. Now what?

Strategy: Graceful Degradation

func (r *OrderRepository) FindByID(ctx context.Context, orderID string) (*Order, error) {
    // Try cache, but don't fail if it's down
    cacheKey := fmt.Sprintf("order:%s", orderID)
    cached, err := r.cache.Get(ctx, cacheKey).Result()

    if err == nil {
        var order Order
        if err := json.Unmarshal([]byte(cached), &order); err == nil {
            return &order, nil
        }
    } else if err != redis.Nil {
        // Cache is down (not just a miss)
        logger.Warn("cache unavailable", zap.Error(err))
    }

    // Fall back to database
    var order Order
    err = r.db.FindOne(ctx, bson.M{"_id": orderID}).Decode(&order)
    if err != nil {
        return nil, err
    }

    // Try to cache, but don't fail if it errors
    orderJSON, _ := json.Marshal(order)
    if err := r.cache.Set(ctx, cacheKey, orderJSON, 5*time.Minute).Err(); err != nil {
        logger.Warn("failed to cache order", zap.Error(err))
    }

    return &order, nil
}

Key: Cache failures don't break your service. They just make it slower.

Monitoring Cache Performance

Track these metrics:

var (
    cacheHits = promauto.NewCounter(prometheus.CounterOpts{
        Name: "cache_hits_total",
        Help: "Total cache hits",
    })

    cacheMisses = promauto.NewCounter(prometheus.CounterOpts{
        Name: "cache_misses_total",
        Help: "Total cache misses",
    })

    cacheErrors = promauto.NewCounter(prometheus.CounterOpts{
        Name: "cache_errors_total",
        Help: "Total cache errors",
    })
)

func (r *OrderRepository) FindByID(ctx context.Context, orderID string) (*Order, error) {
    cached, err := r.cache.Get(ctx, cacheKey).Result()

    if err == nil {
        cacheHits.Inc()
        // ...
    } else if err == redis.Nil {
        cacheMisses.Inc()
        // ...
    } else {
        cacheErrors.Inc()
        // ...
    }
}

Track:

  • Hit rate (hits / (hits + misses)) - aim for >80%

  • Miss rate - sudden spikes indicate cache eviction

  • Error rate - indicates Redis issues

  • Latency - cache should be <5ms p99

Common Caching Mistakes

  1. Caching everything: Wastes memory, doesn't improve performance.

  2. TTL too long: Stale data everywhere.

  3. TTL too short: Cache never hits, constant DB load.

  4. Not handling cache failures: Service breaks when Redis is down.

  5. Ignoring memory limits: Redis runs out of memory, starts evicting.

  6. Caching large objects: Serialization overhead kills performance.

Wrapping Up

Caching is one of the highest-leverage optimizations you can make. A well-placed cache can 10x your throughput and slash latency.

Start simple:

  1. Identify your slowest queries

  2. Add cache-aside pattern with TTL

  3. Monitor hit rates

  4. Tune TTLs based on data

Once you see the benefits, expand to more sophisticated patterns.

Next up: Optimistic locking and handling race conditions in distributed systems.

Questions? Drop a comment. Caching has a lot of nuances—happy to discuss specific use cases.

Resources


Thanks for reading! This is part of my microservices series. Follow along for more posts on concurrency, deployment, and production patterns.

More from this blog

eshah.dev

16 posts