Caching: the Spring Cache Abstraction, Caffeine, and Redis
|
This section documents Spring Boot 4.1.x and Spring Framework 7.0.x on the Java 17/21+ baseline, as described by the official Spring Boot reference documentation and each sub-project’s own site (Spring Data, Spring for Apache Kafka, Micrometer, Project Reactor, springdoc-openapi, Spring gRPC, and the others listed in this section’s Bibliography) — which are the references these pages are written and verified against. This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Spring Boot and its ecosystem continue to evolve; the examples here target the current 4.1.x / 7.0.x releases. This section’s bibliography lists the reference material consulted while preparing these pages. |
Spring’s cache abstraction lets a method’s return value be transparently stored and reused on subsequent
calls with the same arguments, without the method body ever mentioning a cache. This page covers the
annotation-driven abstraction itself, a local in-memory provider (the default ConcurrentMapCacheManager and
the production-grade Caffeine), and a distributed provider (Redis), including how their configuration and
consistency trade-offs differ.
The Spring Cache abstraction
Like @Transactional (see Core Concepts), caching is implemented
as a proxy around the annotated bean: a call from outside the proxy is intercepted, but a self-invocation
(this.someCacheableMethod()) bypasses it and silently skips the cache. Enable the abstraction on a
configuration class:
@Configuration
@EnableCaching
public class CacheConfig {
// cache manager beans go here (see below)
}
@Cacheable
@Cacheable caches a method’s return value, keyed by its arguments. On a hit, the method body never runs:
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Cacheable(cacheNames = "products", key = "#id")
public Product findById(Long id) {
// only executes on a cache miss; the result is then stored under key `id`
return productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
}
cacheNames (or its alias value) names one or more caches; key is a
SpEL expression evaluated against the
method’s arguments, exposed as #argName (or #p0/#a0 positionally). When key is omitted, a
KeyGenerator derives it from all arguments — fine for a single-argument method, ambiguous for several.
@CachePut
@CachePut always runs the method and updates the cache with its result, instead of skipping the call on a
hit. Use it for writes that must also refresh what read paths will see next:
@CachePut(cacheNames = "products", key = "#product.id")
public Product update(Product product) {
// always executes; the returned value replaces the cached entry for this id
return productRepository.save(product);
}
@CacheEvict
@CacheEvict removes one entry (or, with allEntries = true, the whole cache) so a future call falls through
to the method again:
@CacheEvict(cacheNames = "products", key = "#id")
public void delete(Long id) {
productRepository.deleteById(id);
}
@CacheEvict(cacheNames = "products", allEntries = true)
public void clearCatalog() {
// wipes every entry in the "products" cache, e.g. after a bulk import
}
beforeInvocation = true evicts before the method runs rather than after it returns successfully, which
matters if the method may throw.
Conditional caching with SpEL: condition and unless
condition decides, before invocation, whether caching applies at all; unless decides, after invocation,
whether the result is vetoed from being stored — both are SpEL expressions, and unless can inspect the
return value as #result:
@Cacheable(
cacheNames = "products",
key = "#id",
condition = "#id > 0", // skip caching entirely for non-positive ids
unless = "#result != null && #result.discontinued" // don't cache discontinued products
)
public Product findById(Long id) {
return productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
A SpEL expression like condition = "#result != null" is only valid syntax inside a [source,java] annotation
attribute as shown above; the same characters typed as prose need escaping (see the note under Redis
configuration below). Several caches can also be targeted together with @Caching, which groups multiple
@Cacheable/@CachePut/@CacheEvict declarations on one method. See
the Spring Boot caching reference for the full
annotation model, @CacheConfig for class-level defaults, and custom KeyGenerator/CacheResolver beans.
Local caching: ConcurrentMapCacheManager and Caffeine
Adding spring-boot-starter-cache and calling @EnableCaching is enough to get caching working out of the
box: with no other cache library on the classpath, Spring Boot auto-configures a ConcurrentMapCacheManager,
backed by a plain java.util.concurrent.ConcurrentHashMap per cache name.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
ConcurrentMapCacheManager is simple and dependency-free, but it has no eviction policy, no size limit, and no
built-in expiry — every entry lives forever until explicitly evicted or the JVM restarts. It is adequate for
tests, demos, or caches with a naturally small, bounded key space; anything else risks an unbounded memory leak.
Caffeine
Caffeine is a high-performance local cache with size-based eviction
(window TinyLFU), time-based expiry, and statistics. Add it alongside the starter and Spring Boot’s
auto-configuration switches from ConcurrentMapCacheManager to CaffeineCacheManager automatically:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
Size and expiry are configured either declaratively, via a Caffeine spec string in application.yml:
spring:
cache:
cache-names: products, inventory
caffeine:
spec: maximumSize=10000,expireAfterWrite=10m,recordStats
or programmatically, for per-cache tuning that a single shared spec can’t express:
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("products", "inventory");
cacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats());
return cacheManager;
}
}
maximumSize bounds memory use by evicting the least-valuable entries once the limit is reached;
expireAfterWrite bounds staleness by evicting an entry a fixed duration after it was written, regardless of
how often it’s read (expireAfterAccess instead resets the timer on every read); recordStats turns on hit/miss
counters retrievable via CaffeineCache#getNativeCache().stats(), or exported as Micrometer metrics when
spring-boot-starter-actuator is present. See
the Spring Boot caching reference (Caffeine
section) for the full spec-string syntax and the provider-selection order Spring Boot follows when several
cache libraries are on the classpath.
Distributed caching with Redis
A local cache like Caffeine is private to one JVM: in a multi-instance deployment each instance builds up its own copy, and an eviction on one instance leaves the others stale. Redis backs a shared, network-accessible cache that every instance reads and writes consistently.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
With both starters present and a reachable Redis server, Spring Boot auto-configures a RedisCacheManager
backed by Spring Data Redis's
RedisCache, on top of the same @Cacheable/@CachePut/@CacheEvict annotations already shown above — no
code change is needed to move from Caffeine to Redis, only configuration.
spring:
data:
redis:
host: localhost
port: 6379
cache:
type: redis
redis:
time-to-live: 10m
cache-null-values: false
key-prefix: "app::"
use-key-prefix: true
time-to-live sets the TTL applied to every entry written through the cache abstraction — unlike Caffeine’s
in-JVM expiry, this TTL is enforced by the Redis server itself, so it survives an application restart.
cache-null-values: false avoids storing a Redis entry (and its own TTL bookkeeping) for methods whose result
was null; key-prefix/use-key-prefix namespace keys so multiple applications can share one Redis instance
without colliding.
Serialization
Values placed in a RedisCache must be serialized to bytes; the default is JDK serialization, which requires
Serializable and produces a compact but non-human-readable, Java-only format. A RedisCacheManager can be
built explicitly to use JSON instead, which is portable across languages and easier to inspect with redis-cli:
@Configuration
@EnableCaching
public class RedisCacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
Map<String, RedisCacheConfiguration> perCacheConfigs = Map.of(
"inventory", defaultConfig.entryTtl(Duration.ofSeconds(30)) // faster-changing data, shorter TTL
);
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(perCacheConfigs)
.build();
}
}
RedisCacheConfiguration is immutable, so withInitialCacheConfigurations overrides the default per named
cache — here inventory gets a much shorter TTL than every other cache, reflecting how quickly stock levels
go stale. See
Redis’s own guide to the Spring Framework cache
integration for additional serializer options and connection-pool tuning with Lettuce/Jedis.
Cache-aside vs. read/write-through
The annotation-driven abstraction shown throughout this page implements cache-aside (also called
lazy-loading): the application code (via @Cacheable) is responsible for checking the cache first and, on a
miss, loading from the source of truth and populating the cache itself. This is what @Cacheable,
@CachePut, and @CacheEvict give you directly, and it is the right default for most Spring Boot
applications.
Read-through and write-through push that responsibility into the caching layer itself: a read-through
cache loads misses from the underlying store on its own (via a configured loader), and a write-through cache
writes to the underlying store synchronously as part of every cache write, so the cache and the store are never
observably out of sync. Spring’s cache abstraction does not implement read/write-through out of the box — achieving it means writing a CacheLoader-style wrapper around the repository call, or reaching for a library
that supports it natively. In practice cache-aside with a short, deliberately chosen time-to-live (as
configured above) is simpler to reason about and is what most Spring Boot + Redis deployments use; consider
read/write-through only when strict cache/store consistency matters more than that simplicity.
Choosing between Caffeine and Redis
-
Caffeine (local, in-JVM) — lowest latency (no network hop), no extra infrastructure to run, but not shared across instances and lost on restart. Best for read-heavy, per-instance data where brief inconsistency between instances is acceptable.
-
Redis (distributed) — shared and consistent across every application instance, survives an individual instance restart, and can be sized independently of application memory, at the cost of network latency and an operated Redis deployment.
The two are not mutually exclusive: layering a small Caffeine cache in front of a larger Redis cache (a
"near-far" cache) absorbs the hottest keys locally while still sharing the bulk of the data through Redis. See
Near-Far Caches for the pattern’s trade-offs and Spring Boot
implementation options. For the full picture of provider auto-detection, spring.cache.type
override values, and every supported provider (JCache, Hazelcast, Infinispan, Couchbase, and others beyond the
two covered here), see
the Spring Boot caching reference.