How the Spring cache abstraction maps onto Redis
The Spring cache abstraction lets you declare caching behaviour on methods (@Cacheable, @CacheEvict, @CachePut) without writing cache calls into business logic. A proxy intercepts the annotated method, asks a CacheManager for a named Cache, and either returns the stored value or invokes the method and stores its result. With Redis, the CacheManager is a RedisCacheManager. It builds each RedisCache from a RedisCacheConfiguration, which holds the TTL, the key and value serializers, and the key prefix.
The cache name becomes part of the Redis key. By default the prefix is the cache name followed by ::, so products with argument 42 is stored as products::42. The default key generator uses a single parameter directly as the key. It wraps multiple parameters in a composite key and uses a fixed empty key when there are none. Because of this, a parameter's toString and serialized form determine what lands in Redis. A method signature change can silently change every key.
client ──► @Cacheable proxy ──► RedisCacheManager.getCache("products")
│ │
│ RedisCache
│ │ GET products::42
│ ▼
│ Redis ── hit ──► return value
│ │
│ miss
▼ │
invoke real method ◄───────────────┘
│
database
│
└──► SET products::42 ──► Redis ──► return value
Teams adopt this setup because caching becomes configuration rather than code. A shared Redis also gives every instance the same view of cached data. It suits read-heavy services whose data can be recomputed from a source of truth and tolerates brief staleness, such as catalogue lookups, permission checks and rendered reference data.
@Configuration
@EnableCaching
class CacheConfig {
@Bean
RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// Defaults applied to every cache: TTL, serializers, no cached nulls.
// The default key prefix stays "<cacheName>::", e.g. products::42.
RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaults)
// Per-cache override: the "products" cache gets a shorter TTL
.withCacheConfiguration("products", defaults.entryTtl(Duration.ofMinutes(5)))
.build();
}
}
@Service
class ProductService {
private final ProductRepository repository;
ProductService(ProductRepository repository) {
this.repository = repository;
}
// Single parameter -> used directly as the key: findById(42) reads/writes products::42.
// On a miss the proxy calls this method, hits the database, and stores the result.
@Cacheable("products")
public Product findById(long id) {
return repository.findById(id).orElseThrow();
}
}
A RedisCacheManager built from a shared RedisCacheConfiguration (TTL, key and value serializers) with a per-cache TTL override, plus a @Cacheable method whose single argument becomes the products:: key.
How does it work? The request lifecycle step by step
When you call a @Cacheable method on a Spring bean, these steps happen in order:
- Proxy interception.
@EnableCachingwraps the bean in a proxy. The call goes to the proxy first, not to your method. - Key computation. The proxy evaluates the
keySpEL expression if you declared one. Otherwise it uses the configuredKeyGenerator. TheRedisCachethen adds the prefix (by default<cacheName>::) and passes the result through the key serializer, usuallyStringRedisSerializer, to produce the bytes that Redis stores. - Lookup. The
RedisCachedelegates to aRedisCacheWriter, which issues aGETfor that key. - Hit. If Redis returns bytes, the value serializer turns them back into a Java object and the proxy returns it. Your method body never runs.
- Miss. If Redis returns nothing, the proxy invokes your method. The method typically queries the database.
- Store. The proxy serializes the return value and writes it to Redis. If the cache configuration has a non-zero TTL, the entry is written with that expiry. Otherwise it has no expiry. If null caching is disabled and the method returned
null, nothing is written. Writingnullwithout disabling null caching makes the cache fail. - Invalidation.
@CacheEvictdeletes the computed key, or every key under the prefix withallEntries = true.@CachePutalways runs the method and overwrites the key with the new result.
Redis knows nothing about Spring. It sees ordinary string keys with byte values and optional expiries. All the caching logic lives in the application: key derivation, serialization, the decision to cache or skip, and when to evict. That is why the pitfalls in the following sections are almost all application-side configuration problems: missing TTLs, mismatched keys, incompatible serialized formats and bypassed proxies.
Setting TTLs: why @Cacheable has none and what an unset TTL means
@Cacheable declares what to cache and under which key, but it has no attribute for how long. Expiry belongs to the cache manager. In Spring Data Redis, RedisCacheConfiguration.defaultCacheConfig() sets the entry TTL to Duration.ZERO, which means entries are written without an expiry. They stay until something evicts them explicitly or Redis removes them under memory pressure. A cache that never expires keeps serving stale data, and Redis keeps growing until it hits maxmemory.
Fix this in two layers. First, set a default TTL on the base configuration with entryTtl(...), or set the spring.cache.redis.time-to-live property, so that every cache gets a bound. Second, register a RedisCacheManagerBuilderCustomizer bean and call withCacheConfiguration("cacheName", config) for caches whose data changes at a different rate. For example, permission checks might use minutes while catalogue metadata uses hours.
A TTL sets your worst-case staleness window when an eviction is missed. A long TTL gives a better hit ratio and less load on the source of truth, but a missed @CacheEvict stays visible for that whole period. A short TTL limits that damage, but it adds misses and recomputation cost. Choose the TTL from how much staleness the business can accept, not from hit-ratio targets.
Watch for synchronized expiry. If a deploy or a warm-up job fills thousands of keys at the same moment, they all expire together. That sends a burst of misses to the database at one predictable time. You can spread these expiries by staggering warm-up or by adding jitter to per-entry TTLs.
import java.time.Duration;
import org.springframework.boot.autoconfigure.cache.RedisCacheManagerBuilderCustomizer;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
@Configuration
@EnableCaching
public class CacheTtlConfig {
// Base config for every cache. defaultCacheConfig() uses Duration.ZERO,
// which means no expiry, so always set an explicit bound here.
// When this bean exists, Spring Boot uses it instead of the
// spring.cache.redis.* properties.
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.disableCachingNullValues();
}
// Per-cache overrides for data that changes at a different rate.
// The TTL is the worst-case staleness if a @CacheEvict is missed.
@Bean
public RedisCacheManagerBuilderCustomizer perCacheTtls(RedisCacheConfiguration base) {
return builder -> builder
// Permission changes must show up quickly: short window.
.withCacheConfiguration("permissions",
base.entryTtl(Duration.ofMinutes(2)))
// Catalogue metadata changes rarely: favour hit ratio.
.withCacheConfiguration("catalogue",
base.entryTtl(Duration.ofHours(6)));
}
}
A default 30-minute TTL on the base RedisCacheConfiguration replaces the never-expire Duration.ZERO default, and a RedisCacheManagerBuilderCustomizer overrides the TTL for caches whose data changes faster or slower than the default.
Choosing a serializer: JDK, generic JSON or typed JSON
By default, RedisCacheManager stores values with JdkSerializationRedisSerializer. It works for any Serializable object, but it is the weakest choice once a cache outlives a single deploy.
| Criterion | JDK serialization | Generic JSON (embedded @class) |
Typed JSON per cache |
|---|---|---|---|
Readable in redis-cli |
No, binary | Yes | Yes |
| Payload size | Large (class descriptors) | Medium (type metadata) | Smallest |
| Read from other languages | Practically Java only | Possible, but tied to Java class names | Plain JSON |
| Class changes between deploys | Breaks on incompatible changes or a serialVersionUID mismatch |
Breaks on class rename or move | Tolerates renames; unknown fields can be ignored |
| Polymorphic deserialization risk | High if Redis is writable by others | Present; needs a restrictive type validator | None, because the target type is fixed |
Generic JSON (GenericJackson2JsonRedisSerializer) writes the fully qualified class name into every value, and the reader instantiates whatever class that value names. The class name becomes part of your storage format. Anyone who can write to Redis can also influence which classes get constructed during deserialization. If you use it, restrict the allowed types with a PolymorphicTypeValidator that permits only your own packages.
A typed serializer such as Jackson2JsonRedisSerializer<T> binds each cache to one known type. To configure one, build a RedisCacheConfiguration that calls serializeValuesWith with that serializer. Then register it for a single cache name through RedisCacheManagerBuilderCustomizer.withCacheConfiguration, and leave the other caches on the default.
The cost is one configuration per cache. Collection values such as List<Product> also need an explicit JavaType so Jackson knows the element type.
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;
import org.springframework.boot.autoconfigure.cache.RedisCacheManagerBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;
@Configuration
public class ProductCacheConfig {
@Bean
RedisCacheManagerBuilderCustomizer productCacheCustomizer() {
// Ignore fields added by a newer deploy instead of failing the read
ObjectMapper mapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
// The target type is fixed here, so no class name is stored in the value
// and nothing in Redis can choose which class gets instantiated
Jackson2JsonRedisSerializer<Product> serializer =
new Jackson2JsonRedisSerializer<>(mapper, Product.class);
RedisCacheConfiguration productsConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeValuesWith(SerializationPair.fromSerializer(serializer));
// Applies only to the "products" cache; other caches keep the default config
return builder -> builder.withCacheConfiguration("products", productsConfig);
}
}
Binds the "products" cache to a typed Jackson2JsonRedisSerializer through RedisCacheManagerBuilderCustomizer. Values are stored as plain JSON without embedded class names, and every other cache keeps the default serializer.
Surviving deploys: key versioning and failing safe on deserialization errors
Serialized bytes outlive the code that wrote them. If you rename a field, change a type, or move a class to another package, entries written by the previous release are still in Redis. The new release can't read them, or it reads them incorrectly. During a rolling deploy, old and new instances also share the same keyspace and overwrite each other's entries.
The clean fix is to version the key, not the data. Put a schema version in the cache name (products-v2) or in the prefix via RedisCacheConfiguration.computePrefixWith, and bump it whenever the cached shape changes incompatibly. Each release then reads and writes only its own entries. You pay with one cold cache per bump, and old and new entries sit side by side in Redis until the old ones go away. With a TTL, they expire on their own. Without one, they stay until maxmemory eviction removes them or someone deletes them by pattern. Under a noeviction policy, those orphans can push Redis into rejecting writes.
Versioning won't catch every mismatch, so also treat cache errors as misses. By default, Spring rethrows exceptions from the cache layer. A SerializationException or a Redis connection timeout will therefore fail the request even though the database could have answered it. Register a custom CacheErrorHandler through CachingConfigurer that logs and swallows get, put and evict errors. Spring then falls through to the method body.
The trade-off is that failing open turns a Redis outage into a database load test. Every request that used to hit the cache now goes to the database. Keep Redis command timeouts short so each miss is cheap, keep the database connection pool bounded, and alert on the error handler's log rate so a sustained outage gets noticed.
@Configuration
@EnableCaching
public class CacheConfig implements CachingConfigurer {
private static final Logger log = LoggerFactory.getLogger(CacheConfig.class);
// Bump when any cached class changes incompatibly; old keys become orphans.
private static final String SCHEMA_VERSION = "v2";
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
// A TTL lets orphaned entries from the previous version expire on their own.
.entryTtl(Duration.ofMinutes(30))
// Produces keys like "v2:products::42"
.computePrefixWith(cacheName -> SCHEMA_VERSION + ":" + cacheName + "::");
}
@Override
public CacheErrorHandler errorHandler() {
// Log and swallow cache errors so Spring runs the method body instead of failing the request.
// Alert on the rate of these log lines: a sustained rate means the database is taking full load.
return new CacheErrorHandler() {
public void handleCacheGetError(RuntimeException e, Cache cache, Object key) {
log.warn("Cache get failed, treating as miss: cache={} key={}", cache.getName(), key, e);
}
public void handleCachePutError(RuntimeException e, Cache cache, Object key, Object value) {
log.warn("Cache put failed: cache={} key={}", cache.getName(), key, e);
}
public void handleCacheEvictError(RuntimeException e, Cache cache, Object key) {
// A failed evict can leave a stale entry; the TTL bounds how long it can be served.
log.error("Cache evict failed: cache={} key={}", cache.getName(), key, e);
}
public void handleCacheClearError(RuntimeException e, Cache cache) {
log.error("Cache clear failed: cache={}", cache.getName(), e);
}
};
}
}
A Spring Boot cache configuration that adds a schema version to every Redis key prefix, sets a TTL so entries orphaned by a version bump expire, and registers a CacheErrorHandler that logs cache and deserialization errors and treats them as misses.
Eviction done right: @CacheEvict, @CachePut and the proxy pitfall
An eviction only works if it computes exactly the key that @Cacheable wrote. With the default key generator, @Cacheable on findUser(Long id) keys on id. A @CacheEvict on updateUser(User user) keys on the whole User object, matches nothing and fails silently. Declare explicit SpEL keys such as key = "#user.id" on both sides. The same applies to @CachePut, which always runs the method and stores its return value. That value must be the same type the reader expects, or readers will fail to deserialize it.
By default, eviction runs after the method returns successfully, so a thrown exception leaves the entry in place. beforeInvocation = true evicts first, regardless of outcome. Use it when a partial write could leave the database changed even though the method failed.
allEntries = true looks cheap but is not. Redis has no namespaces, so clearing a cache means pattern-matching every key under its prefix. By default the cache writer uses KEYS, which blocks the server. A SCAN-based batch strategy avoids the block but is still proportional to keyspace size. For bulk invalidation, bump the key version prefix or rely on short TTLs instead.
Caching annotations are applied by a proxy. When a method in the same bean calls this.findUser(), the call skips the proxy and the cache entirely. Move the annotated method into a separate bean.
The Redis cache stores nulls by default. That protects against repeated lookups of missing rows, but it also pins a "not found" result until expiry. Disable null caching or add unless = "#result == null" where that matters.
Finally, annotations only see writes that go through annotated methods. Batch jobs, other services and direct SQL change the data without touching the cache, so those entries stay stale until their TTL expires.
Stampedes, sync=true and Redis memory policies
When a hot entry expires, every request that misses at that moment recomputes it. A hundred concurrent requests become a hundred identical database queries. That is a cache stampede, and it usually lands at the worst time, under peak load.
Setting sync = true on @Cacheable makes concurrent callers for the same key wait while one thread loads the value. The others then read the result instead of recomputing it. This has two costs:
- Waiting callers inherit the loader's latency. A slow query now blocks a queue of threads rather than one.
- The lock is local to the JVM. It is not a Redis lock. Ten instances behind a load balancer can still issue ten concurrent loads for the same key.
So sync reduces a stampede by a factor of your instance count, but it does not remove it. Cross-fleet protection needs a different technique, such as a distributed lock, early probabilistic refresh, or randomized TTLs so that hot keys do not expire together.
Spring's TTL and Redis's maxmemory-policy are separate mechanisms:
| Mechanism | Triggered by | Removes | Risk |
|---|---|---|---|
| Spring TTL (key expiry) | Time elapsed | The expired key only | None beyond cache misses |
noeviction |
Memory limit reached | Nothing | Write commands fail with an OOM error |
allkeys-lru / allkeys-lfu |
Memory limit reached | Any key | Non-cache data can be silently deleted |
volatile-lru / volatile-lfu / volatile-ttl |
Memory limit reached | Only keys with an expiry set | Writes fail if no key has an expiry |
Sharing one Redis instance between cache entries and durable data, such as sessions or queues, forces a choice of policy that suits only one workload. Under noeviction, a growing cache can fill memory, and Redis then rejects writes to your durable data as well. Cache misses turn into failed requests. Give the cache its own instance or database server with an allkeys-* policy.
Who should use it?
Spring Boot Redis caching is aimed at these teams and situations:
- Backend teams running Spring Boot services that already operate Redis, or can run a dedicated Redis for caching. If Redis is not already part of your platform, adding it purely for caching brings a new piece of infrastructure to provision, secure and monitor.
- Services that run multiple instances and repeat the same expensive reads. Examples include lookups by ID, permission or entitlement checks, configuration and reference data, and responses assembled from slow downstream calls. A shared Redis gives every instance the same cached view and survives application restarts. A per-instance heap cache does neither.
- Teams that own the write paths to the cached data. Annotated eviction only works when updates go through methods you can annotate. If other services or batch jobs write the same tables, you can only rely on TTLs, and you must accept the resulting staleness.
- Platform or architecture groups that want one caching convention across many services. These groups can ship a shared
RedisCacheConfigurationwith mandatory TTLs, typed serializers, versioned prefixes and aCacheErrorHandler, so individual teams only add annotations.
It is a poor fit for teams that need read-your-writes consistency on the cached data, for single-instance applications with small datasets, and for workloads where most reads are unique and the hit ratio would be low.
How can it save engineering time?
The time savings come from removing repeated hand-written cache code, not from Redis itself.
- No per-method cache-aside boilerplate. Without the abstraction, every cached read needs code to build the key, call
GET, deserialize, handle a miss, call the loader, serialize, write with a TTL, and handle Redis errors. With annotations, that logic exists once, in the proxy and the shared configuration. The method keeps only its business logic. - Cross-cutting decisions live in one place. TTLs, serializers, key prefixes, null handling and error handling are set in a
RedisCacheConfigurationand aCacheErrorHandler. Changing the serializer or bumping a schema version is a configuration change, not an edit across every caching call site. - Easier testing. Because business code depends on the
CacheManagerabstraction rather than on Redis calls, tests can swap inConcurrentMapCacheManagerorNoOpCacheManagerand exercise service logic without a Redis server. - Swappable backends. The same annotations work with other
CacheManagerimplementations. Moving a cache from in-process to Redis, or the reverse, mostly means changing configuration rather than rewriting services.
Illustrative estimate, using assumed inputs only: suppose a service has 12 cached read methods, and hand-written cache-aside code takes an assumed 15 lines per method once you include key building, serialization, TTL and error handling. That is roughly 180 lines of cache plumbing to write, review and test. The annotation approach needs about 12 annotations plus one configuration class. Your own figures will depend on how much you would have factored into helpers anyway.
These savings only hold if the pitfalls in this article are handled in the shared configuration. A missing TTL, a mismatched eviction key or an incompatible serializer can cost more debugging time than the boilerplate saved. That is why the next section treats explicit TTLs and listed evicting methods as review requirements.
When to use it, when to avoid it, alternatives and engineering cost
Annotation-driven Redis caching fits read-heavy data that is expensive to fetch, tolerates seconds to minutes of staleness, and is written through a few code paths you control. Avoid it when readers must see their own writes immediately, such as balances or stock reservations. Avoid it when batch jobs, other services or manual SQL update the same rows without passing through your @CacheEvict methods. Avoid it for datasets small enough to hold in each instance's heap, where the network hop costs more than it saves.
| Option | Staleness control | Added latency | Main cost |
|---|---|---|---|
@Cacheable + Redis |
TTL plus annotated evictions | One network round trip | Behaviour hidden in annotations |
| In-process cache | Per-instance TTL, no cross-node eviction | None | Instances disagree, heap pressure |
Cache-aside with RedisTemplate |
Explicit in code | One network round trip | More code, but visible in review |
| Two-level (local + Redis) | Hardest: two layers to invalidate | Local on hit | Needs pub/sub or short local TTLs |
| Database read replica | Replication lag only | Database query | Infrastructure, not code |
Annotations save boilerplate, but the cost moves to review. A diff that adds @Cacheable hides the key, the TTL and every write path that must evict. Require each cache name to have an explicit TTL in configuration and a listed set of evicting methods.
For observability, track these:
- Hit ratio: enable cache statistics and export them as metrics.
- Key counts per prefix: sample with
SCANandMATCH. Never useKEYS. - Memory: compare
used_memoryfromINFO memoryagainstmaxmemory. - Errors: register a
CacheErrorHandlerthat logs and counts failures, so a Redis outage degrades to database reads instead of failed requests. Confirm the database can absorb that load.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to spring boot redis cache, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.