Cache Patterns & Strategies — Deep Dive

Level: Intermediate Pre-reading: 03 · Microservices Patterns · 09 · Deployment & Infrastructure


Caching Layers

graph LR
    Client["Client Browser"] -->|Browser cache<br/>304 Not Modified| CDN["CDN<br/>CloudFront"]
    CDN -->|App cache<br/>Redis/Memcached| APP["Application<br/>Order Service"]
    APP -->|DB cache<br/>Row cache| DB["Database<br/>PostgreSQL"]

    style Client fill:#e1f5ff
    style CDN fill:#fff3e0
    style APP fill:#f3e5f5
    style DB fill:#e8f5e9
Layer Examples TTL Invalidation
Browser/CDN Static assets, HTML Hours to days Cache headers; URL versioning
Application API responses, objects Minutes to hours Event-based; TTL
Database Row-level caching Milliseconds to seconds Query result cache; invalidation on write
Hardware CPU L1/L2/L3 cache Nanoseconds Automatic; CPU managed

Cache-Aside Pattern (Lazy Loading)

Logic: Check cache → miss → load from DB → store in cache → return.

@Service
public class ProductService {

    @Autowired
    private RedisTemplate<String, Product> redisTemplate;

    @Autowired
    private ProductRepository productRepository;

    public Product getProduct(String productId) {
        // 1. Check cache
        String cacheKey = "product:" + productId;
        Product cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return cached;  // Cache hit; return immediately
        }

        // 2. Cache miss; load from DB
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new ProductNotFoundException());

        // 3. Store in cache (with TTL of 1 hour)
        redisTemplate.opsForValue().set(
            cacheKey,
            product,
            Duration.ofHours(1)
        );

        // 4. Return
        return product;
    }
}

Pros & Cons

Pros Cons
Simple to implement Cache warming on startup needed; slow first request
Only caches accessed items Stale data possible (expired items)
Easy to remove cached items Cache stampede: many threads reload same key

Write-Through Pattern

Logic: Write to cache AND DB together.

@Service
public class ProductService {

    public void updateProduct(String productId, Product product) {
        // 1. Write to cache
        String cacheKey = "product:" + productId;
        redisTemplate.opsForValue().set(cacheKey, product, Duration.ofHours(1));

        // 2. Write to DB
        productRepository.save(product);
    }
}

Pros & Cons

Pros Cons
Cache always consistent with DB Writes slower (must wait for both DB + cache)
No stale reads possible Extra load on cache during writes

Write-Behind (Write-Back) Pattern

Logic: Write to cache immediately; async flush to DB.

@Service
public class ProductService {

    @Autowired
    private RedisTemplate<String, Product> redisTemplate;

    public void updateProduct(String productId, Product product) {
        // 1. Write to cache immediately (fast)
        String cacheKey = "product:" + productId;
        redisTemplate.opsForValue().set(
            cacheKey,
            product,
            Duration.ofHours(1)
        );

        // 2. Async write to DB (deferred)
        asyncDbWriter.schedule(() -> {
            productRepository.save(product);
        }, Duration.ofSeconds(5));
    }
}

Pros & Cons

Pros Cons
Writes fast (cache only) Stale DB; data loss if cache fails before flush
Batching: flush 10 updates in 1 DB call Complex; need flush logic

When to Use Write-Behind

  • High-write scenarios: Analytics, counters, logs
  • Non-critical data: Recommendations, statistics

When NOT to Use

  • Financial transactions: Critical data must be in DB immediately
  • Inventory: Overselling risk if cache fails

Cache Invalidation Strategies

Two hard problems in CS: Cache invalidation and naming things.

TTL-Based (Time-To-Live)

// Cache expires after 1 hour
redisTemplate.opsForValue().set(
    "product:123",
    product,
    Duration.ofHours(1)  // TTL
);

Pro: Simple; automatic cleanup. Con: Stale data until expiry.

Event-Based

@Service
public class ProductService {

    @Autowired
    private RedisTemplate<String, Product> redisTemplate;

    @Autowired
    private ApplicationEventPublisher eventPublisher;

    public void updateProduct(String productId, Product product) {
        // Update DB
        productRepository.save(product);

        // Invalidate cache
        redisTemplate.delete("product:" + productId);

        // Publish event for other services
        eventPublisher.publishEvent(new ProductUpdatedEvent(productId));
    }
}

Pro: Immediate consistency. Con: Must remember to invalidate everywhere.

Cache-Aside with Versioning

@Service
public class ProductService {

    public Product getProduct(String productId) {
        // Version-based cache key
        int version = productVersionRepository.getVersion(productId);
        String cacheKey = "product:" + productId + ":v" + version;

        Product cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return cached;
        }

        Product product = productRepository.findById(productId).orElseThrow();
        redisTemplate.opsForValue().set(cacheKey, product, Duration.ofHours(1));
        return product;
    }

    public void updateProduct(String productId, Product product) {
        productRepository.save(product);
        productVersionRepository.incrementVersion(productId);
        // Old cache keys become orphaned; automatic cleanup via TTL
    }
}

Pro: No explicit invalidation needed. Con: Cache fragmentation over time.


Cache Stampede Prevention

Problem: Multiple requests hit expired cache key; all reload from DB simultaneously.

Cache expires: product:123
100 requests arrive at same time
All miss cache → all query DB
DB gets 100 queries; slow

Solution 1: Probabilistic TTL

public Product getProduct(String productId) {
    Product cached = redisTemplate.opsForValue().get("product:" + productId);

    if (cached != null && !shouldRefresh()) {
        return cached;  // Use stale cache
    }

    // Refresh cache
    Product fresh = productRepository.findById(productId).orElseThrow();
    redisTemplate.opsForValue().set("product:" + productId, fresh, Duration.ofMinutes(60));
    return fresh;
}

private boolean shouldRefresh() {
    // 10% chance to refresh; spreads load
    return Math.random() < 0.1;
}

Solution 2: Distributed Lock

public Product getProduct(String productId) {
    Product cached = redisTemplate.opsForValue().get("product:" + productId);
    if (cached != null) {
        return cached;
    }

    // Prevent thundering herd with distributed lock
    String lockKey = "lock:" + productId;
    if (!redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(5))) {
        // Another thread has lock; wait and retry cache
        Thread.sleep(100);
        return getProduct(productId);
    }

    try {
        // This thread refreshes
        Product fresh = productRepository.findById(productId).orElseThrow();
        redisTemplate.opsForValue().set("product:" + productId, fresh, Duration.ofHours(1));
        return fresh;
    } finally {
        redisTemplate.delete(lockKey);
    }
}

Database Sharding for Horizontal Scaling

Problem: Single database becomes bottleneck.

Solution: Split data across multiple databases (shards).

graph LR
    APP["Application"] -->|"Order 100"| SHARD0["Shard 0<br/>Orders 0-99"]
    APP -->|"Order 150"| SHARD1["Shard 1<br/>Orders 100-199"]
    APP -->|"Order 250"| SHARD2["Shard 2<br/>Orders 200-299"]

Sharding Strategies

Strategy Example Pros Cons
Range-based Orders 0-99 → Shard A; 100-199 → Shard B Simple; range queries fast Uneven distribution (hot shards)
Hash-based hash(orderId) % num_shards Uniform distribution Expensive range queries
Geographic US data → Shard A; EU → Shard B Data locality; compliance Complex to rebalance
Directory-based Lookup table: orderId → shard Flexible; easy rebalancing Extra lookup overhead

Hash-Based Sharding Example

@Repository
public class ShardedOrderRepository {

    private List<JdbcTemplate> shards;

    public Order findById(String orderId) {
        int shardId = Math.abs(orderId.hashCode()) % shards.size();
        JdbcTemplate shard = shards.get(shardId);

        return shard.queryForObject(
            "SELECT * FROM orders WHERE id = ?",
            new OrderRowMapper(),
            orderId
        );
    }

    public void save(Order order) {
        int shardId = Math.abs(order.getId().hashCode()) % shards.size();
        JdbcTemplate shard = shards.get(shardId);

        shard.update(
            "INSERT INTO orders (id, total) VALUES (?, ?)",
            order.getId(),
            order.getTotal()
        );
    }
}

Rebalancing Shards

When adding new shard: rehash all data; move subset to new shard.

Before: 3 shards
  Shard 0: 1/3 data
  Shard 1: 1/3 data
  Shard 2: 1/3 data

Add Shard 3:
  Rehash all data: hash(orderId) % 4
  1/4 of data moves to Shard 3
  Shard 0, 1, 2 each lose 1/12 of data

Downtime: Yes (migration required)

Redis Cluster for Distributed Caching

Scale beyond single Redis instance:

# Redis Cluster: 6 nodes (3 primary + 3 replica)
redis-node-1 (primary, slots 0-5461)
redis-node-2 (primary, slots 5462-10922)
redis-node-3 (primary, slots 10923-16383)
redis-node-4 (replica of node-1)
redis-node-5 (replica of node-2)
redis-node-6 (replica of node-3)

Feature:

  • Sharding built-in
  • Failover: replica promoted if primary fails
  • Cluster-aware clients

Cache Warming

Pre-populate cache on startup; avoid slow first requests.

@Component
public class CacheWarmer {

    @Autowired
    private ProductService productService;

    @Autowired
    private RedisTemplate<String, Product> redisTemplate;

    @EventListener(ApplicationReadyEvent.class)
    public void warmCache() {
        // Load top 1000 products on startup
        List<Product> topProducts = productService.getTopProducts(1000);

        topProducts.forEach(product -> {
            String key = "product:" + product.getId();
            redisTemplate.opsForValue().set(key, product, Duration.ofHours(1));
        });

        log.info("Cache warmed with {} products", topProducts.size());
    }
}

Should I cache at the application layer or database layer?

Both: app layer (Redis) for hot data; DB layer (query cache) for expensive queries. App layer is faster and decouples services.

How much memory should I allocate to Redis?

Rule of thumb: cache working set (80% of queries touch 20% of data). Monitor hit ratio; target > 90%. Start with 10–20% of dataset size.

What happens when Redis runs out of memory?

Eviction policy kicks in: LRU (least recently used), LFU (least frequently used), or TTL-based. Configure with maxmemory-policy in redis.conf.