Intermediate · Accelerate

Production Implementation: Redis Cache-Aside Catalog

Wire the cache-aside slice end to end: namespaced keys, TTL bounds, DTO-only storage, and eviction on every write path.

Primary source: Spring Boot 3.5 Reference: Caching

45 min

Lesson 4 of 4

1d streak

The Architectural Question

Caching bolted on without ownership rules serves stale prices forever and stores entity graphs whose lazy fields break serialization at random.

What this guide proves

  • Cache read-model DTOs, never managed entities
  • Bound every entry with an explicit TTL
  • Evict or update on the same transaction that writes

Resilient Caching: Fail-Open Error Handling

A critical production failure pattern is when an outage in the caching tier (e.g. Redis network partition or CPU throttle) brings down the entire business application. If Redis throws a RedisConnectionFailureException, Spring default behavior is to propagate the exception out of the controller, returning HTTP 500 Internal Server Error to the client.

A cache is an optimization, NOT the primary system of record. When the cache is unreachable, the system must fail open: log a warning, bypass the cache, and read directly from the database without failing user requests.

src/main/java/com/example/config/ResilientCachingConfig.java
package com.example.config;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.Cache;
import org.springframework.cache.annotation.CachingConfigurer;
import org.springframework.cache.interceptor.CacheErrorHandler;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class ResilientCachingConfig implements CachingConfigurer {

    private static final Logger log = LoggerFactory.getLogger(ResilientCachingConfig.class);

    @Override
    public CacheErrorHandler errorHandler() {
        return new CacheErrorHandler() {
            @Override
            public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
                log.warn("Cache GET failed for cache='{}', key='{}'. Falling back to database source.", 
                    cache.getName(), key, exception);
                // Silently swallow exception so Spring executes the underlying method body!
            }

            @Override
            public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) {
                log.error("Cache PUT failed for cache='{}', key='{}'. Data may not be cached.", 
                    cache.getName(), key, exception);
            }

            @Override
            public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {
                log.error("CRITICAL: Cache EVICT failed for cache='{}', key='{}'. Key may contain stale data!", 
                    cache.getName(), key, exception);
            }

            @Override
            public void handleCacheClearError(RuntimeException exception, Cache cache) {
                log.error("CRITICAL: Cache CLEAR failed for cache='{}'", cache.getName(), exception);
            }
        };
    }
}

Fail-Open on Reads vs Alerting on Eviction Failures

While handleCacheGetError should fail open quietly to preserve system uptime, handleCacheEvictError must trigger alerts because failing to evict leaves stale data in the cache that will be served to users until TTL expires.

Cache Observability: Micrometer & Prometheus Metrics

Production caching requires continuous observability. Spring Boot Actuator automatically registers Micrometer metrics for CacheManager instances, publishing cache hit ratio, miss count, eviction count, and put latency.

Metric NameTagsAlert ThresholdActionable Remediation
cache.gets (result: hit)cache: <name>Hit ratio < 70%Investigate key cardinality or insufficient TTL
cache.gets (result: miss)cache: <name>Sudden miss spike > 50%Investigate cache eviction burst or stampede
cache.evictionscache: <name>High continuous evictionsRedis maxmemory exceeded; increase Redis memory
cache.putscache: <name>Puts >> GetsIneffective caching; keys written but never reused

Near-Cache: Two-Level L1 (Caffeine) & L2 (Redis) Architecture

In extreme low-latency environments (sub-millisecond p99), round-trip network hops to a Redis cluster (1–3ms) represent significant overhead. A two-level caching architecture provides the optimal balance:

1. L1 Local Cache (Caffeine): Resides in JVM heap memory. Access latency is < 100 nanoseconds. No network or serialization overhead.

2. L2 Distributed Cache (Redis): Shared across all Kubernetes pods. Prevents cold starts when new pods scale up.

To prevent L1 desynchronization across pods when an update occurs, use Redis Pub/Sub topic to broadcast eviction messages across all cluster instances when a write happens.

Disaster Recovery and Resilience Proving Checkpoints

Test cache disaster recovery by cutting network connectivity to Redis while driving traffic:

simulate-redis-outage.sh
# 1. Simulate Redis outage by pausing the Redis container
docker pause redis-prod

# 2. Query the cached REST endpoint
curl -i http://localhost:8080/api/products/SKU-42
# EXPECTED: HTTP/1.1 200 OK (returned from database, no 500 error!)

# 3. Check logs for fail-open warning:
# WARN  c.e.c.ResilientCachingConfig - Cache GET failed for cache='products', key='SKU-42'. Falling back to database source.

# 4. Resume Redis container
docker unpause redis-prod

Active Recall Checkpoint

What is the danger of returning HTTP 500 when Redis is down, and how does CacheErrorHandler prevent it?

Hint: Contrast a temporary latency increase (DB fallback) with a complete service outage.

Show answer & explanation

Without a custom CacheErrorHandler, Redis connection exceptions propagate to the caller, taking down healthy services. CacheErrorHandler swallows read exceptions, allowing Spring to transparently fall back to the primary database.

Working Implementation

Core Spring Implementation

Redis cache-aside product views

Serve product views from Redis cache-aside: versioned key namespaces, bounded TTLs, and eviction after successful writes keep staleness measurable.

Component Boundaries

  1. 01PostgreSQL remains the source of truth; Redis stores serialized ProductView snapshots only, never managed entities.
  2. 02Cache names double as Redis key prefixes: catalog:v1:product maps to keys like catalog:v1:product::42 with a 10-minute TTL.
  3. 03Reads fill on miss through @Cacheable; the @CacheEvict interceptor drops the key after the write method completes successfully.

Required Evidence

  • /actuator/metrics/spring.cache.gets and spring.cache.misses report hit ratio per cache name once micrometer-core is on the classpath.
  • redis-cli --scan --pattern "catalog:v1:*" returns only reviewed prefixes, each carrying a positive TTL.
  • Stopping Redis during business hours raises GET latency but every request still answers 200 from PostgreSQL and no write is lost.

Implementation sequence

Step-by-Step Integration

Follow these structured steps to inject Core Spring functionality cleanly across your boundaries.

01

Namespace caches and bound them with TTLs

CacheConfiguration.java

Flat key names collide across tenants and schema versions; a versioned cache name yields one clearable namespace, and entryTtl caps how long any snapshot misleads readers.

CacheConfiguration.java
@Configuration
@EnableCaching
class CacheConfiguration {
    @Bean
    RedisCacheManagerBuilderCustomizer catalogCache() {
        return builder -> builder.withCacheConfiguration(
            "catalog:v1:product",
            RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10)));
    }
}

Verify Configuration

After the first GET /api/products/42, redis-cli --scan --pattern "catalog:v1:*" lists catalog:v1:product::42 and redis-cli TTL catalog:v1:product::42 prints a value between 1 and 600.

02

Cache DTO reads and evict after writes

ProductCatalogService.java

Managed entities drag lazy proxies into the serializer; caching the projection keeps payloads stable, and evicting on a successful rename prevents stale reads after updates.

ProductCatalogService.java
@Cacheable(cacheNames = "catalog:v1:product", key = "#id")
@Transactional(readOnly = true)
ProductView view(UUID id) {
    return products.findView(id).orElseThrow(ProductNotFound::new);
}

@CacheEvict(cacheNames = "catalog:v1:product", key = "#id")
@Transactional
void rename(UUID id, String name) {
    products.require(id).rename(name);
}

Verify Configuration

TRACE logging on org.springframework.cache.interceptor prints a miss for the first lookup and "Cache entry for key 42 found in cache" for the second; after rename, redis-cli EXISTS catalog:v1:product::42 prints 0.

Synthesis & Production Verification

Deliberate Failure Condition

  1. Break: Serialize a managed product entity straight into Redis with no TTL
  2. Observe: Serialization failures on lazy relations and price changes never appearing for hours
  3. Repair: Cache small DTOs with explicit TTLs and evict inside the write path so staleness has a hard upper bound.

Production Acceptance Criteria

  • Second identical read hits Redis without touching the database
  • A write evicts the affected key within the same request
  • No Hibernate proxy ever reaches the serializer

Architectural & Technical Review

Core Interview Questions

Q01Why must production applications register a custom CacheErrorHandler with fail-open semantics?Show answer

A cache is an optimization, not the source of record. When Redis is down, handleCacheGetError swallows the exception and logs a warning so Spring transparently falls back to the database instead of returning HTTP 500.

Q02Which Micrometer metrics are essential for diagnosing cache health, and what does a high eviction rate indicate?Show answer

Monitor cache.gets (tagged by result:hit and miss), cache.puts, and cache.evictions. Continuous high evictions indicate that cache size or Redis maxmemory is undersized, causing premature key churn.

Q03How does a two-level near-cache (Caffeine L1 + Redis L2) eliminate network latency while preserving cluster consistency?Show answer

L1 Caffeine provides sub-microsecond in-memory reads without network serialization. L2 Redis prevents cold starts when pods scale. Redis Pub/Sub invalidation broadcasts key evictions to clear L1 across all pods when a write occurs.

Production Pitfalls

Critical Anti-Patterns

  • Pitfall: Failing user HTTP requests with 500 errors when Redis is temporarily unreachable — Violates the principle that a cache is an optimization, not a single point of failure.
  • Pitfall: Failing to alert on handleCacheEvictError exceptions in the cache error handler — Leaves stale data in the cache silently without notifying operational staff.

Module Certification Quiz

Prove Module Mastery

Comprehensive question set covering all lessons in this module.

Take Module Quiz

Primary Documentation & Specifications

Supplementary Module Resources