Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Caching Strategies in Spring Boot

Spring cache abstraction in practice: @Cacheable key design, choosing between Caffeine and Redis, per-cache TTLs, cache stampedes, and a two-level cache that survives a Redis outage.

Intermediate6 min readUpdated
On this page

Caching is the cheapest performance win available and the easiest way to serve wrong data confidently. The Spring cache abstraction makes the mechanics almost free, which means the interesting decisions are all about keys, invalidation and what happens when the cache itself fails.

Key Takeaways

  • @Cacheable is proxy-based, so self-invocation silently bypasses it.
  • Key design is the whole game: keys must be stable, bounded and collision-free.
  • Caffeine is a local cache; Redis is a shared one. They solve different problems.
  • Always set a TTL and a size bound — an unbounded cache is a memory leak with good PR.
  • A cache outage must degrade the application, not break it.

The abstraction

@EnableCaching installs a proxy that consults a CacheManager before invoking the method. Three annotations cover the operations:

  • @Cacheable — look up first; on a miss, invoke and store.
  • @CachePut — always invoke, then store the result. For write-through updates.
  • @CacheEvict — remove one entry or all of them.
CatalogService.java
@Service
public class CatalogService {
 
    @Cacheable(cacheNames = "products", key = "#sku", unless = "#result == null")
    public Product findBySku(String sku) {
        return repository.findBySku(sku).orElse(null);
    }
 
    // Only cache the expensive branch. condition is evaluated before the call,
    // unless is evaluated after and can inspect #result.
    @Cacheable(cacheNames = "search", key = "#query.cacheKey()",
               condition = "#query.limit() <= 50",
               unless = "#result.isEmpty()")
    public List<Product> search(SearchQuery query) {
        return searchEngine.execute(query);
    }
 
    @CachePut(cacheNames = "products", key = "#product.sku()")
    public Product update(Product product) {
        return repository.save(product);
    }
 
    @Caching(evict = {
        @CacheEvict(cacheNames = "products", key = "#sku"),
        @CacheEvict(cacheNames = "search", allEntries = true)
    })
    public void delete(String sku) {
        repository.deleteBySku(sku);
    }
}

Note the @Caching block on delete. Removing a product invalidates one product entry but every search result that might have contained it — there is no way to know which, so the whole search cache goes. That asymmetry is normal and is a good argument for keeping short TTLs on derived caches.

Key design

The default key generator uses all method parameters, which is convenient and occasionally wrong. Be explicit with SpEL:

ExpressionMeaning
#skuA named parameter
#product.sku()A property of a parameter
#root.methodNameThe method name
#root.args[0]Positional argument
T(java.util.Objects).hash(#a, #b)A composite

Three rules that prevent most cache bugs. Keys must be stable — the same logical input must produce the same key across instances and restarts, which rules out anything derived from object identity or the current time. Keys must be bounded — a key derived from a free-text search string will fill the cache with single-use entries. And keys must be complete — if the result depends on the caller's tenant or locale, that must be in the key, or one tenant will read another's data. That last one is a security bug, not a performance bug.

Choosing a cache manager

A two-level cache: a nanosecond local lookup in front of a shared network cache in front of the source of truth.
CaffeineRedis
Latency~100ns~0.5–2ms
Shared across instancesNoYes
Survives restartNoYes
Size limitJVM heapRedis memory
Failure modeN/ANetwork partition
Best forHot reference dataSessions, cross-instance consistency

Per-cache configuration

CacheConfig.java
@Configuration
@EnableCaching
public class CacheConfig {
 
    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        var base = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .disableCachingNullValues()
                .prefixCacheNameWith("acme:")
                .serializeValuesWith(SerializationPair.fromSerializer(
                        new GenericJackson2JsonRedisSerializer()));
 
        // Different data ages at different rates. One global TTL is always
        // wrong for something.
        Map<String, RedisCacheConfiguration> perCache = Map.of(
                "products",   base.entryTtl(Duration.ofHours(6)),
                "search",     base.entryTtl(Duration.ofMinutes(2)),
                "fxRates",    base.entryTtl(Duration.ofSeconds(30)));
 
        return RedisCacheManager.builder(factory)
                .cacheDefaults(base)
                .withInitialCacheConfigurations(perCache)
                .build();
    }
}

Prefer JSON serialisation over JDK serialisation. JDK serialisation ties cached entries to the exact class version, so a deploy that adds a field to a cached record makes every existing entry unreadable — and, worse, deserialising untrusted bytes is a well-known remote code execution vector.

Cache stampedes

When a popular key expires, every concurrent request misses simultaneously and hits the database at once. Three defences, which combine well:

  1. sync = true on @Cacheable. Only one thread computes the value per instance; the rest wait. This is local, so with ten instances you still get ten queries instead of ten thousand.
  2. Jittered TTLs. Adding a random 10% to each entry's TTL prevents everything written at deploy time from expiring in the same second.
  3. Refresh-ahead. Caffeine's refreshAfterWrite returns the stale value immediately and refreshes in the background, so no request ever waits for a recomputation.
CaffeineConfig.java
@Bean
public CaffeineCacheManager caffeineCacheManager() {
    var manager = new CaffeineCacheManager();
    manager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofMinutes(10))
            .refreshAfterWrite(Duration.ofMinutes(5))
            .recordStats());
    return manager;
}

recordStats() is worth switching on. Micrometer picks it up automatically and gives you hit ratio per cache, which is the only way to know whether a cache is earning its complexity. A cache with a 20% hit rate is usually costing more than it saves.

Failing gracefully

By default, a Redis outage turns every cached method call into an exception. That is rarely what you want — the database is still there, and serving slowly beats serving 500.

ResilientCacheErrorHandler.java
@Bean
public CacheErrorHandler cacheErrorHandler() {
    return new SimpleCacheErrorHandler() {
        private final Logger log = LoggerFactory.getLogger("cache");
 
        @Override
        public void handleCacheGetError(RuntimeException ex, Cache cache, Object key) {
            log.warn("cache get failed for {}/{} — falling through to source", cache.getName(), key);
            // Swallowing means a miss, which means the method runs normally.
        }
 
        @Override
        public void handleCachePutError(RuntimeException ex, Cache cache, Object key, Object value) {
            log.warn("cache put failed for {}/{}", cache.getName(), key);
        }
    };
}

Pair this with a short Lettuce command timeout. Without one, a partitioned Redis makes every request wait for the default socket timeout, and the cache designed to make you fast becomes the reason you are down.

Transaction awareness

An eviction that runs before the surrounding transaction commits opens a window where another thread repopulates the cache from the old, uncommitted state. Set setTransactionAware(true) on the cache manager, or bind the eviction to commit explicitly:

AfterCommitEviction.java
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onProductUpdated(ProductUpdatedEvent event) {
    cacheManager.getCache("products").evict(event.sku());
}

What to take away

Cache things that are expensive and read far more often than written. Put the tenant and locale in the key. Bound every cache by size and time, record the hit rate so you can tell whether it is working, and make sure a cache outage degrades latency rather than availability.

Frequently Asked Questions

Why is my @Cacheable method still being executed every time?
Usually self-invocation: the call comes from inside the same class, so it never passes through the caching proxy. The other frequent cause is a key that is not stable — using an object without a proper equals/hashCode, or a key expression that includes a timestamp, produces a fresh key on every call.
Caffeine or Redis?
Caffeine when the data is small, read-heavy and a few seconds of staleness per instance is acceptable — it costs a hash lookup. Redis when entries must be consistent across instances, survive a restart, or are too large for local heap. Many systems want both, with Caffeine in front of Redis.
How do I stop a cache from serving stale data after a write?
Use @CacheEvict or @CachePut on the write path so the cache is updated in the same transaction boundary as the database. For evictions that must not happen if the transaction rolls back, evict in an @TransactionalEventListener bound to AFTER_COMMIT.

Related tutorials