Revision note (2026-09-15). The earlier version of this article targeted Spring Boot 2.7 and JDK 11 and configured the connection with spring.redis.host and spring.redis.port, which Spring Boot 3 ignores (the properties moved to spring.data.redis.* in 3.0; Boot 3.5.16 reports the old names as errors). Its configuration disabled null caching with disableCachingNullValues() while its @Cacheable method returned findById(id).orElse(null), a combination that throws IllegalArgumentException on the first missing id. It injected RedisTemplate<String, Object>, a bean Boot does not define. Its "distributed invalidation" section published eviction messages over Redis pub/sub for a cache whose store is already Redis, subscribed with a raw RedisConnection.subscribe call in @PostConstruct, and decoded the message body with new String(bytes) although the template it used writes Java-serialized bytes. It also replaced Boot's auto-configured cache manager with a custom CacheManager bean, which silently switches off every spring.cache.redis.* property, and cited a Martin Fowler article that does not exist. This version is rebuilt around a lab with 17 tests and a recorded curl session; every number below comes from that lab.
Setup that matches Boot 3
Tested versions: Spring Boot 3.5.16 (Spring Data Redis 3.5.13, Lettuce 6.6.0.RELEASE, Spring Framework 6.2.19), Java 17 (Amazon Corretto 17.0.14), Redis 7.4.11 from the redis:7 Docker image on port 6380, Gradle 8.8.
Dependencies are the two starters the earlier article named, and they are still right:
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
Properties, with the Boot 3 names:
spring.data.redis.host=localhost
spring.data.redis.port=6380
spring.cache.type=redis
spring.cache.redis.time-to-live=30m
spring.cache.redis.enable-statistics=true
spring.cache.cache-names=products,short-lived
The lab has a test that binds spring.redis.host=redis.example and spring.redis.port=6380 only: RedisProperties still reads localhost and 6379. Boot's configuration metadata for 3.5.16 lists spring.redis.host with deprecation level error, replacement spring.data.redis.host, since 3.0.0. If you copied the old names into a Boot 3 project, your application is talking to localhost:6379 no matter what you wrote.
@EnableCaching goes on the application class. Nothing else is required to get a RedisCacheManager: Boot builds it from the properties above. The place to change serializers or per-cache settings is a RedisCacheManagerBuilderCustomizer, not a replacement CacheManager bean, because Boot's determineConfiguration uses spring.cache.redis.* only when you have not supplied your own RedisCacheConfiguration or CacheManager.
@Configuration
public class RedisCacheConfig {
@Bean
RedisCacheManagerBuilderCustomizer jsonValuesAndPerCacheTtl() {
return builder -> {
SerializationPair<Object> json =
SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer());
RedisCacheConfiguration defaults = builder.cacheDefaults().serializeValuesWith(json);
builder.cacheDefaults(defaults);
for (String name : builder.getConfiguredCaches()) {
builder.getCacheConfigurationFor(name)
.ifPresent(existing -> builder.withCacheConfiguration(name, existing.serializeValuesWith(json)));
}
builder.withCacheConfiguration("short-lived", defaults.entryTtl(Duration.ofSeconds(30)));
};
}
}
The for loop is there because of an ordering trap. When spring.cache.cache-names is set, Boot calls builder.initialCacheNames(...) before it runs customizers, and in Spring Data Redis 3.5.13 that method copies the defaults of that moment into a per-cache configuration:
cacheNames.forEach(it -> withCacheConfiguration(it, defaultCacheConfiguration));
A customizer that only changes cacheDefaults therefore leaves products on the JDK serializer it was registered with. In the lab the first symptom was SerializationException: Cannot serialize ... DefaultSerializer requires a Serializable payload but received an object of type [Product] on the first @Cacheable call. A test pins both the broken and the fixed customizer without opening a Redis connection.
What Spring actually writes to Redis
The service under test:
@Service
public class ProductService {
@Cacheable(cacheNames = "products", key = "#id")
public Product getById(Long id) {
return repository.findById(id).orElse(null);
}
@CacheEvict(cacheNames = "products", key = "#product.id")
public Product update(Product product) {
return repository.save(product);
}
@CachePut(cacheNames = "products", key = "#product.id")
public Product updateAndRefresh(Product product) {
return repository.save(product);
}
}
The repository is an in-memory map with a read counter and a configurable sleep. The session below ran against gradle bootRun on port 8098 with a 500 ms sleep, after flushing Redis:
$ curl -s -w "nHTTP %{http_code} time_total=%{time_total}sn" localhost:8098/products/1
{"id":1,"name":"Keyboard","priceCents":4900}
HTTP 200 time_total=0.546415s
$ curl -s -w "nHTTP %{http_code} time_total=%{time_total}sn" localhost:8098/products/1
{"id":1,"name":"Keyboard","priceCents":4900}
HTTP 200 time_total=0.026704s
$ docker exec dd-redis-a redis-cli KEYS "*"
products::1
$ docker exec dd-redis-a redis-cli TTL products::1
1800
$ docker exec dd-redis-a redis-cli GET products::1
{"@class":"com.devdrunk.rediscache.Product","id":1,"name":"Keyboard","priceCents":4900}
The key is the cache name, ::, and the key argument converted to a string (CacheKeyPrefix.simple()). The TTL is the 30 minutes from spring.cache.redis.time-to-live; the short-lived cache shows 30 seconds, from the customizer. GenericJackson2JsonRedisSerializer writes the class name into the JSON, which is what lets it deserialize a Product back.
Two things the earlier article did not say about keys. With two method parameters and no key expression, the default generator produces a SimpleKey, and the Redis key becomes products::SimpleKey [1, eu], which works but is not something you want to type into redis-cli. And spring.cache.redis.key-prefix=dd: produces dd:products::1: the prefix goes in front of the cache name, it does not replace the :: separator.
Evict versus put
Continuing the session:
$ curl -s -X PUT -H "Content-Type: application/json" -d '{"name":"Keyboard v2","priceCents":5900}' localhost:8098/products/1
{"id":1,"name":"Keyboard v2","priceCents":5900}
$ docker exec dd-redis-a redis-cli EXISTS products::1
0
$ curl -s -w "nHTTP %{http_code} time_total=%{time_total}sn" localhost:8098/products/1
{"id":1,"name":"Keyboard v2","priceCents":5900}
HTTP 200 time_total=0.512792s
$ curl -s -X POST -H "Content-Type: application/json" -d '{"name":"Keyboard v3","priceCents":6900}' localhost:8098/products/1/refresh
{"id":1,"name":"Keyboard v3","priceCents":6900}
$ docker exec dd-redis-a redis-cli GET products::1
{"@class":"com.devdrunk.rediscache.Product","id":1,"name":"Keyboard v3","priceCents":6900}
$ curl -s -w "nHTTP %{http_code} time_total=%{time_total}sn" localhost:8098/products/1
{"id":1,"name":"Keyboard v3","priceCents":6900}
HTTP 200 time_total=0.005155s
$ curl -s localhost:8098/repository/reads
2
@CacheEvict deleted the key, and the next read paid the repository latency again. @CachePut wrote the method's return value under the key, and the next read was a hit. Two repository reads for the whole session. The choice between them is about the write path: @CachePut saves the reload but caches whatever the write method returns, so the method must return the full, current entity, not a partial update or a status.
Timings are from one run on a laptop and only separate "hit the repository" from "hit Redis"; they are not a benchmark.
Null results: cached by default, and an exception if you turn that off
By default spring.cache.redis.cache-null-values is true. Reading id 99, which does not exist, twice: the repository was called once, and Redis holds products::99. Its value is not JSON; it is a fixed, JDK-serialized NullValue marker (RedisCache.BINARY_NULL_VALUE), regardless of the value serializer:
$ curl -s -w "nHTTP %{http_code}n" localhost:8098/products/99
HTTP 404
$ docker exec dd-redis-a redis-cli --no-raw GET products::99
"xacxedx00x05srx00+org.springframework.cache.support.NullValuex00x00x00x00x00x00x00x01x02x00x00xp"
That entry lives for the full TTL, so a product created after a 404 stays invisible through the cache for up to 30 minutes unless the write path evicts it.
With spring.cache.redis.cache-null-values=false (the property form of disableCachingNullValues()), the same getById(99) throws:
IllegalArgumentException: Cache 'products' does not allow 'null' values; Avoid storing null via
'@Cacheable(unless="#result == null")' or configure RedisCache to allow 'null' via RedisCacheConfiguration
The message comes from RedisCache.processAndCheckValue in Spring Data Redis 3.5.13, and it tells you the fix: @Cacheable(cacheNames = "products", key = "#id", unless = "#result == null"). The lab's getByIdSkippingNull uses it; two reads of a missing id call the repository twice and write nothing. The earlier article's configuration and its service method were incompatible with each other, which is the kind of thing only a running test catches.
Multiple instances: the cache is already shared
The earlier article proposed Redis pub/sub so that "multi-instance apps" learn about evictions. With RedisCacheManager, the cache is Redis. The lab builds a second RedisCacheManager on the same connection factory, standing in for a second JVM: after instance A's getById(1), instance B's getCache("products").get(1L, Product.class) returns the product; after instance A's @CacheEvict, instance B gets null. Nothing was published; B simply read the same key. An eviction message for a Redis-backed cache tells every instance to delete a key that is already gone.
Pub/sub is the right tool when each instance keeps a local, in-process copy in front of Redis (Caffeine, a map, a Hibernate second-level cache). The Spring Data Redis way to subscribe is a RedisMessageListenerContainer, which owns its own connection, with a MessageListenerAdapter that converts the body before calling your method:
@Bean
MessageListenerAdapter nearCacheListener(NearCache nearCache) {
return new MessageListenerAdapter(nearCache, "evict");
}
@Bean
RedisMessageListenerContainer invalidationListenerContainer(RedisConnectionFactory connectionFactory,
MessageListenerAdapter nearCacheListener) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(nearCacheListener, new ChannelTopic("cache:invalidate"));
return container;
}
Publishing with StringRedisTemplate.convertAndSend("cache:invalidate", "products::1") reached one subscriber and the near cache dropped the key. The lab also publishes the same string through Boot's auto-configured RedisTemplate<Object, Object>: the body that arrives is 18 bytes starting with 0xAC 0xED, the Java serialization stream header, not the 11-character key. The earlier subscriber did cacheManager.getCache("items").evict(new String(message.getBody())) on such a body, so it would have evicted a key that never existed. Use StringRedisTemplate for string channels, or make both sides agree on a serializer.
On injection: Boot defines RedisTemplate<Object, Object> and StringRedisTemplate. A constructor parameter of type RedisTemplate<String, Object> fails with NoSuchBeanDefinitionException because generic parameters take part in autowiring; the lab pins that with a context runner.
Hit and miss counts
RedisCache keeps statistics only when spring.cache.redis.enable-statistics=true. Three reads of the same missing id gave 1 miss and 2 hits on RedisCache.getStatistics(). Because the cache is named in spring.cache.cache-names, it exists at startup and Boot's cache metrics register it, so /actuator/metrics/cache.gets?tag=name:products&tag=result:hit answers (the session above ended with 2 hits and 2 misses: id 1 was a miss then a hit, id 99 was a miss, the read after @CachePut was a hit). Caches created lazily on first use are not bound to Micrometer automatically.
What this does not cover
- Keyspace notifications and Redis Streams; only channel pub/sub was run.
- Redis Sentinel or Cluster, TLS, authentication.
- Behavior when Redis is unreachable; no
CacheErrorHandlerwas written or tested. - Transaction-aware caching and the ordering of a database commit against the eviction.
- Concurrent misses on one key (
@Cacheable(sync = true)) were not measured. - Changing the shape of a cached class while entries with the old shape are still in Redis.
- Securing the demo endpoints; they exist for the session above.
Reproduce it
docker run -d --name dd-redis-a -p 6380:6379 redis:7
gradle test --no-daemon --no-build-cache --rerun-tasks --console=plain # 17 tests
gradle bootRun --no-daemon --args='--server.port=8098 --demo.repository-latency=500ms'
curl -s -w "nHTTP %{http_code} time_total=%{time_total}sn" localhost:8098/products/1
docker exec dd-redis-a redis-cli TTL products::1
The lab is examples/spring-boot-redis-cache-invalidation in the site's repository; the README holds the full test output, the complete curl session, and the source lines cited above.
Sources
- Spring Framework reference: Cache Abstraction
- Spring Boot reference: Caching
- Spring Boot: Common Application Properties
- Spring Boot 3.0 Migration Guide: Redis properties moved to spring.data.redis
- Spring Data Redis reference: Redis Cache
- Redis documentation: Pub/Sub
- Redis documentation: Keyspace notifications
