Intermediate · Accelerate

Design Redis Caching for Consistency

Choose TTL, invalidation, serialization, and degraded behavior for a multi-instance service.

Primary source: Spring Boot 3.5 Reference: Caching

45 min

Lesson 2 of 4

1d streak

The Architectural Question

Evicting before commit opens a window where concurrent readers reload the old value into the fresh cache; caching managed entities serializes Hibernate internals you did not choose.

What this guide proves

  • Cache DTO-like values rather than managed entities
  • Evict only after the source-of-truth write succeeds
  • Bound staleness and monitor hit, miss, and load latency

Production RedisCacheManager & JSON Serialization Setup

The default Redis cache serializer is JDK serialization in common Spring Data Redis configurations. That format is opaque and brittle across service updates, and serialized bytes must never be accepted from an untrusted source. Prefer an explicit JSON serializer for a known cache value type.

For a production cache, configure RedisCacheConfiguration with an explicit JSON serializer for a known DTO, per-cache TTLs, null-value caching prevention, and a versioned key prefix.

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

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;
import java.util.HashMap;
import java.util.Map;

@Configuration(proxyBeanMethods = false)
public class RedisCacheConfig {

    @Bean
    public RedisCacheManager cacheManager(
            RedisConnectionFactory connectionFactory,
            ObjectMapper objectMapper) {

        // ProductCacheValue is an explicit DTO with a stable cache schema.
        Jackson2JsonRedisSerializer<ProductCacheValue> jsonSerializer =
            new Jackson2JsonRedisSerializer<>(objectMapper, ProductCacheValue.class);

        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .disableCachingNullValues()
            .serializeKeysWith(
                RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer))
            .prefixCacheNameWith("app:v1:");

        // Specific TTL overrides per cache container
        Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
        cacheConfigs.put("exchange-rates", defaultConfig.entryTtl(Duration.ofMinutes(1)));
        cacheConfigs.put("product-categories", defaultConfig.entryTtl(Duration.ofHours(24)));

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(cacheConfigs)
            .build();
    }
}

Prefixing Prevents Collision Across Environments

Using prefixCacheNameWith("app:v1:") guarantees that keys in shared Redis clusters cannot accidentally overwrite keys belonging to other applications or preceding microservice versions.

Dual-Write Consistency: Cache-Aside vs Write-Through Ordering

Maintaining consistency between an authoritative relational database (e.g. PostgreSQL) and a distributed cache (Redis) is one of the hardest distributed systems challenges. A naive update pattern that updates the database and immediately updates the cache creates concurrent race conditions where stale values overwrite fresh data.

Use it when

Use Cache Invalidation (Eviction) instead of Cache Updating on writes. If multiple concurrent requests update the same entity, concurrent cache updates can arrive out of order, writing stale data into Redis permanently. Evicting the key forces the next read to load the true state atomically from the database.

Reconsider when

Reconsider only when the cost of rebuilding the cache item from the database is prohibitive (> 500ms) and protected by a distributed lock.

Furthermore, invalidation must execute AFTER the database transaction commits. If you evict before the transaction commits, a concurrent read will query the database, read the uncommitted old state, and re-populate the cache with stale data before the write commits!

src/main/java/com/example/catalog/OrderService.java
package com.example.catalog;

import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final CacheManager cacheManager;

    public OrderService(OrderRepository orderRepository, CacheManager cacheManager) {
        this.orderRepository = orderRepository;
        this.cacheManager = cacheManager;
    }

    @Transactional
    public void completeOrder(Long orderId) {
        orderRepository.updateStatus(orderId, OrderStatus.COMPLETED);

        // Register post-commit cache eviction hook
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                var cache = cacheManager.getCache("orders");
                if (cache != null) {
                    cache.evict(orderId);
                }
            }
        });
    }
}

Never Evict Inside an Uncommitted Transaction

If an exception causes the database transaction to rollback after the cache key was evicted, your cache is now cleared while the database remains unchanged. In a dual-write failure where eviction fails post-commit, schedule a retry or set a tight TTL as a safety boundary.

TTL Strategies and Redis Memory Eviction Policies

Every single key stored in Redis must have a deliberate Time-To-Live (TTL). Caching data indefinitely without TTLs guarantees eventual Out-Of-Memory (OOM) crashes as your dataset grows.

Redis Maxmemory PolicyBehavior When Memory FullSuitability for Cache
noevictionReturns out-of-memory errors for write commandsNever use for caching; crashes app writes
allkeys-lruEvicts least recently used keys regardless of TTLHigh throughput general-purpose cache
volatile-lruEvicts least recently used keys among those with TTL setRecommended when sharing Redis with persistent tokens
volatile-ttlEvicts keys with the shortest remaining TTL firstGood for deterministic expiration schedules

CLI Diagnostics and Cache Invalidation Verification

Inspect raw Redis keys, TTL remaining, and JSON payloads using redis-cli:

verify-redis-keys.sh
# 1. Connect to Redis CLI and check remaining TTL in seconds
redis-cli TTL "app:v1:products::SKU-42"
# Output: (integer) 584 (seconds remaining)

# 2. Inspect serialized JSON payload to verify type metadata
redis-cli GET "app:v1:products::SKU-42"
# Output: ["com.example.catalog.Product",{"sku":"SKU-42","name":"Widget","priceCents":1999}]

# 3. Trigger write operation in Spring Boot and verify immediate eviction
curl -X DELETE http://localhost:8080/api/products/SKU-42
redis-cli EXISTS "app:v1:products::SKU-42"
# Output: (integer) 0 (successfully evicted)

Active Recall Checkpoint

Why should cache eviction occur in afterCommit() rather than immediately inside the @Transactional method body?

Hint: Think about transaction isolation and race conditions between reader and writer threads.

Show answer & explanation

Evicting before commit allows concurrent reads to read the uncommitted database row, re-populating the cache with stale data. If the transaction rolls back, the cache eviction would also have fired unnecessarily.

Runtime Architecture & Flow

Persistence context · runtime visualization

Design Redis Caching for Consistency

Transaction

Cache DTO-like values rather than managed entities

Entity state

Evict only after the source-of-truth write succeeds

Flush

Bound staleness and monitor hit, miss, and load latency

Commit

Implement cache-aside reads and an after-commit eviction path.

Working Implementation

Code Walkthrough

Implement cache-aside reads and an after-commit eviction path.

RedisCacheConsistencyLab.java
package com.example.cache;

import org.springframework.cache.CacheManager;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import java.time.Duration;

@Service
public class RedisCacheConsistencyLab {

    private final CacheManager cacheManager;

    public RedisCacheConsistencyLab(CacheManager cacheManager) {
        this.cacheManager = cacheManager;
    }

    public static RedisCacheConfiguration buildCacheConfig() {
        return RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .disableCachingNullValues()
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
            .prefixCacheNameWith("app:v1:");
    }

    @Transactional
    public void updateAndEvict(String orderId, String newStatus) {
        // 1. Update database system of record
        // (orderRepository.updateStatus(orderId, newStatus);)

        // 2. Register post-commit cache eviction hook to prevent reading uncommitted data
        if (TransactionSynchronizationManager.isSynchronizationActive()) {
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    var cache = cacheManager.getCache("orders");
                    if (cache != null) {
                        cache.evict(orderId);
                    }
                }
            });
        }
    }
}
GenericJackson2JsonRedisSerializer stores clean JSON with type metadata instead of dangerous Java binary serialization.
Eviction is scheduled in TransactionSynchronization.afterCommit() so rolling back a transaction does not accidentally wipe cache, and concurrent reads do not cache uncommitted rows.
Cache names are prefixed with app:v1: to prevent collision across microservice versions.

Synthesis & Production Verification

Production Acceptance Criteria

  • Cache DTO-shaped product values with an explicit TTL
  • Evict only after the source-of-truth write commits
  • Serve requests with degraded freshness when Redis is unreachable

Architectural & Technical Review

Core Interview Questions

Q01Why should production Spring Boot apps configure Jackson 2 JSON serialization instead of default Java serialization in Redis?Show answer

Default JdkSerializationRedisSerializer produces opaque binary payloads that are unreadable in CLI diagnostics, break across class changes, and expose severe Java deserialization security vulnerabilities.

Q02Why must cache eviction execute inside TransactionSynchronization.afterCommit() rather than immediately in the service method?Show answer

Evicting before commit allows concurrent reads to read the uncommitted database row, re-populating the cache with stale data. If the transaction rolls back, premature eviction leaves the cache cleared unnecessarily.

Q03What is the danger of using noeviction maxmemory policy in Redis when using it as a shared cache?Show answer

Under noeviction, Redis throws out-of-memory errors on write operations once memory fills up, causing application writes to fail instead of evicting least-recently-used cache entries.

Production Pitfalls

Critical Anti-Patterns

  • Pitfall: Evicting the cache before the relational database transaction commits — Allows concurrent readers to read old database state and re-populate the cache with stale data.
  • Pitfall: Storing mutable entities or un-versioned binary payloads in Redis — Leads to ClassCastException across application deployments and deserialization vulnerabilities.

Primary Documentation & Specifications

Supplementary Module Resources