Command Palette

Search for a command to run...

[Advanced Spring Boot] Caching in Spring Boot: the Cache Abstraction, Caffeine, Redis and Invalidation

A product page reads the same product thousands of times between two price changes, and each read runs the same join and aggregate against the database. A cache keeps the result of that lookup in memory, either in the application's own heap or in a shared server, and answers the next request for the same key without touching the database. The cost is that the cache now holds a second copy of the data, and every write has to decide what happens to that copy.

This article adds caching to a slow catalogue lookup with Spring's cache abstraction, first with Caffeine inside the JVM and then with Redis shared between instances. It measures what each one does: SQL statements per request, cache hits, latency, a stampede of concurrent misses, stale reads after a write, and requests while Redis is down. The examples use Spring Boot 4.1.1 and Java 21, with PostgreSQL 18 and Redis 8.

A slow database lookup answered from a cache

The first section builds the lookup; the next three cover the abstraction, Caffeine and Redis; the rest measures latency, stampedes, invalidation and failure, and ends with how to choose.

The catalogue lookup and how the outputs were produced

The project came from Spring Initializr with the cache and Redis starters next to the usual web, JPA and Flyway ones:

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,data-jpa,postgresql,flyway,validation,cache,data-redis,actuator" -o demo.zip

Initializr has no Caffeine entry, so the one line marked below was added by hand. Its version, 3.2.4, comes from Boot's dependency management. Note the -test starter Boot 4 generates for every main starter:

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-actuator'
	implementation 'org.springframework.boot:spring-boot-starter-cache'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-data-redis'
	implementation 'org.springframework.boot:spring-boot-starter-flyway'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	implementation 'com.github.ben-manes.caffeine:caffeine'
	implementation 'org.flywaydb:flyway-database-postgresql'
	runtimeOnly 'org.postgresql:postgresql'
	testImplementation 'org.springframework.boot:spring-boot-starter-actuator-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-cache-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-data-redis-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-flyway-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

The catalogue has four categories, 200 products and 40 reviews per product, 8,000 in all, created by Flyway and checked by Hibernate with ddl-auto=validate:

src/main/resources/db/migration/V1__create_catalog.sql
create table categories (
    id   bigint generated by default as identity primary key,
    name varchar(60) not null unique
);
 
create table products (
    id          bigint generated by default as identity primary key,
    sku         varchar(40)    not null unique,
    name        varchar(120)   not null,
    price       numeric(10, 2) not null,
    category_id bigint         not null references categories (id)
);
 
create index products_category_id_idx on products (category_id);
 
create table reviews (
    id         bigint generated by default as identity primary key,
    product_id bigint   not null references products (id),
    rating     smallint not null check (rating between 1 and 5),
    body       varchar(500)
);
 
create index reviews_product_id_idx on reviews (product_id);

What gets cached is a read model, not an entity: a record with the product, its category name and its review statistics.

src/main/java/com/example/demo/catalog/ProductView.java
public record ProductView(long id, String sku, String name, BigDecimal price, String category,
        long reviewCount, BigDecimal averageRating) {
}

The lookup joins three tables and aggregates the reviews. On 8,000 reviews with an index, PostgreSQL executed that in 0.174 ms (explain analyze), which is too fast for a cache to matter. So the query carries a stand-in for a slow query: cross join (select pg_sleep(0.02)) adds 20 ms to every execution. Every "no cache" number below is that 20 ms plus the application's own overhead:

src/main/java/com/example/demo/catalog/ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    /** One product with its category and review statistics; pg_sleep stands in for a heavy query. */
    @NativeQuery("""
            select p.id, p.sku, p.name, p.price, c.name as category,
                   count(r.id) as review_count, coalesce(round(avg(r.rating), 2), 0) as average_rating
            from products p
            join categories c on c.id = p.category_id
            left join reviews r on r.product_id = p.id
            cross join (select pg_sleep(0.02)) as slow
            where p.id = :id
            group by p.id, c.name
            """)
    Optional<ProductView> findView(long id);
 
    @NativeQuery("""
            select p.id, p.sku, p.name, p.price, c.name as category,
                   count(r.id) as review_count, coalesce(round(avg(r.rating), 2), 0) as average_rating
            from products p
            join categories c on c.id = p.category_id
            left join reviews r on r.product_id = p.id
            cross join (select pg_sleep(0.02)) as slow
            where lower(c.name) = lower(:category)
            group by p.id, c.name
            order by average_rating desc, p.id
            limit :limit
            """)
    List<ProductView> findTopRated(String category, int limit);
}

The configuration follows the series: open-in-view off, Flyway owning the schema, ProblemDetail for errors. Port 8209 is this lab's; the second instance later runs on 9209:

src/main/resources/application.properties
server.port=8209
 
spring.datasource.url=jdbc:postgresql://localhost:5509/demo
spring.datasource.username=demo
spring.datasource.password=demo
spring.jpa.open-in-view=false
spring.jpa.hibernate.ddl-auto=validate
 
spring.data.redis.port=6309
 
spring.cache.type=caffeine
spring.cache.cache-names=products,topRated,productEntities
spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m,recordStats
 
spring.mvc.problemdetails.enabled=true
management.endpoints.web.exposure.include=health,caches,metrics
 
logging.level.org.hibernate.SQL=debug

"How many SQL statements did that take" is answered by a counter rather than by reading logs. Hibernate hands every statement it prepares, native queries included, to a StatementInspector, and Boot's HibernatePropertiesCustomizer lets a Spring bean be that inspector:

src/main/java/com/example/demo/support/SqlStatementCounter.java
@Component
public class SqlStatementCounter implements StatementInspector, HibernatePropertiesCustomizer {
 
    private final AtomicLong count = new AtomicLong();
 
    @Override
    public String inspect(String sql) {
        count.incrementAndGet();
        return sql;
    }
 
    @Override
    public void customize(Map<String, Object> hibernateProperties) {
        hibernateProperties.put(AvailableSettings.STATEMENT_INSPECTOR, this);
    }
 
    public long count() {
        return count.get();
    }
}

A lab-only endpoint, GET /lab/stats, returns that count next to the cache.gets meters from Micrometer, per cache and result. The application ran as a jar with -Xmx512m. Every timing below comes with the 1-, 5- and 15-minute load averages from uptime, taken just before it.

The cache abstraction: @EnableCaching, @Cacheable and the proxy

Spring's cache abstraction is a set of annotations on your methods plus a CacheManager behind them. An interceptor in the bean's proxy reads the annotations, computes a key from the arguments, and asks the cache manager's Cache for it. The provider (Caffeine, Redis or anything else) only has to implement Cache, so the annotations stay the same when the provider changes.

Is @EnableCaching still required in Spring Boot 4?

Yes. In Boot 4.1.1, CacheAutoConfiguration carries @ConditionalOnBean(CacheAspectSupport.class), and CacheAspectSupport is the interceptor that @EnableCaching registers. Without the annotation there is no interceptor, so Boot does not create a CacheManager at all:

src/main/java/com/example/demo/cache/CacheConfig.java
@Configuration(proxyBeanMethods = false)
@EnableCaching
public class CacheConfig {
}

With the annotation removed, the same two requests for product 7 both went to the database, and the Actuator endpoint listed no cache manager:

Bash
curl -s localhost:8209/api/products/7
curl -s localhost:8209/api/products/7
curl -s localhost:8209/lab/stats
curl -s localhost:8209/actuator/caches
Text
{"sqlStatements":2,"cacheGets":{},"cacheManager":[]}
{"cacheManagers":{}}

Nothing in the startup log mentioned caching: no warning, no error. @Cacheable on a method is only metadata until something reads it.

A miss and a hit

The cached method is a single annotation on the service, and the controller maps an empty Optional to a 404:

src/main/java/com/example/demo/catalog/ProductService.java
@Service
public class ProductService {
 
    private final ProductRepository products;
 
    ProductService(ProductRepository products) {
        this.products = products;
    }
 
    @Cacheable("products")
    public Optional<ProductView> find(long id) {
        return products.findView(id);
    }
}
src/main/java/com/example/demo/catalog/ProductController.java
    @GetMapping("/{id}")
    public ProductView get(@PathVariable long id) {
        return service.find(id).orElseThrow(() -> new ProductNotFoundException(id));
    }

Two requests for product 7 on a fresh instance returned the same body and ran one SQL statement:

Text
{"id":7,"sku":"P-007","name":"Monitor 7","price":278.00,"category":"Monitors","reviewCount":40,"averageRating":2.00}
{"id":7,"sku":"P-007","name":"Monitor 7","price":278.00,"category":"Monitors","reviewCount":40,"averageRating":2.00}
{"sqlStatements":1,"cacheGets":{"productEntities.hit":0.0,"productEntities.miss":0.0,"products.hit":1.0,"products.miss":1.0,"topRated.hit":0.0,"topRated.miss":0.0},"cacheManager":["org.springframework.cache.caffeine.CaffeineCacheManager"]}

With logging.level.org.springframework.cache=trace, CacheInterceptor logs each step. Here the lines are shortened to time, thread, logger and message, with the operation description cut:

Text
14:43:55.032 [nio-8209-exec-1] o.s.cache.interceptor.CacheInterceptor : Computed cache key '7' for operation Builder[public java.util.Optional com.example.demo.catalog.ProductService.find(long)] caches=[products] | key='' | …
14:43:55.033 [nio-8209-exec-1] o.s.cache.interceptor.CacheInterceptor : No cache entry for key '7' in cache(s) [products]
14:43:55.082 [nio-8209-exec-1] org.hibernate.SQL : select p.id, p.sku, p.name, p.price, c.name as category,
14:43:55.119 [nio-8209-exec-1] o.s.cache.interceptor.CacheInterceptor : Creating cache entry for key '7' in cache(s) [products]
14:43:55.140 [nio-8209-exec-2] o.s.cache.interceptor.CacheInterceptor : Computed cache key '7' for operation Builder[public java.util.Optional com.example.demo.catalog.ProductService.find(long)] caches=[products] | key='' | …
14:43:55.141 [nio-8209-exec-2] o.s.cache.interceptor.CacheInterceptor : Cache entry for key '7' found in cache(s) [products]

On the miss, the interceptor computed the key, found nothing, let the method run the query, and stored the result. On the hit, it found the entry and returned it: the method body did not run, and neither did the query.

The cache interceptor's path on a miss and on a hit for key 7, with the SQL count and cache.gets for each

Keys: SimpleKey by default, SpEL when you need it

With one parameter the key is the argument itself, here the Long 7. With several, the default SimpleKeyGenerator wraps them all in a SimpleKey. The top-rated list takes a category and a limit:

src/main/java/com/example/demo/catalog/ProductService.java
    @Cacheable("topRated")
    public List<ProductView> topRated(String category, int limit) {
        return products.findTopRated(category, limit);
    }

Requesting category=Keyboards&limit=3 and then category=keyboards&limit=3 ran two statements for the same three products, because the SQL compares with lower() but the key does not. A lab endpoint that lists a Caffeine cache's keys shows two entries:

Text
{"SimpleKey SimpleKey [keyboards, 3]":"java.util.ArrayList","SimpleKey SimpleKey [Keyboards, 3]":"java.util.ArrayList"}

key takes a SpEL expression over the parameters. condition decides before the call whether the cache is used at all, and unless decides after the call whether the result is stored:

src/main/java/com/example/demo/catalog/ProductService.java
    @Cacheable("topRated") 
    @Cacheable(cacheNames = "topRated", key = "#category.toLowerCase() + ':' + #limit", 
            condition = "#limit <= 20", unless = "#result.isEmpty()") 
    public List<ProductView> topRated(String category, int limit) {
        return products.findTopRated(category, limit);
    }

The same requests on a fresh instance, followed by limit=50 twice and an unknown category twice:

RequestsSQL statementstopRated hit / missWhat happened
Keyboards, then keyboards, limit 311 / 1One key, keyboards:3
Mice, limit 50, twice2unchangedcondition false: the cache was not consulted
Nope, limit 3, twice2+0 / +2Looked up, but unless kept the empty list out

The only key left was {"String keyboards:3":"java.util.ArrayList"}. Parameter names such as #category resolve because Boot's Gradle plugin compiles with -parameters; #root.args[0] works without it.

Caching Optional and null

find returns Optional<ProductView>, but the cache stores what is inside it: the record for a present product, and null for an empty one. Caffeine keeps that null as Spring's NullValue marker. Two requests for a product that does not exist:

Http
HTTP/1.1 404
Content-Type: application/problem+json
 
{"detail":"No product with id 9999","instance":"/api/products/9999","status":404,"title":"Product not found"}

The second 404 came from the cache: one SQL statement for two requests, and the keys of products were:

Text
{"Long 7":"com.example.demo.catalog.ProductView","Long 9999":"org.springframework.cache.support.NullValue"}

Caching the miss protects the database from repeated lookups of ids that do not exist. It also means a row inserted with that key stays invisible until the entry expires or is evicted. To opt out, unless = "#result == null" works on an Optional method too, because #result is the unwrapped value: two requests for id 9998 through such a variant ran two statements.

@CachePut, @CacheEvict and @Caching

A price change has to update the cached product and invalidate every cached top-rated list, since any of them may contain it. @CachePut always runs the method and stores its result. @CacheEvict removes one key, or with allEntries = true the whole cache. @Caching groups several operations on one method:

src/main/java/com/example/demo/catalog/ProductService.java
    @Transactional
    @Caching(
            put = @CachePut(cacheNames = "products", key = "#id"),
            evict = @CacheEvict(cacheNames = "topRated", allEntries = true))
    public Optional<ProductView> changePrice(long id, BigDecimal price) {
        Product product = products.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
        product.setPrice(price);
        products.flush();
        return products.findView(id);
    }

The key is explicit because the default would be SimpleKey [13, 24.50] from both parameters, which is not the key find(13) reads. With product 13 and two top-rated lists cached, the price change ran three statements: the select, the update and the view query that produced the value for @CachePut.

Bash
curl -s -X PUT localhost:8209/api/products/13/price -H 'Content-Type: application/json' -d '{"price": 24.50}'
Text
{"id":13,"sku":"P-013","name":"Keyboard 13","price":24.50,"category":"Keyboards","reviewCount":40,"averageRating":4.68}

Afterwards topRated was empty ({}), and GET /api/products/13 returned 24.50 as a hit with no SQL. A @CachePut value must have the same shape as what the reading method stores: here both are the content of an Optional<ProductView>. A price of 0 was rejected by @DecimalMin("0.01") with a 400 ProblemDetail before any of this ran.

Cache records, not entities

The interceptor stores the returned object itself. For Caffeine, which keeps values on the heap, that is the same instance for every caller: a lab endpoint called find(7) twice and compared the results.

Text
{"first":1473438077,"sameInstance":true,"second":1473438077}

A record with immutable components cannot be changed by one caller behind another's back. A JPA entity can, and it brings a second problem: its lazy associations belong to the persistence context that loaded it. This lab caches Product entities on purpose:

src/main/java/com/example/demo/lab/LabLookups.java
    /** The mistake: caching a managed entity. */
    @Cacheable("productEntities")
    public Product loadEntity(long id) {
        return products.findById(id).orElseThrow();
    }
src/main/java/com/example/demo/lab/ProductDescriber.java
    @Transactional(readOnly = true)
    public String describe(long id) {
        Product product = lookups.loadEntity(id);
        return product.getName() + " in " + product.getCategory().getName();
    }

Calling describe(5) twice worked both times: the first call loaded the product inside its transaction, touched the lazy category, and cached an entity whose category proxy was already initialized. Then, on a fresh instance, a different endpoint that only reads the price cached product 5 first, and describe(5) ran after it:

Bash
curl -s localhost:8209/lab/price-of/5
curl -s localhost:8209/lab/describe/5

The first request printed 204.00. The second returned a 500, and the log had:

Text
org.hibernate.LazyInitializationException: Could not initialize proxy [com.example.demo.catalog.Category#1] - no session

The cached Product came from a session that had closed. Its category was still an uninitialized proxy, and the new transaction's session does not own it. The outcome depended on which caller filled the cache first, which is the worst kind of bug to reproduce. Map to a record or DTO before caching, and fetch whatever the record needs inside the cached method.

Self-invocation skips the cache

Article 3 explained why a call on this never passes through the proxy. For caching it looks like this: a batch lookup that calls find internally.

src/main/java/com/example/demo/catalog/ProductService.java
    /** Looks up several products by calling find() on this, which skips the proxy. */
    public List<ProductView> findAll(List<Long> ids) {
        return ids.stream().map(this::find).flatMap(Optional::stream).toList();
    }

GET /api/products?ids=1,2,3 twice ran six statements, and cache.gets for products did not move: the interceptor was never asked. The fixes are the ones from article 3: move the cached method to another bean, or call it through the injected proxy.

Caffeine as a local cache

Caffeine keeps entries on the application's heap, in a concurrent map with an eviction policy. spring.cache.caffeine.spec is one Caffeine spec string applied to every cache the manager creates. It is already in the configuration above:

src/main/resources/application.properties
spring.cache.type=caffeine
spring.cache.cache-names=products,topRated,productEntities
spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m,recordStats

maximumSize bounds the entry count, expireAfterWrite is the TTL counted from the last write, and recordStats turns on the counters behind the metrics. spring.cache.cache-names does two things with Caffeine: it creates those caches at startup, and it makes the manager static, so a cache name that is not listed fails at the first call:

Text
java.lang.IllegalArgumentException: Cannot find cache named 'topRated' for Builder[public java.util.List com.example.demo.catalog.ProductService.topRated(java.lang.String,int)] caches=[topRated] | key='' | …

Eviction by size, observed

With maximumSize=100, 150 distinct products requested once each, then the same 150 again:

Text
after 150 distinct ids:
{"sqlStatements":150,"cacheGets":{…,"products.hit":0.0,"products.miss":150.0,…}}
cache.size [{'statistic': 'VALUE', 'value': 100.0}]
cache.evictions [{'statistic': 'COUNT', 'value': 50.0}]
cache.puts [{'statistic': 'COUNT', 'value': 0.0}]
after the same 150 again:
{"sqlStatements":201,"cacheGets":{…,"products.hit":99.0,"products.miss":201.0,…}}
cache.size [{'statistic': 'VALUE', 'value': 100.0}]
cache.evictions [{'statistic': 'COUNT', 'value': 101.0}]

The cache held 100 entries and had evicted 50. The second pass got 99 hits and 51 misses. Which 100 survive is Caffeine's decision (it weighs frequency as well as recency), not simply the most recent 100. cache.puts stayed at 0 although 150 values were stored: in Caffeine's statistics that counter follows loads through a loading cache, and Spring stores values with put, so do not read it as "entries written".

Actuator: /actuator/caches and the cache.gets metrics

/actuator/caches lists every cache manager and its caches with the native class behind each:

JSON
{"cacheManagers":{"cacheManager":{"caches":{"topRated":{"target":"com.github.benmanes.caffeine.cache.BoundedLocalCache$BoundedLocalManualCache"},"productEntities":{"target":"com.github.benmanes.caffeine.cache.BoundedLocalCache$BoundedLocalManualCache"},"products":{"target":"com.github.benmanes.caffeine.cache.BoundedLocalCache$BoundedLocalManualCache"}}}}}

The Caffeine meters were cache.eviction.weight, cache.evictions, cache.gets, cache.puts and cache.size. cache.gets carries the tags result (hit or miss), cache, name and cache.manager, so the hit count of one cache is:

Bash
curl -s 'localhost:8209/actuator/metrics/cache.gets?tag=cache:products&tag=result:hit'
JSON
{"availableTags":[{"tag":"cache.manager","values":["cacheManager"]},{"tag":"name","values":["products"]}],"description":"The number of times cache lookup methods have returned a cached (hit) or uncached (newly loaded or null) value (miss).","measurements":[{"statistic":"COUNT","value":4.0}],"name":"cache.gets"}

Two conditions have to hold for those meters to exist:

ConfigurationWhat happened
recordStats missing from the specA WARN per cache at startup: "The cache 'products' is not recording statistics. No meters except 'cache.size' will be registered."
spring.cache.cache-names emptyThe cache was created on first use and cached correctly (1 SQL for 2 requests), but /actuator/metrics/cache.gets returned 404

The second one happens because Boot binds cache metrics at startup, to the caches the manager knows about then. A cache created later by the first @Cacheable call is never instrumented.

Caffeine and Redis on the same classpath

This project has both Caffeine and the Redis starter. With spring.cache.type unset, Boot 4.1.1 picked Redis:

Text
{"sqlStatements":0,"cacheGets":{…},"cacheManager":["org.springframework.data.redis.cache.RedisCacheManager"]}

Boot tries the providers in the order of its CacheType enum: GENERIC, JCACHE, HAZELCAST, COUCHBASE, INFINISPAN, REDIS, CACHE2K, CAFFEINE, SIMPLE, NONE. The first one whose conditions hold creates the CacheManager, and the rest back off. A RedisConnectionFactory bean exists whenever the Redis starter is present, so Redis comes before Caffeine. Set spring.cache.type explicitly whenever more than one provider is on the classpath.

Redis as a distributed cache

A Redis cache lives outside the JVM. Every instance reads and writes the same entries, a restart of the application loses nothing, and each lookup is a network round trip plus deserialization. Redis as a general data store, with its own structures, RedisTemplate and @RedisHash, is article 11; here it is only the backend for @Cacheable.

RedisCacheManager from spring.cache.redis properties

The Redis settings went into a profile, so one jar could run either provider:

src/main/resources/application-redis.properties
spring.cache.type=redis
spring.cache.redis.time-to-live=10m
spring.cache.redis.key-prefix=catalog:
spring.cache.redis.enable-statistics=true

Boot builds RedisCacheManager from these: time-to-live becomes the entry TTL, key-prefix goes in front of every key, cache-null-values (default true) allows the null marker, and enable-statistics feeds the cache.gets meters. Without it the Redis meters exist but stay at 0. The Redis meters were cache.gets with result = hit, miss or pending, cache.puts, cache.removals and cache.lock.duration.

The default value serializer is JDK serialization

Boot configures JdkSerializationRedisSerializer for values. The first request for product 7 ran the query and then failed while storing the result:

Text
java.lang.IllegalStateException: Cannot serialize value of type com.example.demo.catalog.ProductView without a serializer
	at org.springframework.data.redis.serializer.DefaultRedisElementWriter.write(DefaultRedisElementWriter.java:55)
	at org.springframework.data.redis.serializer.RedisSerializationContext$SerializationPair.write(RedisSerializationContext.java:291)
	at org.springframework.data.redis.cache.RedisCache.serializeCacheValue(RedisCache.java:358)
	at org.springframework.data.redis.cache.RedisCache.put(RedisCache.java:206)

The JDK serializer only accepts Serializable types, and the record is not one. The failure surfaced at the first put, not at startup, and it cost a 500 for a query that had succeeded. The null for product 9999 was stored fine, since NullValue is Serializable. Here is what redis-cli showed for it:

Bash
docker exec sba-a9-redis redis-cli KEYS '*'
docker exec sba-a9-redis redis-cli TTL 'catalog:products::9999'
docker exec sba-a9-redis redis-cli --no-raw GET 'catalog:products::9999'
Text
catalog:products::9999
592
"\xac\xed\x00\x05sr\x00+org.springframework.cache.support.NullValue\x00\x00\x00\x00\x00\x00\x00\x01\x02\x00\x00xp"

The key is prefix + cacheName + "::" + key, the TTL counts down from 600 seconds, and the value is a Java serialization stream (\xac\xed is its magic number). Making the record Serializable would work, but the bytes are then readable only by a JVM that has the same class, and nothing but Java can inspect them.

A JSON serializer for Jackson 3

Spring Data Redis 4 ships two generic JSON serializers: GenericJackson2JsonRedisSerializer for Jackson 2, and GenericJacksonJsonRedisSerializer for Jackson 3 (the tools.jackson packages Boot 4 uses). RedisSerializer.json() returns the Jackson 3 one, built with enableUnsafeDefaultTyping(). Its Javadoc warns that without a type validator, deserialization "is vulnerable to arbitrary code execution when reading from untrusted sources". The version below restricts the types instead and plugs the serializer in through Boot's RedisCacheManagerBuilderCustomizer:

src/main/java/com/example/demo/cache/RedisCacheConfig.java
@Configuration(proxyBeanMethods = false)
public class RedisCacheConfig {
 
    static RedisSerializer<Object> jsonSerializer() {
        PolymorphicTypeValidator types = BasicPolymorphicTypeValidator.builder()
                .allowIfSubType("com.example.demo.")
                .allowIfSubType("java.util.")
                .allowIfSubType("java.math.")
                .build();
        return GenericJacksonJsonRedisSerializer.builder()
                .enableSpringCacheNullValueSupport()
                .enableDefaultTyping(types)
                .build();
    }
 
    @Bean
    RedisCacheManagerBuilderCustomizer jsonCacheValues() {
        RedisSerializer<Object> json = jsonSerializer();
        return builder -> {
            RedisCacheConfiguration defaults = builder.cacheDefaults()
                    .serializeValuesWith(SerializationPair.fromSerializer(json));
            builder.cacheDefaults(defaults);
            builder.getConfiguredCaches().forEach(name -> builder.withCacheConfiguration(name, defaults));
            builder.withCacheConfiguration("topRated", defaults.entryTtl(Duration.ofMinutes(1)));
        };
    }
}

The first version of the validator allowed only com.example.demo. and java.util.. Writing worked, and the second request, the first hit, failed while reading:

Text
org.springframework.data.redis.serializer.SerializationException: Could not read JSON: Could not resolve type id 'java.math.BigDecimal' as a subtype of `java.math.BigDecimal`: Configured `PolymorphicTypeValidator` (of type `tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator`) denied resolution

The serializer writes a type id for every value except primitives, their wrappers, enums and final classes in java packages such as String. BigDecimal is not final, so each price and rating carries its class name, and the validator has to allow it. With java.math. added, a miss, a hit, the 404 and a top-rated list all worked. redis-cli showed what the type information looks like:

Bash
docker exec sba-a9-redis redis-cli GET 'catalog:products::7'
docker exec sba-a9-redis redis-cli GET 'catalog:topRated::mice:2'
Text
{"@class":"com.example.demo.catalog.ProductView","id":7,"sku":"P-007","name":"Monitor 7","price":["java.math.BigDecimal",278.00],"category":"Monitors","reviewCount":40,"averageRating":["java.math.BigDecimal",2.00]}
["java.util.ArrayList",[{"@class":"com.example.demo.catalog.ProductView","id":18,"sku":"P-018","name":"Mouse 18","price":["java.math.BigDecimal",205.00],"category":"Mice","reviewCount":40,"averageRating":["java.math.BigDecimal",4.68]},{"@class":"com.example.demo.catalog.ProductView","id":38,"sku":"P-038","name":"Mouse 38","price":["java.math.BigDecimal",465.00],"category":"Mice","reviewCount":40,"averageRating":["java.math.BigDecimal",4.68]}]]

The record is final, yet it gets an @class property, because the serializer skips type ids only for final types in java packages. That is what lets the hit deserialize into a ProductView instead of a LinkedHashMap. The list is written as a [type, value] pair. MONITOR showed the commands behind a miss, a hit and the price change:

Text
"GET" "catalog:products::7"
"SET" "catalog:products::7" "{\"@class\":\"com.example.demo.catalog.ProductView\",…}" "PX" "600000"
"GET" "catalog:products::7"
"SET" "catalog:products::7" "{\"@class\":\"com.example.demo.catalog.ProductView\",…}" "PX" "600000"
"KEYS" "catalog:topRated::*"

@CachePut is a plain SET with the TTL in milliseconds. allEntries = true became KEYS catalog:topRated::*, which the writer follows with a DEL of whatever matched (here nothing had). KEYS walks the whole keyspace and blocks Redis while it does, so on a large shared Redis a cache-wide evict is not free. RedisCacheWriter accepts BatchStrategies.scan(…) in place of the default keys().

Because every hit deserializes a new object, the identity check that returned the same instance on Caffeine returned two on Redis: {"first":2069918412,"second":1727265053,"sameInstance":false}.

Two serializer configurations that silently drop settings

The customizer above is longer than it seems it needs to be. Both shorter versions ran, and both lost something:

ConfigurationWhat Redis held after GET /api/products/7
Customizer that only calls builder.cacheDefaults(… json …)Nothing: the request failed with the same Cannot serialize value of type … ProductView without a serializer
A RedisCacheConfiguration bean with the JSON serializerJSON under the key products::7, TTL -1

The first fails because Boot calls initialCacheNames(...) with spring.cache.cache-names before it runs the customizers, and that call copies the default configuration of the moment, JDK serialization, into each named cache. Changing the defaults afterwards affects only caches created later. Hence getConfiguredCaches().forEach(...). The second fails because Boot uses a RedisCacheConfiguration bean instead of the properties: the catalog: prefix and the 10-minute TTL were gone, and entries never expired. Either set everything in the bean, or use a customizer that starts from builder.cacheDefaults(), which already has the properties applied.

TTL per cache and null values

The customizer gave topRated its own TTL. After one request each, redis-cli TTL returned 600 for catalog:products::7 and 60 for catalog:topRated::mice:2.

A cached null does not go through the JSON serializer. RedisCache stores NullValue as a fixed JDK-serialized byte array whatever the value serializer is: with JSON configured, catalog:products::9999 still held the 64-byte \xac\xed…NullValue stream shown earlier. With spring.cache.redis.cache-null-values=false, the empty Optional for 9999 was not stored, and the request failed:

Text
java.lang.IllegalArgumentException: Cache 'products' does not allow 'null' values; Avoid storing null via '@Cacheable(unless="#result == null")' or configure RedisCache to allow 'null' via RedisCacheConfiguration

So disabling null values requires unless = "#result == null" on every method that can return null or an empty Optional.

Latency: no cache, Caffeine and Redis measured

A small Java client sent sequential GET /api/products/{id} requests over one keep-alive HTTP connection, cycling through 100 ids: 300 warm-up requests, then 2,000 measured ones. Each configuration ran three times on a freshly started instance. The table has the run with the best median; the SQL column is the counter's change during the 2,000 measured requests:

ConfigurationMedianp99MaxSQL statementsLoad average (1, 5, 15 min)
No cache (spring.cache.type=none)24.08 ms31.12 ms47.46 ms2,0005.23 5.30 4.38
Caffeine0.197 ms1.348 ms5.006 ms04.16 4.87 4.38
Redis, JSON values0.556 ms2.093 ms7.188 ms04.92 4.99 4.43

Across the three runs the medians ranged from 24.08 to 25.00 ms with no cache, 0.197 to 0.512 ms with Caffeine, and 0.556 to 1.471 ms with Redis. The numbers are indicative, with Redis in a Docker container on the same machine as the application. The 24 ms is the 20 ms stand-in plus HTTP, JDBC and mapping. A Caffeine hit is a hash lookup on the heap. A Redis hit adds a round trip to another process and a JSON parse, about 2.8 times Caffeine's median here, and still about 1/40 of the query. On a real network the Redis round trip grows; the database round trip it replaces grows too.

Median and p99 latency of the same lookup with no cache, Caffeine and Redis, with the SQL statements each ran

Cache stampede: N concurrent misses and sync = true

When a popular entry expires, every request that arrives before it is back in the cache misses at the same time, and each one runs the query. A lab endpoint reproduces that: it evicts product 42, starts N threads that wait on a CountDownLatch, releases them together, and counts SQL statements:

src/main/java/com/example/demo/lab/LabController.java
        CountDownLatch start = new CountDownLatch(1);
        List<Future<Optional<ProductView>>> results = new ArrayList<>();
        try (ExecutorService pool = Executors.newFixedThreadPool(threads)) {
            for (int i = 0; i < threads; i++) {
                long key = distinct ? id + i : id;
                results.add(pool.submit(() -> {
                    start.await();
                    return sync ? lookups.findSync(key) : service.find(key);
                }));
            }
            start.countDown();
            for (Future<Optional<ProductView>> f : results) {
                f.get();
            }
        }

sync = true tells the interceptor to call Cache.get(key, valueLoader) instead of get-then-put, and leaves it to the cache to let only one caller load a given key:

src/main/java/com/example/demo/catalog/ProductService.java
    @Cacheable("products") 
    @Cacheable(cacheNames = "products", sync = true) 
    public Optional<ProductView> find(long id) {
        return products.findView(id);
    }

Twenty threads, one key, two rounds per configuration. HikariCP's default pool of 10 connections was shared by the threads that did go to the database:

Provider and writersyncSQL statementsWall timeLoad average
Caffeinefalse20, 20148 ms, 50 ms8.10 5.70 4.70
Caffeinetrue1, 128 ms, 54 ms8.10 5.70 4.70
Redis, default writerfalse20, 20134 ms, 51 ms7.77 5.71 4.71
Redis, default writertrue20, 2056 ms, 51 ms7.77 5.71 4.71
Redis, locking writerfalse20, 15149 ms, 118 ms7.47 5.69 4.71
Redis, locking writertrue1, 1742 ms, 387 ms7.47 5.69 4.71

Which cache providers honour sync = true?

Caffeine does: Spring's CaffeineCache.get(key, valueLoader) delegates to Caffeine's per-key atomic get, so 19 threads waited for the one that loaded. ConcurrentMapCache does the same with computeIfAbsent.

Redis with Boot's default setup does not. RedisCache.get(key, valueLoader) hands the loader to the cache writer. Boot builds the manager with RedisCacheManager.builder(connectionFactory), whose writer is the non-locking one, and that writer's version is just "GET, and on a miss load and SET" with no lock. Twenty threads, twenty queries. The locking writer takes a lock in Redis around the load, which is why it held the stampede to one query:

src/main/java/com/example/demo/cache/RedisCacheConfig.java
    @Bean
    RedisCacheManagerBuilderCustomizer lockingCacheWriter(RedisConnectionFactory connectionFactory) {
        return builder -> builder.cacheWriter(RedisCacheWriter.lockingRedisCacheWriter(connectionFactory));
    }

That lock has two costs, both visible in the runs. It is one lock per cache, the key products~lock, not one per key, and waiters poll it with a sleep. The writer's plain get checks the same lock, so while one key loads, reads of every other key in the cache wait as well. The one-key stampede took 387 to 742 ms instead of about 50. Twenty threads each loading a different key with sync = true took 1,156 and 1,059 ms with the locking writer, against 160 and 55 ms with the default writer and 111 and 49 ms with Caffeine (load 6.31, 6.19 and 6.18). All 20 queries ran in every case, and with the locking writer they ran one after another.

The difference between local and distributed locking shows with two instances, 10 threads each, released at the same moment against the same key:

Setup, sync = trueSQL on 8209SQL on 9209Total, two roundsLoad average
Caffeine on each instance112 and 25.34 5.41 4.69
Redis, locking writer011 and 15.26 5.39 4.67
Redis, default writer101020 and 205.48 5.43 4.69

A local lock prevents a stampede per JVM, so N instances run the query N times, which is usually acceptable. The locking writer is the only setup here that loaded once across instances, and it pays for that with a cache-wide lock that every read and write checks.

sync = true has restrictions of its own: CacheAspectSupport rejects it combined with other cache operations on the method, with more than one cache, and with unless. The check happens at the first call, not at startup:

Text
java.lang.IllegalStateException: A sync=true operation does not support the unless attribute on 'Builder[public java.util.Optional com.example.demo.lab.LabLookups.tempSyncUnless(long)] caches=[products] | key='' | … | unless='#result == null' | sync='true''

Invalidation strategies

A cache entry is correct until the row it was built from changes. Whatever happens after that is invalidation: either the entry expires on its own, or the write removes or replaces it.

TTL alone and the staleness window

With a TTL and no eviction, an entry is stale from the moment the row changes until it expires. A script cached product 31 on a Caffeine instance with expireAfterWrite=30s, changed the price with psql 5.2 seconds later (bypassing the application, as another service or a migration would), and polled every 200 ms:

Text
t=+0.0s  reader 8209 caches price 206.0
t=+5.2s  price set to 111.00 by psql UPDATE (bypassing the application)
t=+30.3s  reader 8209 returns 111.0: 121 stale reads, stale for 25.1s after the write

The window was the TTL minus the entry's age at the write, and in the worst case it is the whole TTL (load 5.13 5.37 4.70). TTL alone is the right tool for data that other systems change and where a bounded delay is acceptable. It is the only invalidation that works when the application does not see the write.

Where the evict runs relative to the commit

changePrice carries @Transactional and the cache annotations on the same method. With transaction and cache logging on, the cache operations ran after the commit:

Text
o.s.orm.jpa.JpaTransactionManager : Creating new transaction with name [com.example.demo.catalog.ProductService.changePrice]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
org.hibernate.SQL : select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.id=?
org.hibernate.SQL : update products set category_id=?,name=?,price=?,sku=? where id=?
org.hibernate.SQL : select p.id, p.sku, p.name, p.price, c.name as category,
o.s.orm.jpa.JpaTransactionManager : Initiating transaction commit
o.s.orm.jpa.JpaTransactionManager : Committing JPA transaction on EntityManager [SessionImpl(1757222655<open>)]
o.s.cache.interceptor.CacheInterceptor : Creating cache entry for key '13' in cache(s) [products]
o.s.cache.interceptor.CacheInterceptor : Invalidating entire cache for operation Builder[public java.util.Optional com.example.demo.catalog.ProductService.changePrice(long,java.math.BigDecimal)] caches=[topRated] | …

In this application the cache interceptor wrapped the transaction interceptor. @EnableCaching and @EnableTransactionManagement both default to Ordered.LOWEST_PRECEDENCE, so that nesting is not something either annotation promises. The case that matters in practice is a different one: the cached write method is usually called from a transaction that is larger than it, and then its evict runs inside that transaction, before the commit.

Evict-on-write inside a larger transaction: the race

A price feed is applied in one transaction, so either all rows change or none do. Each row goes through a method that evicts the product's key:

src/main/java/com/example/demo/catalog/ProductService.java
    @Transactional
    @CacheEvict(cacheNames = "products", key = "#id")
    public void changePriceEvicting(long id, BigDecimal price) {
        Product product = products.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
        product.setPrice(price);
    }
src/main/java/com/example/demo/catalog/PriceImportService.java
    /** A price feed applied in one transaction: every row commits, or none does. */
    @Transactional
    public void importPrices(Map<Long, BigDecimal> prices) {
        prices.forEach(products::changePriceEvicting);
        log.info("all prices applied, committing");
        pause.pauseIfArmed();
    }

pause.pauseIfArmed() is a lab hook: two latches hold the writer after the evicts and before the commit, while a reader runs. On Redis, with product 21 at 316.00 and the feed setting 99.00:

Text
14:35:03.368662 reader primes the cache: price=316.00 (1 SQL, miss)
14:35:03.395632 writer: price changed, changePriceEvicting returned, transaction still open
14:35:03.430812 reader in the gap: price=316.00 (1 SQL, miss)
14:35:03.441432 writer committed
14:35:03.450664 reader after the commit: price=316.00 (0 SQL, hit)
14:35:03.454719 database: price=99.00

An excerpt of the log shows the order on both threads:

Text
14:35:03.393 [writer] o.s.cache.interceptor.CacheInterceptor : Invalidating cache key [21] for operation Builder[public void com.example.demo.catalog.ProductService.changePriceEvicting(long,java.math.
14:35:03.395 [writer] c.e.demo.catalog.PriceImportService : all prices applied, committing
14:35:03.396 [nio-8209-exec-1] o.s.cache.interceptor.CacheInterceptor : No cache entry for key '21' in cache(s) [products]
14:35:03.397 [nio-8209-exec-1] org.hibernate.SQL : select p.id, p.sku, p.name, p.price, c.name as category,
14:35:03.430 [nio-8209-exec-1] o.s.cache.interceptor.CacheInterceptor : Creating cache entry for key '21' in cache(s) [products]
14:35:03.430 [writer] o.s.orm.jpa.JpaTransactionManager : Initiating transaction commit
14:35:03.437 [writer] org.hibernate.SQL : update products set category_id=?,name=?,price=?,sku=? where id=?
14:35:03.450 [nio-8209-exec-1] o.s.cache.interceptor.CacheInterceptor : Cache entry for key '21' found in cache(s) [products]

The evict emptied the key while the new price existed only in the writer's persistence context. Hibernate did not even send the UPDATE until the flush at commit. The reader missed, read the committed 316.00, and put it back. After the commit Redis held 316.00 with TTL 600, so every instance served the old price for up to ten minutes. The pause only made the timing deterministic; in production the gap is however long the rest of the transaction takes.

Transaction-aware caching: evict and put after the commit

Spring's TransactionAwareCacheDecorator defers put, evict and clear to an afterCommit callback when a transaction is active, and drops them on rollback. RedisCacheManager extends AbstractTransactionSupportingCacheManager, and its builder exposes the switch. Boot has no property for it, so it goes into the customizer:

src/main/java/com/example/demo/cache/RedisCacheConfig.java
        return builder -> {
            RedisCacheConfiguration defaults = builder.cacheDefaults()
                    .serializeValuesWith(SerializationPair.fromSerializer(json));
            builder.cacheDefaults(defaults);
            builder.getConfiguredCaches().forEach(name -> builder.withCacheConfiguration(name, defaults));
            builder.withCacheConfiguration("topRated", defaults.entryTtl(Duration.ofMinutes(1)));
            builder.transactionAware(); 
        };

The same forced interleaving, from 316.00 again:

Text
14:34:46.778112 reader primes the cache: price=316.00 (1 SQL, miss)
14:34:46.804579 writer: price changed, changePriceEvicting returned, transaction still open
14:34:46.814594 reader in the gap: price=316.00 (0 SQL, hit)
14:34:46.831221 writer committed
14:34:46.856745 reader after the commit: price=99.00 (1 SQL, miss)
14:34:46.861394 database: price=99.00

The reader in the gap got the old price as a hit, which is correct: 99.00 was not committed yet. The evict ran after the commit, so the next read missed and cached 99.00. CaffeineCacheManager is not transaction-supporting and Boot offers nothing for it; the equivalent is to define your own CacheManager bean wrapped in TransactionAwareCacheManagerProxy, which also means building the Caffeine manager yourself.

The same switch fixes a second problem with @CachePut in a larger transaction. A two-row import through changePrice, whose second row does not exist:

BeforeAfter the rollbackDatabase
Plain RedisCacheManager353.00 (miss)50.00 (hit)353.00
transactionAware()353.00 (miss)353.00 (hit)353.00

Without it, the first row's @CachePut wrote 50.00 into Redis before the second row threw ProductNotFoundException. The transaction rolled back, and the cache kept serving a price that was never committed. Deferring to after the commit leaves one narrower window. A reader that loaded the row before the commit but writes its result after the evict still puts the old value back. TTL is the backstop for that.

Two instances: local Caffeine goes stale, shared Redis does not

Two copies of the application ran on 8209 and 9209 against the same database. With Caffeine (expireAfterWrite=30s), 9209 cached product 32, and 5.5 seconds later the price changed through 8209. 8209's @CachePut updated 8209's cache only:

Text
t=+0.0s  reader 9209 caches price 243.0
t=+5.5s  price set to 122.00 by PUT through 8209
t=+30.6s  reader 9209 returns 122.0: 121 stale reads, stale for 25.1s after the write

The same test with both instances on Redis:

Text
t=+0.0s  reader 9209 caches price 280.0
t=+5.8s  price set to 133.00 by PUT through 8209
t=+5.8s  reader 9209 returns 133.0: 0 stale reads, stale for 0.0s after the write

Load averages were 4.09 and 6.54. With local caches, an evict or put reaches only the instance that made the write, and every other instance is stale for up to one TTL. With a shared cache there is one entry, and 9209's first read after the write was a hit on the value 8209 had put.

Two instances with local caches serving a stale price after a write through one of them, a shared Redis cache serving the new one, and the evict-before-commit race that re-caches the old row

Two-level caching

The two can be combined: a small, short-TTL Caffeine cache in front of Redis in each instance, so the hottest keys cost a heap lookup and the rest a Redis round trip. The price is the local layer's staleness from the test above, bounded by its own TTL, unless every write also broadcasts an invalidation to all instances, for example over Redis pub/sub. Spring has no built-in two-level CacheManager; you compose one from two Cache implementations, or use a library that does. It pays off when Redis latency or Redis load is the measured bottleneck, and not before.

When Redis stops answering

A script sent one request per second and changed Redis's state in the middle. Products 1 to 3 were cached before the change. With Boot's defaults and Redis stopped (docker stop):

Text
t=+  2.0s  GET /api/products/1 -> 200 in    30.6 ms
t=+  3.3s  docker stop sba-a9-redis
t=+  3.3s  GET /api/products/2 -> 500 in    16.8 ms
t=+  4.3s  GET /api/products/3 -> 500 in     3.0 ms
t=+  5.3s  GET /api/products/1 -> 500 in     2.8 ms
Text
org.springframework.data.redis.RedisSystemException: Redis exception
java.net.SocketException: Connection reset

Every request failed, including ones whose answer was a 20 ms query away (load 5.67 5.41 4.83). The failure was fast because Docker Desktop's port forwarding reset each connection attempt. A Redis that stops answering without closing the connection behaves differently. docker pause freezes the process:

Text
t=+  2.1s  docker pause sba-a9-redis
t=+  2.1s  GET /api/products/1 -> 500 in 60018.5 ms
t=+ 62.1s  GET /api/products/2 -> 500 in 60030.4 ms
t=+122.1s  GET /api/products/3 -> 500 in 59996.2 ms
t=+182.2s  docker unpause sba-a9-redis
t=+182.2s  GET /api/products/1 -> 200 in    11.0 ms
Text
org.springframework.dao.QueryTimeoutException: Redis command timed out
io.lettuce.core.RedisCommandTimeoutException: Command timed out after 1 minute(s)

That run's load average was 5.59 5.36 4.83. Boot leaves spring.data.redis.timeout unset, so Lettuce's own default command timeout of one minute applies, and each request held a Tomcat thread for 60 seconds. At 200 worker threads, Tomcat's default, anything above about three requests per second runs out of threads before the first timeout. Setting the timeout is the first fix:

src/main/resources/application-redis.properties
spring.data.redis.timeout=250ms

The requests then failed in 255 to 268 ms with Command timed out after 250 millisecond(s) (load 4.64 4.81 4.68). They still failed, though. A CacheErrorHandler decides what a cache exception does. Spring ships LoggingCacheErrorHandler, which logs and swallows it, so a failed get is treated as a miss and the method runs. It is registered through CachingConfigurer:

src/main/java/com/example/demo/cache/CacheErrorHandlingConfig.java
/** Treats a failing cache as a miss: log it, then run the method. */
@Configuration(proxyBeanMethods = false)
public class CacheErrorHandlingConfig implements CachingConfigurer {
 
    @Override
    public CacheErrorHandler errorHandler() {
        return new LoggingCacheErrorHandler(false);
    }
}

With the 250 ms timeout and the handler, the same pause and the same stop:

Text
t=+  3.1s  docker pause sba-a9-redis
t=+  3.1s  GET /api/products/2 -> 200 in   286.3 ms
t=+  4.1s  GET /api/products/3 -> 200 in   290.2 ms
t=+  5.1s  GET /api/products/1 -> 200 in   291.8 ms
t=+  6.2s  docker unpause sba-a9-redis
t=+  6.2s  GET /api/products/2 -> 200 in    17.9 ms
Text
t=+  3.3s  docker stop sba-a9-redis
t=+  3.3s  GET /api/products/2 -> 200 in    27.1 ms
t=+  4.4s  GET /api/products/3 -> 200 in    35.8 ms
t=+  5.4s  GET /api/products/1 -> 200 in    39.9 ms
t=+  6.5s  docker start sba-a9-redis
t=+  6.5s  GET /api/products/2 -> 200 in    30.8 ms
Text
WARN o.s.c.i.LoggingCacheErrorHandler : Cache 'products' failed to get entry with key '2'

Every request succeeded from the database: the 250 ms get timeout plus the query while Redis hung, just the query while it refused connections (load 5.02 and 4.40). The handler logged only failed gets, never failed puts, and the puts did not add 250 ms. That is because Spring Data Redis 4 writes cache entries asynchronously by default: put, evict and clear are sent without waiting for Redis (RedisCacheWriterConfigurer.immediateWrites() turns that off). A price change sent while Redis was paused returned 200 in 30 ms with no warning at all. Fast, but a write-side failure never reaches the error handler: an evict that does not arrive goes unnoticed, and only the TTL removes the entry it was meant to remove.

Two limits remain. Each request during a hang still pays the full timeout; a circuit breaker in front of the cache would skip Redis after the first failures, which is beyond this article. And the database now takes the whole load the cache was absorbing, which is fine for this catalogue and not for a system sized on the assumption of a 99% hit rate.

Choosing between no cache, Caffeine and Redis

No cacheCaffeine (local)Redis (distributed)
Latency, measured median / p9924.08 / 31.12 ms, 1 SQL per request0.197 / 1.348 ms, 0 SQL0.556 / 2.093 ms, 0 SQL
Consistency across instancesAlways currentOne copy per instance: 25.1 s stale on the other instance with a 30 s TTLOne shared copy: 0 stale reads after a write through either instance
Invalidation costNoneAn evict reaches one heap; other instances wait for their TTL or need a broadcastOne network command per key; allEntries runs KEYS over the keyspace
Stampede, 20 threads on one key20 queries1 query with sync = true, per instance20 queries even with sync = true; 1 with the locking writer and its cache-wide lock
ValuesNot storedThe object itself, shared by referenceA serialized copy per hit; needs a serializer and a type validator
When the cache failsNothing to failFails only with the JVM; memory bounded by maximumSizeDefault: HTTP 500 on every cached request, or a 60 s hang each if Redis stops answering; with a timeout and LoggingCacheErrorHandler: served from the database

Start with no cache and a measured problem. Choose Caffeine when the data tolerates staleness up to a TTL across instances, or when there is only one instance. Choose Redis when instances must agree after a write, or when the cached set is too large for each heap. Two neighbours are out of scope here. Hibernate's second-level cache caches entities and collections below the repository, keyed by id, and suits entity-centric reads more than read models like this one. HTTP caching (Cache-Control, ETag) keeps responses in browsers and proxies, where no server code runs at all.

FAQ

Do I need @EnableCaching in Spring Boot 4?

Yes. Boot's CacheAutoConfiguration only runs when the CacheAspectSupport bean that @EnableCaching registers exists. Without the annotation, Boot 4.1.1 created no CacheManager, @Cacheable was silently ignored (two requests, two SQL statements), /actuator/caches returned {"cacheManagers":{}}, and the log said nothing.

Why does Spring Boot use Redis instead of Caffeine for my cache?

With spring.cache.type unset, Boot tries providers in the order of its CacheType enum, and REDIS comes before CAFFEINE. Any application with the Redis starter has a RedisConnectionFactory, so it gets a RedisCacheManager even if Caffeine is on the classpath. Set spring.cache.type=caffeine (or redis) explicitly.

Why does @Cacheable not work when I call the method from the same class?

The call goes to this, not to the proxy that holds the cache interceptor, so no lookup happens: a batch method calling find internally ran six statements for two requests of three ids, and cache.gets did not change. Put the cached method on another bean, or call it through the injected proxy.

How do I store cache values as JSON in Redis with Spring Boot 4 and Jackson 3?

Use GenericJacksonJsonRedisSerializer from Spring Data Redis 4, enable default typing with a BasicPolymorphicTypeValidator that allows your packages plus java.util. and java.math., and set it in a RedisCacheManagerBuilderCustomizer on builder.cacheDefaults() and on every name in builder.getConfiguredCaches(). Setting only the defaults leaves the caches listed in spring.cache.cache-names on JDK serialization. A RedisCacheConfiguration bean works but discards every spring.cache.redis.* property.

Does @Cacheable(sync = true) prevent a cache stampede with Redis?

Not with Boot's default setup. The default non-locking RedisCacheWriter takes no lock, and 20 concurrent misses ran 20 queries with sync = true. RedisCacheWriter.lockingRedisCacheWriter(...) reduced it to 1, across two instances too, but its lock covers the whole cache: 20 misses on 20 different keys took over a second, against 55 to 160 ms with the default writer. Caffeine honours sync = true per key.

Should @CacheEvict run before or after the transaction commits?

After. When the evict runs inside the transaction, a concurrent reader can miss, read the old committed row and put it back before the commit; the lab reproduced that and kept a stale price in Redis with a 600-second TTL. RedisCacheManager.builder(...).transactionAware() (through a RedisCacheManagerBuilderCustomizer) defers evicts and puts to after the commit and drops them on rollback.

What happens to requests when Redis is down?

With Boot's defaults every cached request fails. A stopped Redis gave an immediate RedisSystemException; a hung one gave a 60-second wait and QueryTimeoutException, from Lettuce's default one-minute command timeout. Set spring.data.redis.timeout and register LoggingCacheErrorHandler through CachingConfigurer: requests then fell back to the database in about 290 ms while Redis hung.

Conclusion

The cache abstraction is small: @EnableCaching (still required in Boot 4.1.1, and silent when missing), @Cacheable, @CachePut, @CacheEvict and @Caching, with keys from SimpleKey or SpEL, and condition and unless to decide what is looked up and what is stored. What it stores is the returned object, which should be a record: a cached entity threw LazyInitializationException or not depending on which caller filled the cache. Caffeine turned a 24 ms lookup into a 0.197 ms median with zero SQL, showed its evictions and hit counts through Actuator once recordStats and cache-names were set, and held a stampede to one query per instance. Redis, which Boot picks over Caffeine when both are present, cost 0.556 ms and gave every instance the same entry. It also needed care before it worked at all: JDK serialization refused the record, GenericJacksonJsonRedisSerializer needed a type validator that allows BigDecimal, and two plausible ways of setting the serializer each dropped part of the configuration.

Invalidation carried most of the risk. A TTL bounds staleness, and it is the only guard against writes the application never sees. An evict inside a larger transaction let a reader put the old price back for ten minutes, and a @CachePut in a rolled-back transaction cached a price that never existed; transactionAware() fixed both. Local caches on two instances disagreed for 25 seconds after a write, and the shared one did not disagree at all. sync = true did nothing on Redis's default writer. And a Redis that hung held every request for a minute until a timeout and a CacheErrorHandler turned the outage into slower reads from the database.

The next article covers multiple datasources: configuring more than one DataSource in Spring Boot, and routing reads and writes between a primary and a read replica.

Related Posts

[Advanced Spring Boot] Locking and Concurrency in Spring Boot: Optimistic @Version, Pessimistic Locks and Race Conditions

Locking and concurrency in Spring Boot 4.1.1 on PostgreSQL: the lost update when two users edit one product, @Version and the update … where version=? it sends, the exception chain that reaches your code, saveAll, dirty checking and the bulk @Modifying update that bypasses the version, the version over HTTP answered with a 409 ProblemDetail, retrying a conflict around the whole transaction and the placement that never retries, PESSIMISTIC_WRITE vs PESSIMISTIC_READ, NOWAIT and jakarta.persistence.lock.timeout as set local lock_timeout, SKIP LOCKED for a work queue, a real deadlock (40P01) and its fix, the atomic conditional update, a CHECK constraint, and a table for choosing between them.

[Advanced Spring Boot] Spring AOP: JDK and CGLIB Proxies, Aspects and Self-Invocation

Spring AOP on Spring Boot 4.1.1: spring-boot-starter-aop is gone from the BOM and spring-boot-starter-aspectj replaces it, JDK dynamic proxy against CGLIB subclass with the real class names, the ClassCastException a JDK proxy causes, the final class that throws AopConfigException, the final method that quietly NPEs because Objenesis skipped the constructor, the pointcut designators that matter, the measured order of all five advice kinds on both paths, @Order between aspects, the self-invocation trap underneath @Transactional and @Async with three fixes compared, the nanosecond cost of a proxied call, and Advised#getAdvisors for debugging.

[Advanced Spring Boot] Extending the Spring Container: BeanFactoryPostProcessor, BeanPostProcessor and Aware

The container extension points of Spring Boot 4.1.1, traced through one startup: a numbered run from EnvironmentPostProcessor to the runners, how each callback is registered in Boot 4 and why context.initializer.classes no longer works, BeanFactoryPostProcessor against BeanDefinitionRegistryPostProcessor, a BeanPostProcessor that returns a JDK dynamic proxy, the "not eligible for getting processed by all BeanPostProcessors" trap with the transaction it silently loses, the measured post-processor chain that shows @Order being ignored, the Aware interfaces worth knowing, and what an exception in an ApplicationRunner does to the exit code.

[Advanced Spring Boot] Writing Your Own Auto-configuration and Starter

Build and ship a real Spring Boot 4.1.1 starter: the three Gradle projects and the x-spring-boot-starter naming rule, an @AutoConfiguration class with @ConditionalOnMissingBean and @ConditionalOnProperty, registration in AutoConfiguration.imports, ordering with before/after against a Boot auto-configuration, a validated @ConfigurationProperties record with generated spring-configuration-metadata.json, a custom SpringBootCondition with its ConditionOutcome message in the report, five ApplicationContextRunner tests including FilteredClassLoader, publishing to mavenLocal and consuming it, and a FailureAnalyzer for the misconfiguration.