Spring Boot 3 Cache Invalidation with Redis: @CacheEvict, @CachePut, TTL, Null Entries, and What Pub/Sub Is Actually For

Code tested with Spring Boot 3.5.16, Spring Data Redis 3.5.13, Redis 7.4.11 and Java 17; 17 tests plus a recorded curl session

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 = &quot;products&quot;, key = &quot;#id&quot;, unless = &quot;#result == null&quot;). 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(&quot;products&quot;).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(&quot;cache:invalidate&quot;, &quot;products::1&quot;) reached one subscriber and the near cache dropped the key. The lab also publishes the same string through Boot's auto-configured RedisTemplate&lt;Object, Object&gt;: 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(&quot;items&quot;).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&lt;Object, Object&gt; and StringRedisTemplate. A constructor parameter of type RedisTemplate&lt;String, Object&gt; 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&amp;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 CacheErrorHandler was 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