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
Lesson 2 of 4
1d streakThe 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.
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
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!
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
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 Policy | Behavior When Memory Full | Suitability for Cache |
|---|---|---|
| noeviction | Returns out-of-memory errors for write commands | Never use for caching; crashes app writes |
| allkeys-lru | Evicts least recently used keys regardless of TTL | High throughput general-purpose cache |
| volatile-lru | Evicts least recently used keys among those with TTL set | Recommended when sharing Redis with persistent tokens |
| volatile-ttl | Evicts keys with the shortest remaining TTL first | Good for deterministic expiration schedules |
CLI Diagnostics and Cache Invalidation Verification
Inspect raw Redis keys, TTL remaining, and JSON payloads using redis-cli:
# 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.
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);
}
}
});
}
}
}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
Key Takeaway
Redis cache consistency requires cache-aside eviction ordered strictly after database commit, backed by Jackson 2 JSON serialization and explicit per-cache TTLs.
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
Lesson Primary References
Supplementary Module Resources