A Redis Cache
Share one cache across every instance with Redis, set a time-to-live per cache, store values as JSON and keep serving requests when Redis is down.
spring.cache.type=redis
spring.cache.redis.time-to-live=10m
@Bean
RedisCacheManagerBuilderCustomizer caches() {
return builder -> builder.withCacheConfiguration("name", RedisCacheConfiguration.defaultCacheConfig().entryTtl(...));
}An in-memory cache lives inside one JVM. Run three replicas and you have three caches that disagree: an eviction on one instance leaves the others serving the old value. Redis fixes that by keeping one cache outside the application that every instance reads and writes. Entries also survive a deployment, so a fresh instance starts warm.
Wiring it up
Add spring-boot-starter-data-redis next to spring-boot-starter-cache, keep @EnableCaching, and point the application at Redis with the spring.data.redis.* properties. When a Redis connection is available, Spring Boot picks Redis as the cache provider; set spring.cache.type=redis to make that choice explicit. Your @Cacheable methods do not change.
spring.cache.type=redis
spring.cache.redis.time-to-live=10m
spring.cache.redis.key-prefix=catalogue::
spring.data.redis.host=${REDIS_HOST:localhost}
spring.data.redis.password=${REDIS_PASSWORD}spring.cache.redis.time-to-live is the default for every cache that has no configuration of its own. Without it entries never expire, which turns a cache into a slowly growing copy of your database.
How values are stored
By default the Redis cache uses Java serialisation, so every cached type must implement Serializable, and the bytes are unreadable in redis-cli. Renaming a field or a class makes old entries fail to load after a deployment. JSON is the usual choice. Spring Data Redis 4 serialises with Jackson 3: JacksonJsonRedisSerializer writes one known type, and GenericJacksonJsonRedisSerializer stores type information inside the JSON so one cache can hold several types.
A RedisCacheManagerBuilderCustomizer bean (package org.springframework.boot.cache.autoconfigure in Boot 4) lets you give individual caches their own TTL and serialiser while Boot still builds the cache manager. A per-cache configuration replaces the defaults rather than adding to them: one built from RedisCacheConfiguration.defaultCacheConfig() ignores spring.cache.redis.*, including the key prefix, so set everything that cache needs on it.
Production concerns
- Redis outages. By default a failed cache read throws, so a Redis restart becomes failing API requests. Register a
CacheErrorHandlerthat logs and carries on, and the method simply runs against the database. - Key changes. Changing the shape of a cached record breaks entries written by the previous version. Put a version in the key prefix or cache name (
products-v2) when the shape changes. - Network cost. Every hit is a network round trip. Redis is still far cheaper than a slow query, but caching a value that takes a millisecond to compute gains nothing.
- Personal data. Anything you cache sits in Redis in plain text. Do not cache tokens or secrets, and give user-specific entries a short TTL.
Example
import java.math.BigDecimal;
import java.time.Duration;
import org.springframework.boot.cache.autoconfigure.RedisCacheManagerBuilderCustomizer;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.JacksonJsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;
record ProductView(long id, String name, BigDecimal price) {
}
record Quote(String from, String to, BigDecimal rate) {
}
public class RedisCacheConfig {
RedisCacheManagerBuilderCustomizer cacheCustomizer() {
return builder -> builder
.withCacheConfiguration("products", jsonCache(ProductView.class, Duration.ofMinutes(30)))
.withCacheConfiguration("rates", jsonCache(Quote.class, Duration.ofMinutes(2)));
}
private static <T> RedisCacheConfiguration jsonCache(Class<T> type, Duration ttl) {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(ttl)
.disableCachingNullValues()
.serializeValuesWith(SerializationPair.fromSerializer(new JacksonJsonRedisSerializer<>(type)));
}
}
When to use it
- Three replicas of a catalogue API share one Redis cache so an eviction after a price change is seen by every instance at once.
- A pricing service gives exchange rates a two-minute TTL and product details thirty minutes, each configured per cache.
- A booking API keeps answering from the database during a Redis failover because a CacheErrorHandler turns cache errors into misses.
More examples
Connection and cache defaults in YAML
The cache defaults apply to any cache that has no per-cache configuration. A short spring.data.redis.timeout matters: when Redis is slow, a long timeout makes every cached method slow too, while a short one fails fast and lets the error handler fall back to the database.
spring:
cache:
type: redis
cache-names: products,rates
redis:
time-to-live: 10m
key-prefix: "catalogue::"
cache-null-values: false
data:
redis:
host: ${REDIS_HOST:localhost}
port: 6379
password: ${REDIS_PASSWORD}
timeout: 500ms
Keep serving when Redis is down
LoggingCacheErrorHandler logs cache failures and swallows them, so a failed get behaves like a cache miss and the method body runs. Implementing only errorHandler() leaves the cache manager to Spring Boot. Pair it with a health check or a metric on Redis so a silent outage does not go unnoticed while the database takes the full load.
import org.springframework.cache.annotation.CachingConfigurer;
import org.springframework.cache.interceptor.CacheErrorHandler;
import org.springframework.cache.interceptor.LoggingCacheErrorHandler;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CacheErrorConfig implements CachingConfigurer {
@Override
public CacheErrorHandler errorHandler() {
return new LoggingCacheErrorHandler(false);
}
}
Testing against a real Redis
@ServiceConnection(name = "redis") tells Spring Boot that this generic container is Redis, so it sets the connection details without any properties. The test gives the products cache the same JacksonJsonRedisSerializer configuration the application uses, so the record is written as JSON rather than with Java serialisation (it does not implement Serializable, so the default serialiser would fail). Writing and reading through the real cache manager catches serialisation problems that the in-memory cache hides, such as a record that Jackson cannot read back or a per-cache serialiser written for a different type.
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.cache.autoconfigure.CacheAutoConfiguration;
import org.springframework.boot.cache.autoconfigure.RedisCacheManagerBuilderCustomizer;
import org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.JacksonJsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
@SpringBootTest(properties = "spring.cache.type=redis")
@Testcontainers
class RedisCacheIntegrationTest {
record ProductView(long id, String name, BigDecimal price) {
}
@SpringBootConfiguration
@EnableCaching
@ImportAutoConfiguration({DataRedisAutoConfiguration.class, CacheAutoConfiguration.class})
static class TestConfig {
@Bean
RedisCacheManagerBuilderCustomizer jsonProducts() {
return builder -> builder.withCacheConfiguration("products",
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeValuesWith(SerializationPair.fromSerializer(
new JacksonJsonRedisSerializer<>(ProductView.class))));
}
}
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379);
@Autowired
CacheManager cacheManager;
@Test
void storesAndReadsAProductViewAsJson() {
var cache = cacheManager.getCache("products");
var view = new ProductView(42L, "Desk lamp", new BigDecimal("24.99"));
cache.put(42L, view);
assertThat(cache.get(42L, ProductView.class)).isEqualTo(view);
}
}
Discussion