Distributed Caching With Redis in Spring Boot — Beyond the Basics
by Eric Hanson, Backend Developer at Clean Systems Consulting
Why the default configuration isn't enough
Spring Boot's default Redis cache configuration uses Java serialization — JdkSerializationRedisSerializer. This works but has serious production problems:
- Java serialization is slow and produces large payloads
- Serialized data is binary and unreadable in Redis CLI — debugging requires deserialization
- Any change to a serialized class (adding a field, renaming a class) breaks deserialization of existing cache entries
- Java serialization is a known security risk when the deserializing class is not controlled
Configure Redis cache with JSON serialization immediately:
@Configuration
@EnableCaching
public class RedisCacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory,
ObjectMapper objectMapper) {
// Use a copy of ObjectMapper configured for type information
ObjectMapper cacheMapper = objectMapper.copy()
.activateDefaultTyping(
objectMapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
RedisSerializer<Object> jsonSerializer =
new GenericJackson2JsonRedisSerializer(cacheMapper);
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer))
.disableCachingNullValues();
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withCacheConfiguration("products",
defaultConfig.entryTtl(Duration.ofHours(1)))
.withCacheConfiguration("user-sessions",
defaultConfig.entryTtl(Duration.ofMinutes(30)))
.build();
}
}
activateDefaultTyping embeds the Java class name in the JSON so Spring knows which class to deserialize into. This is necessary because Redis stores values without type context — without the type hint, Jackson can't reconstruct the original object.
The tradeoff: the embedded type name couples the cached JSON to the Java class name. Renaming or moving a class without invalidating the cache produces deserialization failures. Two mitigation strategies:
Version the cache key prefix when making class changes:
// Cache v1: "products::v1::123"
// After renaming ProductResponse to ProductSummaryResponse, bump to v2
@Cacheable(value = "products", key = "'v2::' + #productId")
public ProductSummaryResponse getProduct(Long productId) { ... }
Old entries under v1:: are ignored (different key) and eventually expire. New entries under v2:: use the new class name.
Use dedicated cache DTOs. Cache serialization-specific DTOs rather than domain objects. DTOs have stable names and field sets:
// Domain object — may be refactored
public class Product { ... }
// Cache DTO — stable contract, changes deliberately
public record CachedProduct(Long id, String name, long priceInCents, String currency) {
public static CachedProduct from(Product p) {
return new CachedProduct(p.getId(), p.getName(),
p.getPrice().amountInCents(), p.getPrice().currency().code());
}
}
Key design at scale
Redis keys are strings. Good key design affects memory efficiency, debugging ease, and cache invalidation patterns.
Namespace prefix. Prefix all keys with the application and cache name:
myapp:products:123
myapp:user-sessions:user-456
myapp:rates:USD:EUR
Without a prefix, cache keys from different applications or different caches on the same Redis instance collide. Spring Boot adds the cache name automatically — RedisCacheConfiguration.computePrefixWith() controls the format:
RedisCacheConfiguration.defaultCacheConfig()
.computePrefixWith(cacheName -> "myapp:" + cacheName + ":")
Avoid high-cardinality keys with short TTLs. A cache key per user per request (user-123:orders:page-1, user-456:orders:page-1) with a 1-minute TTL generates millions of keys. Redis handles millions of keys fine, but the key space becomes a memory management problem and SCAN-based operations (monitoring, bulk eviction) become expensive.
Pattern-based invalidation with key sets. When you need to invalidate all cache entries for a particular user or entity, maintaining a set of keys is more reliable than SCAN with a glob pattern (SCAN 0 MATCH user-123:*). SCAN with patterns is O(n) over all keys:
// Track keys for pattern-based invalidation
public void cacheUserData(String userId, String type, Object data) {
String key = "user:" + userId + ":" + type;
redisTemplate.opsForValue().set(key, data, Duration.ofMinutes(30));
// Track this key in the user's key set for batch invalidation
redisTemplate.opsForSet().add("user-keys:" + userId, key);
redisTemplate.expire("user-keys:" + userId, Duration.ofMinutes(35));
}
public void invalidateUser(String userId) {
Set<String> keys = redisTemplate.opsForSet().members("user-keys:" + userId);
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
redisTemplate.delete("user-keys:" + userId);
}
Cache-aside vs read-through
Cache-aside (the @Cacheable model): the application checks the cache, loads from the database on miss, and populates the cache. The application manages the cache explicitly.
Read-through: the cache itself loads from the database on miss — the application always reads from the cache. Spring's @Cacheable is cache-aside; there's no built-in read-through in Spring Data Redis.
For most Spring Boot applications, cache-aside is correct. Read-through makes sense when the cache and the data source should be tightly coupled — when you want cache misses to be invisible to the application layer.
A manual read-through pattern with RedisTemplate:
@Service
public class ProductCache {
private final RedisTemplate<String, CachedProduct> redisTemplate;
private final ProductRepository repository;
public CachedProduct get(Long productId) {
String key = "products:" + productId;
CachedProduct cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
// Load and populate — read-through
CachedProduct product = repository.findById(productId)
.map(CachedProduct::from)
.orElseThrow(() -> new ProductNotFoundException(productId));
redisTemplate.opsForValue().set(key, product, Duration.ofHours(1));
return product;
}
}
The benefit over @Cacheable: explicit control over the cache key, TTL, and error handling. The cost: more code.
Atomic operations with Lua scripts
Redis is single-threaded for command execution — but a sequence of commands from Java is not atomic. Between two GET/SET calls, another client can modify the key:
// NOT atomic — race condition between GET and SET
Long current = redisTemplate.opsForValue().get("counter:" + userId);
if (current == null) current = 0L;
redisTemplate.opsForValue().set("counter:" + userId, current + 1);
For atomic operations, use Redis commands that are inherently atomic (INCR, SETNX, GETSET) or Lua scripts:
// Atomic increment — INCR is a single Redis command
redisTemplate.opsForValue().increment("counter:" + userId);
// Atomic conditional set — Lua script
private static final RedisScript<Boolean> SET_IF_LESS = RedisScript.of("""
local current = tonumber(redis.call('GET', KEYS[1]))
if current == nil or current < tonumber(ARGV[1]) then
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
return 1
end
return 0
""", Boolean.class);
public boolean setIfLess(String key, long value, Duration ttl) {
return Boolean.TRUE.equals(redisTemplate.execute(
SET_IF_LESS,
List.of(key),
String.valueOf(value),
String.valueOf(ttl.getSeconds())
));
}
Lua scripts execute atomically on the Redis server — no other commands run between lines of the script. Use them for conditional operations, atomic multi-key operations, and rate limiting logic.
Rate limiting with Redis
Redis's atomic operations make it the natural choice for distributed rate limiting. A sliding window counter using INCR and EXPIRE:
@Service
public class RateLimiter {
private final RedisTemplate<String, Long> redisTemplate;
public boolean isAllowed(String identifier, int maxRequests, Duration window) {
String key = "rate:" + identifier + ":" +
(System.currentTimeMillis() / window.toMillis()); // window bucket
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
redisTemplate.expire(key, window.multipliedBy(2)); // TTL = 2 windows
}
return count <= maxRequests;
}
}
Each time window gets its own key (bucketed by current window). The counter increments atomically. The key expires automatically — no cleanup required.
For more precise sliding window rate limiting, the sorted set approach tracks individual request timestamps:
public boolean isAllowedSlidingWindow(String identifier, int maxRequests, Duration window) {
String key = "sliding-rate:" + identifier;
long now = System.currentTimeMillis();
long windowStart = now - window.toMillis();
redisTemplate.execute(new SessionCallback<>() {
@Override
public Object execute(RedisOperations operations) {
operations.multi();
operations.opsForZSet().removeRangeByScore(key, 0, windowStart);
operations.opsForZSet().add(key, String.valueOf(now), now);
operations.opsForZSet().zCard(key);
operations.expire(key, window);
return operations.exec();
}
});
Long count = redisTemplate.opsForZSet().zCard(key);
return count != null && count <= maxRequests;
}
Connection pool configuration
Spring Boot's Redis connection pooling via Lettuce (the default client):
spring:
data:
redis:
host: redis.internal
port: 6379
lettuce:
pool:
max-active: 16 # max connections
max-idle: 8 # max idle connections
min-idle: 4 # min idle connections
max-wait: 100ms # max wait for connection from pool
Lettuce's default is not to use a connection pool — a single shared connection with pipelining. For high-concurrency Spring Boot applications, the connection pool is necessary. Without it, commands queue on a single connection and latency spikes under load.
The right max-active depends on Redis server capacity and application concurrency. Start with 16 and adjust based on redis.commands.pending (Micrometer metric) — sustained pending commands indicate pool exhaustion.
Monitoring Redis performance
Micrometer auto-configures Redis metrics when Spring Boot Actuator is present:
redis.commands.command.count{command="GET"} — command throughput
redis.commands.command.latency.percentile{command="GET", quantile="0.99"} — p99 command latency
spring.cache.gets{name="products", result="miss"} — application-level cache miss rate
spring.cache.gets{name="products", result="hit"} — application-level cache hit rate
Redis's own INFO command provides cluster-level metrics:
redis-cli INFO stats | grep -E "instantaneous_ops_per_sec|keyspace_hits|keyspace_misses"
keyspace_hits / (keyspace_hits + keyspace_misses) is the Redis-level hit rate. This differs from the application-level hit rate reported by Micrometer — Redis sees all GET commands (including those where the key doesn't exist); Micrometer sees the application's view. Both are useful.
Alert on p99 Redis command latency above 5ms — for local network Redis, latency above 5ms indicates contention, network issues, or slow commands (KEYS, SMEMBERS on large sets, SORT without LIMIT). Alert on keyspace_misses rate increase — a sudden increase in misses indicates cache invalidation or TTL expiry patterns worth investigating.