Spring Boot 9 min read

Redis Caching Patterns: The Difference Between 200ms and 2ms Response Times

Beyond @Cacheable - learn cache-aside, write-through, read-through patterns, cache invalidation strategies, and how to avoid the thundering herd problem.

MR

Moshiour Rahman

Advertisement

The Caching Paradox

Caching is simple: store frequently accessed data closer to the consumer.

Caching is hard: knowing WHEN to cache, WHAT to cache, and HOW to invalidate.

The classic mistake: Cache everything, invalidate nothing, debug for weeks when users see stale data.

Why Redis?

The performance difference between Redis and your database is dramatic. Here’s the comparison that matters:

Redis vs Database Performance

Sub-millisecond reads vs 5-50ms database queries. That’s the difference between a snappy UI and users hitting refresh.

Setup: Spring Boot + Redis

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
spring:
  data:
    redis:
      host: localhost
      port: 6379
      timeout: 2000ms
      lettuce:
        pool:
          max-active: 8
          max-idle: 8
          min-idle: 2
@Configuration
@EnableCaching
public class RedisConfig {

    @Bean
    public RedisCacheConfiguration cacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .disableCachingNullValues()
            .serializeKeysWith(
                RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
    }

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        return RedisCacheManager.builder(factory)
            .cacheDefaults(cacheConfiguration())
            .withCacheConfiguration("users",
                cacheConfiguration().entryTtl(Duration.ofHours(1)))
            .withCacheConfiguration("products",
                cacheConfiguration().entryTtl(Duration.ofMinutes(30)))
            .build();
    }
}

Pattern 1: Cache-Aside (Most Common)

How it works: Application manages cache directly. Check cache first, fetch from DB if miss, update cache.

Cache-Aside Pattern

Implementation with @Cacheable

@Service
public class UserService {

    private final UserRepository userRepository;

    @Cacheable(value = "users", key = "#id")
    public User findById(Long id) {
        log.info("Cache miss for user {}", id);  // Only logs on miss
        return userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
    }

    @Cacheable(value = "users", key = "#email")
    public User findByEmail(String email) {
        return userRepository.findByEmail(email)
            .orElseThrow(() -> new UserNotFoundException(email));
    }

    @CachePut(value = "users", key = "#result.id")
    public User create(String email, String name) {
        User user = new User(email, name);
        return userRepository.save(user);
    }

    @CacheEvict(value = "users", key = "#id")
    public void delete(Long id) {
        userRepository.deleteById(id);
    }

    @CacheEvict(value = "users", allEntries = true)
    public void clearUserCache() {
        // Clears entire user cache
    }
}

The Update Problem

// ❌ WRONG: Cache becomes stale
@Transactional
public User update(Long id, String newName) {
    User user = userRepository.findById(id).orElseThrow();
    user.setName(newName);
    return userRepository.save(user);
    // Cache still has old data!
}

// ✅ CORRECT: Evict then update
@CacheEvict(value = "users", key = "#id")
@Transactional
public User update(Long id, String newName) {
    User user = userRepository.findById(id).orElseThrow();
    user.setName(newName);
    return userRepository.save(user);
}

// ✅ ALSO CORRECT: Update cache with result
@CachePut(value = "users", key = "#id")
@Transactional
public User update(Long id, String newName) {
    User user = userRepository.findById(id).orElseThrow();
    user.setName(newName);
    return userRepository.save(user);
}

Pattern 2: Write-Through

How it works: Writes go to cache AND database atomically. Cache is always current.

Write-Through Pattern

@Service
public class ProductService {

    private final ProductRepository productRepository;
    private final RedisTemplate<String, Product> redisTemplate;

    @Transactional
    public Product save(Product product) {
        // Write to database first
        Product saved = productRepository.save(product);

        // Then update cache
        String key = "product:" + saved.getId();
        redisTemplate.opsForValue().set(key, saved, Duration.ofHours(1));

        return saved;
    }

    public Product findById(Long id) {
        String key = "product:" + id;

        // Check cache first
        Product cached = redisTemplate.opsForValue().get(key);
        if (cached != null) {
            return cached;
        }

        // Cache miss - fetch and cache
        Product product = productRepository.findById(id)
            .orElseThrow(() -> new ProductNotFoundException(id));

        redisTemplate.opsForValue().set(key, product, Duration.ofHours(1));
        return product;
    }
}

Pattern 3: Write-Behind (Write-Back)

How it works: Write to cache immediately, asynchronously persist to DB later.

Write-Behind Pattern

@Service
public class PageViewService {

    private final StringRedisTemplate redisTemplate;
    private final PageViewRepository repository;

    // Increment counter in Redis (fast)
    public void recordView(String pageId) {
        redisTemplate.opsForValue().increment("pageview:" + pageId);
    }

    // Batch persist to DB (scheduled)
    @Scheduled(fixedRate = 60000)  // Every minute
    public void persistViewCounts() {
        Set<String> keys = redisTemplate.keys("pageview:*");
        if (keys == null || keys.isEmpty()) return;

        Map<String, Long> counts = new HashMap<>();
        for (String key : keys) {
            String pageId = key.replace("pageview:", "");
            String value = redisTemplate.opsForValue().getAndDelete(key);
            if (value != null) {
                counts.put(pageId, Long.parseLong(value));
            }
        }

        // Batch insert to database
        repository.batchUpdateCounts(counts);
    }
}

The Thundering Herd Problem

When cache expires, hundreds of requests hit the database simultaneously. This is one of the most dangerous caching pitfalls:

Thundering Herd Problem

Two battle-tested solutions: distributed locks ensure only one request fetches while others wait, or probabilistic early refresh prevents cache from ever actually expiring.

Solution: Distributed Lock

@Service
public class CachedProductService {

    private final ProductRepository productRepository;
    private final RedisTemplate<String, Object> redisTemplate;
    private final RedisLockRegistry lockRegistry;

    public Product findById(Long id) {
        String cacheKey = "product:" + id;

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

        // Cache miss - use lock to prevent thundering herd
        String lockKey = "lock:product:" + id;
        Lock lock = lockRegistry.obtain(lockKey);

        try {
            // Try to acquire lock (wait up to 5 seconds)
            if (lock.tryLock(5, TimeUnit.SECONDS)) {
                try {
                    // Double-check cache (another thread might have populated it)
                    cached = (Product) redisTemplate.opsForValue().get(cacheKey);
                    if (cached != null) {
                        return cached;
                    }

                    // Fetch from DB and cache
                    Product product = productRepository.findById(id)
                        .orElseThrow(() -> new ProductNotFoundException(id));

                    redisTemplate.opsForValue().set(cacheKey, product, Duration.ofHours(1));
                    return product;
                } finally {
                    lock.unlock();
                }
            } else {
                // Couldn't get lock - another thread is fetching
                // Wait a bit and try cache again
                Thread.sleep(100);
                cached = (Product) redisTemplate.opsForValue().get(cacheKey);
                if (cached != null) {
                    return cached;
                }
                // Still no cache - fall back to DB (rare)
                return productRepository.findById(id)
                    .orElseThrow(() -> new ProductNotFoundException(id));
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Lock acquisition interrupted", e);
        }
    }
}

Solution: Probabilistic Early Refresh

@Service
public class SmartCacheService {

    private final RedisTemplate<String, CacheEntry> redisTemplate;
    private final Random random = new Random();

    public <T> T getOrFetch(String key, Duration ttl, Supplier<T> fetcher) {
        CacheEntry entry = redisTemplate.opsForValue().get(key);

        if (entry != null) {
            // Check if we should proactively refresh
            long remainingTtl = entry.expiresAt() - System.currentTimeMillis();
            long softTtl = ttl.toMillis() * 8 / 10;  // 80% of TTL

            if (remainingTtl > softTtl) {
                // Fresh enough - return immediately
                return (T) entry.value();
            }

            // Getting stale - probabilistic refresh
            // Higher probability as we get closer to expiration
            double refreshProbability = 1.0 - (remainingTtl / (double) softTtl);
            if (random.nextDouble() < refreshProbability) {
                // This request will refresh (async)
                CompletableFuture.runAsync(() -> refresh(key, ttl, fetcher));
            }

            // Return current value while refresh happens
            return (T) entry.value();
        }

        // Cache miss - fetch synchronously
        return refresh(key, ttl, fetcher);
    }

    private <T> T refresh(String key, Duration ttl, Supplier<T> fetcher) {
        T value = fetcher.get();
        CacheEntry entry = new CacheEntry(value, System.currentTimeMillis() + ttl.toMillis());
        redisTemplate.opsForValue().set(key, entry, ttl);
        return value;
    }

    record CacheEntry(Object value, long expiresAt) {}
}

Cache Invalidation Strategies

“There are only two hard things in Computer Science: cache invalidation and naming things.” - Phil Karlton

Strategy 1: TTL-Based (Time-To-Live)

// Simple - cache expires after fixed time
@Cacheable(value = "products", key = "#id")
// TTL configured in RedisCacheConfiguration

Pros: Simple, eventually consistent Cons: Stale data for up to TTL duration

Strategy 2: Event-Based Invalidation

@Service
public class ProductService {

    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public Product update(Long id, UpdateProductRequest request) {
        Product product = productRepository.findById(id).orElseThrow();
        product.setName(request.name());
        product.setPrice(request.price());
        Product saved = productRepository.save(product);

        // Publish event
        eventPublisher.publishEvent(new ProductUpdatedEvent(saved.getId()));

        return saved;
    }
}

@Component
public class CacheInvalidationListener {

    private final CacheManager cacheManager;

    @EventListener
    public void onProductUpdated(ProductUpdatedEvent event) {
        Cache cache = cacheManager.getCache("products");
        if (cache != null) {
            cache.evict(event.productId());
        }
    }

    // For distributed systems, use Redis Pub/Sub or Kafka
    @EventListener
    public void onCategoryUpdated(CategoryUpdatedEvent event) {
        // Invalidate all products in this category
        // This is where it gets complex...
    }
}

Strategy 3: Version-Based Keys

@Service
public class VersionedCacheService {

    private final StringRedisTemplate redisTemplate;

    public Product getProduct(Long id) {
        // Get current version
        String version = redisTemplate.opsForValue().get("product:version:" + id);
        if (version == null) version = "1";

        // Cache key includes version
        String cacheKey = "product:" + id + ":v" + version;

        Product cached = getFromCache(cacheKey);
        if (cached != null) {
            return cached;
        }

        // Fetch and cache with versioned key
        Product product = fetchFromDb(id);
        putInCache(cacheKey, product);
        return product;
    }

    public void invalidateProduct(Long id) {
        // Increment version - old cache key becomes orphaned
        redisTemplate.opsForValue().increment("product:version:" + id);
        // Old cached values will eventually expire via TTL
    }
}

Multi-Level Caching (L1 + L2)

Multi-Level Caching

@Configuration
@EnableCaching
public class MultiLevelCacheConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory redisFactory) {
        // L1: Caffeine (local, fast)
        CaffeineCacheManager caffeineManager = new CaffeineCacheManager();
        caffeineManager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(Duration.ofMinutes(5)));

        // L2: Redis (distributed, shared)
        RedisCacheManager redisManager = RedisCacheManager.builder(redisFactory)
            .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(30)))
            .build();

        // Composite: Check L1 first, then L2
        return new CompositeCacheManager(caffeineManager, redisManager);
    }
}

Monitoring Cache Performance

@Component
public class CacheMetrics {

    private final MeterRegistry meterRegistry;
    private final Counter cacheHits;
    private final Counter cacheMisses;
    private final Timer cacheFetchTime;

    public CacheMetrics(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
        this.cacheHits = Counter.builder("cache.hits")
            .tag("cache", "products")
            .register(meterRegistry);
        this.cacheMisses = Counter.builder("cache.misses")
            .tag("cache", "products")
            .register(meterRegistry);
        this.cacheFetchTime = Timer.builder("cache.fetch.time")
            .tag("cache", "products")
            .register(meterRegistry);
    }

    public <T> T getWithMetrics(String key, Supplier<T> cacheGetter, Supplier<T> dbFetcher) {
        return cacheFetchTime.record(() -> {
            T cached = cacheGetter.get();
            if (cached != null) {
                cacheHits.increment();
                return cached;
            }
            cacheMisses.increment();
            return dbFetcher.get();
        });
    }
}
# Prometheus metrics to watch
- cache_hits_total
- cache_misses_total
- cache_hit_ratio (hits / (hits + misses))
- cache_fetch_time_seconds (p99)

When NOT to Cache

ScenarioWhy
Data changes every requestCache hit rate ≈ 0%
User-specific dataCache grows unboundedly
Real-time requirementsAny staleness is unacceptable
Write-heavy workloadsInvalidation overhead > benefit
Small datasetsDatabase is fast enough

Code Sample

Full working example: github.com/Moshiour027/techyowls-io-blog-public/redis-caching-guide

Summary

PatternBest ForTrade-off
Cache-AsideGeneral useCold start latency
Write-ThroughConsistency priorityWrite latency
Write-BehindWrite performanceData loss risk
Multi-LevelExtreme performanceComplexity

The golden rule: Cache only what you can afford to be stale, for as long as you can tolerate.

Start with @Cacheable and TTL. Add complexity only when metrics prove you need it.

Advertisement

MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

Moshiour Rahman

Software Architect & AI Engineer

Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.

Related Articles

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.